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).
This commit is contained in:
Hein
2026-09-15 11:26:28 +02:00
parent 6bd6a6f164
commit 4730255532
3 changed files with 38 additions and 12 deletions
+12
View File
@@ -720,7 +720,13 @@ func (h *Handler) readMultiple(hookCtx *HookContext) (data interface{}, metadata
} }
op := strings.ToLower(filter.Operator) op := strings.ToLower(filter.Operator)
if op == "like" || op == "ilike" { if op == "like" || op == "ilike" {
// 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) query = query.Where(fmt.Sprintf("CAST(%s AS TEXT) %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value)
}
} else { } else {
query = query.Where(fmt.Sprintf("%s %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value) 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) op := strings.ToLower(filter.Operator)
if op == "like" || op == "ilike" { if op == "like" || op == "ilike" {
// 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) countQuery = countQuery.Where(fmt.Sprintf("CAST(%s AS TEXT) %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value)
}
} else { } else {
countQuery = countQuery.Where(fmt.Sprintf("%s %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value) countQuery = countQuery.Where(fmt.Sprintf("%s %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value)
} }
+19 -10
View File
@@ -269,7 +269,7 @@ func (h *Handler) executeRead(ctx context.Context, schema, entity, id string, op
} }
// Filters // Filters
query = h.applyFilters(query, options.Filters) query = h.applyFilters(query, options.Filters, model)
// Custom operators // Custom operators
for _, customOp := range options.CustomOperators { for _, customOp := range options.CustomOperators {
@@ -751,8 +751,10 @@ func (h *Handler) executeDelete(ctx context.Context, schema, entity, id string)
return recordToDelete, nil return recordToDelete, nil
} }
// applyFilters applies all filters with OR grouping logic. // applyFilters applies all filters with OR grouping logic. model, when
func (h *Handler) applyFilters(query common.SelectQuery, filters []common.FilterOption) common.SelectQuery { // 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 { if len(filters) == 0 {
return query return query
} }
@@ -768,10 +770,10 @@ func (h *Handler) applyFilters(query common.SelectQuery, filters []common.Filter
orGroup = append(orGroup, filters[j]) orGroup = append(orGroup, filters[j])
j++ j++
} }
query = h.applyFilterGroup(query, orGroup) query = h.applyFilterGroup(query, orGroup, model)
i = j i = j
} else { } else {
condition, args := h.buildFilterCondition(filters[i]) condition, args := h.buildFilterCondition(filters[i], model)
if condition != "" { if condition != "" {
query = query.Where(condition, args...) query = query.Where(condition, args...)
} }
@@ -782,12 +784,12 @@ func (h *Handler) applyFilters(query common.SelectQuery, filters []common.Filter
return query 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 conditions []string
var args []interface{} var args []interface{}
for _, filter := range filters { for _, filter := range filters {
condition, filterArgs := h.buildFilterCondition(filter) condition, filterArgs := h.buildFilterCondition(filter, model)
if condition != "" { if condition != "" {
conditions = append(conditions, condition) conditions = append(conditions, condition)
args = append(args, filterArgs...) 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...) 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 { switch filter.Operator {
case "eq", "=": case "eq", "=":
return fmt.Sprintf("%s = ?", filter.Column), []interface{}{filter.Value} return fmt.Sprintf("%s = ?", filter.Column), []interface{}{filter.Value}
@@ -818,9 +827,9 @@ func (h *Handler) buildFilterCondition(filter common.FilterOption) (condition st
case "lte", "<=": case "lte", "<=":
return fmt.Sprintf("%s <= ?", filter.Column), []interface{}{filter.Value} return fmt.Sprintf("%s <= ?", filter.Column), []interface{}{filter.Value}
case "like": 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": 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": case "in":
condition, args := common.BuildInCondition(filter.Column, filter.Value) condition, args := common.BuildInCondition(filter.Column, filter.Value)
return condition, args return condition, args
+5
View File
@@ -863,6 +863,11 @@ func (h *Handler) buildFilterCondition(filter common.FilterOption, model interfa
op := strings.ToLower(filter.Operator) op := strings.ToLower(filter.Operator)
if op == "like" || op == "ilike" { if op == "like" || op == "ilike" {
operatorSQL := h.getOperatorSQL(filter.Operator) 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} return fmt.Sprintf("CAST(%s AS TEXT) %s ?", filter.Column, operatorSQL), []interface{}{filter.Value}
} }
operatorSQL := h.getOperatorSQL(filter.Operator) operatorSQL := h.getOperatorSQL(filter.Operator)