feat(resolvespec): add OR logic support in filters

* Introduce `logic_operator` field to combine filters with OR logic.
* Implement grouping for consecutive OR filters to ensure proper SQL precedence.
* Add support for custom SQL operators in filter conditions.
* Enhance `fetch_row_number` functionality to return specific record with its position.
* Update tests to cover new filter logic and grouping behavior.

Features Implemented:

  1. OR Logic Filter Support (SearchOr)
    - Added to resolvespec, restheadspec, and websocketspec
    - Consecutive OR filters are automatically grouped with parentheses
    - Prevents SQL logic errors: (A OR B OR C) AND D instead of A OR B OR C AND D
  2. CustomOperators
    - Allows arbitrary SQL conditions in resolvespec
    - Properly integrated with filter logic
  3. FetchRowNumber
    - Uses SQL window functions: ROW_NUMBER() OVER (ORDER BY ...)
    - Returns only the specific record (not all records)
    - Available in resolvespec and restheadspec
    - Perfect for "What's my rank?" queries
  4. RowNumber Field Auto-Population
    - Now available in all three packages: resolvespec, restheadspec, and websocketspec
    - Uses simple offset-based math: offset + index + 1
    - Automatically populates RowNumber int64 field if it exists on models
    - Perfect for displaying paginated lists with sequential numbering
This commit is contained in:
Hein
2026-02-10 16:55:55 +02:00
parent 4bf3d0224e
commit a6c7edb0e4
6 changed files with 1364 additions and 56 deletions

View File

@@ -214,6 +214,146 @@ Content-Type: application/json
```json
{
"filters": [
{
"column": "status",
"operator": "eq",
"value": "active"
},
{
"column": "status",
"operator": "eq",
"value": "pending",
"logic_operator": "OR"
},
{
"column": "age",
"operator": "gte",
"value": 18
}
]
}
```
Produces: `WHERE (status = 'active' OR status = 'pending') AND age >= 18`
This grouping ensures OR conditions don't interfere with other AND conditions in the query.
### Custom Operators
Add custom SQL conditions when needed:
```json
{
"operation": "read",
"options": {
"customOperators": [
{
"name": "email_domain_filter",
"sql": "LOWER(email) LIKE '%@example.com'"
},
{
"name": "recent_records",
"sql": "created_at > NOW() - INTERVAL '7 days'"
}
]
}
}
```
Custom operators are applied as additional WHERE conditions to your query.
### Fetch Row Number
Get the row number (position) of a specific record in the filtered and sorted result set. **When `fetch_row_number` is specified, only that specific record is returned** (not all records).
```json
{
"operation": "read",
"options": {
"filters": [
{
"column": "status",
"operator": "eq",
"value": "active"
}
],
"sort": [
{
"column": "score",
"direction": "desc"
}
],
"fetch_row_number": "12345"
}
}
```
**Response - Returns ONLY the specified record with its position:**
```json
{
"success": true,
"data": {
"id": 12345,
"name": "John Doe",
"score": 850,
"status": "active"
},
"metadata": {
"total": 1000,
"count": 1,
"filtered": 1000,
"row_number": 42
}
}
```
**Use Case:** Perfect for "Show me this user and their ranking" - you get just that one user with their position in the leaderboard.
**Note:** This is different from the `RowNumber` field feature, which automatically numbers all records in a paginated response based on offset. That feature uses simple math (`offset + index + 1`), while `fetch_row_number` uses SQL window functions to calculate the actual position in a sorted/filtered set. To use the `RowNumber` field feature, simply add a `RowNumber int64` field to your model - it will be automatically populated with the row position based on pagination.
## Preloading
Load related entities with custom configuration:
```json
{
"operation": "read",
"options": {
"columns": ["id", "name", "email"],
"preload": [
{
"relation": "posts",
"columns": ["id", "title", "created_at"],
"filters": [
{
"column": "status",
"operator": "eq",
"value": "published"
}
],
"sort": [
{
"column": "created_at",
"direction": "desc"
}
],
"limit": 5
},
{
"relation": "profile",
"columns": ["bio", "website"]
}
]
}
}
```
## Cursor Pagination
Efficient pagination for large datasets:
### First Request (No Cursor)
```json
@@ -427,7 +567,7 @@ Define virtual columns using SQL expressions:
// Check permissions
if !userHasPermission(ctx.Context, ctx.Entity) {
return fmt.Errorf("unauthorized access to %s", ctx.Entity)
return nil
}
// Modify query options
if ctx.Options.Limit == nil || *ctx.Options.Limit > 100 {
@@ -435,17 +575,24 @@ Add custom SQL conditions when needed:
}
return nil
users[i].Email = maskEmail(users[i].Email)
}
})
// Register an after-read hook (e.g., for data transformation)
handler.Hooks().Register(resolvespec.AfterRead, func(ctx *resolvespec.HookContext) error {
})
// Transform or filter results
if users, ok := ctx.Result.([]User); ok {
for i := range users {
users[i].Email = maskEmail(users[i].Email)
}
}
return nil
})
// Register a before-create hook (e.g., for validation)
handler.Hooks().Register(resolvespec.BeforeCreate, func(ctx *resolvespec.HookContext) error {
// Validate data
if user, ok := ctx.Data.(*User); ok {
if user.Email == "" {
return fmt.Errorf("email is required")
}
// Add timestamps