feat(restheadspec): match NULL when x-fieldfilter compares a numeric column to 0

Adds a SetNullAsZero handler option: on by default in restheadspec (for
x-fieldfilter only), off by default in resolvespec. When enabled, an eq
filter of 0 on a numeric column renders (col = 0 OR col IS NULL).
This commit is contained in:
Hein
2026-10-12 15:49:57 +02:00
parent fbfc1aadb5
commit d3252f3d02
7 changed files with 239 additions and 2 deletions
+39
View File
@@ -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
}
+4
View File
@@ -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 {
+17 -2
View File
@@ -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)
+41
View File
@@ -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)
}
})
}
}
+110
View File
@@ -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)
}
}
+27
View File
@@ -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)
+1
View File
@@ -432,6 +432,7 @@ func (h *Handler) parseFieldFilter(options *ExtendedRequestOptions, headerKey, v
Operator: "eq",
Value: value,
LogicOperator: "AND", // Default to AND
NullAsZero: true,
})
}