diff --git a/pkg/common/json_column.go b/pkg/common/json_column.go new file mode 100644 index 0000000..2296278 --- /dev/null +++ b/pkg/common/json_column.go @@ -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 `, 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 `. + 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 `::` 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() +} diff --git a/pkg/common/json_column_test.go b/pkg/common/json_column_test.go new file mode 100644 index 0000000..bac9c9d --- /dev/null +++ b/pkg/common/json_column_test.go @@ -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) + } + } +} diff --git a/pkg/common/json_condition.go b/pkg/common/json_condition.go new file mode 100644 index 0000000..2809b96 --- /dev/null +++ b/pkg/common/json_condition.go @@ -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 +} diff --git a/pkg/common/json_condition_test.go b/pkg/common/json_condition_test.go new file mode 100644 index 0000000..920931b --- /dev/null +++ b/pkg/common/json_condition_test.go @@ -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) + } +} diff --git a/pkg/common/validation.go b/pkg/common/validation.go index 23b6817..4ef9d26 100644 --- a/pkg/common/validation.go +++ b/pkg/common/validation.go @@ -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) diff --git a/pkg/common/validation_json_test.go b/pkg/common/validation_json_test.go index 79a0f36..380be25 100644 --- a/pkg/common/validation_json_test.go +++ b/pkg/common/validation_json_test.go @@ -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) + } + } +} diff --git a/pkg/mqttspec/handler.go b/pkg/mqttspec/handler.go index 284d876..9d5dd9c 100644 --- a/pkg/mqttspec/handler.go +++ b/pkg/mqttspec/handler.go @@ -676,7 +676,7 @@ func (h *Handler) readByID(hookCtx *HookContext) (interface{}, error) { // Apply columns if hookCtx.Options != nil && len(hookCtx.Options.Columns) > 0 { - query = query.Column(hookCtx.Options.Columns...) + query = common.ApplySelectColumns(query, hookCtx.Model, "", hookCtx.Options.Columns) } // Apply preloads (simplified) @@ -714,6 +714,10 @@ func (h *Handler) readMultiple(hookCtx *HookContext) (data interface{}, metadata if hookCtx.Options != nil { // Apply filters for _, filter := range hookCtx.Options.Filters { + if cond, jargs, ok := common.BuildJSONFilterCondition(hookCtx.Model, "", filter.Column, filter.Operator, filter.Value); ok { + query = query.Where(cond, jargs...) + continue + } op := strings.ToLower(filter.Operator) if op == "like" || op == "ilike" { query = query.Where(fmt.Sprintf("CAST(%s AS TEXT) %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value) @@ -728,6 +732,10 @@ func (h *Handler) readMultiple(hookCtx *HookContext) (data interface{}, metadata if sort.Direction == "desc" { direction = "DESC" } + if expr, jargs, _, ok := common.ResolveJSONColumnExpr(hookCtx.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)) } @@ -746,7 +754,7 @@ func (h *Handler) readMultiple(hookCtx *HookContext) (data interface{}, metadata // Apply columns if len(hookCtx.Options.Columns) > 0 { - query = query.Column(hookCtx.Options.Columns...) + query = common.ApplySelectColumns(query, hookCtx.Model, "", hookCtx.Options.Columns) } } @@ -772,6 +780,10 @@ func (h *Handler) readMultiple(hookCtx *HookContext) (data interface{}, metadata countQuery := h.db.NewSelect().Model(hookCtx.ModelPtr).Table(hookCtx.TableName) if hookCtx.Options != nil { for _, filter := range hookCtx.Options.Filters { + if cond, jargs, ok := common.BuildJSONFilterCondition(hookCtx.Model, "", filter.Column, filter.Operator, filter.Value); ok { + countQuery = countQuery.Where(cond, jargs...) + continue + } op := strings.ToLower(filter.Operator) if op == "like" || op == "ilike" { countQuery = countQuery.Where(fmt.Sprintf("CAST(%s AS TEXT) %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value) diff --git a/pkg/reflection/spectypes_helpers.go b/pkg/reflection/spectypes_helpers.go index a7f3a3e..4cf7c81 100644 --- a/pkg/reflection/spectypes_helpers.go +++ b/pkg/reflection/spectypes_helpers.go @@ -1,17 +1,19 @@ package reflection import ( + "encoding/json" "reflect" + "strings" "github.com/bitechdev/ResolveSpec/pkg/spectypes" ) -// getColumnFieldType resolves the reflect.Type of the struct field that backs -// colName (matched by json tag, field name or snake_case), following the same -// rules as GetColumnTypeFromModel. -func getColumnFieldType(model interface{}, colName string) (reflect.Type, bool) { +// getColumnStructField resolves the struct field that backs colName (matched by +// json tag, field name or snake_case), following the same rules as +// GetColumnTypeFromModel. +func getColumnStructField(model interface{}, colName string) (reflect.StructField, bool) { if model == nil { - return nil, false + return reflect.StructField{}, false } sourceColName := ExtractSourceColumn(colName) @@ -20,7 +22,7 @@ func getColumnFieldType(model interface{}, colName string) (reflect.Type, bool) modelType = modelType.Elem() } if modelType == nil || modelType.Kind() != reflect.Struct { - return nil, false + return reflect.StructField{}, false } for i := 0; i < modelType.NumField(); i++ { @@ -28,17 +30,28 @@ func getColumnFieldType(model interface{}, colName string) (reflect.Type, bool) if jsonTag := field.Tag.Get("json"); jsonTag != "" { if name := jsonTagName(jsonTag); name == sourceColName { - return field.Type, true + return field, true } } if equalFold(field.Name, sourceColName) { - return field.Type, true + return field, true } if ToSnakeCase(field.Name) == sourceColName { - return field.Type, true + return field, true } } - return nil, false + return reflect.StructField{}, false +} + +// getColumnFieldType resolves the reflect.Type of the struct field that backs +// colName (matched by json tag, field name or snake_case), following the same +// rules as GetColumnTypeFromModel. +func getColumnFieldType(model interface{}, colName string) (reflect.Type, bool) { + f, ok := getColumnStructField(model, colName) + if !ok { + return nil, false + } + return f.Type, true } func jsonTagName(tag string) string { @@ -92,3 +105,62 @@ func IsVectorColumn(model interface{}, colName string) bool { t, ok := getColumnFieldType(model, colName) return ok && spectypes.IsVectorType(t) } + +var rawMessageType = reflect.TypeOf(json.RawMessage(nil)) + +// IsJSONColumn reports whether colName is backed by a JSON/JSONB column on the +// model. It recognises the spectypes SqlJSONB wrapper, encoding/json.RawMessage, +// map-typed fields, and fields carrying a bun/gorm `type:json` / `type:jsonb` +// tag. colName should be a bare column name (callers pass the parsed base column +// of a JSON path, not the full "col->>'x'" expression). +func IsJSONColumn(model interface{}, colName string) bool { + f, ok := getColumnStructField(model, colName) + if !ok { + return false + } + + ft := f.Type + for ft != nil && ft.Kind() == reflect.Pointer { + ft = ft.Elem() + } + if ft == nil { + return false + } + + if spectypes.IsJSONType(ft) { + return true + } + if ft == rawMessageType { + return true + } + if ft.Kind() == reflect.Map { + return true + } + if tagDeclaresJSON(f.Tag.Get("bun")) || tagDeclaresJSON(f.Tag.Get("gorm")) { + return true + } + return false +} + +// tagDeclaresJSON reports whether an ORM struct tag declares a json/jsonb column +// type, e.g. `bun:"meta,type:jsonb"` or `gorm:"column:meta;type:json"`. +func tagDeclaresJSON(tag string) bool { + if tag == "" { + return false + } + for _, part := range strings.FieldsFunc(tag, func(r rune) bool { + return r == ',' || r == ';' || r == ' ' + }) { + value, found := strings.CutPrefix(strings.TrimSpace(part), "type:") + if !found { + continue + } + value = strings.ToLower(strings.TrimSpace(value)) + // Match "json" and "jsonb", including parametrised forms just in case. + if value == "json" || value == "jsonb" || + strings.HasPrefix(value, "json(") || strings.HasPrefix(value, "jsonb(") { + return true + } + } + return false +} diff --git a/pkg/reflection/spectypes_helpers_test.go b/pkg/reflection/spectypes_helpers_test.go new file mode 100644 index 0000000..89a917f --- /dev/null +++ b/pkg/reflection/spectypes_helpers_test.go @@ -0,0 +1,59 @@ +package reflection + +import ( + "encoding/json" + "testing" + + "github.com/bitechdev/ResolveSpec/pkg/spectypes" +) + +type jsonColModel struct { + ID int64 `json:"id"` + Name string `json:"name"` + Meta spectypes.SqlJSONB `json:"meta"` + Raw json.RawMessage `json:"raw"` + Attrs map[string]interface{} `json:"attrs"` + Config []byte `json:"config" bun:"config,type:jsonb"` + Settings string `json:"settings" gorm:"column:settings;type:json"` + Blob []byte `json:"blob"` +} + +func TestIsJSONColumn(t *testing.T) { + m := jsonColModel{} + + jsonCols := []string{"meta", "raw", "attrs", "config", "settings"} + for _, c := range jsonCols { + if !IsJSONColumn(m, c) { + t.Errorf("expected %q to be a JSON column", c) + } + } + + notJSON := []string{"id", "name", "blob", "missing"} + for _, c := range notJSON { + if IsJSONColumn(m, c) { + t.Errorf("expected %q NOT to be a JSON column", c) + } + } + + if IsJSONColumn(nil, "meta") { + t.Error("nil model must not report JSON columns") + } +} + +func TestTagDeclaresJSON(t *testing.T) { + cases := map[string]bool{ + "config,type:jsonb": true, + "column:settings;type:json": true, + "col,type:text": false, + "column:name": false, + "": false, + "col,type:jsonb,notnull": true, + "column:x;type:varchar(255)": false, + "col , type:json": true, + } + for tag, want := range cases { + if got := tagDeclaresJSON(tag); got != want { + t.Errorf("tagDeclaresJSON(%q) = %v; want %v", tag, got, want) + } + } +} diff --git a/pkg/resolvespec/filter_test.go b/pkg/resolvespec/filter_test.go index 6ee2ea7..629acc0 100644 --- a/pkg/resolvespec/filter_test.go +++ b/pkg/resolvespec/filter_test.go @@ -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) diff --git a/pkg/resolvespec/handler.go b/pkg/resolvespec/handler.go index 5772683..c23fb26 100644 --- a/pkg/resolvespec/handler.go +++ b/pkg/resolvespec/handler.go @@ -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 { diff --git a/pkg/resolvespec/json_column_test.go b/pkg/resolvespec/json_column_test.go new file mode 100644 index 0000000..cee1368 --- /dev/null +++ b/pkg/resolvespec/json_column_test.go @@ -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) + } +} diff --git a/pkg/restheadspec/handler.go b/pkg/restheadspec/handler.go index 6ebc895..ae30338 100644 --- a/pkg/restheadspec/handler.go +++ b/pkg/restheadspec/handler.go @@ -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 diff --git a/pkg/restheadspec/json_column_test.go b/pkg/restheadspec/json_column_test.go new file mode 100644 index 0000000..9fa1559 --- /dev/null +++ b/pkg/restheadspec/json_column_test.go @@ -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) + } + }) +} diff --git a/pkg/spectypes/type_names.go b/pkg/spectypes/type_names.go index 471224c..7885f8b 100644 --- a/pkg/spectypes/type_names.go +++ b/pkg/spectypes/type_names.go @@ -91,3 +91,9 @@ func IsVectorType(t reflect.Type) bool { n, ok := SQLTypeName(t) return ok && (n == "vector" || n == "halfvec" || n == "sparsevec") } + +// IsJSONType reports whether t is a spectypes JSON/JSONB wrapper. +func IsJSONType(t reflect.Type) bool { + n, ok := SQLTypeName(t) + return ok && (n == "jsonb" || n == "json") +} diff --git a/pkg/spectypes/type_names_test.go b/pkg/spectypes/type_names_test.go index fdda16f..57aaa76 100644 --- a/pkg/spectypes/type_names_test.go +++ b/pkg/spectypes/type_names_test.go @@ -51,6 +51,20 @@ func TestIsSpatialType(t *testing.T) { } } +func TestIsJSONType(t *testing.T) { + if !IsJSONType(reflect.TypeOf(SqlJSONB{})) { + t.Error("SqlJSONB should be a JSON type") + } + if !IsJSONType(reflect.TypeOf(&SqlJSONB{})) { + t.Error("*SqlJSONB should be a JSON type (pointer unwrapped)") + } + for _, v := range []any{SqlGeometry{}, SqlVector{}, SqlString{}, SqlStringArray{}, ""} { + if IsJSONType(reflect.TypeOf(v)) { + t.Errorf("%T should not be a JSON type", v) + } + } +} + func TestIsVectorType(t *testing.T) { for _, v := range []any{SqlVector{}, SqlHalfVector{}, SqlSparseVector{}} { if !IsVectorType(reflect.TypeOf(v)) { diff --git a/pkg/websocketspec/handler.go b/pkg/websocketspec/handler.go index 9c4abf9..37bccbf 100644 --- a/pkg/websocketspec/handler.go +++ b/pkg/websocketspec/handler.go @@ -564,7 +564,7 @@ func (h *Handler) readByID(hookCtx *HookContext) (interface{}, error) { // Apply columns if hookCtx.Options != nil && len(hookCtx.Options.Columns) > 0 { - query = query.Column(hookCtx.Options.Columns...) + query = common.ApplySelectColumns(query, hookCtx.Model, "", hookCtx.Options.Columns) } // Apply preloads (simplified for now) @@ -606,7 +606,7 @@ func (h *Handler) readMultiple(hookCtx *HookContext) (data interface{}, metadata // Apply options (simplified implementation) if hookCtx.Options != nil { // Apply filters with OR grouping support - query = h.applyFilters(query, hookCtx.Options.Filters) + query = h.applyFilters(query, hookCtx.Options.Filters, hookCtx.Model) // Apply sorting for _, sort := range hookCtx.Options.Sort { @@ -614,6 +614,10 @@ func (h *Handler) readMultiple(hookCtx *HookContext) (data interface{}, metadata if sort.Direction == "desc" { direction = "DESC" } + if expr, jargs, _, ok := common.ResolveJSONColumnExpr(hookCtx.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)) } @@ -632,7 +636,7 @@ func (h *Handler) readMultiple(hookCtx *HookContext) (data interface{}, metadata // Apply columns if len(hookCtx.Options.Columns) > 0 { - query = query.Column(hookCtx.Options.Columns...) + query = common.ApplySelectColumns(query, hookCtx.Model, "", hookCtx.Options.Columns) } } @@ -665,7 +669,7 @@ func (h *Handler) readMultiple(hookCtx *HookContext) (data interface{}, metadata countQuery := h.db.NewSelect().Model(hookCtx.ModelPtr).Table(hookCtx.TableName) if hookCtx.Options != nil { for _, filter := range hookCtx.Options.Filters { - cond, args := h.buildFilterCondition(filter) + cond, args := h.buildFilterCondition(filter, hookCtx.Model) if cond != "" { countQuery = countQuery.Where(cond, args...) } @@ -776,7 +780,7 @@ func (h *Handler) getMetadata(schema, entity string, model interface{}) map[stri // getOperatorSQL converts filter operator to SQL operator // applyFilters applies all filters with proper grouping for OR logic // Groups consecutive OR filters together to ensure proper query precedence -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 } @@ -796,11 +800,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...) } @@ -813,7 +817,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 } @@ -823,7 +827,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...) @@ -844,8 +848,14 @@ 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{}) { + if cond, jargs, ok := common.BuildJSONFilterCondition(model, "", filter.Column, filter.Operator, filter.Value); ok { + return cond, jargs + } if strings.EqualFold(filter.Operator, "in") { cond, args := common.BuildInCondition(filter.Column, filter.Value) return cond, args