diff --git a/pkg/resolvespec/EXAMPLES.md b/pkg/resolvespec/EXAMPLES.md new file mode 100644 index 0000000..991eba7 --- /dev/null +++ b/pkg/resolvespec/EXAMPLES.md @@ -0,0 +1,572 @@ +# ResolveSpec Query Features Examples + +This document provides examples of using the advanced query features in ResolveSpec, including OR logic filters, Custom Operators, and FetchRowNumber. + +## OR Logic in Filters (SearchOr) + +### Basic OR Filter Example + +Find all users with status "active" OR "pending": + +```json +POST /users +{ + "operation": "read", + "options": { + "filters": [ + { + "column": "status", + "operator": "eq", + "value": "active" + }, + { + "column": "status", + "operator": "eq", + "value": "pending", + "logic_operator": "OR" + } + ] + } +} +``` + +### Combined AND/OR Filters + +Find users with (status="active" OR status="pending") AND age >= 18: + +```json +{ + "operation": "read", + "options": { + "filters": [ + { + "column": "status", + "operator": "eq", + "value": "active" + }, + { + "column": "status", + "operator": "eq", + "value": "pending", + "logic_operator": "OR" + }, + { + "column": "age", + "operator": "gte", + "value": 18 + } + ] + } +} +``` + +**SQL Generated:** `WHERE (status = 'active' OR status = 'pending') AND age >= 18` + +**Important Notes:** +- By default, filters use AND logic +- Consecutive filters with `"logic_operator": "OR"` are automatically grouped with parentheses +- This grouping ensures OR conditions don't interfere with AND conditions +- You don't need to specify `"logic_operator": "AND"` as it's the default + +### Multiple OR Groups + +You can have multiple separate OR groups: + +```json +{ + "operation": "read", + "options": { + "filters": [ + { + "column": "status", + "operator": "eq", + "value": "active" + }, + { + "column": "status", + "operator": "eq", + "value": "pending", + "logic_operator": "OR" + }, + { + "column": "priority", + "operator": "eq", + "value": "high" + }, + { + "column": "priority", + "operator": "eq", + "value": "urgent", + "logic_operator": "OR" + } + ] + } +} +``` + +**SQL Generated:** `WHERE (status = 'active' OR status = 'pending') AND (priority = 'high' OR priority = 'urgent')` + +## Custom Operators + +### Simple Custom SQL Condition + +Filter by email domain using custom SQL: + +```json +{ + "operation": "read", + "options": { + "customOperators": [ + { + "name": "company_emails", + "sql": "email LIKE '%@company.com'" + } + ] + } +} +``` + +### Multiple Custom Operators + +Combine multiple custom SQL conditions: + +```json +{ + "operation": "read", + "options": { + "customOperators": [ + { + "name": "recent_active", + "sql": "last_login > NOW() - INTERVAL '30 days'" + }, + { + "name": "high_score", + "sql": "score > 1000" + } + ] + } +} +``` + +### Complex Custom Operator + +Use complex SQL expressions: + +```json +{ + "operation": "read", + "options": { + "customOperators": [ + { + "name": "priority_users", + "sql": "(subscription = 'premium' AND points > 500) OR (subscription = 'enterprise')" + } + ] + } +} +``` + +### Combining Custom Operators with Regular Filters + +Mix custom operators with standard filters: + +```json +{ + "operation": "read", + "options": { + "filters": [ + { + "column": "country", + "operator": "eq", + "value": "USA" + } + ], + "customOperators": [ + { + "name": "active_last_month", + "sql": "last_activity > NOW() - INTERVAL '1 month'" + } + ] + } +} +``` + +## Row Numbers + +### Two Ways to Get Row Numbers + +There are two different features for row numbers: + +1. **`fetch_row_number`** - Get the position of ONE specific record in a sorted/filtered set +2. **`RowNumber` field in models** - Automatically number all records in the response + +### 1. FetchRowNumber - Get Position of Specific Record + +Get the rank/position of a specific user in a leaderboard. **Important:** When `fetch_row_number` is specified, the response contains **ONLY that specific record**, not all records. + +```json +{ + "operation": "read", + "options": { + "sort": [ + { + "column": "score", + "direction": "desc" + } + ], + "fetch_row_number": "12345" + } +} +``` + +**Response - Contains ONLY the specified user:** +```json +{ + "success": true, + "data": { + "id": 12345, + "name": "Alice Smith", + "score": 9850, + "level": 42 + }, + "metadata": { + "total": 10000, + "count": 1, + "filtered": 10000, + "row_number": 42 + } +} +``` + +**Result:** User "12345" is ranked #42 out of 10,000 users. The response includes only Alice's data, not the other 9,999 users. + +### Row Number with Filters + +Find position within a filtered subset (e.g., "What's my rank in my country?"): + +```json +{ + "operation": "read", + "options": { + "filters": [ + { + "column": "country", + "operator": "eq", + "value": "USA" + }, + { + "column": "status", + "operator": "eq", + "value": "active" + } + ], + "sort": [ + { + "column": "score", + "direction": "desc" + } + ], + "fetch_row_number": "12345" + } +} +``` + +**Response:** +```json +{ + "success": true, + "data": { + "id": 12345, + "name": "Bob Johnson", + "country": "USA", + "score": 7200, + "status": "active" + }, + "metadata": { + "total": 2500, + "count": 1, + "filtered": 2500, + "row_number": 156 + } +} +``` + +**Result:** Bob is ranked #156 out of 2,500 active USA users. Only Bob's record is returned. + +### 2. RowNumber Field - Auto-Number All Records + +If your model has a `RowNumber int64` field, restheadspec will automatically populate it for paginated results. + +**Model Definition:** +```go +type Player struct { + ID int64 `json:"id"` + Name string `json:"name"` + Score int64 `json:"score"` + RowNumber int64 `json:"row_number"` // Will be auto-populated +} +``` + +**Request (with pagination):** +```json +{ + "operation": "read", + "options": { + "sort": [{"column": "score", "direction": "desc"}], + "limit": 10, + "offset": 20 + } +} +``` + +**Response - RowNumber automatically set:** +```json +{ + "success": true, + "data": [ + { + "id": 456, + "name": "Player21", + "score": 8900, + "row_number": 21 + }, + { + "id": 789, + "name": "Player22", + "score": 8850, + "row_number": 22 + }, + { + "id": 123, + "name": "Player23", + "score": 8800, + "row_number": 23 + } + // ... records 24-30 ... + ] +} +``` + +**How It Works:** +- `row_number = offset + index + 1` (1-based) +- With offset=20, first record gets row_number=21 +- With offset=20, second record gets row_number=22 +- Perfect for displaying "Rank" in paginated tables + +**Use Case:** Displaying leaderboards with rank numbers: +``` +Rank | Player | Score +-----|-----------|------- +21 | Player21 | 8900 +22 | Player22 | 8850 +23 | Player23 | 8800 +``` + +**Note:** This feature is available in all three packages: resolvespec, restheadspec, and websocketspec. + +### When to Use Each Feature + +| Feature | Use Case | Returns | Performance | +|---------|----------|---------|-------------| +| `fetch_row_number` | "What's my rank?" | 1 record with position | Fast - 1 record | +| `RowNumber` field | "Show top 10 with ranks" | Many records numbered | Fast - simple math | + +**Combined Example - Full Leaderboard UI:** + +```javascript +// Request 1: Get current user's rank +const userRank = await api.read({ + fetch_row_number: currentUserId, + sort: [{column: "score", direction: "desc"}] +}); +// Returns: {id: 123, name: "You", score: 7500, row_number: 156} + +// Request 2: Get top 10 with rank numbers +const top10 = await api.read({ + sort: [{column: "score", direction: "desc"}], + limit: 10, + offset: 0 +}); +// Returns: [{row_number: 1, ...}, {row_number: 2, ...}, ...] + +// Display: +// "Your Rank: #156" +// "Top Players:" +// "#1 - Alice - 9999" +// "#2 - Bob - 9876" +// ... +``` + +## Complete Example: Advanced Query + +Combine all features for a complex query: + +```json +{ + "operation": "read", + "options": { + "columns": ["id", "name", "email", "score", "status"], + "filters": [ + { + "column": "status", + "operator": "eq", + "value": "active" + }, + { + "column": "status", + "operator": "eq", + "value": "trial", + "logic_operator": "OR" + }, + { + "column": "score", + "operator": "gte", + "value": 100 + } + ], + "customOperators": [ + { + "name": "recent_activity", + "sql": "last_login > NOW() - INTERVAL '7 days'" + }, + { + "name": "verified_email", + "sql": "email_verified = true" + } + ], + "sort": [ + { + "column": "score", + "direction": "desc" + }, + { + "column": "created_at", + "direction": "asc" + } + ], + "fetch_row_number": "12345", + "limit": 50, + "offset": 0 + } +} +``` + +This query: +- Selects specific columns +- Filters for users with status "active" OR "trial" +- AND score >= 100 +- Applies custom SQL conditions for recent activity and verified emails +- Sorts by score (descending) then creation date (ascending) +- Returns the row number of user "12345" in this filtered/sorted set +- Returns 50 records starting from the first one + +## Use Cases + +### 1. Leaderboards - Get Current User's Rank + +Get the current user's position and data (returns only their record): + +```json +{ + "operation": "read", + "options": { + "filters": [ + { + "column": "game_id", + "operator": "eq", + "value": "game123" + } + ], + "sort": [ + { + "column": "score", + "direction": "desc" + } + ], + "fetch_row_number": "current_user_id" + } +} +``` + +**Tip:** For full leaderboards, make two requests: +1. One with `fetch_row_number` to get user's rank +2. One with `limit` and `offset` to get top players list + +### 2. Multi-Status Search + +```json +{ + "operation": "read", + "options": { + "filters": [ + { + "column": "order_status", + "operator": "eq", + "value": "pending" + }, + { + "column": "order_status", + "operator": "eq", + "value": "processing", + "logic_operator": "OR" + }, + { + "column": "order_status", + "operator": "eq", + "value": "shipped", + "logic_operator": "OR" + } + ] + } +} +``` + +### 3. Advanced Date Filtering + +```json +{ + "operation": "read", + "options": { + "customOperators": [ + { + "name": "this_month", + "sql": "created_at >= DATE_TRUNC('month', CURRENT_DATE)" + }, + { + "name": "business_hours", + "sql": "EXTRACT(HOUR FROM created_at) BETWEEN 9 AND 17" + } + ] + } +} +``` + +## Security Considerations + +**Warning:** Custom operators allow raw SQL, which can be a security risk if not properly handled: + +1. **Never** directly interpolate user input into custom operator SQL +2. Always validate and sanitize custom operator SQL on the backend +3. Consider using a whitelist of allowed custom operators +4. Use prepared statements or parameterized queries when possible +5. Implement proper authorization checks before executing queries + +Example of safe custom operator handling in Go: + +```go +// Whitelist of allowed custom operators +allowedOperators := map[string]string{ + "recent_week": "created_at > NOW() - INTERVAL '7 days'", + "active_users": "status = 'active' AND last_login > NOW() - INTERVAL '30 days'", + "premium_only": "subscription_level = 'premium'", +} + +// Validate custom operators from request +for _, op := range req.Options.CustomOperators { + if sql, ok := allowedOperators[op.Name]; ok { + op.SQL = sql // Use whitelisted SQL + } else { + return errors.New("custom operator not allowed: " + op.Name) + } +} +``` diff --git a/pkg/resolvespec/README.md b/pkg/resolvespec/README.md index ddde8a2..84c61ee 100644 --- a/pkg/resolvespec/README.md +++ b/pkg/resolvespec/README.md @@ -214,6 +214,146 @@ Content-Type: application/json } ``` +### OR Logic in Filters (SearchOr) + +Use the `logic_operator` field to combine filters with OR logic instead of the default AND: + +```json +{ + "operation": "read", + "options": { + "filters": [ + { + "column": "status", + "operator": "eq", + "value": "active" + }, + { + "column": "status", + "operator": "eq", + "value": "pending", + "logic_operator": "OR" + }, + { + "column": "priority", + "operator": "eq", + "value": "high", + "logic_operator": "OR" + } + ] + } +} +``` + +This will produce: `WHERE (status = 'active' OR status = 'pending' OR priority = 'high')` + +**Important:** Consecutive OR filters are automatically grouped together with parentheses to ensure proper query logic. + +#### Mixing AND and OR + +Consecutive OR filters are grouped, then combined with AND filters: + +```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: @@ -427,7 +567,7 @@ Define virtual columns using SQL expressions: ## Custom Operators -Add custom SQL conditions when needed: +Add custom SQL conditions when standard filters aren't sufficient: ```json { @@ -435,17 +575,24 @@ Add custom SQL conditions when needed: "options": { "customOperators": [ { - "condition": "LOWER(email) LIKE ?", - "values": ["%@example.com"] + "name": "email_domain_filter", + "sql": "LOWER(email) LIKE '%@example.com'" }, { - "condition": "created_at > NOW() - INTERVAL '7 days'" + "name": "recent_records", + "sql": "created_at > NOW() - INTERVAL '7 days'" + }, + { + "name": "complex_condition", + "sql": "(status = 'active' AND score > 100) OR (status = 'pending' AND priority = 'high')" } ] } } ``` +**Note:** Custom operators are applied as WHERE conditions. Make sure to properly escape and sanitize any user input to prevent SQL injection. + ## Lifecycle Hooks Register hooks for all CRUD operations: diff --git a/pkg/resolvespec/filter_test.go b/pkg/resolvespec/filter_test.go new file mode 100644 index 0000000..177997f --- /dev/null +++ b/pkg/resolvespec/filter_test.go @@ -0,0 +1,143 @@ +package resolvespec + +import ( + "strings" + "testing" + + "github.com/bitechdev/ResolveSpec/pkg/common" +) + +// TestBuildFilterCondition tests the filter condition builder +func TestBuildFilterCondition(t *testing.T) { + h := &Handler{} + + tests := []struct { + name string + filter common.FilterOption + expectedCondition string + expectedArgsCount int + }{ + { + name: "Equal operator", + filter: common.FilterOption{ + Column: "status", + Operator: "eq", + Value: "active", + }, + expectedCondition: "status = ?", + expectedArgsCount: 1, + }, + { + name: "Greater than operator", + filter: common.FilterOption{ + Column: "age", + Operator: "gt", + Value: 18, + }, + expectedCondition: "age > ?", + expectedArgsCount: 1, + }, + { + name: "IN operator", + filter: common.FilterOption{ + Column: "status", + Operator: "in", + Value: []string{"active", "pending"}, + }, + expectedCondition: "status IN (?)", + expectedArgsCount: 1, + }, + { + name: "LIKE operator", + filter: common.FilterOption{ + Column: "email", + Operator: "like", + Value: "%@example.com", + }, + expectedCondition: "email LIKE ?", + expectedArgsCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + condition, args := h.buildFilterCondition(tt.filter) + + if condition != tt.expectedCondition { + t.Errorf("Expected condition '%s', got '%s'", tt.expectedCondition, condition) + } + + if len(args) != tt.expectedArgsCount { + t.Errorf("Expected %d args, got %d", tt.expectedArgsCount, len(args)) + } + + // Note: Skip value comparison for slices as they can't be compared with == + // The important part is that args are populated correctly + }) + } +} + +// TestORGrouping tests that consecutive OR filters are properly grouped +func TestORGrouping(t *testing.T) { + // This is a conceptual test - in practice we'd need a mock SelectQuery + // to verify the actual SQL grouping behavior + t.Run("Consecutive OR filters should be grouped", func(t *testing.T) { + filters := []common.FilterOption{ + {Column: "status", Operator: "eq", Value: "active"}, + {Column: "status", Operator: "eq", Value: "pending", LogicOperator: "OR"}, + {Column: "status", Operator: "eq", Value: "trial", LogicOperator: "OR"}, + {Column: "age", Operator: "gte", Value: 18}, + } + + // Expected behavior: (status='active' OR status='pending' OR status='trial') AND age>=18 + // The first three filters should be grouped together + // The fourth filter should be separate with AND + + // Count OR groups + orGroupCount := 0 + inORGroup := false + + for i := 1; i < len(filters); i++ { + if strings.EqualFold(filters[i].LogicOperator, "OR") && !inORGroup { + orGroupCount++ + inORGroup = true + } else if !strings.EqualFold(filters[i].LogicOperator, "OR") { + inORGroup = false + } + } + + // We should have detected one OR group + if orGroupCount != 1 { + t.Errorf("Expected 1 OR group, detected %d", orGroupCount) + } + }) + + t.Run("Multiple OR groups should be handled correctly", func(t *testing.T) { + filters := []common.FilterOption{ + {Column: "status", Operator: "eq", Value: "active"}, + {Column: "status", Operator: "eq", Value: "pending", LogicOperator: "OR"}, + {Column: "priority", Operator: "eq", Value: "high"}, + {Column: "priority", Operator: "eq", Value: "urgent", LogicOperator: "OR"}, + } + + // Expected: (status='active' OR status='pending') AND (priority='high' OR priority='urgent') + // Should have two OR groups + + orGroupCount := 0 + inORGroup := false + + for i := 1; i < len(filters); i++ { + if strings.EqualFold(filters[i].LogicOperator, "OR") && !inORGroup { + orGroupCount++ + inORGroup = true + } else if !strings.EqualFold(filters[i].LogicOperator, "OR") { + inORGroup = false + } + } + + // We should have detected two OR groups + if orGroupCount != 2 { + t.Errorf("Expected 2 OR groups, detected %d", orGroupCount) + } + }) +} diff --git a/pkg/resolvespec/handler.go b/pkg/resolvespec/handler.go index c191936..0b1251f 100644 --- a/pkg/resolvespec/handler.go +++ b/pkg/resolvespec/handler.go @@ -280,10 +280,13 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st } } - // Apply filters - for _, filter := range options.Filters { - logger.Debug("Applying filter: %s %s %v", filter.Column, filter.Operator, filter.Value) - query = h.applyFilter(query, filter) + // Apply filters with proper grouping for OR logic + query = h.applyFilters(query, options.Filters) + + // Apply custom operators + for _, customOp := range options.CustomOperators { + logger.Debug("Applying custom operator: %s - %s", customOp.Name, customOp.SQL) + query = query.Where(customOp.SQL) } // Apply sorting @@ -381,24 +384,94 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st } } - // Apply pagination - if options.Limit != nil && *options.Limit > 0 { - logger.Debug("Applying limit: %d", *options.Limit) - query = query.Limit(*options.Limit) + // Handle FetchRowNumber if requested + var rowNumber *int64 + if options.FetchRowNumber != nil && *options.FetchRowNumber != "" { + logger.Debug("Fetching row number for ID: %s", *options.FetchRowNumber) + pkName := reflection.GetPrimaryKeyName(model) + + // Build ROW_NUMBER window function SQL + rowNumberSQL := "ROW_NUMBER() OVER (" + if len(options.Sort) > 0 { + rowNumberSQL += "ORDER BY " + for i, sort := range options.Sort { + if i > 0 { + rowNumberSQL += ", " + } + direction := "ASC" + if strings.EqualFold(sort.Direction, "desc") { + direction = "DESC" + } + rowNumberSQL += fmt.Sprintf("%s %s", sort.Column, direction) + } + } + rowNumberSQL += ")" + + // Create a query to fetch the row number using a subquery approach + // We'll select the PK and row_number, then filter by the target ID + type RowNumResult struct { + RowNum int64 `bun:"row_num"` + } + + rowNumQuery := h.db.NewSelect().Table(tableName). + ColumnExpr(fmt.Sprintf("%s AS row_num", rowNumberSQL)). + Column(pkName) + + // Apply the same filters as the main query + for _, filter := range options.Filters { + rowNumQuery = h.applyFilter(rowNumQuery, filter) + } + + // Apply custom operators + for _, customOp := range options.CustomOperators { + rowNumQuery = rowNumQuery.Where(customOp.SQL) + } + + // Filter for the specific ID we want the row number for + rowNumQuery = rowNumQuery.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), *options.FetchRowNumber) + + // Execute query to get row number + var result RowNumResult + if err := rowNumQuery.Scan(ctx, &result); err != nil { + if err != sql.ErrNoRows { + logger.Warn("Error fetching row number: %v", err) + } + } else { + rowNumber = &result.RowNum + logger.Debug("Found row number: %d", *rowNumber) + } } - if options.Offset != nil && *options.Offset > 0 { - logger.Debug("Applying offset: %d", *options.Offset) - query = query.Offset(*options.Offset) + + // Apply pagination (skip if FetchRowNumber is set - we want only that record) + if options.FetchRowNumber == nil || *options.FetchRowNumber == "" { + if options.Limit != nil && *options.Limit > 0 { + logger.Debug("Applying limit: %d", *options.Limit) + query = query.Limit(*options.Limit) + } + if options.Offset != nil && *options.Offset > 0 { + logger.Debug("Applying offset: %d", *options.Offset) + query = query.Offset(*options.Offset) + } } // Execute query var result interface{} - if id != "" { - logger.Debug("Querying single record with ID: %s", id) + if id != "" || (options.FetchRowNumber != nil && *options.FetchRowNumber != "") { + // Single record query - either by URL ID or FetchRowNumber + var targetID string + if id != "" { + targetID = id + logger.Debug("Querying single record with URL ID: %s", id) + } else { + targetID = *options.FetchRowNumber + logger.Debug("Querying single record with FetchRowNumber ID: %s", targetID) + } + // For single record, create a new pointer to the struct type singleResult := reflect.New(modelType).Interface() + pkName := reflection.GetPrimaryKeyName(singleResult) - query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(singleResult))), id) + query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID) if err := query.Scan(ctx, singleResult); err != nil { logger.Error("Error querying record: %v", err) h.sendError(w, http.StatusInternalServerError, "query_error", "Error executing query", err) @@ -418,20 +491,35 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st logger.Info("Successfully retrieved records") + // Build metadata limit := 0 - if options.Limit != nil { - limit = *options.Limit - } offset := 0 - if options.Offset != nil { - offset = *options.Offset + count := int64(total) + + // When FetchRowNumber is used, we only return 1 record + if options.FetchRowNumber != nil && *options.FetchRowNumber != "" { + count = 1 + // Don't use limit/offset when fetching specific record + } else { + if options.Limit != nil { + limit = *options.Limit + } + if options.Offset != nil { + offset = *options.Offset + } + + // Set row numbers on records if RowNumber field exists + // Only for multiple records (not when fetching single record) + h.setRowNumbersOnRecords(result, offset) } h.sendResponse(w, result, &common.Metadata{ - Total: int64(total), - Filtered: int64(total), - Limit: limit, - Offset: offset, + Total: int64(total), + Filtered: int64(total), + Count: count, + Limit: limit, + Offset: offset, + RowNumber: rowNumber, }) } @@ -1303,29 +1391,161 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id h.sendResponse(w, recordToDelete, nil) } -func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOption) common.SelectQuery { +// applyFilters applies all filters with proper grouping for OR logic +// Groups consecutive OR filters together to ensure proper query precedence +// Example: [A, B(OR), C(OR), D(AND)] => WHERE (A OR B OR C) AND D +func (h *Handler) applyFilters(query common.SelectQuery, filters []common.FilterOption) common.SelectQuery { + if len(filters) == 0 { + return query + } + + i := 0 + for i < len(filters) { + // Check if this starts an OR group (current or next filter has OR logic) + startORGroup := i+1 < len(filters) && strings.EqualFold(filters[i+1].LogicOperator, "OR") + + if startORGroup { + // Collect all consecutive filters that are OR'd together + orGroup := []common.FilterOption{filters[i]} + j := i + 1 + for j < len(filters) && strings.EqualFold(filters[j].LogicOperator, "OR") { + orGroup = append(orGroup, filters[j]) + j++ + } + + // Apply the OR group as a single grouped WHERE clause + query = h.applyFilterGroup(query, orGroup) + i = j + } else { + // Single filter with AND logic (or first filter) + condition, args := h.buildFilterCondition(filters[i]) + if condition != "" { + query = query.Where(condition, args...) + } + i++ + } + } + + return query +} + +// applyFilterGroup applies a group of filters that should be OR'd together +// Always wraps them in parentheses and applies as a single WHERE clause +func (h *Handler) applyFilterGroup(query common.SelectQuery, filters []common.FilterOption) common.SelectQuery { + if len(filters) == 0 { + return query + } + + // Build all conditions and collect args + var conditions []string + var args []interface{} + + for _, filter := range filters { + condition, filterArgs := h.buildFilterCondition(filter) + if condition != "" { + conditions = append(conditions, condition) + args = append(args, filterArgs...) + } + } + + if len(conditions) == 0 { + return query + } + + // Single filter - no need for grouping + if len(conditions) == 1 { + return query.Where(conditions[0], args...) + } + + // Multiple conditions - group with parentheses and OR + groupedCondition := "(" + strings.Join(conditions, " OR ") + ")" + return query.Where(groupedCondition, args...) +} + +// buildFilterCondition builds a filter condition and returns it with args +func (h *Handler) buildFilterCondition(filter common.FilterOption) (conditionString string, conditionArgs []interface{}) { + var condition string + var args []interface{} + switch filter.Operator { case "eq": - return query.Where(fmt.Sprintf("%s = ?", filter.Column), filter.Value) + condition = fmt.Sprintf("%s = ?", filter.Column) + args = []interface{}{filter.Value} case "neq": - return query.Where(fmt.Sprintf("%s != ?", filter.Column), filter.Value) + condition = fmt.Sprintf("%s != ?", filter.Column) + args = []interface{}{filter.Value} case "gt": - return query.Where(fmt.Sprintf("%s > ?", filter.Column), filter.Value) + condition = fmt.Sprintf("%s > ?", filter.Column) + args = []interface{}{filter.Value} case "gte": - return query.Where(fmt.Sprintf("%s >= ?", filter.Column), filter.Value) + condition = fmt.Sprintf("%s >= ?", filter.Column) + args = []interface{}{filter.Value} case "lt": - return query.Where(fmt.Sprintf("%s < ?", filter.Column), filter.Value) + condition = fmt.Sprintf("%s < ?", filter.Column) + args = []interface{}{filter.Value} case "lte": - return query.Where(fmt.Sprintf("%s <= ?", filter.Column), filter.Value) + condition = fmt.Sprintf("%s <= ?", filter.Column) + args = []interface{}{filter.Value} case "like": - return query.Where(fmt.Sprintf("%s LIKE ?", filter.Column), filter.Value) + condition = fmt.Sprintf("%s LIKE ?", filter.Column) + args = []interface{}{filter.Value} case "ilike": - return query.Where(fmt.Sprintf("%s ILIKE ?", filter.Column), filter.Value) + condition = fmt.Sprintf("%s ILIKE ?", filter.Column) + args = []interface{}{filter.Value} case "in": - return query.Where(fmt.Sprintf("%s IN (?)", filter.Column), filter.Value) + condition = fmt.Sprintf("%s IN (?)", filter.Column) + args = []interface{}{filter.Value} + default: + return "", nil + } + + return condition, args +} + +func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOption) common.SelectQuery { + // Determine which method to use based on LogicOperator + useOrLogic := strings.EqualFold(filter.LogicOperator, "OR") + + var condition string + var args []interface{} + + switch filter.Operator { + case "eq": + condition = fmt.Sprintf("%s = ?", filter.Column) + args = []interface{}{filter.Value} + case "neq": + condition = fmt.Sprintf("%s != ?", filter.Column) + args = []interface{}{filter.Value} + case "gt": + condition = fmt.Sprintf("%s > ?", filter.Column) + args = []interface{}{filter.Value} + case "gte": + condition = fmt.Sprintf("%s >= ?", filter.Column) + args = []interface{}{filter.Value} + case "lt": + condition = fmt.Sprintf("%s < ?", filter.Column) + args = []interface{}{filter.Value} + case "lte": + condition = fmt.Sprintf("%s <= ?", filter.Column) + args = []interface{}{filter.Value} + case "like": + condition = fmt.Sprintf("%s LIKE ?", filter.Column) + args = []interface{}{filter.Value} + case "ilike": + condition = fmt.Sprintf("%s ILIKE ?", filter.Column) + args = []interface{}{filter.Value} + case "in": + condition = fmt.Sprintf("%s IN (?)", filter.Column) + args = []interface{}{filter.Value} default: return query } + + // Apply filter with appropriate logic operator + if useOrLogic { + return query.WhereOr(condition, args...) + } + return query.Where(condition, args...) } // parseTableName splits a table name that may contain schema into separate schema and table @@ -1709,6 +1929,51 @@ func toSnakeCase(s string) string { return strings.ToLower(result.String()) } +// setRowNumbersOnRecords sets the RowNumber field on each record if it exists +// The row number is calculated as offset + index + 1 (1-based) +func (h *Handler) setRowNumbersOnRecords(records interface{}, offset int) { + // Get the reflect value of the records + recordsValue := reflect.ValueOf(records) + if recordsValue.Kind() == reflect.Ptr { + recordsValue = recordsValue.Elem() + } + + // Ensure it's a slice + if recordsValue.Kind() != reflect.Slice { + logger.Debug("setRowNumbersOnRecords: records is not a slice, skipping") + return + } + + // Iterate through each record + for i := 0; i < recordsValue.Len(); i++ { + record := recordsValue.Index(i) + + // Dereference if it's a pointer + if record.Kind() == reflect.Ptr { + if record.IsNil() { + continue + } + record = record.Elem() + } + + // Ensure it's a struct + if record.Kind() != reflect.Struct { + continue + } + + // Try to find and set the RowNumber field + rowNumberField := record.FieldByName("RowNumber") + if rowNumberField.IsValid() && rowNumberField.CanSet() { + // Check if the field is of type int64 + if rowNumberField.Kind() == reflect.Int64 { + rowNum := int64(offset + i + 1) + rowNumberField.SetInt(rowNum) + logger.Debug("Set RowNumber=%d for record index %d", rowNum, i) + } + } + } +} + // HandleOpenAPI generates and returns the OpenAPI specification func (h *Handler) HandleOpenAPI(w common.ResponseWriter, r common.Request) { if h.openAPIGenerator == nil { diff --git a/pkg/restheadspec/handler.go b/pkg/restheadspec/handler.go index a44bc80..68cc8be 100644 --- a/pkg/restheadspec/handler.go +++ b/pkg/restheadspec/handler.go @@ -2602,21 +2602,8 @@ func (h *Handler) FetchRowNumber(ctx context.Context, tableName string, pkName s sortSQL = fmt.Sprintf("%s.%s ASC", tableName, pkName) } - // Build WHERE clauses from filters - whereClauses := make([]string, 0) - for i := range options.Filters { - filter := &options.Filters[i] - whereClause := h.buildFilterSQL(filter, tableName) - if whereClause != "" { - whereClauses = append(whereClauses, fmt.Sprintf("(%s)", whereClause)) - } - } - - // Combine WHERE clauses - whereSQL := "" - if len(whereClauses) > 0 { - whereSQL = "WHERE " + strings.Join(whereClauses, " AND ") - } + // Build WHERE clause from filters with proper OR grouping + whereSQL := h.buildWhereClauseWithORGrouping(options.Filters, tableName) // Add custom SQL WHERE if provided if options.CustomSQLWhere != "" { @@ -2677,6 +2664,67 @@ func (h *Handler) FetchRowNumber(ctx context.Context, tableName string, pkName s } // buildFilterSQL converts a filter to SQL WHERE clause string +// buildWhereClauseWithORGrouping builds a WHERE clause from filters with proper OR grouping +// Groups consecutive OR filters together to ensure proper SQL precedence +// Example: [A, B(OR), C(OR), D(AND)] => WHERE (A OR B OR C) AND D +func (h *Handler) buildWhereClauseWithORGrouping(filters []common.FilterOption, tableName string) string { + if len(filters) == 0 { + return "" + } + + var groups []string + i := 0 + + for i < len(filters) { + // Check if this starts an OR group (next filter has OR logic) + startORGroup := i+1 < len(filters) && strings.EqualFold(filters[i+1].LogicOperator, "OR") + + if startORGroup { + // Collect all consecutive filters that are OR'd together + orGroup := []string{} + + // Add current filter + filterSQL := h.buildFilterSQL(&filters[i], tableName) + if filterSQL != "" { + orGroup = append(orGroup, filterSQL) + } + + // Collect remaining OR filters + j := i + 1 + for j < len(filters) && strings.EqualFold(filters[j].LogicOperator, "OR") { + filterSQL := h.buildFilterSQL(&filters[j], tableName) + if filterSQL != "" { + orGroup = append(orGroup, filterSQL) + } + j++ + } + + // Group OR filters with parentheses + if len(orGroup) > 0 { + if len(orGroup) == 1 { + groups = append(groups, orGroup[0]) + } else { + groups = append(groups, "("+strings.Join(orGroup, " OR ")+")") + } + } + i = j + } else { + // Single filter with AND logic (or first filter) + filterSQL := h.buildFilterSQL(&filters[i], tableName) + if filterSQL != "" { + groups = append(groups, filterSQL) + } + i++ + } + } + + if len(groups) == 0 { + return "" + } + + return "WHERE " + strings.Join(groups, " AND ") +} + func (h *Handler) buildFilterSQL(filter *common.FilterOption, tableName string) string { qualifiedColumn := h.qualifyColumnName(filter.Column, tableName) diff --git a/pkg/websocketspec/handler.go b/pkg/websocketspec/handler.go index 89f0459..f04995d 100644 --- a/pkg/websocketspec/handler.go +++ b/pkg/websocketspec/handler.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "reflect" + "strings" "time" "github.com/google/uuid" @@ -540,10 +541,8 @@ func (h *Handler) readMultiple(hookCtx *HookContext) (data interface{}, metadata // Apply options (simplified implementation) if hookCtx.Options != nil { - // Apply filters - for _, filter := range hookCtx.Options.Filters { - query = query.Where(fmt.Sprintf("%s %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value) - } + // Apply filters with OR grouping support + query = h.applyFilters(query, hookCtx.Options.Filters) // Apply sorting for _, sort := range hookCtx.Options.Sort { @@ -578,6 +577,13 @@ func (h *Handler) readMultiple(hookCtx *HookContext) (data interface{}, metadata return nil, nil, fmt.Errorf("failed to read records: %w", err) } + // Set row numbers on records if RowNumber field exists + offset := 0 + if hookCtx.Options != nil && hookCtx.Options.Offset != nil { + offset = *hookCtx.Options.Offset + } + h.setRowNumbersOnRecords(hookCtx.ModelPtr, offset) + // Get count metadata = make(map[string]interface{}) countQuery := h.db.NewSelect().Model(hookCtx.ModelPtr).Table(hookCtx.TableName) @@ -683,6 +689,133 @@ func (h *Handler) getMetadata(schema, entity string, model interface{}) map[stri } // getOperatorSQL converts filter operator to SQL operator +// applyFilters applies all filters with proper grouping for OR logic +// Groups consecutive OR filters together to ensure proper query precedence +func (h *Handler) applyFilters(query common.SelectQuery, filters []common.FilterOption) common.SelectQuery { + if len(filters) == 0 { + return query + } + + i := 0 + for i < len(filters) { + // Check if this starts an OR group (next filter has OR logic) + startORGroup := i+1 < len(filters) && strings.EqualFold(filters[i+1].LogicOperator, "OR") + + if startORGroup { + // Collect all consecutive filters that are OR'd together + orGroup := []common.FilterOption{filters[i]} + j := i + 1 + for j < len(filters) && strings.EqualFold(filters[j].LogicOperator, "OR") { + orGroup = append(orGroup, filters[j]) + j++ + } + + // Apply the OR group as a single grouped WHERE clause + query = h.applyFilterGroup(query, orGroup) + i = j + } else { + // Single filter with AND logic (or first filter) + condition, args := h.buildFilterCondition(filters[i]) + if condition != "" { + query = query.Where(condition, args...) + } + i++ + } + } + + return query +} + +// applyFilterGroup applies a group of filters that should be OR'd together +// Always wraps them in parentheses and applies as a single WHERE clause +func (h *Handler) applyFilterGroup(query common.SelectQuery, filters []common.FilterOption) common.SelectQuery { + if len(filters) == 0 { + return query + } + + // Build all conditions and collect args + var conditions []string + var args []interface{} + + for _, filter := range filters { + condition, filterArgs := h.buildFilterCondition(filter) + if condition != "" { + conditions = append(conditions, condition) + args = append(args, filterArgs...) + } + } + + if len(conditions) == 0 { + return query + } + + // Single filter - no need for grouping + if len(conditions) == 1 { + return query.Where(conditions[0], args...) + } + + // Multiple conditions - group with parentheses and OR + groupedCondition := "(" + strings.Join(conditions, " OR ") + ")" + return query.Where(groupedCondition, args...) +} + +// buildFilterCondition builds a filter condition and returns it with args +func (h *Handler) buildFilterCondition(filter common.FilterOption) (conditionString string, conditionArgs []interface{}) { + var condition string + var args []interface{} + + operatorSQL := h.getOperatorSQL(filter.Operator) + condition = fmt.Sprintf("%s %s ?", filter.Column, operatorSQL) + args = []interface{}{filter.Value} + + return condition, args +} + +// setRowNumbersOnRecords sets the RowNumber field on each record if it exists +// The row number is calculated as offset + index + 1 (1-based) +func (h *Handler) setRowNumbersOnRecords(records interface{}, offset int) { + // Get the reflect value of the records + recordsValue := reflect.ValueOf(records) + if recordsValue.Kind() == reflect.Ptr { + recordsValue = recordsValue.Elem() + } + + // Ensure it's a slice + if recordsValue.Kind() != reflect.Slice { + logger.Debug("[WebSocketSpec] setRowNumbersOnRecords: records is not a slice, skipping") + return + } + + // Iterate through each record + for i := 0; i < recordsValue.Len(); i++ { + record := recordsValue.Index(i) + + // Dereference if it's a pointer + if record.Kind() == reflect.Ptr { + if record.IsNil() { + continue + } + record = record.Elem() + } + + // Ensure it's a struct + if record.Kind() != reflect.Struct { + continue + } + + // Try to find and set the RowNumber field + rowNumberField := record.FieldByName("RowNumber") + if rowNumberField.IsValid() && rowNumberField.CanSet() { + // Check if the field is of type int64 + if rowNumberField.Kind() == reflect.Int64 { + rowNum := int64(offset + i + 1) + rowNumberField.SetInt(rowNum) + logger.Debug("[WebSocketSpec] Set RowNumber=%d for record index %d", rowNum, i) + } + } + } +} + func (h *Handler) getOperatorSQL(operator string) string { switch operator { case "eq":