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

@@ -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 {