diff --git a/pkg/common/null_as_zero.go b/pkg/common/null_as_zero.go new file mode 100644 index 0000000..4438213 --- /dev/null +++ b/pkg/common/null_as_zero.go @@ -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 +} diff --git a/pkg/common/types.go b/pkg/common/types.go index 92295d6..5a8c2b8 100644 --- a/pkg/common/types.go +++ b/pkg/common/types.go @@ -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 { diff --git a/pkg/resolvespec/handler.go b/pkg/resolvespec/handler.go index 0eae678..a8189c5 100644 --- a/pkg/resolvespec/handler.go +++ b/pkg/resolvespec/handler.go @@ -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) diff --git a/pkg/resolvespec/null_as_zero_test.go b/pkg/resolvespec/null_as_zero_test.go new file mode 100644 index 0000000..d6a4118 --- /dev/null +++ b/pkg/resolvespec/null_as_zero_test.go @@ -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) + } + }) + } +} diff --git a/pkg/restheadspec/filter_cast_test.go b/pkg/restheadspec/filter_cast_test.go index 3b1afef..db80c26 100644 --- a/pkg/restheadspec/filter_cast_test.go +++ b/pkg/restheadspec/filter_cast_test.go @@ -173,3 +173,113 @@ 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) + } +} diff --git a/pkg/restheadspec/handler.go b/pkg/restheadspec/handler.go index b357c83..b6e59bf 100644 --- a/pkg/restheadspec/handler.go +++ b/pkg/restheadspec/handler.go @@ -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) @@ -2588,6 +2600,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 { @@ -2627,6 +2649,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) diff --git a/pkg/restheadspec/headers.go b/pkg/restheadspec/headers.go index 3bb069b..2924f19 100644 --- a/pkg/restheadspec/headers.go +++ b/pkg/restheadspec/headers.go @@ -432,6 +432,7 @@ func (h *Handler) parseFieldFilter(options *ExtendedRequestOptions, headerKey, v Operator: "eq", Value: value, LogicOperator: "AND", // Default to AND + NullAsZero: true, }) }