mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-09-10 18:32:35 +00:00
feat: add JSON/JSONB sub-field select, filter and sort
Support column references that traverse into JSON/JSONB values —
data->>'x', data#>>'{a,b}', data->'a'->>'b', and the dotted data.a.b
shorthand — in SELECT column lists, WHERE filters and ORDER BY, across
the restheadspec, resolvespec, websocketspec and mqttspec handlers.
- pkg/common/json_column.go: canonical ParseColumnRef + ColumnRef.SQL()
builder. JSON path segments are bound as a single ?::text[] parameter,
never interpolated; cast targets are whitelisted via NormalizeCastTarget.
- pkg/common/json_condition.go: shared entry points mirroring
BuildSpatialCondition - ResolveJSONColumnExpr (select/sort),
BuildJSONFilterCondition (where, full operator set; infers ::numeric for
ordered comparisons on numeric values when no explicit cast is given),
and the ApplySelectColumns helper.
- pkg/reflection.IsJSONColumn / pkg/spectypes.IsJSONType: disambiguate the
dotted shorthand (data.city is JSON only when the base is a JSON column).
- pkg/common/validation.go: ColumnValidator accepts JSON tokens.
- Handlers: thread model through the filter call chains and wire the
select/sort paths.
funcspec (raw-SQL string builder, no param binding or model) and the
FetchRowNumber raw-SQL builders are left as follow-ups, as is OpenAPI
reporting of JSON sub-field columns.
This commit is contained in:
@@ -128,7 +128,7 @@ func TestBuildFilterCondition(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
condition, args := h.buildFilterCondition(tt.filter)
|
||||
condition, args := h.buildFilterCondition(tt.filter, nil)
|
||||
|
||||
if condition != tt.expectedCondition {
|
||||
t.Errorf("Expected condition '%s', got '%s'", tt.expectedCondition, condition)
|
||||
|
||||
+33
-11
@@ -347,6 +347,10 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
||||
if len(options.Columns) > 0 {
|
||||
logger.Debug("Selecting columns: %v", options.Columns)
|
||||
for _, col := range options.Columns {
|
||||
if expr, jargs, alias, ok := common.ResolveJSONColumnExpr(model, "", col); ok {
|
||||
query = query.ColumnExpr(expr+" AS "+common.QuoteIdent(alias), jargs...)
|
||||
continue
|
||||
}
|
||||
query = query.Column(reflection.ExtractSourceColumn(col))
|
||||
}
|
||||
}
|
||||
@@ -393,7 +397,7 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
||||
}
|
||||
|
||||
// Apply filters with proper grouping for OR logic
|
||||
query = h.applyFilters(query, options.Filters)
|
||||
query = h.applyFilters(query, options.Filters, model)
|
||||
|
||||
// Apply custom operators
|
||||
for _, customOp := range options.CustomOperators {
|
||||
@@ -413,6 +417,10 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
||||
direction = "DESC"
|
||||
}
|
||||
logger.Debug("Applying sort: %s %s", sort.Column, direction)
|
||||
if expr, jargs, _, ok := common.ResolveJSONColumnExpr(model, "", sort.Column); ok {
|
||||
query = query.OrderExpr(fmt.Sprintf("%s %s", expr, direction), jargs...)
|
||||
continue
|
||||
}
|
||||
query = query.Order(fmt.Sprintf("%s %s", sort.Column, direction))
|
||||
}
|
||||
|
||||
@@ -536,7 +544,7 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
||||
|
||||
// Apply the same filters as the main query
|
||||
for _, filter := range options.Filters {
|
||||
rowNumQuery = h.applyFilter(rowNumQuery, filter)
|
||||
rowNumQuery = h.applyFilter(rowNumQuery, filter, model)
|
||||
}
|
||||
|
||||
// Apply custom operators
|
||||
@@ -1829,7 +1837,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
|
||||
// applyFilters applies all filters with proper grouping for OR logic
|
||||
// Groups consecutive OR filters together to ensure proper query precedence
|
||||
// Example: [A, B(OR), C(OR), D(AND)] => WHERE (A OR B OR C) AND D
|
||||
func (h *Handler) applyFilters(query common.SelectQuery, filters []common.FilterOption) common.SelectQuery {
|
||||
func (h *Handler) applyFilters(query common.SelectQuery, filters []common.FilterOption, model interface{}) common.SelectQuery {
|
||||
if len(filters) == 0 {
|
||||
return query
|
||||
}
|
||||
@@ -1849,11 +1857,11 @@ func (h *Handler) applyFilters(query common.SelectQuery, filters []common.Filter
|
||||
}
|
||||
|
||||
// Apply the OR group as a single grouped WHERE clause
|
||||
query = h.applyFilterGroup(query, orGroup)
|
||||
query = h.applyFilterGroup(query, orGroup, model)
|
||||
i = j
|
||||
} else {
|
||||
// Single filter with AND logic (or first filter)
|
||||
condition, args := h.buildFilterCondition(filters[i])
|
||||
condition, args := h.buildFilterCondition(filters[i], model)
|
||||
if condition != "" {
|
||||
query = query.Where(condition, args...)
|
||||
}
|
||||
@@ -1866,7 +1874,7 @@ func (h *Handler) applyFilters(query common.SelectQuery, filters []common.Filter
|
||||
|
||||
// applyFilterGroup applies a group of filters that should be OR'd together
|
||||
// Always wraps them in parentheses and applies as a single WHERE clause
|
||||
func (h *Handler) applyFilterGroup(query common.SelectQuery, filters []common.FilterOption) common.SelectQuery {
|
||||
func (h *Handler) applyFilterGroup(query common.SelectQuery, filters []common.FilterOption, model interface{}) common.SelectQuery {
|
||||
if len(filters) == 0 {
|
||||
return query
|
||||
}
|
||||
@@ -1876,7 +1884,7 @@ func (h *Handler) applyFilterGroup(query common.SelectQuery, filters []common.Fi
|
||||
var args []interface{}
|
||||
|
||||
for _, filter := range filters {
|
||||
condition, filterArgs := h.buildFilterCondition(filter)
|
||||
condition, filterArgs := h.buildFilterCondition(filter, model)
|
||||
if condition != "" {
|
||||
conditions = append(conditions, condition)
|
||||
args = append(args, filterArgs...)
|
||||
@@ -1897,11 +1905,18 @@ func (h *Handler) applyFilterGroup(query common.SelectQuery, filters []common.Fi
|
||||
return query.Where(groupedCondition, args...)
|
||||
}
|
||||
|
||||
// buildFilterCondition builds a filter condition and returns it with args
|
||||
func (h *Handler) buildFilterCondition(filter common.FilterOption) (conditionString string, conditionArgs []interface{}) {
|
||||
// buildFilterCondition builds a filter condition and returns it with args.
|
||||
// model, when non-nil, lets JSON sub-field references (data->>'x', data#>>'{a,b}',
|
||||
// or the dotted data.x shorthand for a JSON column) resolve to a safe,
|
||||
// parameterised expression before the ordinary operator handling below.
|
||||
func (h *Handler) buildFilterCondition(filter common.FilterOption, model interface{}) (conditionString string, conditionArgs []interface{}) {
|
||||
var condition string
|
||||
var args []interface{}
|
||||
|
||||
if cond, jargs, ok := common.BuildJSONFilterCondition(model, "", filter.Column, filter.Operator, filter.Value); ok {
|
||||
return cond, jargs
|
||||
}
|
||||
|
||||
switch filter.Operator {
|
||||
case "eq", "=":
|
||||
condition = fmt.Sprintf("%s = ?", filter.Column)
|
||||
@@ -1958,13 +1973,20 @@ func (h *Handler) buildFilterCondition(filter common.FilterOption) (conditionStr
|
||||
return condition, args
|
||||
}
|
||||
|
||||
func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOption) common.SelectQuery {
|
||||
func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOption, model interface{}) common.SelectQuery {
|
||||
// Determine which method to use based on LogicOperator
|
||||
useOrLogic := strings.EqualFold(filter.LogicOperator, "OR")
|
||||
|
||||
var condition string
|
||||
var args []interface{}
|
||||
|
||||
if cond, jargs, ok := common.BuildJSONFilterCondition(model, "", filter.Column, filter.Operator, filter.Value); ok {
|
||||
if useOrLogic {
|
||||
return query.WhereOr(cond, jargs...)
|
||||
}
|
||||
return query.Where(cond, jargs...)
|
||||
}
|
||||
|
||||
switch filter.Operator {
|
||||
case "eq", "=":
|
||||
condition = fmt.Sprintf("%s = ?", filter.Column)
|
||||
@@ -2394,7 +2416,7 @@ func (h *Handler) applyPreloads(model interface{}, query common.SelectQuery, pre
|
||||
|
||||
if len(preload.Filters) > 0 {
|
||||
for _, filter := range preload.Filters {
|
||||
sq = h.applyFilter(sq, filter)
|
||||
sq = h.applyFilter(sq, filter, nil)
|
||||
}
|
||||
}
|
||||
if len(preload.Sort) > 0 {
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package resolvespec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/common"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||
)
|
||||
|
||||
// jsonColModel has a real JSONB column so the dotted "data.x" shorthand is
|
||||
// recognised as JSON access.
|
||||
type jsonColModel struct {
|
||||
ID int64 `json:"id" bun:"id,pk"`
|
||||
Name string `json:"name" bun:"name"`
|
||||
Data spectypes.SqlJSONB `json:"data" bun:"data"`
|
||||
}
|
||||
|
||||
type jsonCapCall struct {
|
||||
method string
|
||||
query string
|
||||
args []interface{}
|
||||
}
|
||||
|
||||
// jsonCapQuery records the string + args of the calls the handler makes.
|
||||
type jsonCapQuery struct {
|
||||
calls []jsonCapCall
|
||||
}
|
||||
|
||||
func (m *jsonCapQuery) rec(method, query string, args []interface{}) common.SelectQuery {
|
||||
m.calls = append(m.calls, jsonCapCall{method: method, query: query, args: args})
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *jsonCapQuery) Model(interface{}) common.SelectQuery { return m }
|
||||
func (m *jsonCapQuery) Table(string) common.SelectQuery { return m }
|
||||
func (m *jsonCapQuery) Column(cols ...string) common.SelectQuery {
|
||||
for _, c := range cols {
|
||||
m.rec("Column", c, nil)
|
||||
}
|
||||
return m
|
||||
}
|
||||
func (m *jsonCapQuery) ColumnExpr(q string, args ...interface{}) common.SelectQuery {
|
||||
return m.rec("ColumnExpr", q, args)
|
||||
}
|
||||
func (m *jsonCapQuery) Where(q string, args ...interface{}) common.SelectQuery {
|
||||
return m.rec("Where", q, args)
|
||||
}
|
||||
func (m *jsonCapQuery) WhereOr(q string, args ...interface{}) common.SelectQuery {
|
||||
return m.rec("WhereOr", q, args)
|
||||
}
|
||||
func (m *jsonCapQuery) Join(string, ...interface{}) common.SelectQuery { return m }
|
||||
func (m *jsonCapQuery) LeftJoin(string, ...interface{}) common.SelectQuery { return m }
|
||||
func (m *jsonCapQuery) Preload(string, ...interface{}) common.SelectQuery { return m }
|
||||
func (m *jsonCapQuery) PreloadRelation(string, ...func(common.SelectQuery) common.SelectQuery) common.SelectQuery {
|
||||
return m
|
||||
}
|
||||
func (m *jsonCapQuery) JoinRelation(string, ...func(common.SelectQuery) common.SelectQuery) common.SelectQuery {
|
||||
return m
|
||||
}
|
||||
func (m *jsonCapQuery) Order(o string) common.SelectQuery { return m.rec("Order", o, nil) }
|
||||
func (m *jsonCapQuery) OrderExpr(o string, args ...interface{}) common.SelectQuery {
|
||||
return m.rec("OrderExpr", o, args)
|
||||
}
|
||||
func (m *jsonCapQuery) Limit(int) common.SelectQuery { return m }
|
||||
func (m *jsonCapQuery) Offset(int) common.SelectQuery { return m }
|
||||
func (m *jsonCapQuery) Group(string) common.SelectQuery { return m }
|
||||
func (m *jsonCapQuery) Having(string, ...interface{}) common.SelectQuery { return m }
|
||||
func (m *jsonCapQuery) Scan(context.Context, interface{}) error { return nil }
|
||||
func (m *jsonCapQuery) ScanModel(context.Context) error { return nil }
|
||||
func (m *jsonCapQuery) Count(context.Context) (int, error) { return 0, nil }
|
||||
func (m *jsonCapQuery) Exists(context.Context) (bool, error) { return false, nil }
|
||||
|
||||
func (m *jsonCapQuery) only(t *testing.T) jsonCapCall {
|
||||
t.Helper()
|
||||
if len(m.calls) != 1 {
|
||||
t.Fatalf("expected exactly 1 recorded call, got %d: %+v", len(m.calls), m.calls)
|
||||
}
|
||||
return m.calls[0]
|
||||
}
|
||||
|
||||
func TestBuildFilterCondition_JSONColumn(t *testing.T) {
|
||||
h := &Handler{}
|
||||
model := jsonColModel{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
filter common.FilterOption
|
||||
wantCond string
|
||||
wantArgs []interface{}
|
||||
}{
|
||||
{
|
||||
name: "arrow syntax eq stays text",
|
||||
filter: common.FilterOption{Column: "data->>'city'", Operator: "eq", Value: "LA"},
|
||||
wantCond: `("data" #>> ?::text[]) = ?`,
|
||||
wantArgs: []interface{}{"{city}", "LA"},
|
||||
},
|
||||
{
|
||||
name: "dotted shorthand numeric cast inference",
|
||||
filter: common.FilterOption{Column: "data.age", Operator: "gt", Value: 18},
|
||||
wantCond: `(("data" #>> ?::text[]))::numeric > ?`,
|
||||
wantArgs: []interface{}{"{age}", 18},
|
||||
},
|
||||
{
|
||||
name: "hash path with explicit cast",
|
||||
filter: common.FilterOption{Column: "data#>>'{a,b}'::int", Operator: "lte", Value: "5"},
|
||||
wantCond: `(("data" #>> ?::text[]))::integer <= ?`,
|
||||
wantArgs: []interface{}{"{a,b}", "5"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cond, args := h.buildFilterCondition(tc.filter, model)
|
||||
if cond != tc.wantCond {
|
||||
t.Fatalf("cond = %q, want %q", cond, tc.wantCond)
|
||||
}
|
||||
if !reflect.DeepEqual(args, tc.wantArgs) {
|
||||
t.Fatalf("args = %#v, want %#v", args, tc.wantArgs)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Non-JSON column falls through to ordinary handling.
|
||||
cond, _ := h.buildFilterCondition(common.FilterOption{Column: "name", Operator: "eq", Value: "x"}, model)
|
||||
if cond != "name = ?" {
|
||||
t.Fatalf("non-JSON cond = %q", cond)
|
||||
}
|
||||
|
||||
// Without a model the dotted shorthand must NOT be treated as JSON.
|
||||
cond, _ = h.buildFilterCondition(common.FilterOption{Column: "data.age", Operator: "eq", Value: "x"}, nil)
|
||||
if cond != "data.age = ?" {
|
||||
t.Fatalf("nil-model dotted cond = %q, want ordinary handling", cond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFilter_JSONColumn(t *testing.T) {
|
||||
h := &Handler{}
|
||||
model := jsonColModel{}
|
||||
|
||||
q := &jsonCapQuery{}
|
||||
h.applyFilter(q, common.FilterOption{
|
||||
Column: "data->>'tier'", Operator: "in", Value: []string{"a", "b"}, LogicOperator: "OR",
|
||||
}, model)
|
||||
c := q.only(t)
|
||||
if c.method != "WhereOr" || c.query != `("data" #>> ?::text[]) IN (?,?)` {
|
||||
t.Fatalf("call = %+v", c)
|
||||
}
|
||||
if !reflect.DeepEqual(c.args, []interface{}{"{tier}", "a", "b"}) {
|
||||
t.Fatalf("args = %#v", c.args)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user