mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-09-13 03:42:36 +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:
+40
-12
@@ -471,7 +471,14 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
||||
// Apply column selection
|
||||
if len(options.Columns) > 0 {
|
||||
logger.Debug("Selecting columns: %v", options.Columns)
|
||||
selectAlias := reflection.ExtractTableNameOnly(tableName)
|
||||
for _, col := range options.Columns {
|
||||
// JSON sub-field selection (data->>'x', data.x, data#>>'{a,b}'):
|
||||
// emit a parameterised expression aliased to a stable name.
|
||||
if expr, jargs, alias, ok := common.ResolveJSONColumnExpr(model, selectAlias, col); ok {
|
||||
query = query.ColumnExpr(expr+" AS "+common.QuoteIdent(alias), jargs...)
|
||||
continue
|
||||
}
|
||||
query = query.Column(reflection.ExtractSourceColumn(col))
|
||||
}
|
||||
|
||||
@@ -610,12 +617,12 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
||||
|
||||
// Apply the OR group as a single grouped condition
|
||||
logger.Debug("Applying OR filter group with %d conditions", len(orFilters))
|
||||
query = h.applyOrFilterGroup(query, orFilters, orCastInfo, tableName)
|
||||
query = h.applyOrFilterGroup(query, orFilters, orCastInfo, tableName, model)
|
||||
i = j
|
||||
} else {
|
||||
// Single AND filter - apply normally
|
||||
logger.Debug("Applying filter: %s %s %v (needsCast=%v, logic=%s)", filter.Column, filter.Operator, filter.Value, castInfo.NeedsCast, logicOp)
|
||||
query = h.applyFilter(query, *filter, tableName, castInfo.NeedsCast, logicOp)
|
||||
query = h.applyFilter(query, *filter, tableName, castInfo.NeedsCast, logicOp, model)
|
||||
i++
|
||||
}
|
||||
}
|
||||
@@ -715,8 +722,12 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
||||
}
|
||||
logger.Debug("Applying sort: %s %s", sort.Column, direction)
|
||||
|
||||
// Check if it's an expression (enclosed in brackets) - use directly without quoting
|
||||
if strings.HasPrefix(sort.Column, "(") && strings.HasSuffix(sort.Column, ")") {
|
||||
// JSON sub-field reference (data->>'x', data#>>'{a,b}', or dotted
|
||||
// shorthand when the base is a JSON column) - resolve to a safe
|
||||
// parameterised expression before the generic branches.
|
||||
if expr, jargs, _, ok := common.ResolveJSONColumnExpr(model, tableAlias, sort.Column); ok {
|
||||
query = query.OrderExpr(fmt.Sprintf("%s %s", expr, direction), jargs...)
|
||||
} else if strings.HasPrefix(sort.Column, "(") && strings.HasSuffix(sort.Column, ")") {
|
||||
// For expressions, pass as raw SQL to prevent auto-quoting
|
||||
query = query.OrderExpr(fmt.Sprintf("%s %s", sort.Column, direction))
|
||||
} else if strings.Contains(sort.Column, ".") {
|
||||
@@ -1063,7 +1074,7 @@ func (h *Handler) applyPreloadWithRecursion(query common.SelectQuery, preload co
|
||||
// Apply filters
|
||||
if len(preload.Filters) > 0 {
|
||||
for _, filter := range preload.Filters {
|
||||
sq = h.applyFilter(sq, filter, "", false, "AND")
|
||||
sq = h.applyFilter(sq, filter, "", false, "AND", nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2288,16 +2299,11 @@ func (h *Handler) qualifyColumnName(columnName, fullTableName string) string {
|
||||
return fmt.Sprintf("%s.%s", tableOnly, columnName)
|
||||
}
|
||||
|
||||
func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOption, tableName string, needsCast bool, logicOp string) common.SelectQuery {
|
||||
func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOption, tableName string, needsCast bool, logicOp string, model interface{}) common.SelectQuery {
|
||||
// Qualify the column name with table name if not already qualified
|
||||
rawQualifiedColumn := h.qualifyColumnName(filter.Column, tableName)
|
||||
qualifiedColumn := rawQualifiedColumn
|
||||
|
||||
// Apply casting to text if needed for non-numeric columns or non-numeric values
|
||||
if needsCast {
|
||||
qualifiedColumn = fmt.Sprintf("CAST(%s AS TEXT)", rawQualifiedColumn)
|
||||
}
|
||||
|
||||
// Helper function to apply the correct Where method based on logic operator
|
||||
applyWhere := func(condition string, args ...interface{}) common.SelectQuery {
|
||||
if logicOp == "OR" {
|
||||
@@ -2306,6 +2312,19 @@ func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOpti
|
||||
return query.Where(condition, args...)
|
||||
}
|
||||
|
||||
// JSON sub-field access (data->>'x', data#>>'{a,b}', or the dotted data.x
|
||||
// shorthand when "data" is a JSON column): resolve to a safe, parameterised
|
||||
// expression before the ordinary column handling below.
|
||||
tableAlias := reflection.ExtractTableNameOnly(tableName)
|
||||
if cond, jargs, ok := common.BuildJSONFilterCondition(model, tableAlias, filter.Column, filter.Operator, filter.Value); ok {
|
||||
return applyWhere(cond, jargs...)
|
||||
}
|
||||
|
||||
// Apply casting to text if needed for non-numeric columns or non-numeric values
|
||||
if needsCast {
|
||||
qualifiedColumn = fmt.Sprintf("CAST(%s AS TEXT)", rawQualifiedColumn)
|
||||
}
|
||||
|
||||
switch strings.ToLower(filter.Operator) {
|
||||
case "eq", "equals":
|
||||
return applyWhere(fmt.Sprintf("%s = ?", qualifiedColumn), filter.Value)
|
||||
@@ -2377,16 +2396,25 @@ func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOpti
|
||||
|
||||
// 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) common.SelectQuery {
|
||||
func (h *Handler) applyOrFilterGroup(query common.SelectQuery, filters []*common.FilterOption, castInfo []ColumnCastInfo, tableName string, model interface{}) common.SelectQuery {
|
||||
if len(filters) == 0 {
|
||||
return query
|
||||
}
|
||||
|
||||
tableAlias := reflection.ExtractTableNameOnly(tableName)
|
||||
|
||||
// Build individual filter conditions
|
||||
conditions := []string{}
|
||||
args := []interface{}{}
|
||||
|
||||
for i, filter := range filters {
|
||||
// JSON sub-field access: resolve to a safe parameterised condition first.
|
||||
if cond, jargs, ok := common.BuildJSONFilterCondition(model, tableAlias, filter.Column, filter.Operator, filter.Value); ok {
|
||||
conditions = append(conditions, cond)
|
||||
args = append(args, jargs...)
|
||||
continue
|
||||
}
|
||||
|
||||
// Qualify the column name with table name if not already qualified
|
||||
rawQualifiedColumn := h.qualifyColumnName(filter.Column, tableName)
|
||||
qualifiedColumn := rawQualifiedColumn
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package restheadspec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/common"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||
)
|
||||
|
||||
// jsonColModel exercises the JSON-column wiring: Data is 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"`
|
||||
}
|
||||
|
||||
// jsonCapQuery is a minimal common.SelectQuery that records the string + args of
|
||||
// the calls the handler makes so a test can assert on them.
|
||||
type jsonCapQuery struct {
|
||||
calls []jsonCapCall
|
||||
}
|
||||
|
||||
type jsonCapCall struct {
|
||||
method string
|
||||
query string
|
||||
args []interface{}
|
||||
}
|
||||
|
||||
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) WhereIn(col string, values interface{}) common.SelectQuery {
|
||||
return m.rec("WhereIn", col, []interface{}{values})
|
||||
}
|
||||
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) Join(string, ...interface{}) common.SelectQuery { return m }
|
||||
func (m *jsonCapQuery) LeftJoin(string, ...interface{}) 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) 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) 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) GetUnderlyingQuery() interface{} { return nil }
|
||||
func (m *jsonCapQuery) GetModel() interface{} { return 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 TestApplyFilter_JSONColumn(t *testing.T) {
|
||||
h := &Handler{}
|
||||
model := jsonColModel{}
|
||||
|
||||
t.Run("arrow syntax eq", func(t *testing.T) {
|
||||
q := &jsonCapQuery{}
|
||||
h.applyFilter(q, common.FilterOption{
|
||||
Column: "data->>'city'", Operator: "eq", Value: "LA",
|
||||
}, "public.things", false, "AND", model)
|
||||
c := q.only(t)
|
||||
if c.method != "Where" || c.query != `("things"."data" #>> ?::text[]) = ?` {
|
||||
t.Fatalf("call = %+v", c)
|
||||
}
|
||||
if !reflect.DeepEqual(c.args, []interface{}{"{city}", "LA"}) {
|
||||
t.Fatalf("args = %#v", c.args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dotted shorthand with numeric cast inference, OR logic", func(t *testing.T) {
|
||||
q := &jsonCapQuery{}
|
||||
h.applyFilter(q, common.FilterOption{
|
||||
Column: "data.age", Operator: "gt", Value: 18,
|
||||
}, "public.things", false, "OR", model)
|
||||
c := q.only(t)
|
||||
if c.method != "WhereOr" || c.query != `(("things"."data" #>> ?::text[]))::numeric > ?` {
|
||||
t.Fatalf("call = %+v", c)
|
||||
}
|
||||
if !reflect.DeepEqual(c.args, []interface{}{"{age}", 18}) {
|
||||
t.Fatalf("args = %#v", c.args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-JSON column is untouched", func(t *testing.T) {
|
||||
q := &jsonCapQuery{}
|
||||
h.applyFilter(q, common.FilterOption{
|
||||
Column: "name", Operator: "eq", Value: "x",
|
||||
}, "public.things", false, "AND", model)
|
||||
c := q.only(t)
|
||||
if c.query != "things.name = ?" {
|
||||
t.Fatalf("call = %+v", c)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil model: explicit syntax still works, dotted does not", func(t *testing.T) {
|
||||
q := &jsonCapQuery{}
|
||||
h.applyFilter(q, common.FilterOption{
|
||||
Column: "data->>'city'", Operator: "eq", Value: "LA",
|
||||
}, "public.things", false, "AND", nil)
|
||||
if c := q.only(t); c.query != `("things"."data" #>> ?::text[]) = ?` {
|
||||
t.Fatalf("explicit call = %+v", c)
|
||||
}
|
||||
|
||||
q2 := &jsonCapQuery{}
|
||||
h.applyFilter(q2, common.FilterOption{
|
||||
Column: "data.city", Operator: "eq", Value: "LA",
|
||||
}, "public.things", false, "AND", nil)
|
||||
if c := q2.only(t); c.query == `("things"."data" #>> ?::text[]) = ?` {
|
||||
t.Fatalf("dotted shorthand should not resolve without a model: %+v", c)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user