mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-12 16:26:29 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82f82b998f | ||
|
|
5dbe187915 | ||
|
|
d3252f3d02 |
@@ -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 == "" {
|
||||
|
||||
@@ -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 (?,?)`,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package common
|
||||
|
||||
import "github.com/bitechdev/ResolveSpec/pkg/reflection"
|
||||
|
||||
// IsNumericZeroFilter reports whether value is a numeric zero compared against
|
||||
// a numeric column of model. Used by the "null as zero" option, which turns
|
||||
// col = 0 into (col = 0 OR col IS NULL).
|
||||
func IsNumericZeroFilter(model interface{}, column string, value interface{}) bool {
|
||||
if model == nil || !reflection.IsNumericType(reflection.GetColumnTypeFromModel(model, column)) {
|
||||
return false
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return v == 0
|
||||
case int8:
|
||||
return v == 0
|
||||
case int16:
|
||||
return v == 0
|
||||
case int32:
|
||||
return v == 0
|
||||
case int64:
|
||||
return v == 0
|
||||
case uint:
|
||||
return v == 0
|
||||
case uint8:
|
||||
return v == 0
|
||||
case uint16:
|
||||
return v == 0
|
||||
case uint32:
|
||||
return v == 0
|
||||
case uint64:
|
||||
return v == 0
|
||||
case float32:
|
||||
return v == 0
|
||||
case float64:
|
||||
return v == 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -87,6 +87,10 @@ type FilterOption struct {
|
||||
Operator string `json:"operator"`
|
||||
Value interface{} `json:"value"`
|
||||
LogicOperator string `json:"logic_operator"` // "AND" or "OR" - how this filter combines with previous filters
|
||||
|
||||
// NullAsZero makes an "eq" filter on a numeric column with a zero value also
|
||||
// match NULLs (COALESCE(col, 0) = 0). Set for x-fieldfilter headers only.
|
||||
NullAsZero bool `json:"-"`
|
||||
}
|
||||
|
||||
type SortOption struct {
|
||||
|
||||
@@ -49,6 +49,11 @@ func (v *ColumnValidator) buildValidColumns() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Relation fields are not columns
|
||||
if isBunRelationTag(field.Tag.Get("bun")) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get column name from bun, gorm, or json tag
|
||||
columnName := v.getColumnName(field)
|
||||
if columnName != "" && columnName != "-" {
|
||||
@@ -57,6 +62,18 @@ func (v *ColumnValidator) buildValidColumns() {
|
||||
}
|
||||
}
|
||||
|
||||
// isBunRelationTag reports whether a bun tag describes a relation (rel:, join:, m2m:)
|
||||
// rather than a database column.
|
||||
func isBunRelationTag(tag string) bool {
|
||||
for _, part := range strings.Split(tag, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if strings.HasPrefix(part, "rel:") || strings.HasPrefix(part, "join:") || strings.HasPrefix(part, "m2m:") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getColumnName extracts the column name from a struct field's tags
|
||||
// Supports both Bun and GORM tags
|
||||
func (v *ColumnValidator) getColumnName(field reflect.StructField) string {
|
||||
|
||||
@@ -545,3 +545,29 @@ func TestFilterRequestOptions_PreloadColumnsParentModelFallback(t *testing.T) {
|
||||
t.Errorf("Expected preload column 'id', got '%s'", cols[0])
|
||||
}
|
||||
}
|
||||
|
||||
type relationTestModel struct {
|
||||
ID int64 `bun:"id,pk"`
|
||||
Name string `bun:"name"`
|
||||
Children []*relationTestKid `bun:"rel:has-many,join:id=parent_id"`
|
||||
Parent *relationTestKid `bun:"rel:has-one,join:parent_id=id"`
|
||||
Tags []*relationTestKid `bun:"m2m:model_tags,join:Model=Tag"`
|
||||
}
|
||||
|
||||
type relationTestKid struct{}
|
||||
|
||||
func TestNewColumnValidator_SkipsRelationFields(t *testing.T) {
|
||||
cols := NewColumnValidator(relationTestModel{}).GetValidColumns()
|
||||
set := map[string]bool{}
|
||||
for _, c := range cols {
|
||||
set[c] = true
|
||||
}
|
||||
if !set["id"] || !set["name"] {
|
||||
t.Errorf("expected id and name columns, got %v", cols)
|
||||
}
|
||||
for _, bad := range []string{"rel:has-many", "rel:has-one", "m2m:model_tags", "children", "parent", "tags"} {
|
||||
if set[bad] {
|
||||
t.Errorf("relation %q must not be a valid column (got %v)", bad, cols)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ type Handler struct {
|
||||
openAPIGenerator func() (string, error)
|
||||
defaultSort map[string][]common.SortOption
|
||||
disallowNulls bool
|
||||
nullAsZero bool
|
||||
}
|
||||
|
||||
// NewHandler creates a new API handler with database and registry abstractions
|
||||
@@ -61,6 +62,12 @@ func (h *Handler) SetDisallowNulls(disallow bool) {
|
||||
h.disallowNulls = disallow
|
||||
}
|
||||
|
||||
// SetNullAsZero controls whether an equality filter against 0 on a numeric
|
||||
// column also matches NULLs: (col = 0 OR col IS NULL). Disabled by default.
|
||||
func (h *Handler) SetNullAsZero(enabled bool) {
|
||||
h.nullAsZero = enabled
|
||||
}
|
||||
|
||||
// SetFallbackHandler sets a fallback handler to be called when no model is found
|
||||
// If not set, the handler will simply return (pass through to next route)
|
||||
func (h *Handler) SetFallbackHandler(fallback FallbackHandler) {
|
||||
@@ -2026,7 +2033,11 @@ func (h *Handler) buildFilterConditionAlias(filter common.FilterOption, model in
|
||||
|
||||
switch filter.Operator {
|
||||
case "eq", "=":
|
||||
condition = fmt.Sprintf("%s = ?", filter.Column)
|
||||
if h.nullAsZero && common.IsNumericZeroFilter(model, rawColumn, filter.Value) {
|
||||
condition = fmt.Sprintf("(%s = ? OR %s IS NULL)", filter.Column, filter.Column)
|
||||
} else {
|
||||
condition = fmt.Sprintf("%s = ?", filter.Column)
|
||||
}
|
||||
args = []interface{}{filter.Value}
|
||||
case "neq", "!=", "<>":
|
||||
condition = fmt.Sprintf("%s != ?", filter.Column)
|
||||
@@ -2111,7 +2122,11 @@ func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOpti
|
||||
|
||||
switch filter.Operator {
|
||||
case "eq", "=":
|
||||
condition = fmt.Sprintf("%s = ?", filter.Column)
|
||||
if h.nullAsZero && common.IsNumericZeroFilter(model, rawColumn, filter.Value) {
|
||||
condition = fmt.Sprintf("(%s = ? OR %s IS NULL)", filter.Column, filter.Column)
|
||||
} else {
|
||||
condition = fmt.Sprintf("%s = ?", filter.Column)
|
||||
}
|
||||
args = []interface{}{filter.Value}
|
||||
case "neq", "!=", "<>":
|
||||
condition = fmt.Sprintf("%s != ?", filter.Column)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package resolvespec
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/common"
|
||||
)
|
||||
|
||||
type nullAsZeroModel struct {
|
||||
Inactive int `json:"inactive" bun:"inactive"`
|
||||
Name string `json:"name" bun:"name"`
|
||||
}
|
||||
|
||||
func TestNullAsZero_ResolveSpec(t *testing.T) {
|
||||
model := nullAsZeroModel{}
|
||||
tests := []struct {
|
||||
name string
|
||||
enabled bool
|
||||
filter common.FilterOption
|
||||
want string
|
||||
}{
|
||||
{"default off, zero", false, common.FilterOption{Column: "inactive", Operator: "eq", Value: float64(0)}, `"t"."inactive" = ?`},
|
||||
{"on, zero", true, common.FilterOption{Column: "inactive", Operator: "eq", Value: float64(0)}, `("t"."inactive" = ? OR "t"."inactive" IS NULL)`},
|
||||
{"on, int zero", true, common.FilterOption{Column: "inactive", Operator: "eq", Value: 0}, `("t"."inactive" = ? OR "t"."inactive" IS NULL)`},
|
||||
{"on, positive", true, common.FilterOption{Column: "inactive", Operator: "eq", Value: float64(5)}, `"t"."inactive" = ?`},
|
||||
{"on, string column", true, common.FilterOption{Column: "name", Operator: "eq", Value: float64(0)}, `"t"."name" = ?`},
|
||||
{"on, neq untouched", true, common.FilterOption{Column: "inactive", Operator: "neq", Value: float64(0)}, `"t"."inactive" != ?`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
h := &Handler{}
|
||||
if tt.enabled {
|
||||
h.SetNullAsZero(true)
|
||||
}
|
||||
got, _ := h.buildFilterConditionAlias(tt.filter, model, "t")
|
||||
if got != tt.want {
|
||||
t.Fatalf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -173,3 +173,146 @@ func TestValidateAndAdjustFilterForColumnType_NumericColumn_Ilike(t *testing.T)
|
||||
t.Fatalf("args = %#v, want [\"%%345346346%%\"]", c.args)
|
||||
}
|
||||
}
|
||||
|
||||
type inactiveModel struct {
|
||||
Inactive spectypes.SqlInt64 `json:"inactive" bun:"inactive"`
|
||||
Name string `json:"name" bun:"name"`
|
||||
}
|
||||
|
||||
func TestFieldFilter_ZeroOnNumericColumn_CoalescesNull(t *testing.T) {
|
||||
h := NewHandler(nil, nil)
|
||||
model := inactiveModel{}
|
||||
|
||||
run := func(header, val string) (string, []interface{}) {
|
||||
req := &MockRequest{headers: map[string]string{header: val}, queryParams: map[string]string{}}
|
||||
options := h.parseOptionsFromHeaders(req, model)
|
||||
f := options.Filters[0]
|
||||
ci := h.ValidateAndAdjustFilterForColumnType(&f, model)
|
||||
q := &jsonCapQuery{}
|
||||
h.applyFilter(q, f, "public.t", ci.NeedsCast, "AND", model)
|
||||
c := q.only(t)
|
||||
return c.query, c.args
|
||||
}
|
||||
|
||||
if got, args := run("x-fieldfilter-inactive", "0"); got != "(t.inactive = ? OR t.inactive IS NULL)" || !reflect.DeepEqual(args, []interface{}{int64(0)}) {
|
||||
t.Fatalf("got %q %#v", got, args)
|
||||
}
|
||||
if got, _ := run("x-fieldfilter-inactive", "1"); got != "t.inactive = ?" {
|
||||
t.Fatalf("non-zero value must not coalesce, got %q", got)
|
||||
}
|
||||
if got, _ := run("x-fieldfilter-name", "0"); got != "t.name = ?" {
|
||||
t.Fatalf("string column must not coalesce, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldFilter_NullAsZero_Cases(t *testing.T) {
|
||||
h := NewHandler(nil, nil)
|
||||
model := inactiveModel{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
column string
|
||||
value string
|
||||
wantQuery string
|
||||
wantArg interface{}
|
||||
}{
|
||||
{"zero", "inactive", "0", "(t.inactive = ? OR t.inactive IS NULL)", int64(0)},
|
||||
{"positive", "inactive", "1", "t.inactive = ?", int64(1)},
|
||||
{"larger positive", "inactive", "42", "t.inactive = ?", int64(42)},
|
||||
{"negative", "inactive", "-1", "t.inactive = ?", int64(-1)},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name+"/single", func(t *testing.T) {
|
||||
req := &MockRequest{headers: map[string]string{"x-fieldfilter-" + tt.column: tt.value}, queryParams: map[string]string{}}
|
||||
f := h.parseOptionsFromHeaders(req, model).Filters[0]
|
||||
ci := h.ValidateAndAdjustFilterForColumnType(&f, model)
|
||||
q := &jsonCapQuery{}
|
||||
h.applyFilter(q, f, "public.t", ci.NeedsCast, "AND", model)
|
||||
c := q.only(t)
|
||||
if c.query != tt.wantQuery || !reflect.DeepEqual(c.args, []interface{}{tt.wantArg}) {
|
||||
t.Fatalf("got %q %#v, want %q [%#v]", c.query, c.args, tt.wantQuery, tt.wantArg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(tt.name+"/or-group", func(t *testing.T) {
|
||||
req := &MockRequest{headers: map[string]string{"x-fieldfilter-" + tt.column: tt.value}, queryParams: map[string]string{}}
|
||||
f := h.parseOptionsFromHeaders(req, model).Filters[0]
|
||||
ci := h.ValidateAndAdjustFilterForColumnType(&f, model)
|
||||
q := &jsonCapQuery{}
|
||||
h.applyOrFilterGroup(q, []*common.FilterOption{&f}, []ColumnCastInfo{ci}, "public.t", model)
|
||||
c := q.only(t)
|
||||
want := "(" + tt.wantQuery + ")"
|
||||
if c.query != want || !reflect.DeepEqual(c.args, []interface{}{tt.wantArg}) {
|
||||
t.Fatalf("got %q %#v, want %q [%#v]", c.query, c.args, want, tt.wantArg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Filters not coming from x-fieldfilter must keep a plain equality, even for 0.
|
||||
func TestNullAsZero_NotAppliedWithoutFlag(t *testing.T) {
|
||||
h := NewHandler(nil, nil)
|
||||
model := inactiveModel{}
|
||||
f := common.FilterOption{Column: "inactive", Operator: "eq", Value: "0"}
|
||||
ci := h.ValidateAndAdjustFilterForColumnType(&f, model)
|
||||
q := &jsonCapQuery{}
|
||||
h.applyFilter(q, f, "public.t", ci.NeedsCast, "AND", model)
|
||||
if got := q.only(t).query; got != "t.inactive = ?" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNullAsZero_RestHeadSpecConfig(t *testing.T) {
|
||||
model := inactiveModel{}
|
||||
build := func(h *Handler) string {
|
||||
req := &MockRequest{headers: map[string]string{"x-fieldfilter-inactive": "0"}, queryParams: map[string]string{}}
|
||||
f := h.parseOptionsFromHeaders(req, model).Filters[0]
|
||||
ci := h.ValidateAndAdjustFilterForColumnType(&f, model)
|
||||
q := &jsonCapQuery{}
|
||||
h.applyFilter(q, f, "public.t", ci.NeedsCast, "AND", model)
|
||||
return q.only(t).query
|
||||
}
|
||||
|
||||
if got := build(NewHandler(nil, nil)); got != "(t.inactive = ? OR t.inactive IS NULL)" {
|
||||
t.Fatalf("default should be on, got %q", got)
|
||||
}
|
||||
h := NewHandler(nil, nil)
|
||||
h.SetNullAsZero(false)
|
||||
if got := build(h); got != "t.inactive = ?" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ type Handler struct {
|
||||
openAPIGenerator func() (string, error)
|
||||
defaultSort map[string][]common.SortOption
|
||||
disallowNulls bool
|
||||
nullAsZero bool
|
||||
}
|
||||
|
||||
// NewHandler creates a new API handler with database and registry abstractions
|
||||
@@ -42,6 +43,8 @@ func NewHandler(db common.Database, registry common.ModelRegistry) *Handler {
|
||||
db: db,
|
||||
registry: registry,
|
||||
hooks: NewHookRegistry(),
|
||||
// x-fieldfilter on a numeric column with 0 also matches NULLs by default
|
||||
nullAsZero: true,
|
||||
}
|
||||
// Initialize nested processor
|
||||
handler.nestedProcessor = common.NewNestedCUDProcessor(db, registry, handler)
|
||||
@@ -68,6 +71,12 @@ func (h *Handler) SetDisallowNulls(disallow bool) {
|
||||
h.disallowNulls = disallow
|
||||
}
|
||||
|
||||
// SetNullAsZero controls whether an x-fieldfilter equality against 0 on a numeric
|
||||
// column also matches NULLs: (col = 0 OR col IS NULL). Enabled by default.
|
||||
func (h *Handler) SetNullAsZero(enabled bool) {
|
||||
h.nullAsZero = enabled
|
||||
}
|
||||
|
||||
// SetFallbackHandler sets a fallback handler to be called when no model is found
|
||||
// If not set, the handler will simply return (pass through to next route)
|
||||
func (h *Handler) SetFallbackHandler(fallback FallbackHandler) {
|
||||
@@ -2518,6 +2527,9 @@ func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOpti
|
||||
|
||||
switch strings.ToLower(filter.Operator) {
|
||||
case "eq", "equals":
|
||||
if h.nullAsZeroApplies(&filter, model, needsCast) {
|
||||
return applyWhere(fmt.Sprintf("(%s = ? OR %s IS NULL)", rawQualifiedColumn, rawQualifiedColumn), filter.Value)
|
||||
}
|
||||
return applyWhere(fmt.Sprintf("%s = ?", qualifiedColumn), filter.Value)
|
||||
case "neq", "not_equals", "ne":
|
||||
return applyWhere(fmt.Sprintf("%s != ?", qualifiedColumn), filter.Value)
|
||||
@@ -2538,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 == "" {
|
||||
@@ -2588,6 +2603,16 @@ func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOpti
|
||||
}
|
||||
}
|
||||
|
||||
// nullAsZeroApplies reports whether an eq filter should also match NULLs, i.e.
|
||||
// (col = 0 OR col IS NULL). This is the case for x-fieldfilter on a numeric
|
||||
// column compared against zero, where a NULL is treated the same as 0.
|
||||
func (h *Handler) nullAsZeroApplies(filter *common.FilterOption, model interface{}, needsCast bool) bool {
|
||||
if !h.nullAsZero || !filter.NullAsZero || needsCast {
|
||||
return false
|
||||
}
|
||||
return common.IsNumericZeroFilter(model, filter.Column, filter.Value)
|
||||
}
|
||||
|
||||
// applyOrFilterGroup applies a group of OR filters as a single grouped condition
|
||||
// This ensures OR conditions are properly grouped with parentheses to prevent OR logic from escaping
|
||||
func (h *Handler) applyOrFilterGroup(query common.SelectQuery, filters []*common.FilterOption, castInfo []ColumnCastInfo, tableName string, model interface{}) common.SelectQuery {
|
||||
@@ -2614,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.
|
||||
@@ -2627,6 +2652,11 @@ func (h *Handler) applyOrFilterGroup(query common.SelectQuery, filters []*common
|
||||
}
|
||||
|
||||
// Build the condition based on operator
|
||||
if strings.EqualFold(filter.Operator, "eq") && h.nullAsZeroApplies(filter, model, castInfo[i].NeedsCast) {
|
||||
conditions = append(conditions, fmt.Sprintf("(%s = ? OR %s IS NULL)", rawQualifiedColumn, rawQualifiedColumn))
|
||||
args = append(args, filter.Value)
|
||||
continue
|
||||
}
|
||||
condition, filterArgs := h.buildFilterCondition(qualifiedColumn, filter, tableName)
|
||||
if condition != "" {
|
||||
conditions = append(conditions, condition)
|
||||
@@ -2665,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
|
||||
@@ -3413,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))
|
||||
|
||||
@@ -432,6 +432,7 @@ func (h *Handler) parseFieldFilter(options *ExtendedRequestOptions, headerKey, v
|
||||
Operator: "eq",
|
||||
Value: value,
|
||||
LogicOperator: "AND", // Default to AND
|
||||
NullAsZero: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -495,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":
|
||||
@@ -1484,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}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user