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:
Hein
2026-09-07 17:05:52 +02:00
parent f841d58c59
commit 206edd4bfd
17 changed files with 1666 additions and 47 deletions
+401
View File
@@ -0,0 +1,401 @@
package common
import (
"fmt"
"regexp"
"strings"
)
// This file implements a single canonical parser + SQL builder for column
// references that traverse into JSON / JSONB values. It is used by SELECT,
// WHERE (filter) and ORDER BY handling so that all three treat JSON access
// consistently and safely.
//
// Supported input syntaxes (all PostgreSQL-oriented):
//
// data->>'city' arrow chain, text extraction
// data->'addr'->>'city' nested arrow chain
// data->2->>'name' arrow chain with array index
// data#>>'{addr,city}' hash-path, text extraction
// data#>'{addr,city}' hash-path, jsonb result
// data.addr.city dotted shorthand (Ambiguous: caller must
// confirm "data" is a JSON column)
// data->>'age'::int trailing cast (whitelisted targets only)
// (data->>'city') AS city parenthesised, with output alias
//
// JSON path segments are never interpolated into SQL: SQL() emits a `#>>` /
// `#>` operator with the path bound as a single `text[]` parameter.
// ColumnRef is a parsed reference to a (possibly JSON-traversing) column.
type ColumnRef struct {
// Base is the bare base column name, e.g. "data". Always a simple
// identifier ([A-Za-z_][A-Za-z0-9_]*); qualified names are rejected.
Base string
// Path is the JSON key / array-index path, e.g. ["address", "city"].
// Empty for a plain column reference.
Path []string
// AsText is true when the final extraction should yield text (->> / #>>)
// rather than jsonb (-> / #>).
AsText bool
// Cast is a normalised SQL type name to cast the whole expression to
// (e.g. "integer", "numeric", "timestamptz"), or "" for no cast.
Cast string
// Alias is a validated output identifier for `AS <alias>`, or "".
Alias string
// Ambiguous is true when Path was produced from the dotted "a.b.c"
// shorthand. The caller MUST verify that Base is a JSON column
// (reflection.IsJSONColumn) before treating this as a JSON expression,
// otherwise "a.b" is an ordinary table-qualified column.
Ambiguous bool
}
var (
reSimpleIdent = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
reSimpleSegment = regexp.MustCompile(`^[A-Za-z0-9_]+$`)
reAliasSuffix = regexp.MustCompile(`(?i)\s+AS\s+("?[A-Za-z_][A-Za-z0-9_]*"?)\s*$`)
reArrowStep = regexp.MustCompile(`^\s*(->>|->)\s*(?:'((?:[^']|'')*)'|(\d+))\s*`)
)
const (
maxJSONPathDepth = 32
maxJSONSegmentSize = 128
)
// castAliases maps accepted cast spellings to their canonical PostgreSQL type.
var castAliases = map[string]string{
"int": "integer",
"int4": "integer",
"integer": "integer",
"int2": "smallint",
"smallint": "smallint",
"int8": "bigint",
"bigint": "bigint",
"numeric": "numeric",
"decimal": "numeric",
"real": "real",
"float4": "real",
"float": "double precision",
"float8": "double precision",
"double precision": "double precision",
"bool": "boolean",
"boolean": "boolean",
"text": "text",
"varchar": "text",
"uuid": "uuid",
"date": "date",
"time": "time",
"timestamp": "timestamp",
"timestamptz": "timestamptz",
"json": "json",
"jsonb": "jsonb",
}
// NormalizeCastTarget returns the canonical PostgreSQL type name for a
// user-supplied cast spelling, and whether it is on the allowlist.
func NormalizeCastTarget(s string) (string, bool) {
c, ok := castAliases[strings.ToLower(strings.TrimSpace(s))]
return c, ok
}
// ParseColumnRef parses a column token that traverses into a JSON value.
//
// ok is true only when the token carries JSON traversal syntax (arrow chain,
// hash-path, or dotted shorthand with at least one sub-key). For a plain
// column name — with or without an alias/cast — ok is false and the caller
// should handle the token the way it did before.
//
// When ok is true and ref.Ambiguous is true, the caller must confirm that
// ref.Base is a JSON column before using ref.SQL; otherwise the dotted token
// is an ordinary "table.column" reference.
func ParseColumnRef(raw string) (ColumnRef, bool) {
expr := strings.TrimSpace(raw)
if expr == "" {
return ColumnRef{}, false
}
var ref ColumnRef
// 1. Trailing `AS <alias>`.
if m := reAliasSuffix.FindStringSubmatch(expr); m != nil {
ref.Alias = strings.Trim(m[1], `"`)
expr = strings.TrimSpace(expr[:len(expr)-len(m[0])])
if expr == "" {
return ColumnRef{}, false
}
}
// 2. Trailing `::<type>` cast (take the last `::` in the string).
if idx := strings.LastIndex(expr, "::"); idx != -1 {
candidate := strings.TrimSpace(expr[idx+2:])
if canonical, allowed := NormalizeCastTarget(candidate); allowed {
ref.Cast = canonical
expr = strings.TrimSpace(expr[:idx])
} else if candidate != "" && looksLikeCastTail(candidate) {
// An explicit but unsupported cast target — reject rather than
// silently dropping it.
return ColumnRef{}, false
}
}
// 3. One layer of wrapping parentheses: "(expr)" -> "expr".
if wrapped, ok := stripWrappingParens(expr); ok {
expr = strings.TrimSpace(wrapped)
if expr == "" {
return ColumnRef{}, false
}
}
// 4. Parse the core expression.
switch {
case strings.Contains(expr, "#>>") || strings.Contains(expr, "#>"):
if !parseHashPath(expr, &ref) {
return ColumnRef{}, false
}
case strings.Contains(expr, "->"):
if !parseArrowChain(expr, &ref) {
return ColumnRef{}, false
}
case strings.Contains(expr, "."):
if !parseDottedPath(expr, &ref) {
return ColumnRef{}, false
}
default:
// Plain column — nothing JSON about it.
return ColumnRef{}, false
}
if !validateRef(&ref) {
return ColumnRef{}, false
}
return ref, true
}
// SQL renders the reference as a parameterised SQL expression plus its args.
// tableAlias, when non-empty, qualifies the base column (each dot-separated
// part is quoted independently, so "public.users" -> `"public"."users"`).
func (r ColumnRef) SQL(tableAlias string) (string, []interface{}) {
base := quoteQualifiedIdent(r.Base)
if tableAlias != "" {
base = quoteQualifiedIdent(tableAlias) + "." + QuoteIdent(r.Base)
}
if len(r.Path) == 0 {
if r.Cast != "" {
return fmt.Sprintf("(%s)::%s", base, r.Cast), nil
}
return base, nil
}
op := "#>"
if r.AsText {
op = "#>>"
}
expr := fmt.Sprintf("(%s %s ?::text[])", base, op)
args := []interface{}{pgTextArrayLiteral(r.Path)}
if r.Cast != "" {
expr = fmt.Sprintf("(%s)::%s", expr, r.Cast)
}
return expr, args
}
// OutputAlias returns the alias to use for this reference in a SELECT list:
// the explicit alias when given, otherwise a deterministic name derived from
// the base column and path (e.g. "data_address_city").
func (r ColumnRef) OutputAlias() string {
if r.Alias != "" {
return r.Alias
}
if len(r.Path) == 0 {
return r.Base
}
parts := make([]string, 0, len(r.Path)+1)
parts = append(parts, r.Base)
for _, p := range r.Path {
parts = append(parts, sanitizeAliasPart(p))
}
return strings.Join(parts, "_")
}
// ── parsing helpers ─────────────────────────────────────────────────────────
func parseHashPath(expr string, ref *ColumnRef) bool {
op := "#>>"
ref.AsText = true
if !strings.Contains(expr, "#>>") {
op = "#>"
ref.AsText = false
}
parts := strings.SplitN(expr, op, 2)
if len(parts) != 2 {
return false
}
ref.Base = strings.TrimSpace(parts[0])
rhs := strings.TrimSpace(parts[1])
// Expect a single-quoted array literal: '{a,b,c}'
if len(rhs) < 2 || rhs[0] != '\'' || rhs[len(rhs)-1] != '\'' {
return false
}
rhs = rhs[1 : len(rhs)-1]
rhs = strings.TrimSpace(rhs)
rhs = strings.TrimPrefix(rhs, "{")
rhs = strings.TrimSuffix(rhs, "}")
if strings.TrimSpace(rhs) == "" {
return false
}
for _, seg := range strings.Split(rhs, ",") {
seg = strings.TrimSpace(seg)
seg = strings.Trim(seg, `"`)
if seg == "" {
return false
}
ref.Path = append(ref.Path, seg)
}
return true
}
func parseArrowChain(expr string, ref *ColumnRef) bool {
arrowIdx := strings.Index(expr, "->")
if arrowIdx <= 0 {
return false
}
ref.Base = strings.TrimSpace(expr[:arrowIdx])
rest := expr[arrowIdx:]
for strings.TrimSpace(rest) != "" {
m := reArrowStep.FindStringSubmatch(rest)
if m == nil {
return false
}
ref.AsText = m[1] == "->>"
if m[3] != "" {
// unquoted array index
ref.Path = append(ref.Path, m[3])
} else {
// quoted key; unescape doubled single quotes
ref.Path = append(ref.Path, strings.ReplaceAll(m[2], "''", "'"))
}
rest = rest[len(m[0]):]
}
return len(ref.Path) > 0
}
func parseDottedPath(expr string, ref *ColumnRef) bool {
segs := strings.Split(expr, ".")
if len(segs) < 2 {
return false
}
for i, s := range segs {
s = strings.TrimSpace(s)
if !reSimpleSegment.MatchString(s) {
return false
}
if i == 0 {
ref.Base = s
} else {
ref.Path = append(ref.Path, s)
}
}
ref.AsText = true
ref.Ambiguous = true
return true
}
func validateRef(ref *ColumnRef) bool {
if !reSimpleIdent.MatchString(ref.Base) {
return false
}
if len(ref.Path) == 0 || len(ref.Path) > maxJSONPathDepth {
return false
}
for _, seg := range ref.Path {
if seg == "" || len(seg) > maxJSONSegmentSize || strings.ContainsRune(seg, 0) {
return false
}
}
if ref.Alias != "" && !reSimpleIdent.MatchString(ref.Alias) {
return false
}
return true
}
// looksLikeCastTail reports whether s is plausibly meant as a `::type` target
// (letters/digits/spaces only) rather than, say, part of a JSON operator.
func looksLikeCastTail(s string) bool {
for _, r := range s {
if !(r == ' ' || r == '_' ||
(r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
return false
}
}
return true
}
// stripWrappingParens removes one layer of parentheses when they wrap the whole
// expression, e.g. "(a->>'b')" -> "a->>'b'". It respects single-quoted strings.
func stripWrappingParens(s string) (string, bool) {
s = strings.TrimSpace(s)
if len(s) < 2 || s[0] != '(' || s[len(s)-1] != ')' {
return s, false
}
depth := 0
inQuote := false
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c == '\'':
inQuote = !inQuote
case inQuote:
// skip
case c == '(':
depth++
case c == ')':
depth--
if depth == 0 && i != len(s)-1 {
// closing paren is not the last char -> not a full wrap
return s, false
}
}
}
if depth != 0 {
return s, false
}
return s[1 : len(s)-1], true
}
// pgTextArrayLiteral builds a PostgreSQL text[] array literal ("{a,b,c}") from
// path segments, quoting and escaping any segment that is not a bare word.
func pgTextArrayLiteral(segs []string) string {
escaper := strings.NewReplacer(`\`, `\\`, `"`, `\"`)
parts := make([]string, len(segs))
for i, s := range segs {
if reSimpleSegment.MatchString(s) {
parts[i] = s
} else {
parts[i] = `"` + escaper.Replace(s) + `"`
}
}
return "{" + strings.Join(parts, ",") + "}"
}
// quoteQualifiedIdent quotes each dot-separated part of an identifier.
func quoteQualifiedIdent(ident string) string {
parts := strings.Split(ident, ".")
for i, p := range parts {
parts[i] = QuoteIdent(p)
}
return strings.Join(parts, ".")
}
func sanitizeAliasPart(s string) string {
var b strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' {
b.WriteRune(r)
} else {
b.WriteRune('_')
}
}
return b.String()
}
+274
View File
@@ -0,0 +1,274 @@
package common
import (
"reflect"
"testing"
)
func TestParseColumnRef_Valid(t *testing.T) {
tests := []struct {
name string
input string
base string
path []string
asText bool
cast string
alias string
ambiguous bool
}{
{
name: "arrow text extraction",
input: "data->>'city'",
base: "data", path: []string{"city"}, asText: true,
},
{
name: "arrow whitespace tolerant",
input: "data ->> 'city'",
base: "data", path: []string{"city"}, asText: true,
},
{
name: "nested arrow chain",
input: "data->'address'->>'city'",
base: "data", path: []string{"address", "city"}, asText: true,
},
{
name: "arrow jsonb result",
input: "data->'address'",
base: "data", path: []string{"address"}, asText: false,
},
{
name: "arrow array index",
input: "items->0->>'name'",
base: "items", path: []string{"0", "name"}, asText: true,
},
{
name: "hash path text",
input: "data#>>'{address,city}'",
base: "data", path: []string{"address", "city"}, asText: true,
},
{
name: "hash path jsonb",
input: "data#>'{address,city}'",
base: "data", path: []string{"address", "city"}, asText: false,
},
{
name: "dotted shorthand",
input: "data.address.city",
base: "data", path: []string{"address", "city"}, asText: true, ambiguous: true,
},
{
name: "trailing cast",
input: "data->>'age'::int",
base: "data", path: []string{"age"}, asText: true, cast: "integer",
},
{
name: "cast normalises",
input: "data->>'ts'::timestamptz",
base: "data", path: []string{"ts"}, asText: true, cast: "timestamptz",
},
{
name: "parenthesised with alias",
input: "(data->>'city') AS city_name",
base: "data", path: []string{"city"}, asText: true, alias: "city_name",
},
{
name: "paren wrap and cast",
input: "(data->>'age')::numeric",
base: "data", path: []string{"age"}, asText: true, cast: "numeric",
},
{
name: "quoted key with spaces",
input: "data->>'key with space'",
base: "data", path: []string{"key with space"}, asText: true,
},
{
name: "quoted key with escaped quote",
input: "data->>'o''brien'",
base: "data", path: []string{"o'brien"}, asText: true,
},
{
name: "relation column is ambiguous json",
input: "orders.total",
base: "orders", path: []string{"total"}, asText: true, ambiguous: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ref, ok := ParseColumnRef(tc.input)
if !ok {
t.Fatalf("ParseColumnRef(%q) returned ok=false", tc.input)
}
if ref.Base != tc.base {
t.Errorf("Base = %q, want %q", ref.Base, tc.base)
}
if !reflect.DeepEqual(ref.Path, tc.path) {
t.Errorf("Path = %#v, want %#v", ref.Path, tc.path)
}
if ref.AsText != tc.asText {
t.Errorf("AsText = %v, want %v", ref.AsText, tc.asText)
}
if ref.Cast != tc.cast {
t.Errorf("Cast = %q, want %q", ref.Cast, tc.cast)
}
if ref.Alias != tc.alias {
t.Errorf("Alias = %q, want %q", ref.Alias, tc.alias)
}
if ref.Ambiguous != tc.ambiguous {
t.Errorf("Ambiguous = %v, want %v", ref.Ambiguous, tc.ambiguous)
}
})
}
}
func TestParseColumnRef_NotJSON(t *testing.T) {
// These must return ok=false so callers fall back to their normal handling.
inputs := []string{
"",
" ",
"name",
"data",
"created_at",
"(id)",
}
for _, in := range inputs {
if ref, ok := ParseColumnRef(in); ok {
t.Errorf("ParseColumnRef(%q) = %+v, ok=true; want ok=false", in, ref)
}
}
}
func TestParseColumnRef_Rejected(t *testing.T) {
// Malformed or unsafe tokens must be rejected outright.
inputs := []string{
"data->>'x'::bogus", // cast not on allowlist
"data->>'x' AS 1bad", // invalid alias
"(data->>'a') OR (x->>'b')", // not a single wrapped expr
"data->>'x'); DROP TABLE users; --", // injection attempt
"data->b", // unquoted non-numeric key
"data->>''", // empty key
"data#>>'{}'", // empty hash path
"data#>>address", // hash path not a quoted literal
"weird col->>'x'", // base not an identifier
"data.address.city.but.way.too...deep.", // trailing dot -> empty segment
}
for _, in := range inputs {
if ref, ok := ParseColumnRef(in); ok {
t.Errorf("ParseColumnRef(%q) = %+v, ok=true; want rejected", in, ref)
}
}
}
func TestColumnRef_SQL(t *testing.T) {
tests := []struct {
name string
ref ColumnRef
alias string
wantExpr string
wantArgs []interface{}
}{
{
name: "text extraction qualified",
ref: ColumnRef{Base: "data", Path: []string{"address", "city"}, AsText: true},
alias: "u",
wantExpr: `("u"."data" #>> ?::text[])`,
wantArgs: []interface{}{"{address,city}"},
},
{
name: "jsonb extraction unqualified",
ref: ColumnRef{Base: "data", Path: []string{"a"}, AsText: false},
alias: "",
wantExpr: `("data" #> ?::text[])`,
wantArgs: []interface{}{"{a}"},
},
{
name: "with cast",
ref: ColumnRef{Base: "data", Path: []string{"age"}, AsText: true, Cast: "integer"},
alias: "t",
wantExpr: `(("t"."data" #>> ?::text[]))::integer`,
wantArgs: []interface{}{"{age}"},
},
{
name: "schema qualified alias",
ref: ColumnRef{Base: "data", Path: []string{"k"}, AsText: true},
alias: "public.users",
wantExpr: `("public"."users"."data" #>> ?::text[])`,
wantArgs: []interface{}{"{k}"},
},
{
name: "key needing quoting",
ref: ColumnRef{Base: "data", Path: []string{"key with space", `ev"il`}, AsText: true},
alias: "",
wantExpr: `("data" #>> ?::text[])`,
wantArgs: []interface{}{`{"key with space","ev\"il"}`},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
expr, args := tc.ref.SQL(tc.alias)
if expr != tc.wantExpr {
t.Errorf("expr = %q, want %q", expr, tc.wantExpr)
}
if !reflect.DeepEqual(args, tc.wantArgs) {
t.Errorf("args = %#v, want %#v", args, tc.wantArgs)
}
})
}
}
func TestColumnRef_SQL_RoundTrip(t *testing.T) {
ref, ok := ParseColumnRef("profile->'contact'->>'email'")
if !ok {
t.Fatal("parse failed")
}
expr, args := ref.SQL("customers")
wantExpr := `("customers"."profile" #>> ?::text[])`
if expr != wantExpr {
t.Errorf("expr = %q, want %q", expr, wantExpr)
}
if len(args) != 1 || args[0] != "{contact,email}" {
t.Errorf("args = %#v, want [{contact,email}]", args)
}
}
func TestColumnRef_OutputAlias(t *testing.T) {
cases := []struct {
ref ColumnRef
want string
}{
{ColumnRef{Base: "data", Path: []string{"address", "city"}, AsText: true}, "data_address_city"},
{ColumnRef{Base: "data", Path: []string{"city"}, Alias: "city"}, "city"},
{ColumnRef{Base: "data", Path: []string{"weird key"}}, "data_weird_key"},
{ColumnRef{Base: "data"}, "data"},
}
for _, c := range cases {
if got := c.ref.OutputAlias(); got != c.want {
t.Errorf("OutputAlias(%+v) = %q, want %q", c.ref, got, c.want)
}
}
}
func TestNormalizeCastTarget(t *testing.T) {
ok := map[string]string{
"int": "integer",
"INT": "integer",
" bigint ": "bigint",
"decimal": "numeric",
"float8": "double precision",
"bool": "boolean",
"timestamptz": "timestamptz",
"uuid": "uuid",
}
for in, want := range ok {
got, allowed := NormalizeCastTarget(in)
if !allowed || got != want {
t.Errorf("NormalizeCastTarget(%q) = %q, %v; want %q, true", in, got, allowed, want)
}
}
for _, in := range []string{"", "regclass", "int; drop", "text[]"} {
if got, allowed := NormalizeCastTarget(in); allowed {
t.Errorf("NormalizeCastTarget(%q) = %q, true; want not allowed", in, got)
}
}
}
+209
View File
@@ -0,0 +1,209 @@
package common
import (
"fmt"
"strings"
"github.com/bitechdev/ResolveSpec/pkg/reflection"
)
// This file wires the canonical JSON column parser (json_column.go) into the
// three query-building paths that every spec handler shares: SELECT column
// lists, WHERE filters and ORDER BY. The helpers here are the single place
// those paths call so that JSON access is resolved (and made injection-safe)
// identically everywhere. They mirror the style of BuildSpatialCondition /
// BuildVectorCondition: a boolean ok result tells the caller whether the token
// was a JSON reference it should take over, otherwise the caller keeps its
// existing (non-JSON) behaviour.
// jsonComparisonOps are the operators for which a JSON text extraction should be
// cast to a concrete type when the value looks numeric — otherwise "10" < "9".
var jsonComparisonOps = map[string]bool{
"gt": true, "greater_than": true, ">": true,
"gte": true, "greater_than_equals": true, "ge": true, ">=": true,
"lt": true, "less_than": true, "<": true,
"lte": true, "less_than_equals": true, "le": true, "<=": true,
"between": true, "between_inclusive": true,
}
// ResolveJSONColumnRef parses token and, when it is a usable JSON reference for
// model, returns the parsed ColumnRef. For the dotted "a.b" shorthand (which is
// otherwise indistinguishable from a table-qualified column) ok is true only
// when model confirms the base is a JSON column.
func ResolveJSONColumnRef(model interface{}, token string) (ColumnRef, bool) {
ref, ok := ParseColumnRef(token)
if !ok {
return ColumnRef{}, false
}
if ref.Ambiguous && !reflection.IsJSONColumn(model, ref.Base) {
return ColumnRef{}, false
}
return ref, true
}
// IsJSONColumnToken reports whether token is a JSON reference this package can
// resolve for model (arrow/hash syntax always; dotted shorthand only when the
// base is a JSON column).
func IsJSONColumnToken(model interface{}, token string) bool {
_, ok := ResolveJSONColumnRef(model, token)
return ok
}
// ResolveJSONColumnExpr resolves a raw column token that traverses into a JSON
// value into a parameterised SQL expression plus its args and a deterministic
// output alias. ok is false when the token is not a JSON reference, in which
// case the caller should handle it the way it did before.
//
// tableAlias, when non-empty, qualifies the base column.
func ResolveJSONColumnExpr(model interface{}, tableAlias, token string) (expr string, args []interface{}, alias string, ok bool) {
ref, ok := ResolveJSONColumnRef(model, token)
if !ok {
return "", nil, "", false
}
expr, args = ref.SQL(tableAlias)
return expr, args, ref.OutputAlias(), true
}
// ApplySelectColumns adds the requested columns to query, resolving any that are
// JSON sub-field references (data->>'x', data#>>'{a,b}', or the dotted data.x
// shorthand for a JSON column) into safe parameterised expressions with a
// deterministic alias. Plain columns are passed through reflection.ExtractSourceColumn
// exactly as before. tableAlias, when non-empty, qualifies JSON base columns.
func ApplySelectColumns(query SelectQuery, model interface{}, tableAlias string, columns []string) SelectQuery {
for _, col := range columns {
if expr, args, alias, ok := ResolveJSONColumnExpr(model, tableAlias, col); ok {
query = query.ColumnExpr(expr+" AS "+QuoteIdent(alias), args...)
continue
}
query = query.Column(reflection.ExtractSourceColumn(col))
}
return query
}
// BuildJSONFilterCondition builds a complete WHERE condition for a JSON column
// token. ok is false when the token is not a JSON reference or the operator is
// not one this builder handles (the caller then keeps its existing behaviour).
//
// The JSON path is always bound as a parameter, never interpolated. When the
// reference carries no explicit ::cast and the operator is an ordered
// comparison against a numeric value, the extracted text is cast to numeric so
// the comparison is numeric rather than lexical.
func BuildJSONFilterCondition(model interface{}, tableAlias, token, operator string, value interface{}) (condition string, args []interface{}, ok bool) {
ref, ok := ResolveJSONColumnRef(model, token)
if !ok {
return "", nil, false
}
op := strings.ToLower(strings.TrimSpace(operator))
// Infer a cast for ordered comparisons on numeric values so "10" > "9".
if ref.Cast == "" && jsonComparisonOps[op] && jsonValueIsNumeric(value) {
ref.Cast = "numeric"
}
colExpr, colArgs := ref.SQL(tableAlias)
// prepend copies the column-expression args (the bound JSON path, and any
// others) ahead of the value args so placeholder order matches the SQL.
prepend := func(valueArgs ...interface{}) []interface{} {
out := make([]interface{}, 0, len(colArgs)+len(valueArgs))
out = append(out, colArgs...)
out = append(out, valueArgs...)
return out
}
switch op {
case "eq", "equals", "=":
return fmt.Sprintf("%s = ?", colExpr), prepend(value), true
case "neq", "not_equals", "ne", "!=", "<>":
return fmt.Sprintf("%s != ?", colExpr), prepend(value), true
case "gt", "greater_than", ">":
return fmt.Sprintf("%s > ?", colExpr), prepend(value), true
case "gte", "greater_than_equals", "ge", ">=":
return fmt.Sprintf("%s >= ?", colExpr), prepend(value), true
case "lt", "less_than", "<":
return fmt.Sprintf("%s < ?", colExpr), prepend(value), true
case "lte", "less_than_equals", "le", "<=":
return fmt.Sprintf("%s <= ?", colExpr), prepend(value), true
case "like":
return fmt.Sprintf("%s LIKE ?", colExpr), prepend(value), true
case "ilike":
return fmt.Sprintf("%s ILIKE ?", colExpr), prepend(value), true
case "in":
inCond, inArgs := BuildInCondition(colExpr, value)
if inCond == "" {
return "", nil, false
}
return inCond, prepend(inArgs...), true
case "between", "between_inclusive":
lo, hi, bok := twoBoundValues(value)
if !bok {
return "", nil, false
}
loOp, hiOp := ">", "<"
if op == "between_inclusive" {
loOp, hiOp = ">=", "<="
}
// colExpr appears twice, so its bound args (the JSON path) appear twice.
betweenArgs := make([]interface{}, 0, 2*len(colArgs)+2)
betweenArgs = append(betweenArgs, colArgs...)
betweenArgs = append(betweenArgs, lo)
betweenArgs = append(betweenArgs, colArgs...)
betweenArgs = append(betweenArgs, hi)
return fmt.Sprintf("(%s %s ? AND %s %s ?)", colExpr, loOp, colExpr, hiOp), betweenArgs, true
case "is_null", "isnull":
return fmt.Sprintf("%s IS NULL", colExpr), prepend(), true
case "is_not_null", "isnotnull":
return fmt.Sprintf("%s IS NOT NULL", colExpr), prepend(), true
default:
return "", nil, false
}
}
// jsonValueIsNumeric reports whether value (or every element of a 2-slice) is a
// number or a numeric-looking string.
func jsonValueIsNumeric(value interface{}) bool {
switch v := value.(type) {
case []interface{}:
if len(v) == 0 {
return false
}
for _, e := range v {
if !jsonValueIsNumeric(e) {
return false
}
}
return true
case []string:
if len(v) == 0 {
return false
}
for _, e := range v {
if _, ok := toFloat(e); !ok {
return false
}
}
return true
case string:
_, ok := toFloat(v)
return ok
default:
_, ok := toFloat(value)
return ok
}
}
// twoBoundValues extracts the low/high bounds from a BETWEEN filter value.
func twoBoundValues(value interface{}) (lo, hi interface{}, ok bool) {
switch v := value.(type) {
case []interface{}:
if len(v) == 2 {
return v[0], v[1], true
}
case []string:
if len(v) == 2 {
return v[0], v[1], true
}
}
return nil, nil, false
}
+164
View File
@@ -0,0 +1,164 @@
package common
import (
"reflect"
"testing"
"github.com/bitechdev/ResolveSpec/pkg/spectypes"
)
type jsonCondModel struct {
ID int64 `json:"id"`
Name string `json:"name"`
Data spectypes.SqlJSONB `json:"data"`
}
func TestResolveJSONColumnRef_Gate(t *testing.T) {
m := jsonCondModel{}
// Explicit operator syntax needs no model confirmation.
if _, ok := ResolveJSONColumnRef(m, "data->>'city'"); !ok {
t.Error("arrow syntax should resolve")
}
// Dotted shorthand on a real JSON column resolves.
if ref, ok := ResolveJSONColumnRef(m, "data.city"); !ok || !reflect.DeepEqual(ref.Path, []string{"city"}) {
t.Errorf("dotted shorthand on JSON column should resolve, got ok=%v ref=%+v", ok, ref)
}
// Dotted shorthand on a non-JSON column must NOT be treated as JSON.
if _, ok := ResolveJSONColumnRef(m, "name.first"); ok {
t.Error("dotted shorthand on non-JSON column must not resolve as JSON")
}
// Plain columns never resolve.
if _, ok := ResolveJSONColumnRef(m, "name"); ok {
t.Error("plain column must not resolve")
}
}
func TestResolveJSONColumnExpr(t *testing.T) {
m := jsonCondModel{}
expr, args, alias, ok := ResolveJSONColumnExpr(m, "t", "data->'addr'->>'city'")
if !ok {
t.Fatal("expected ok")
}
if expr != `("t"."data" #>> ?::text[])` {
t.Errorf("expr = %q", expr)
}
if !reflect.DeepEqual(args, []interface{}{"{addr,city}"}) {
t.Errorf("args = %#v", args)
}
if alias != "data_addr_city" {
t.Errorf("alias = %q", alias)
}
if _, _, _, ok := ResolveJSONColumnExpr(m, "t", "name"); ok {
t.Error("plain column must not resolve")
}
}
func TestBuildJSONFilterCondition(t *testing.T) {
m := jsonCondModel{}
tests := []struct {
name string
token string
operator string
value interface{}
wantCond string
wantArgs []interface{}
}{
{
name: "eq stays text", token: "data->>'city'", operator: "eq", value: "LA",
wantCond: `("data" #>> ?::text[]) = ?`,
wantArgs: []interface{}{"{city}", "LA"},
},
{
name: "gt numeric value infers numeric cast", token: "data->>'age'", operator: "gt", value: 18,
wantCond: `(("data" #>> ?::text[]))::numeric > ?`,
wantArgs: []interface{}{"{age}", 18},
},
{
name: "gt non-numeric value stays text", token: "data->>'name'", operator: "gt", value: "m",
wantCond: `("data" #>> ?::text[]) > ?`,
wantArgs: []interface{}{"{name}", "m"},
},
{
name: "explicit cast is respected for lt", token: "data->>'ts'::timestamptz", operator: "lt", value: "2020-01-01",
wantCond: `(("data" #>> ?::text[]))::timestamptz < ?`,
wantArgs: []interface{}{"{ts}", "2020-01-01"},
},
{
name: "ilike", token: "data->>'city'", operator: "ilike", value: "%la%",
wantCond: `("data" #>> ?::text[]) ILIKE ?`,
wantArgs: []interface{}{"{city}", "%la%"},
},
{
name: "in", token: "data->>'tier'", operator: "in", value: []string{"a", "b"},
wantCond: `("data" #>> ?::text[]) IN (?,?)`,
wantArgs: []interface{}{"{tier}", "a", "b"},
},
{
name: "between numeric", token: "data->>'age'", operator: "between", value: []interface{}{10, 20},
wantCond: `((("data" #>> ?::text[]))::numeric > ? AND (("data" #>> ?::text[]))::numeric < ?)`,
wantArgs: []interface{}{"{age}", 10, "{age}", 20},
},
{
name: "is_null", token: "data->>'city'", operator: "is_null", value: nil,
wantCond: `("data" #>> ?::text[]) IS NULL`,
wantArgs: []interface{}{"{city}"},
},
{
name: "hash path", token: "data#>>'{a,b}'", operator: "eq", value: "x",
wantCond: `("data" #>> ?::text[]) = ?`,
wantArgs: []interface{}{"{a,b}", "x"},
},
{
name: "dotted shorthand on json column", token: "data.city", operator: "eq", value: "x",
wantCond: `("data" #>> ?::text[]) = ?`,
wantArgs: []interface{}{"{city}", "x"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cond, args, ok := BuildJSONFilterCondition(m, "", tc.token, tc.operator, tc.value)
if !ok {
t.Fatalf("ok=false for %q", tc.token)
}
if cond != tc.wantCond {
t.Errorf("cond = %q, want %q", cond, tc.wantCond)
}
if !reflect.DeepEqual(args, tc.wantArgs) {
t.Errorf("args = %#v, want %#v", args, tc.wantArgs)
}
})
}
}
func TestBuildJSONFilterCondition_NotJSON(t *testing.T) {
m := jsonCondModel{}
for _, tok := range []string{"name", "id", "name.first"} {
if _, _, ok := BuildJSONFilterCondition(m, "", tok, "eq", "x"); ok {
t.Errorf("BuildJSONFilterCondition(%q) ok=true, want false", tok)
}
}
// Unknown operator on a real JSON ref -> caller keeps its own handling.
if _, _, ok := BuildJSONFilterCondition(m, "", "data->>'x'", "st_intersects", "y"); ok {
t.Error("unknown operator must yield ok=false")
}
}
func TestBuildJSONFilterCondition_QualifiedAndInjectionSafe(t *testing.T) {
m := jsonCondModel{}
// A hostile key never reaches the SQL string — it is bound in the text[] arg.
cond, args, ok := BuildJSONFilterCondition(m, "pub.tbl", "data->>'ev\"il'", "eq", "x")
if !ok {
t.Fatal("ok=false")
}
if cond != `("pub"."tbl"."data" #>> ?::text[]) = ?` {
t.Errorf("cond = %q", cond)
}
if !reflect.DeepEqual(args, []interface{}{`{"ev\"il"}`, "x"}) {
t.Errorf("args = %#v", args)
}
}
+13
View File
@@ -109,6 +109,19 @@ func (v *ColumnValidator) ValidateColumn(column string) error {
return nil
}
// JSON-traversing references (data->>'x', data#>>'{a,b}', or the dotted
// data.x shorthand): validate the base column, and for the ambiguous
// dotted form require that the base is actually a JSON column.
if ref, isJSON := ParseColumnRef(column); isJSON {
if ref.Ambiguous && !reflection.IsJSONColumn(v.model, ref.Base) {
return fmt.Errorf("invalid column '%s': '%s' is not a JSON column", column, ref.Base)
}
if _, exists := v.validColumns[strings.ToLower(ref.Base)]; !exists {
return fmt.Errorf("invalid column '%s': column does not exist in model", column)
}
return nil
}
// Extract source column name (remove JSON operators like ->> or ->)
sourceColumn := reflection.ExtractSourceColumn(column)
+33
View File
@@ -4,6 +4,7 @@ import (
"testing"
"github.com/bitechdev/ResolveSpec/pkg/reflection"
"github.com/bitechdev/ResolveSpec/pkg/spectypes"
)
func TestExtractSourceColumn(t *testing.T) {
@@ -124,3 +125,35 @@ func TestValidateColumnWithJSONOperators(t *testing.T) {
})
}
}
func TestValidateColumn_JSONPathsAndDottedShorthand(t *testing.T) {
type Model struct {
ID int64 `json:"id"`
Name string `json:"name"`
Data spectypes.SqlJSONB `json:"data"`
}
v := NewColumnValidator(Model{})
valid := []string{
"data->>'city'",
"data->'addr'->>'city'",
"data#>>'{addr,city}'",
"data.addr.city", // dotted shorthand, base is JSON -> allowed
"(data->>'age')::int", // cast + paren
}
for _, c := range valid {
if err := v.ValidateColumn(c); err != nil {
t.Errorf("ValidateColumn(%q) = %v, want nil", c, err)
}
}
invalid := []string{
"nope->>'city'", // base column does not exist
"name.first", // dotted shorthand but 'name' is not a JSON column
}
for _, c := range invalid {
if err := v.ValidateColumn(c); err == nil {
t.Errorf("ValidateColumn(%q) = nil, want error", c)
}
}
}