Files
ResolveSpec/pkg/common/json_column_test.go
T
Hein 206edd4bfd 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.
2026-09-07 17:05:52 +02:00

275 lines
7.7 KiB
Go

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