From d5de48011b08104a5598b09552bdaa8bba2c5032 Mon Sep 17 00:00:00 2001 From: Hein Date: Tue, 15 Sep 2026 11:26:28 +0200 Subject: [PATCH] fix(websocketspec,mqttspec,resolvemcp): stop casting citext columns to TEXT for LIKE/ILIKE Same class of bug as the restheadspec/resolvespec fix: these handlers unconditionally rendered CAST(col AS TEXT) LIKE/ILIKE for every column, which flips a citext column to case-sensitive matching and defeats a citext index. Thread the model through to buildFilterCondition/applyFilters so reflection.IsCitextColumn can skip the cast for citext columns. resolvemcp's eq/neq/gt/lt paths never cast (they never had the restheadspec-style reflect.Kind cast heuristic), so this only touches LIKE/ILIKE. funcspec is unaffected: it has no Go struct model to check against (colname/value come straight from SQL function parameters). --- pkg/mqttspec/handler.go | 16 ++++++++++++++-- pkg/resolvemcp/handler.go | 29 +++++++++++++++++++---------- pkg/websocketspec/handler.go | 5 +++++ 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/pkg/mqttspec/handler.go b/pkg/mqttspec/handler.go index 9d5dd9c..c6acad8 100644 --- a/pkg/mqttspec/handler.go +++ b/pkg/mqttspec/handler.go @@ -720,7 +720,13 @@ func (h *Handler) readMultiple(hookCtx *HookContext) (data interface{}, metadata } op := strings.ToLower(filter.Operator) if op == "like" || op == "ilike" { - query = query.Where(fmt.Sprintf("CAST(%s AS TEXT) %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value) + // citext columns are already case-insensitive; casting to TEXT would + // switch to case-sensitive matching and defeat a citext index. + if reflection.IsCitextColumn(hookCtx.Model, filter.Column) { + query = query.Where(fmt.Sprintf("%s %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value) + } else { + query = query.Where(fmt.Sprintf("CAST(%s AS TEXT) %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value) + } } else { query = query.Where(fmt.Sprintf("%s %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value) } @@ -786,7 +792,13 @@ func (h *Handler) readMultiple(hookCtx *HookContext) (data interface{}, metadata } op := strings.ToLower(filter.Operator) if op == "like" || op == "ilike" { - countQuery = countQuery.Where(fmt.Sprintf("CAST(%s AS TEXT) %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value) + // citext columns are already case-insensitive; casting to TEXT would + // switch to case-sensitive matching and defeat a citext index. + if reflection.IsCitextColumn(hookCtx.Model, filter.Column) { + countQuery = countQuery.Where(fmt.Sprintf("%s %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value) + } else { + countQuery = countQuery.Where(fmt.Sprintf("CAST(%s AS TEXT) %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value) + } } else { countQuery = countQuery.Where(fmt.Sprintf("%s %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value) } diff --git a/pkg/resolvemcp/handler.go b/pkg/resolvemcp/handler.go index 6bb462e..94741ad 100644 --- a/pkg/resolvemcp/handler.go +++ b/pkg/resolvemcp/handler.go @@ -269,7 +269,7 @@ func (h *Handler) executeRead(ctx context.Context, schema, entity, id string, op } // Filters - query = h.applyFilters(query, options.Filters) + query = h.applyFilters(query, options.Filters, model) // Custom operators for _, customOp := range options.CustomOperators { @@ -751,8 +751,10 @@ func (h *Handler) executeDelete(ctx context.Context, schema, entity, id string) return recordToDelete, nil } -// applyFilters applies all filters with OR grouping logic. -func (h *Handler) applyFilters(query common.SelectQuery, filters []common.FilterOption) common.SelectQuery { +// applyFilters applies all filters with OR grouping logic. model, when +// non-nil, lets citext columns be recognised so LIKE/ILIKE compares them +// natively instead of casting to TEXT (which would defeat a citext index). +func (h *Handler) applyFilters(query common.SelectQuery, filters []common.FilterOption, model interface{}) common.SelectQuery { if len(filters) == 0 { return query } @@ -768,10 +770,10 @@ func (h *Handler) applyFilters(query common.SelectQuery, filters []common.Filter orGroup = append(orGroup, filters[j]) j++ } - query = h.applyFilterGroup(query, orGroup) + query = h.applyFilterGroup(query, orGroup, model) i = j } else { - condition, args := h.buildFilterCondition(filters[i]) + condition, args := h.buildFilterCondition(filters[i], model) if condition != "" { query = query.Where(condition, args...) } @@ -782,12 +784,12 @@ func (h *Handler) applyFilters(query common.SelectQuery, filters []common.Filter return query } -func (h *Handler) applyFilterGroup(query common.SelectQuery, filters []common.FilterOption) common.SelectQuery { +func (h *Handler) applyFilterGroup(query common.SelectQuery, filters []common.FilterOption, model interface{}) common.SelectQuery { var conditions []string var args []interface{} for _, filter := range filters { - condition, filterArgs := h.buildFilterCondition(filter) + condition, filterArgs := h.buildFilterCondition(filter, model) if condition != "" { conditions = append(conditions, condition) args = append(args, filterArgs...) @@ -803,7 +805,14 @@ func (h *Handler) applyFilterGroup(query common.SelectQuery, filters []common.Fi return query.Where("("+strings.Join(conditions, " OR ")+")", args...) } -func (h *Handler) buildFilterCondition(filter common.FilterOption) (condition string, args []interface{}) { +func (h *Handler) buildFilterCondition(filter common.FilterOption, model interface{}) (condition string, args []interface{}) { + // citext columns are already case-insensitive; casting to TEXT would + // switch to case-sensitive matching and defeat a citext index. + likeColumn := filter.Column + if !reflection.IsCitextColumn(model, filter.Column) { + likeColumn = fmt.Sprintf("CAST(%s AS TEXT)", filter.Column) + } + switch filter.Operator { case "eq", "=": return fmt.Sprintf("%s = ?", filter.Column), []interface{}{filter.Value} @@ -818,9 +827,9 @@ func (h *Handler) buildFilterCondition(filter common.FilterOption) (condition st case "lte", "<=": return fmt.Sprintf("%s <= ?", filter.Column), []interface{}{filter.Value} case "like": - return fmt.Sprintf("CAST(%s AS TEXT) LIKE ?", filter.Column), []interface{}{filter.Value} + return fmt.Sprintf("%s LIKE ?", likeColumn), []interface{}{filter.Value} case "ilike": - return fmt.Sprintf("CAST(%s AS TEXT) ILIKE ?", filter.Column), []interface{}{filter.Value} + return fmt.Sprintf("%s ILIKE ?", likeColumn), []interface{}{filter.Value} case "in": condition, args := common.BuildInCondition(filter.Column, filter.Value) return condition, args diff --git a/pkg/websocketspec/handler.go b/pkg/websocketspec/handler.go index 37bccbf..bb26a12 100644 --- a/pkg/websocketspec/handler.go +++ b/pkg/websocketspec/handler.go @@ -863,6 +863,11 @@ func (h *Handler) buildFilterCondition(filter common.FilterOption, model interfa op := strings.ToLower(filter.Operator) if op == "like" || op == "ilike" { operatorSQL := h.getOperatorSQL(filter.Operator) + // citext columns are already case-insensitive; casting to TEXT would + // switch to case-sensitive matching and defeat a citext index. + if reflection.IsCitextColumn(model, filter.Column) { + return fmt.Sprintf("%s %s ?", filter.Column, operatorSQL), []interface{}{filter.Value} + } return fmt.Sprintf("CAST(%s AS TEXT) %s ?", filter.Column, operatorSQL), []interface{}{filter.Value} } operatorSQL := h.getOperatorSQL(filter.Operator)