From 6e3124e4e010eb257cc55d3b1bb71b4503891f46 Mon Sep 17 00:00:00 2001 From: Hein Date: Tue, 15 Sep 2026 13:55:11 +0200 Subject: [PATCH] fix(restheadspec): prevent casting numeric values in ILIKE filters --- pkg/restheadspec/filter_cast_test.go | 36 ++++++++++++++++++++++++++++ pkg/restheadspec/headers.go | 12 ++++++++++ 2 files changed, 48 insertions(+) diff --git a/pkg/restheadspec/filter_cast_test.go b/pkg/restheadspec/filter_cast_test.go index d1c249f..3b1afef 100644 --- a/pkg/restheadspec/filter_cast_test.go +++ b/pkg/restheadspec/filter_cast_test.go @@ -137,3 +137,39 @@ func TestApplyFilter_Citext_NeverCastForEqOrIlike(t *testing.T) { } }) } + +// TestValidateAndAdjustFilterForColumnType_NumericColumn_Ilike reproduces a +// global "search all columns" request (x-searchor-contains- per column, +// e.g. the X-Filter-All style OR group) landing an ILIKE filter with a +// '%...%'-wrapped numeric-looking value on a numeric column such as +// rid_parent. Before the fix, ValidateAndAdjustFilterForColumnType trimmed +// the '%' wildcards, saw a numeric string, and rewrote filter.Value to an +// int64 -- so applyFilter's CAST(col AS TEXT) ILIKE ? bound an integer +// argument instead of the wildcard string, and Postgres rejected it with +// "operator does not exist: text ~~* integer". +func TestValidateAndAdjustFilterForColumnType_NumericColumn_Ilike(t *testing.T) { + h := &Handler{} + model := atdetailModel{} + + filter := &common.FilterOption{Column: "rid_parent", Operator: "ilike", Value: "%345346346%"} + info := h.ValidateAndAdjustFilterForColumnType(filter, model) + + if !info.NeedsCast { + t.Fatalf("expected NeedsCast=true so the numeric column is cast to TEXT for ILIKE") + } + if filter.Value != "%345346346%" { + t.Fatalf("ILIKE must keep the wildcard-wrapped string value untouched, got %#v", filter.Value) + } + + q := &jsonCapQuery{} + h.applyFilter(q, *filter, "public.atdetail", info.NeedsCast, "OR", model) + + c := q.only(t) + const want = "CAST(atdetail.rid_parent AS TEXT) ILIKE ?" + if c.query != want { + t.Fatalf("query = %q, want %q", c.query, want) + } + if !reflect.DeepEqual(c.args, []interface{}{"%345346346%"}) { + t.Fatalf("args = %#v, want [\"%%345346346%%\"]", c.args) + } +} diff --git a/pkg/restheadspec/headers.go b/pkg/restheadspec/headers.go index cd09453..3bb069b 100644 --- a/pkg/restheadspec/headers.go +++ b/pkg/restheadspec/headers.go @@ -1479,6 +1479,18 @@ func (h *Handler) ValidateAndAdjustFilterForColumnType(filter *common.FilterOpti return ColumnCastInfo{NeedsCast: false, IsNumericType: false} } + // LIKE/ILIKE always compare against text, wildcards and all. Never coerce + // the value to the column's native numeric/bool/time type here: doing so + // strips the '%' wildcards and hands the driver a non-string argument, + // which fails with "operator does not exist: text ~~* integer" once the + // column is cast to TEXT below. + if op := strings.ToLower(filter.Operator); op == "like" || op == "ilike" { + if reflection.IsStringType(colType) { + return ColumnCastInfo{NeedsCast: false, IsNumericType: false} + } + return ColumnCastInfo{NeedsCast: true, IsNumericType: reflection.IsNumericType(colType)} + } + // Check if the input value is numeric valueIsNumeric := false if strVal, ok := filter.Value.(string); ok {