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
+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)
}
})
}
}