mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-09-10 10:22:35 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e957753ce4 | ||
|
|
206edd4bfd |
@@ -0,0 +1,402 @@
|
|||||||
|
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) (expr string, args []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 {
|
||||||
|
isCastChar := r == ' ' || r == '_' ||
|
||||||
|
(r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
|
||||||
|
if !isCastChar {
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,6 +109,19 @@ func (v *ColumnValidator) ValidateColumn(column string) error {
|
|||||||
return nil
|
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 ->)
|
// Extract source column name (remove JSON operators like ->> or ->)
|
||||||
sourceColumn := reflection.ExtractSourceColumn(column)
|
sourceColumn := reflection.ExtractSourceColumn(column)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/bitechdev/ResolveSpec/pkg/reflection"
|
"github.com/bitechdev/ResolveSpec/pkg/reflection"
|
||||||
|
"github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestExtractSourceColumn(t *testing.T) {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+14
-2
@@ -676,7 +676,7 @@ func (h *Handler) readByID(hookCtx *HookContext) (interface{}, error) {
|
|||||||
|
|
||||||
// Apply columns
|
// Apply columns
|
||||||
if hookCtx.Options != nil && len(hookCtx.Options.Columns) > 0 {
|
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)
|
// Apply preloads (simplified)
|
||||||
@@ -714,6 +714,10 @@ func (h *Handler) readMultiple(hookCtx *HookContext) (data interface{}, metadata
|
|||||||
if hookCtx.Options != nil {
|
if hookCtx.Options != nil {
|
||||||
// Apply filters
|
// Apply filters
|
||||||
for _, filter := range hookCtx.Options.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)
|
op := strings.ToLower(filter.Operator)
|
||||||
if op == "like" || op == "ilike" {
|
if op == "like" || op == "ilike" {
|
||||||
query = query.Where(fmt.Sprintf("CAST(%s AS TEXT) %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value)
|
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" {
|
if sort.Direction == "desc" {
|
||||||
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))
|
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
|
// Apply columns
|
||||||
if len(hookCtx.Options.Columns) > 0 {
|
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)
|
countQuery := h.db.NewSelect().Model(hookCtx.ModelPtr).Table(hookCtx.TableName)
|
||||||
if hookCtx.Options != nil {
|
if hookCtx.Options != nil {
|
||||||
for _, filter := range hookCtx.Options.Filters {
|
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)
|
op := strings.ToLower(filter.Operator)
|
||||||
if op == "like" || op == "ilike" {
|
if op == "like" || op == "ilike" {
|
||||||
countQuery = countQuery.Where(fmt.Sprintf("CAST(%s AS TEXT) %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value)
|
countQuery = countQuery.Where(fmt.Sprintf("CAST(%s AS TEXT) %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value)
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
package reflection
|
package reflection
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
"github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||||
)
|
)
|
||||||
|
|
||||||
// getColumnFieldType resolves the reflect.Type of the struct field that backs
|
// getColumnStructField resolves the struct field that backs colName (matched by
|
||||||
// colName (matched by json tag, field name or snake_case), following the same
|
// json tag, field name or snake_case), following the same rules as
|
||||||
// rules as GetColumnTypeFromModel.
|
// GetColumnTypeFromModel.
|
||||||
func getColumnFieldType(model interface{}, colName string) (reflect.Type, bool) {
|
func getColumnStructField(model interface{}, colName string) (reflect.StructField, bool) {
|
||||||
if model == nil {
|
if model == nil {
|
||||||
return nil, false
|
return reflect.StructField{}, false
|
||||||
}
|
}
|
||||||
sourceColName := ExtractSourceColumn(colName)
|
sourceColName := ExtractSourceColumn(colName)
|
||||||
|
|
||||||
@@ -20,7 +22,7 @@ func getColumnFieldType(model interface{}, colName string) (reflect.Type, bool)
|
|||||||
modelType = modelType.Elem()
|
modelType = modelType.Elem()
|
||||||
}
|
}
|
||||||
if modelType == nil || modelType.Kind() != reflect.Struct {
|
if modelType == nil || modelType.Kind() != reflect.Struct {
|
||||||
return nil, false
|
return reflect.StructField{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 0; i < modelType.NumField(); i++ {
|
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 jsonTag := field.Tag.Get("json"); jsonTag != "" {
|
||||||
if name := jsonTagName(jsonTag); name == sourceColName {
|
if name := jsonTagName(jsonTag); name == sourceColName {
|
||||||
return field.Type, true
|
return field, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if equalFold(field.Name, sourceColName) {
|
if equalFold(field.Name, sourceColName) {
|
||||||
return field.Type, true
|
return field, true
|
||||||
}
|
}
|
||||||
if ToSnakeCase(field.Name) == sourceColName {
|
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 {
|
func jsonTagName(tag string) string {
|
||||||
@@ -92,3 +105,62 @@ func IsVectorColumn(model interface{}, colName string) bool {
|
|||||||
t, ok := getColumnFieldType(model, colName)
|
t, ok := getColumnFieldType(model, colName)
|
||||||
return ok && spectypes.IsVectorType(t)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,7 +128,7 @@ func TestBuildFilterCondition(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
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 {
|
if condition != tt.expectedCondition {
|
||||||
t.Errorf("Expected condition '%s', got '%s'", tt.expectedCondition, condition)
|
t.Errorf("Expected condition '%s', got '%s'", tt.expectedCondition, condition)
|
||||||
|
|||||||
+33
-11
@@ -347,6 +347,10 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
|||||||
if len(options.Columns) > 0 {
|
if len(options.Columns) > 0 {
|
||||||
logger.Debug("Selecting columns: %v", options.Columns)
|
logger.Debug("Selecting columns: %v", options.Columns)
|
||||||
for _, col := range 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))
|
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
|
// Apply filters with proper grouping for OR logic
|
||||||
query = h.applyFilters(query, options.Filters)
|
query = h.applyFilters(query, options.Filters, model)
|
||||||
|
|
||||||
// Apply custom operators
|
// Apply custom operators
|
||||||
for _, customOp := range options.CustomOperators {
|
for _, customOp := range options.CustomOperators {
|
||||||
@@ -413,6 +417,10 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
|||||||
direction = "DESC"
|
direction = "DESC"
|
||||||
}
|
}
|
||||||
logger.Debug("Applying sort: %s %s", sort.Column, direction)
|
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))
|
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
|
// Apply the same filters as the main query
|
||||||
for _, filter := range options.Filters {
|
for _, filter := range options.Filters {
|
||||||
rowNumQuery = h.applyFilter(rowNumQuery, filter)
|
rowNumQuery = h.applyFilter(rowNumQuery, filter, model)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply custom operators
|
// 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
|
// applyFilters applies all filters with proper grouping for OR logic
|
||||||
// Groups consecutive OR filters together to ensure proper query precedence
|
// 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
|
// 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 {
|
if len(filters) == 0 {
|
||||||
return query
|
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
|
// Apply the OR group as a single grouped WHERE clause
|
||||||
query = h.applyFilterGroup(query, orGroup)
|
query = h.applyFilterGroup(query, orGroup, model)
|
||||||
i = j
|
i = j
|
||||||
} else {
|
} else {
|
||||||
// Single filter with AND logic (or first filter)
|
// Single filter with AND logic (or first filter)
|
||||||
condition, args := h.buildFilterCondition(filters[i])
|
condition, args := h.buildFilterCondition(filters[i], model)
|
||||||
if condition != "" {
|
if condition != "" {
|
||||||
query = query.Where(condition, args...)
|
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
|
// applyFilterGroup applies a group of filters that should be OR'd together
|
||||||
// Always wraps them in parentheses and applies as a single WHERE clause
|
// 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 {
|
if len(filters) == 0 {
|
||||||
return query
|
return query
|
||||||
}
|
}
|
||||||
@@ -1876,7 +1884,7 @@ func (h *Handler) applyFilterGroup(query common.SelectQuery, filters []common.Fi
|
|||||||
var args []interface{}
|
var args []interface{}
|
||||||
|
|
||||||
for _, filter := range filters {
|
for _, filter := range filters {
|
||||||
condition, filterArgs := h.buildFilterCondition(filter)
|
condition, filterArgs := h.buildFilterCondition(filter, model)
|
||||||
if condition != "" {
|
if condition != "" {
|
||||||
conditions = append(conditions, condition)
|
conditions = append(conditions, condition)
|
||||||
args = append(args, filterArgs...)
|
args = append(args, filterArgs...)
|
||||||
@@ -1897,11 +1905,18 @@ func (h *Handler) applyFilterGroup(query common.SelectQuery, filters []common.Fi
|
|||||||
return query.Where(groupedCondition, args...)
|
return query.Where(groupedCondition, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildFilterCondition builds a filter condition and returns it with args
|
// buildFilterCondition builds a filter condition and returns it with args.
|
||||||
func (h *Handler) buildFilterCondition(filter common.FilterOption) (conditionString string, conditionArgs []interface{}) {
|
// 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 condition string
|
||||||
var args []interface{}
|
var args []interface{}
|
||||||
|
|
||||||
|
if cond, jargs, ok := common.BuildJSONFilterCondition(model, "", filter.Column, filter.Operator, filter.Value); ok {
|
||||||
|
return cond, jargs
|
||||||
|
}
|
||||||
|
|
||||||
switch filter.Operator {
|
switch filter.Operator {
|
||||||
case "eq", "=":
|
case "eq", "=":
|
||||||
condition = fmt.Sprintf("%s = ?", filter.Column)
|
condition = fmt.Sprintf("%s = ?", filter.Column)
|
||||||
@@ -1958,13 +1973,20 @@ func (h *Handler) buildFilterCondition(filter common.FilterOption) (conditionStr
|
|||||||
return condition, args
|
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
|
// Determine which method to use based on LogicOperator
|
||||||
useOrLogic := strings.EqualFold(filter.LogicOperator, "OR")
|
useOrLogic := strings.EqualFold(filter.LogicOperator, "OR")
|
||||||
|
|
||||||
var condition string
|
var condition string
|
||||||
var args []interface{}
|
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 {
|
switch filter.Operator {
|
||||||
case "eq", "=":
|
case "eq", "=":
|
||||||
condition = fmt.Sprintf("%s = ?", filter.Column)
|
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 {
|
if len(preload.Filters) > 0 {
|
||||||
for _, filter := range preload.Filters {
|
for _, filter := range preload.Filters {
|
||||||
sq = h.applyFilter(sq, filter)
|
sq = h.applyFilter(sq, filter, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(preload.Sort) > 0 {
|
if len(preload.Sort) > 0 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
-12
@@ -471,7 +471,14 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
|||||||
// Apply column selection
|
// Apply column selection
|
||||||
if len(options.Columns) > 0 {
|
if len(options.Columns) > 0 {
|
||||||
logger.Debug("Selecting columns: %v", options.Columns)
|
logger.Debug("Selecting columns: %v", options.Columns)
|
||||||
|
selectAlias := reflection.ExtractTableNameOnly(tableName)
|
||||||
for _, col := range options.Columns {
|
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))
|
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
|
// Apply the OR group as a single grouped condition
|
||||||
logger.Debug("Applying OR filter group with %d conditions", len(orFilters))
|
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
|
i = j
|
||||||
} else {
|
} else {
|
||||||
// Single AND filter - apply normally
|
// 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)
|
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++
|
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)
|
logger.Debug("Applying sort: %s %s", sort.Column, direction)
|
||||||
|
|
||||||
// Check if it's an expression (enclosed in brackets) - use directly without quoting
|
// JSON sub-field reference (data->>'x', data#>>'{a,b}', or dotted
|
||||||
if strings.HasPrefix(sort.Column, "(") && strings.HasSuffix(sort.Column, ")") {
|
// 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
|
// For expressions, pass as raw SQL to prevent auto-quoting
|
||||||
query = query.OrderExpr(fmt.Sprintf("%s %s", sort.Column, direction))
|
query = query.OrderExpr(fmt.Sprintf("%s %s", sort.Column, direction))
|
||||||
} else if strings.Contains(sort.Column, ".") {
|
} else if strings.Contains(sort.Column, ".") {
|
||||||
@@ -1063,7 +1074,7 @@ func (h *Handler) applyPreloadWithRecursion(query common.SelectQuery, preload co
|
|||||||
// Apply filters
|
// Apply filters
|
||||||
if len(preload.Filters) > 0 {
|
if len(preload.Filters) > 0 {
|
||||||
for _, filter := range preload.Filters {
|
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)
|
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
|
// Qualify the column name with table name if not already qualified
|
||||||
rawQualifiedColumn := h.qualifyColumnName(filter.Column, tableName)
|
rawQualifiedColumn := h.qualifyColumnName(filter.Column, tableName)
|
||||||
qualifiedColumn := rawQualifiedColumn
|
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
|
// Helper function to apply the correct Where method based on logic operator
|
||||||
applyWhere := func(condition string, args ...interface{}) common.SelectQuery {
|
applyWhere := func(condition string, args ...interface{}) common.SelectQuery {
|
||||||
if logicOp == "OR" {
|
if logicOp == "OR" {
|
||||||
@@ -2306,6 +2312,19 @@ func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOpti
|
|||||||
return query.Where(condition, args...)
|
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) {
|
switch strings.ToLower(filter.Operator) {
|
||||||
case "eq", "equals":
|
case "eq", "equals":
|
||||||
return applyWhere(fmt.Sprintf("%s = ?", qualifiedColumn), filter.Value)
|
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
|
// 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
|
// 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 {
|
if len(filters) == 0 {
|
||||||
return query
|
return query
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tableAlias := reflection.ExtractTableNameOnly(tableName)
|
||||||
|
|
||||||
// Build individual filter conditions
|
// Build individual filter conditions
|
||||||
conditions := []string{}
|
conditions := []string{}
|
||||||
args := []interface{}{}
|
args := []interface{}{}
|
||||||
|
|
||||||
for i, filter := range filters {
|
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
|
// Qualify the column name with table name if not already qualified
|
||||||
rawQualifiedColumn := h.qualifyColumnName(filter.Column, tableName)
|
rawQualifiedColumn := h.qualifyColumnName(filter.Column, tableName)
|
||||||
qualifiedColumn := rawQualifiedColumn
|
qualifiedColumn := rawQualifiedColumn
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package restheadspec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/bitechdev/ResolveSpec/pkg/common"
|
||||||
|
"github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// jsonColModel exercises the JSON-column wiring: Data is a real JSONB column so
|
||||||
|
// the dotted "data.x" shorthand is recognised as JSON access.
|
||||||
|
type jsonColModel struct {
|
||||||
|
ID int64 `json:"id" bun:"id,pk"`
|
||||||
|
Name string `json:"name" bun:"name"`
|
||||||
|
Data spectypes.SqlJSONB `json:"data" bun:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonCapQuery is a minimal common.SelectQuery that records the string + args of
|
||||||
|
// the calls the handler makes so a test can assert on them.
|
||||||
|
type jsonCapQuery struct {
|
||||||
|
calls []jsonCapCall
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsonCapCall struct {
|
||||||
|
method string
|
||||||
|
query string
|
||||||
|
args []interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *jsonCapQuery) rec(method, query string, args []interface{}) common.SelectQuery {
|
||||||
|
m.calls = append(m.calls, jsonCapCall{method: method, query: query, args: args})
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *jsonCapQuery) Model(interface{}) common.SelectQuery { return m }
|
||||||
|
func (m *jsonCapQuery) Table(string) common.SelectQuery { return m }
|
||||||
|
func (m *jsonCapQuery) Column(cols ...string) common.SelectQuery {
|
||||||
|
for _, c := range cols {
|
||||||
|
m.rec("Column", c, nil)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
func (m *jsonCapQuery) ColumnExpr(q string, args ...interface{}) common.SelectQuery {
|
||||||
|
return m.rec("ColumnExpr", q, args)
|
||||||
|
}
|
||||||
|
func (m *jsonCapQuery) Where(q string, args ...interface{}) common.SelectQuery {
|
||||||
|
return m.rec("Where", q, args)
|
||||||
|
}
|
||||||
|
func (m *jsonCapQuery) WhereOr(q string, args ...interface{}) common.SelectQuery {
|
||||||
|
return m.rec("WhereOr", q, args)
|
||||||
|
}
|
||||||
|
func (m *jsonCapQuery) WhereIn(col string, values interface{}) common.SelectQuery {
|
||||||
|
return m.rec("WhereIn", col, []interface{}{values})
|
||||||
|
}
|
||||||
|
func (m *jsonCapQuery) Order(o string) common.SelectQuery { return m.rec("Order", o, nil) }
|
||||||
|
func (m *jsonCapQuery) OrderExpr(o string, args ...interface{}) common.SelectQuery {
|
||||||
|
return m.rec("OrderExpr", o, args)
|
||||||
|
}
|
||||||
|
func (m *jsonCapQuery) Limit(int) common.SelectQuery { return m }
|
||||||
|
func (m *jsonCapQuery) Offset(int) common.SelectQuery { return m }
|
||||||
|
func (m *jsonCapQuery) Join(string, ...interface{}) common.SelectQuery { return m }
|
||||||
|
func (m *jsonCapQuery) LeftJoin(string, ...interface{}) common.SelectQuery { return m }
|
||||||
|
func (m *jsonCapQuery) Group(string) common.SelectQuery { return m }
|
||||||
|
func (m *jsonCapQuery) Having(string, ...interface{}) common.SelectQuery { return m }
|
||||||
|
func (m *jsonCapQuery) Preload(string, ...interface{}) common.SelectQuery { return m }
|
||||||
|
func (m *jsonCapQuery) PreloadRelation(string, ...func(common.SelectQuery) common.SelectQuery) common.SelectQuery {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
func (m *jsonCapQuery) JoinRelation(string, ...func(common.SelectQuery) common.SelectQuery) common.SelectQuery {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
func (m *jsonCapQuery) Scan(context.Context, interface{}) error { return nil }
|
||||||
|
func (m *jsonCapQuery) ScanModel(context.Context) error { return nil }
|
||||||
|
func (m *jsonCapQuery) Count(context.Context) (int, error) { return 0, nil }
|
||||||
|
func (m *jsonCapQuery) Exists(context.Context) (bool, error) { return false, nil }
|
||||||
|
func (m *jsonCapQuery) GetUnderlyingQuery() interface{} { return nil }
|
||||||
|
func (m *jsonCapQuery) GetModel() interface{} { return nil }
|
||||||
|
|
||||||
|
func (m *jsonCapQuery) only(t *testing.T) jsonCapCall {
|
||||||
|
t.Helper()
|
||||||
|
if len(m.calls) != 1 {
|
||||||
|
t.Fatalf("expected exactly 1 recorded call, got %d: %+v", len(m.calls), m.calls)
|
||||||
|
}
|
||||||
|
return m.calls[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyFilter_JSONColumn(t *testing.T) {
|
||||||
|
h := &Handler{}
|
||||||
|
model := jsonColModel{}
|
||||||
|
|
||||||
|
t.Run("arrow syntax eq", func(t *testing.T) {
|
||||||
|
q := &jsonCapQuery{}
|
||||||
|
h.applyFilter(q, common.FilterOption{
|
||||||
|
Column: "data->>'city'", Operator: "eq", Value: "LA",
|
||||||
|
}, "public.things", false, "AND", model)
|
||||||
|
c := q.only(t)
|
||||||
|
if c.method != "Where" || c.query != `("things"."data" #>> ?::text[]) = ?` {
|
||||||
|
t.Fatalf("call = %+v", c)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(c.args, []interface{}{"{city}", "LA"}) {
|
||||||
|
t.Fatalf("args = %#v", c.args)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("dotted shorthand with numeric cast inference, OR logic", func(t *testing.T) {
|
||||||
|
q := &jsonCapQuery{}
|
||||||
|
h.applyFilter(q, common.FilterOption{
|
||||||
|
Column: "data.age", Operator: "gt", Value: 18,
|
||||||
|
}, "public.things", false, "OR", model)
|
||||||
|
c := q.only(t)
|
||||||
|
if c.method != "WhereOr" || c.query != `(("things"."data" #>> ?::text[]))::numeric > ?` {
|
||||||
|
t.Fatalf("call = %+v", c)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(c.args, []interface{}{"{age}", 18}) {
|
||||||
|
t.Fatalf("args = %#v", c.args)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non-JSON column is untouched", func(t *testing.T) {
|
||||||
|
q := &jsonCapQuery{}
|
||||||
|
h.applyFilter(q, common.FilterOption{
|
||||||
|
Column: "name", Operator: "eq", Value: "x",
|
||||||
|
}, "public.things", false, "AND", model)
|
||||||
|
c := q.only(t)
|
||||||
|
if c.query != "things.name = ?" {
|
||||||
|
t.Fatalf("call = %+v", c)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nil model: explicit syntax still works, dotted does not", func(t *testing.T) {
|
||||||
|
q := &jsonCapQuery{}
|
||||||
|
h.applyFilter(q, common.FilterOption{
|
||||||
|
Column: "data->>'city'", Operator: "eq", Value: "LA",
|
||||||
|
}, "public.things", false, "AND", nil)
|
||||||
|
if c := q.only(t); c.query != `("things"."data" #>> ?::text[]) = ?` {
|
||||||
|
t.Fatalf("explicit call = %+v", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
q2 := &jsonCapQuery{}
|
||||||
|
h.applyFilter(q2, common.FilterOption{
|
||||||
|
Column: "data.city", Operator: "eq", Value: "LA",
|
||||||
|
}, "public.things", false, "AND", nil)
|
||||||
|
if c := q2.only(t); c.query == `("things"."data" #>> ?::text[]) = ?` {
|
||||||
|
t.Fatalf("dotted shorthand should not resolve without a model: %+v", c)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -91,3 +91,9 @@ func IsVectorType(t reflect.Type) bool {
|
|||||||
n, ok := SQLTypeName(t)
|
n, ok := SQLTypeName(t)
|
||||||
return ok && (n == "vector" || n == "halfvec" || n == "sparsevec")
|
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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestIsVectorType(t *testing.T) {
|
||||||
for _, v := range []any{SqlVector{}, SqlHalfVector{}, SqlSparseVector{}} {
|
for _, v := range []any{SqlVector{}, SqlHalfVector{}, SqlSparseVector{}} {
|
||||||
if !IsVectorType(reflect.TypeOf(v)) {
|
if !IsVectorType(reflect.TypeOf(v)) {
|
||||||
|
|||||||
@@ -564,7 +564,7 @@ func (h *Handler) readByID(hookCtx *HookContext) (interface{}, error) {
|
|||||||
|
|
||||||
// Apply columns
|
// Apply columns
|
||||||
if hookCtx.Options != nil && len(hookCtx.Options.Columns) > 0 {
|
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)
|
// Apply preloads (simplified for now)
|
||||||
@@ -606,7 +606,7 @@ func (h *Handler) readMultiple(hookCtx *HookContext) (data interface{}, metadata
|
|||||||
// Apply options (simplified implementation)
|
// Apply options (simplified implementation)
|
||||||
if hookCtx.Options != nil {
|
if hookCtx.Options != nil {
|
||||||
// Apply filters with OR grouping support
|
// Apply filters with OR grouping support
|
||||||
query = h.applyFilters(query, hookCtx.Options.Filters)
|
query = h.applyFilters(query, hookCtx.Options.Filters, hookCtx.Model)
|
||||||
|
|
||||||
// Apply sorting
|
// Apply sorting
|
||||||
for _, sort := range hookCtx.Options.Sort {
|
for _, sort := range hookCtx.Options.Sort {
|
||||||
@@ -614,6 +614,10 @@ func (h *Handler) readMultiple(hookCtx *HookContext) (data interface{}, metadata
|
|||||||
if sort.Direction == "desc" {
|
if sort.Direction == "desc" {
|
||||||
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))
|
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
|
// Apply columns
|
||||||
if len(hookCtx.Options.Columns) > 0 {
|
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)
|
countQuery := h.db.NewSelect().Model(hookCtx.ModelPtr).Table(hookCtx.TableName)
|
||||||
if hookCtx.Options != nil {
|
if hookCtx.Options != nil {
|
||||||
for _, filter := range hookCtx.Options.Filters {
|
for _, filter := range hookCtx.Options.Filters {
|
||||||
cond, args := h.buildFilterCondition(filter)
|
cond, args := h.buildFilterCondition(filter, hookCtx.Model)
|
||||||
if cond != "" {
|
if cond != "" {
|
||||||
countQuery = countQuery.Where(cond, args...)
|
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
|
// getOperatorSQL converts filter operator to SQL operator
|
||||||
// applyFilters applies all filters with proper grouping for OR logic
|
// applyFilters applies all filters with proper grouping for OR logic
|
||||||
// Groups consecutive OR filters together to ensure proper query precedence
|
// 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 {
|
if len(filters) == 0 {
|
||||||
return query
|
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
|
// Apply the OR group as a single grouped WHERE clause
|
||||||
query = h.applyFilterGroup(query, orGroup)
|
query = h.applyFilterGroup(query, orGroup, model)
|
||||||
i = j
|
i = j
|
||||||
} else {
|
} else {
|
||||||
// Single filter with AND logic (or first filter)
|
// Single filter with AND logic (or first filter)
|
||||||
condition, args := h.buildFilterCondition(filters[i])
|
condition, args := h.buildFilterCondition(filters[i], model)
|
||||||
if condition != "" {
|
if condition != "" {
|
||||||
query = query.Where(condition, args...)
|
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
|
// applyFilterGroup applies a group of filters that should be OR'd together
|
||||||
// Always wraps them in parentheses and applies as a single WHERE clause
|
// 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 {
|
if len(filters) == 0 {
|
||||||
return query
|
return query
|
||||||
}
|
}
|
||||||
@@ -823,7 +827,7 @@ func (h *Handler) applyFilterGroup(query common.SelectQuery, filters []common.Fi
|
|||||||
var args []interface{}
|
var args []interface{}
|
||||||
|
|
||||||
for _, filter := range filters {
|
for _, filter := range filters {
|
||||||
condition, filterArgs := h.buildFilterCondition(filter)
|
condition, filterArgs := h.buildFilterCondition(filter, model)
|
||||||
if condition != "" {
|
if condition != "" {
|
||||||
conditions = append(conditions, condition)
|
conditions = append(conditions, condition)
|
||||||
args = append(args, filterArgs...)
|
args = append(args, filterArgs...)
|
||||||
@@ -844,8 +848,14 @@ func (h *Handler) applyFilterGroup(query common.SelectQuery, filters []common.Fi
|
|||||||
return query.Where(groupedCondition, args...)
|
return query.Where(groupedCondition, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildFilterCondition builds a filter condition and returns it with args
|
// buildFilterCondition builds a filter condition and returns it with args.
|
||||||
func (h *Handler) buildFilterCondition(filter common.FilterOption) (conditionString string, conditionArgs []interface{}) {
|
// 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") {
|
if strings.EqualFold(filter.Operator, "in") {
|
||||||
cond, args := common.BuildInCondition(filter.Column, filter.Value)
|
cond, args := common.BuildInCondition(filter.Column, filter.Value)
|
||||||
return cond, args
|
return cond, args
|
||||||
|
|||||||
Reference in New Issue
Block a user