mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-13 00:36:27 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5dbe187915 |
@@ -139,6 +139,10 @@ func BuildJSONFilterCondition(model interface{}, tableAlias, token, operator str
|
|||||||
return fmt.Sprintf("%s LIKE ?", colExpr), prepend(value), true
|
return fmt.Sprintf("%s LIKE ?", colExpr), prepend(value), true
|
||||||
case "ilike":
|
case "ilike":
|
||||||
return fmt.Sprintf("%s ILIKE ?", colExpr), prepend(value), true
|
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":
|
case "in":
|
||||||
inCond, inArgs := BuildInCondition(colExpr, value)
|
inCond, inArgs := BuildInCondition(colExpr, value)
|
||||||
if inCond == "" {
|
if inCond == "" {
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ func TestBuildJSONFilterCondition(t *testing.T) {
|
|||||||
wantCond: `("data" #>> ?::text[]) ILIKE ?`,
|
wantCond: `("data" #>> ?::text[]) ILIKE ?`,
|
||||||
wantArgs: []interface{}{"{city}", "%la%"},
|
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"},
|
name: "in", token: "data->>'tier'", operator: "in", value: []string{"a", "b"},
|
||||||
wantCond: `("data" #>> ?::text[]) IN (?,?)`,
|
wantCond: `("data" #>> ?::text[]) IN (?,?)`,
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ Search with specific operators (AND logic).
|
|||||||
|
|
||||||
**Supported Operators:**
|
**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.
|
- `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)
|
- `beginswith` / `startswith` - Starts with (case-insensitive)
|
||||||
- `endswith` - Ends with (case-insensitive)
|
- `endswith` - Ends with (case-insensitive)
|
||||||
- `equals` / `eq` - Exact match
|
- `equals` / `eq` - Exact match
|
||||||
|
|||||||
@@ -283,3 +283,36 @@ func TestNullAsZero_RestHeadSpecConfig(t *testing.T) {
|
|||||||
t.Fatalf("disabled should be plain equality, got %q", got)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2550,6 +2550,9 @@ func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOpti
|
|||||||
// columns are compared natively (see likeColumn above) since citext is
|
// columns are compared natively (see likeColumn above) since citext is
|
||||||
// already case-insensitive.
|
// already case-insensitive.
|
||||||
return applyWhere(fmt.Sprintf("%s ILIKE ?", likeColumn), filter.Value)
|
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":
|
case "in":
|
||||||
cond, inArgs := common.BuildInCondition(qualifiedColumn, filter.Value)
|
cond, inArgs := common.BuildInCondition(qualifiedColumn, filter.Value)
|
||||||
if cond == "" {
|
if cond == "" {
|
||||||
@@ -2636,7 +2639,7 @@ func (h *Handler) applyOrFilterGroup(query common.SelectQuery, filters []*common
|
|||||||
qualifiedColumn := rawQualifiedColumn
|
qualifiedColumn := rawQualifiedColumn
|
||||||
|
|
||||||
op := strings.ToLower(filter.Operator)
|
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.
|
// Cast to TEXT for LIKE/ILIKE to support date/time/timestamp columns.
|
||||||
// citext columns are left native: they're already case-insensitive and
|
// citext columns are left native: they're already case-insensitive and
|
||||||
// casting would defeat a citext index.
|
// 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}
|
return fmt.Sprintf("%s LIKE ?", qualifiedColumn), []interface{}{filter.Value}
|
||||||
case "ilike":
|
case "ilike":
|
||||||
return fmt.Sprintf("%s ILIKE ?", qualifiedColumn), []interface{}{filter.Value}
|
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":
|
case "in":
|
||||||
cond, inArgs := common.BuildInCondition(qualifiedColumn, filter.Value)
|
cond, inArgs := common.BuildInCondition(qualifiedColumn, filter.Value)
|
||||||
return cond, inArgs
|
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)
|
return fmt.Sprintf("%s LIKE '%v'", qualifiedColumn, filter.Value)
|
||||||
case "ilike":
|
case "ilike":
|
||||||
return fmt.Sprintf("%s ILIKE '%v'", qualifiedColumn, filter.Value)
|
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":
|
case "in":
|
||||||
if values, ok := filter.Value.([]any); ok {
|
if values, ok := filter.Value.([]any); ok {
|
||||||
valueStrs := make([]string, len(values))
|
valueStrs := make([]string, len(values))
|
||||||
|
|||||||
@@ -496,6 +496,8 @@ func (h *Handler) mapSearchOperator(colName, operator, value string) common.Filt
|
|||||||
switch operator {
|
switch operator {
|
||||||
case "contains", "contain", "like":
|
case "contains", "contain", "like":
|
||||||
return common.FilterOption{Column: colName, Operator: "ilike", Value: "%" + value + "%"}
|
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":
|
case "beginswith", "startswith":
|
||||||
return common.FilterOption{Column: colName, Operator: "ilike", Value: value + "%"}
|
return common.FilterOption{Column: colName, Operator: "ilike", Value: value + "%"}
|
||||||
case "endswith":
|
case "endswith":
|
||||||
@@ -1485,7 +1487,7 @@ func (h *Handler) ValidateAndAdjustFilterForColumnType(filter *common.FilterOpti
|
|||||||
// strips the '%' wildcards and hands the driver a non-string argument,
|
// strips the '%' wildcards and hands the driver a non-string argument,
|
||||||
// which fails with "operator does not exist: text ~~* integer" once the
|
// which fails with "operator does not exist: text ~~* integer" once the
|
||||||
// column is cast to TEXT below.
|
// 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) {
|
if reflection.IsStringType(colType) {
|
||||||
return ColumnCastInfo{NeedsCast: false, IsNumericType: false}
|
return ColumnCastInfo{NeedsCast: false, IsNumericType: false}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user