From c60565e4e06159d7f064cb3a342ad38a5084ef55 Mon Sep 17 00:00:00 2001 From: Hein Date: Mon, 10 Aug 2026 15:01:23 +0200 Subject: [PATCH] feat(resolvespec): add 'contains' operator for array overlap * Implemented 'contains' operator using BuildArrayOverlapCondition for real array containment. * Updated documentation to clarify differences between text-cast ILIKE and array overlap. --- pkg/common/sql_helpers.go | 18 +++ pkg/common/sql_helpers_test.go | 236 ++++++++++++++++++++------------- pkg/resolvespec/filter_test.go | 30 +++++ pkg/resolvespec/handler.go | 10 ++ pkg/restheadspec/HEADERS.md | 2 +- pkg/restheadspec/README.md | 2 + 6 files changed, 208 insertions(+), 90 deletions(-) diff --git a/pkg/common/sql_helpers.go b/pkg/common/sql_helpers.go index c46ade0..906085c 100644 --- a/pkg/common/sql_helpers.go +++ b/pkg/common/sql_helpers.go @@ -1040,3 +1040,21 @@ func BuildInCondition(column string, v interface{}) (query string, args []interf } return fmt.Sprintf("%s IN (%s)", column, strings.Join(placeholders, ",")), values } + +// BuildArrayOverlapCondition builds a parameterized condition testing whether an +// array column has at least one element in common with the given value(s), using +// PostgreSQL's array overlap operator (&&). Unlike a text-cast ILIKE, this performs +// real element-wise containment (no substring false positives) and can use a GIN +// index on the column. A single value is treated as a one-element array. +// Returns ("", nil) if the value is empty. +func BuildArrayOverlapCondition(column string, v interface{}) (query string, args []interface{}) { + values := FilterValueToSlice(v) + if len(values) == 0 { + return "", nil + } + placeholders := make([]string, len(values)) + for i := range values { + placeholders[i] = "?" + } + return fmt.Sprintf("%s && ARRAY[%s]", column, strings.Join(placeholders, ",")), values +} diff --git a/pkg/common/sql_helpers_test.go b/pkg/common/sql_helpers_test.go index c745b2d..8c73649 100644 --- a/pkg/common/sql_helpers_test.go +++ b/pkg/common/sql_helpers_test.go @@ -542,8 +542,8 @@ func TestSplitByAND(t *testing.T) { expected: []string{"col1 between 1 and 5", "col2 between 10 and 20"}, }, { - name: "complex OR block with multiple BETWEENs (real-world case)", - input: "tbl.applicationdate between '2025-08-31' and '1970-01-01'\n or tbl.capturedate between '2025-08-31' and '1970-01-01'\n or tbl.startdate between '2025-08-31' AND '1970-01-01'", + name: "complex OR block with multiple BETWEENs (real-world case)", + input: "tbl.applicationdate between '2025-08-31' and '1970-01-01'\n or tbl.capturedate between '2025-08-31' and '1970-01-01'\n or tbl.startdate between '2025-08-31' AND '1970-01-01'", expected: []string{"tbl.applicationdate between '2025-08-31' and '1970-01-01'\n or tbl.capturedate between '2025-08-31' and '1970-01-01'\n or tbl.startdate between '2025-08-31' AND '1970-01-01'"}, }, // Quote-aware cases: AND inside a string literal must not split. @@ -889,93 +889,151 @@ func TestSanitizeWhereClause_PreservesParenthesesWithOR(t *testing.T) { } func TestAddTablePrefixToColumns_ComplexConditions(t *testing.T) { -tests := []struct { -name string -where string -tableName string -expected string -}{ -{ -name: "Parentheses with true AND condition - should not prefix true", -where: "(true AND status = 'active')", -tableName: "mastertask", -expected: "(true AND mastertask.status = 'active')", -}, -{ -name: "Parentheses with multiple conditions including true", -where: "(true AND status = 'active' AND id > 5)", -tableName: "mastertask", -expected: "(true AND mastertask.status = 'active' AND mastertask.id > 5)", -}, -{ -name: "Nested parentheses with true", -where: "((true AND status = 'active'))", -tableName: "mastertask", -expected: "((true AND mastertask.status = 'active'))", -}, -{ -name: "Mixed: false AND valid conditions", -where: "(false AND name = 'test')", -tableName: "mastertask", -expected: "(false AND mastertask.name = 'test')", -}, -{ -name: "Mixed: null AND valid conditions", -where: "(null AND status = 'active')", -tableName: "mastertask", -expected: "(null AND mastertask.status = 'active')", -}, -{ -name: "Multiple true conditions in parentheses", -where: "(true AND true AND status = 'active')", -tableName: "mastertask", -expected: "(true AND true AND mastertask.status = 'active')", -}, -{ -name: "Simple true without parens - should not prefix", -where: "true", -tableName: "mastertask", -expected: "true", -}, -{ -name: "Simple condition without parens - should prefix", -where: "status = 'active'", -tableName: "mastertask", -expected: "mastertask.status = 'active'", -}, -{ -name: "Unregistered table with true - should not prefix true", -where: "(true AND status = 'active')", -tableName: "unregistered_table", -expected: "(true AND unregistered_table.status = 'active')", -}, -// BETWEEN regression: date literals inside BETWEEN must not be prefixed as columns. -{ -name: "BETWEEN date range - second date must not be prefixed", -where: "applicationdate between '2025-08-31' and '1970-01-01'", -tableName: "unregistered_table", -expected: "unregistered_table.applicationdate between '2025-08-31' and '1970-01-01'", -}, -{ -name: "Already-prefixed BETWEEN column - unchanged", -where: `"v_webui_clients".applicationdate between '2025-08-31' and '1970-01-01'`, -tableName: "v_webui_clients", -expected: `"v_webui_clients".applicationdate between '2025-08-31' and '1970-01-01'`, -}, -{ -name: "Complex OR block with multiple BETWEENs - date values must not be prefixed", -where: `("v_webui_clients".applicationdate between '2025-08-31' and '1970-01-01' or "v_webui_clients".clientcapturedate between '2025-08-31' and '1970-01-01' or "v_webui_clients".startdate between '2025-08-31' AND '1970-01-01')`, -tableName: "v_webui_clients", -expected: `("v_webui_clients".applicationdate between '2025-08-31' and '1970-01-01' or "v_webui_clients".clientcapturedate between '2025-08-31' and '1970-01-01' or "v_webui_clients".startdate between '2025-08-31' AND '1970-01-01')`, -}, + tests := []struct { + name string + where string + tableName string + expected string + }{ + { + name: "Parentheses with true AND condition - should not prefix true", + where: "(true AND status = 'active')", + tableName: "mastertask", + expected: "(true AND mastertask.status = 'active')", + }, + { + name: "Parentheses with multiple conditions including true", + where: "(true AND status = 'active' AND id > 5)", + tableName: "mastertask", + expected: "(true AND mastertask.status = 'active' AND mastertask.id > 5)", + }, + { + name: "Nested parentheses with true", + where: "((true AND status = 'active'))", + tableName: "mastertask", + expected: "((true AND mastertask.status = 'active'))", + }, + { + name: "Mixed: false AND valid conditions", + where: "(false AND name = 'test')", + tableName: "mastertask", + expected: "(false AND mastertask.name = 'test')", + }, + { + name: "Mixed: null AND valid conditions", + where: "(null AND status = 'active')", + tableName: "mastertask", + expected: "(null AND mastertask.status = 'active')", + }, + { + name: "Multiple true conditions in parentheses", + where: "(true AND true AND status = 'active')", + tableName: "mastertask", + expected: "(true AND true AND mastertask.status = 'active')", + }, + { + name: "Simple true without parens - should not prefix", + where: "true", + tableName: "mastertask", + expected: "true", + }, + { + name: "Simple condition without parens - should prefix", + where: "status = 'active'", + tableName: "mastertask", + expected: "mastertask.status = 'active'", + }, + { + name: "Unregistered table with true - should not prefix true", + where: "(true AND status = 'active')", + tableName: "unregistered_table", + expected: "(true AND unregistered_table.status = 'active')", + }, + // BETWEEN regression: date literals inside BETWEEN must not be prefixed as columns. + { + name: "BETWEEN date range - second date must not be prefixed", + where: "applicationdate between '2025-08-31' and '1970-01-01'", + tableName: "unregistered_table", + expected: "unregistered_table.applicationdate between '2025-08-31' and '1970-01-01'", + }, + { + name: "Already-prefixed BETWEEN column - unchanged", + where: `"v_webui_clients".applicationdate between '2025-08-31' and '1970-01-01'`, + tableName: "v_webui_clients", + expected: `"v_webui_clients".applicationdate between '2025-08-31' and '1970-01-01'`, + }, + { + name: "Complex OR block with multiple BETWEENs - date values must not be prefixed", + where: `("v_webui_clients".applicationdate between '2025-08-31' and '1970-01-01' or "v_webui_clients".clientcapturedate between '2025-08-31' and '1970-01-01' or "v_webui_clients".startdate between '2025-08-31' AND '1970-01-01')`, + tableName: "v_webui_clients", + expected: `("v_webui_clients".applicationdate between '2025-08-31' and '1970-01-01' or "v_webui_clients".clientcapturedate between '2025-08-31' and '1970-01-01' or "v_webui_clients".startdate between '2025-08-31' AND '1970-01-01')`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := AddTablePrefixToColumns(tt.where, tt.tableName) + if result != tt.expected { + t.Errorf("AddTablePrefixToColumns(%q, %q) = %q; want %q", tt.where, tt.tableName, result, tt.expected) + } + }) + } } -for _, tt := range tests { -t.Run(tt.name, func(t *testing.T) { -result := AddTablePrefixToColumns(tt.where, tt.tableName) -if result != tt.expected { -t.Errorf("AddTablePrefixToColumns(%q, %q) = %q; want %q", tt.where, tt.tableName, result, tt.expected) -} -}) -} +func TestBuildArrayOverlapCondition(t *testing.T) { + tests := []struct { + name string + column string + value interface{} + expectedCond string + expectedArgs int + }{ + { + name: "single scalar value", + column: "tags", + value: "urgent", + expectedCond: "tags && ARRAY[?]", + expectedArgs: 1, + }, + { + name: "multiple values", + column: "tags", + value: []string{"urgent", "billing", "vip"}, + expectedCond: "tags && ARRAY[?,?,?]", + expectedArgs: 3, + }, + { + name: "JSON-decoded []interface{} value", + column: "tags", + value: []interface{}{"urgent", "billing"}, + expectedCond: "tags && ARRAY[?,?]", + expectedArgs: 2, + }, + { + name: "nil value", + column: "tags", + value: nil, + expectedCond: "", + expectedArgs: 0, + }, + { + name: "empty slice value", + column: "tags", + value: []string{}, + expectedCond: "", + expectedArgs: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cond, args := BuildArrayOverlapCondition(tt.column, tt.value) + if cond != tt.expectedCond { + t.Errorf("BuildArrayOverlapCondition(%q, %v) condition = %q; want %q", tt.column, tt.value, cond, tt.expectedCond) + } + if len(args) != tt.expectedArgs { + t.Errorf("BuildArrayOverlapCondition(%q, %v) args = %d; want %d", tt.column, tt.value, len(args), tt.expectedArgs) + } + }) + } } diff --git a/pkg/resolvespec/filter_test.go b/pkg/resolvespec/filter_test.go index 7ed8565..938efde 100644 --- a/pkg/resolvespec/filter_test.go +++ b/pkg/resolvespec/filter_test.go @@ -57,6 +57,36 @@ func TestBuildFilterCondition(t *testing.T) { expectedCondition: "CAST(email AS TEXT) LIKE ?", expectedArgsCount: 1, }, + { + name: "CONTAINS operator with single value", + filter: common.FilterOption{ + Column: "tags", + Operator: "contains", + Value: "urgent", + }, + expectedCondition: "tags && ARRAY[?]", + expectedArgsCount: 1, + }, + { + name: "CONTAINS operator with multiple values", + filter: common.FilterOption{ + Column: "tags", + Operator: "contains", + Value: []string{"urgent", "billing"}, + }, + expectedCondition: "tags && ARRAY[?,?]", + expectedArgsCount: 2, + }, + { + name: "CONTAINS operator with empty value", + filter: common.FilterOption{ + Column: "tags", + Operator: "contains", + Value: nil, + }, + expectedCondition: "", + expectedArgsCount: 0, + }, } for _, tt := range tests { diff --git a/pkg/resolvespec/handler.go b/pkg/resolvespec/handler.go index fde0d11..d5a78a9 100644 --- a/pkg/resolvespec/handler.go +++ b/pkg/resolvespec/handler.go @@ -1895,6 +1895,11 @@ func (h *Handler) buildFilterCondition(filter common.FilterOption) (conditionStr if condition == "" { return "", nil } + case "contains": + condition, args = common.BuildArrayOverlapCondition(filter.Column, filter.Value) + if condition == "" { + return "", nil + } default: return "", nil } @@ -1939,6 +1944,11 @@ func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOpti if condition == "" { return query } + case "contains": + condition, args = common.BuildArrayOverlapCondition(filter.Column, filter.Value) + if condition == "" { + return query + } default: return query } diff --git a/pkg/restheadspec/HEADERS.md b/pkg/restheadspec/HEADERS.md index 147f6ce..37c1ab8 100644 --- a/pkg/restheadspec/HEADERS.md +++ b/pkg/restheadspec/HEADERS.md @@ -88,7 +88,7 @@ This will match any records where the column contains the search term (case-inse Search with specific operators (AND logic). **Supported Operators:** -- `contains` - Contains substring (case-insensitive) +- `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. - `beginswith` / `startswith` - Starts with (case-insensitive) - `endswith` - Ends with (case-insensitive) - `equals` / `eq` - Exact match diff --git a/pkg/restheadspec/README.md b/pkg/restheadspec/README.md index 6c23ae7..fe0973a 100644 --- a/pkg/restheadspec/README.md +++ b/pkg/restheadspec/README.md @@ -96,6 +96,8 @@ X-Limit: 50 **Available Operators**: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`, `startswith`, `endswith`, `between`, `betweeninclusive`, `in`, `empty`, `notempty` +> Note: `contains` here is a text-cast ILIKE substring match (works on any column type, including arrays, by stringifying first) — not array containment. resolvespec's `contains` operator has different semantics (real array overlap). See [HEADERS.md](HEADERS.md) for details. + For complete header documentation, see [HEADERS.md](HEADERS.md). ## Lifecycle Hooks