mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-09-10 10:22:35 +00:00
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.
275 lines
7.7 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|