From 5dbe1879154853b10a90b9833a976327de5720bd Mon Sep 17 00:00:00 2001 From: Hein Date: Mon, 12 Oct 2026 15:56:42 +0200 Subject: [PATCH] feat(restheadspec): add x-searchop-notcontain operator Maps notcontain/notcontains/notlike to a not_ilike filter rendered as (col IS NULL OR CAST(col AS TEXT) NOT ILIKE ?), so NULL rows are kept. --- pkg/common/json_condition.go | 4 ++++ pkg/common/json_condition_test.go | 5 +++++ pkg/restheadspec/HEADERS.md | 1 + pkg/restheadspec/filter_cast_test.go | 33 ++++++++++++++++++++++++++++ pkg/restheadspec/handler.go | 10 ++++++++- pkg/restheadspec/headers.go | 4 +++- 6 files changed, 55 insertions(+), 2 deletions(-) diff --git a/pkg/common/json_condition.go b/pkg/common/json_condition.go index efabe31..dd5bcc9 100644 --- a/pkg/common/json_condition.go +++ b/pkg/common/json_condition.go @@ -139,6 +139,10 @@ func BuildJSONFilterCondition(model interface{}, tableAlias, token, operator str return fmt.Sprintf("%s LIKE ?", colExpr), prepend(value), true case "ilike": return fmt.Sprintf("%s ILIKE ?", colExpr), prepend(value), true + case "not_ilike": + // colExpr appears twice, so its bound args must be repeated too. + notArgs := append(append([]interface{}{}, colArgs...), colArgs...) + return fmt.Sprintf("(%s IS NULL OR %s NOT ILIKE ?)", colExpr, colExpr), append(notArgs, value), true case "in": inCond, inArgs := BuildInCondition(colExpr, value) if inCond == "" { diff --git a/pkg/common/json_condition_test.go b/pkg/common/json_condition_test.go index 2775856..e43dc86 100644 --- a/pkg/common/json_condition_test.go +++ b/pkg/common/json_condition_test.go @@ -93,6 +93,11 @@ func TestBuildJSONFilterCondition(t *testing.T) { wantCond: `("data" #>> ?::text[]) ILIKE ?`, wantArgs: []interface{}{"{city}", "%la%"}, }, + { + name: "not_ilike repeats bound path", token: "data->>'city'", operator: "not_ilike", value: "%la%", + wantCond: `(("data" #>> ?::text[]) IS NULL OR ("data" #>> ?::text[]) NOT ILIKE ?)`, + wantArgs: []interface{}{"{city}", "{city}", "%la%"}, + }, { name: "in", token: "data->>'tier'", operator: "in", value: []string{"a", "b"}, wantCond: `("data" #>> ?::text[]) IN (?,?)`, diff --git a/pkg/restheadspec/HEADERS.md b/pkg/restheadspec/HEADERS.md index 37c1ab8..9a94996 100644 --- a/pkg/restheadspec/HEADERS.md +++ b/pkg/restheadspec/HEADERS.md @@ -89,6 +89,7 @@ Search with specific operators (AND logic). **Supported Operators:** - `contains` - Contains substring (case-insensitive). Implemented as `CAST(col AS TEXT) ILIKE '%value%'` for every column type, including arrays (stringifies the array, then substring-matches). **Not** array containment — no GIN index use, and can false-positive on partial matches within array elements. resolvespec (a different spec package in this repo) defines `contains` differently: real PostgreSQL array-overlap (`&&`). Don't assume the two behave the same. +- `notcontains` / `notcontain` / `notlike` - Does not contain substring (case-insensitive). Implemented as `(col IS NULL OR CAST(col AS TEXT) NOT ILIKE '%value%')`, so NULLs are kept - `beginswith` / `startswith` - Starts with (case-insensitive) - `endswith` - Ends with (case-insensitive) - `equals` / `eq` - Exact match diff --git a/pkg/restheadspec/filter_cast_test.go b/pkg/restheadspec/filter_cast_test.go index db80c26..995d892 100644 --- a/pkg/restheadspec/filter_cast_test.go +++ b/pkg/restheadspec/filter_cast_test.go @@ -283,3 +283,36 @@ func TestNullAsZero_RestHeadSpecConfig(t *testing.T) { t.Fatalf("disabled should be plain equality, got %q", got) } } + +func TestSearchOp_NotContain(t *testing.T) { + h := NewHandler(nil, nil) + model := inactiveModel{} + + for _, op := range []string{"notcontain", "notcontains", "notlike"} { + req := &MockRequest{headers: map[string]string{"x-searchop-" + op + "-inactive": "99"}, queryParams: map[string]string{}} + opts := h.parseOptionsFromHeaders(req, model) + if len(opts.Filters) != 1 || opts.Filters[0].Operator != "not_ilike" || opts.Filters[0].Value != "%99%" { + t.Fatalf("%s: unexpected filters %+v", op, opts.Filters) + } + } + + req := &MockRequest{headers: map[string]string{"x-searchop-notcontain-inactive": "99"}, queryParams: map[string]string{}} + f := h.parseOptionsFromHeaders(req, model).Filters[0] + ci := h.ValidateAndAdjustFilterForColumnType(&f, model) + if !ci.NeedsCast { + t.Fatalf("numeric column must be cast to TEXT for NOT ILIKE") + } + q := &jsonCapQuery{} + h.applyFilter(q, f, "public.t", ci.NeedsCast, "AND", model) + c := q.only(t) + if want := "(t.inactive IS NULL OR CAST(t.inactive AS TEXT) NOT ILIKE ?)"; c.query != want || !reflect.DeepEqual(c.args, []interface{}{"%99%"}) { + t.Fatalf("got %q %#v, want %q", c.query, c.args, want) + } + + q = &jsonCapQuery{} + h.applyOrFilterGroup(q, []*common.FilterOption{&f}, []ColumnCastInfo{ci}, "public.t", model) + c = q.only(t) + if want := "((t.inactive IS NULL OR CAST(t.inactive AS TEXT) NOT ILIKE ?))"; c.query != want { + t.Fatalf("or group got %q, want %q", c.query, want) + } +} diff --git a/pkg/restheadspec/handler.go b/pkg/restheadspec/handler.go index b6e59bf..afaac89 100644 --- a/pkg/restheadspec/handler.go +++ b/pkg/restheadspec/handler.go @@ -2550,6 +2550,9 @@ func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOpti // columns are compared natively (see likeColumn above) since citext is // already case-insensitive. return applyWhere(fmt.Sprintf("%s ILIKE ?", likeColumn), filter.Value) + case "not_ilike": + // NULLs don't contain the value, so they are kept + return applyWhere(fmt.Sprintf("(%s IS NULL OR %s NOT ILIKE ?)", rawQualifiedColumn, likeColumn), filter.Value) case "in": cond, inArgs := common.BuildInCondition(qualifiedColumn, filter.Value) if cond == "" { @@ -2636,7 +2639,7 @@ func (h *Handler) applyOrFilterGroup(query common.SelectQuery, filters []*common qualifiedColumn := rawQualifiedColumn op := strings.ToLower(filter.Operator) - if op == "like" || op == "ilike" { + if op == "like" || op == "ilike" || op == "not_ilike" { // Cast to TEXT for LIKE/ILIKE to support date/time/timestamp columns. // citext columns are left native: they're already case-insensitive and // casting would defeat a citext index. @@ -2692,6 +2695,9 @@ func (h *Handler) buildFilterCondition(qualifiedColumn string, filter *common.Fi return fmt.Sprintf("%s LIKE ?", qualifiedColumn), []interface{}{filter.Value} case "ilike": return fmt.Sprintf("%s ILIKE ?", qualifiedColumn), []interface{}{filter.Value} + case "not_ilike": + rawCol := h.qualifyColumnName(filter.Column, tableName) + return fmt.Sprintf("(%s IS NULL OR %s NOT ILIKE ?)", rawCol, qualifiedColumn), []interface{}{filter.Value} case "in": cond, inArgs := common.BuildInCondition(qualifiedColumn, filter.Value) return cond, inArgs @@ -3440,6 +3446,8 @@ func (h *Handler) buildFilterSQL(filter *common.FilterOption, tableName string) return fmt.Sprintf("%s LIKE '%v'", qualifiedColumn, filter.Value) case "ilike": return fmt.Sprintf("%s ILIKE '%v'", qualifiedColumn, filter.Value) + case "not_ilike": + return fmt.Sprintf("(%s IS NULL OR CAST(%s AS TEXT) NOT ILIKE '%v')", qualifiedColumn, qualifiedColumn, filter.Value) case "in": if values, ok := filter.Value.([]any); ok { valueStrs := make([]string, len(values)) diff --git a/pkg/restheadspec/headers.go b/pkg/restheadspec/headers.go index 2924f19..d81f6ad 100644 --- a/pkg/restheadspec/headers.go +++ b/pkg/restheadspec/headers.go @@ -496,6 +496,8 @@ func (h *Handler) mapSearchOperator(colName, operator, value string) common.Filt switch operator { case "contains", "contain", "like": return common.FilterOption{Column: colName, Operator: "ilike", Value: "%" + value + "%"} + case "notcontains", "notcontain", "notlike": + return common.FilterOption{Column: colName, Operator: "not_ilike", Value: "%" + value + "%"} case "beginswith", "startswith": return common.FilterOption{Column: colName, Operator: "ilike", Value: value + "%"} case "endswith": @@ -1485,7 +1487,7 @@ func (h *Handler) ValidateAndAdjustFilterForColumnType(filter *common.FilterOpti // 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 op := strings.ToLower(filter.Operator); op == "like" || op == "ilike" || op == "not_ilike" { if reflection.IsStringType(colType) { return ColumnCastInfo{NeedsCast: false, IsNumericType: false} }