feat: add JSON/JSONB sub-field select, filter and sort

Support column references that traverse into JSON/JSONB values —
data->>'x', data#>>'{a,b}', data->'a'->>'b', and the dotted data.a.b
shorthand — in SELECT column lists, WHERE filters and ORDER BY, across
the restheadspec, resolvespec, websocketspec and mqttspec handlers.

- pkg/common/json_column.go: canonical ParseColumnRef + ColumnRef.SQL()
  builder. JSON path segments are bound as a single ?::text[] parameter,
  never interpolated; cast targets are whitelisted via NormalizeCastTarget.
- pkg/common/json_condition.go: shared entry points mirroring
  BuildSpatialCondition - ResolveJSONColumnExpr (select/sort),
  BuildJSONFilterCondition (where, full operator set; infers ::numeric for
  ordered comparisons on numeric values when no explicit cast is given),
  and the ApplySelectColumns helper.
- pkg/reflection.IsJSONColumn / pkg/spectypes.IsJSONType: disambiguate the
  dotted shorthand (data.city is JSON only when the base is a JSON column).
- pkg/common/validation.go: ColumnValidator accepts JSON tokens.
- Handlers: thread model through the filter call chains and wire the
  select/sort paths.

funcspec (raw-SQL string builder, no param binding or model) and the
FetchRowNumber raw-SQL builders are left as follow-ups, as is OpenAPI
reporting of JSON sub-field columns.
This commit is contained in:
Hein
2026-09-07 17:05:52 +02:00
parent f841d58c59
commit 206edd4bfd
17 changed files with 1666 additions and 47 deletions
+82 -10
View File
@@ -1,17 +1,19 @@
package reflection
import (
"encoding/json"
"reflect"
"strings"
"github.com/bitechdev/ResolveSpec/pkg/spectypes"
)
// getColumnFieldType resolves the reflect.Type of the struct field that backs
// colName (matched by json tag, field name or snake_case), following the same
// rules as GetColumnTypeFromModel.
func getColumnFieldType(model interface{}, colName string) (reflect.Type, bool) {
// getColumnStructField resolves the struct field that backs colName (matched by
// json tag, field name or snake_case), following the same rules as
// GetColumnTypeFromModel.
func getColumnStructField(model interface{}, colName string) (reflect.StructField, bool) {
if model == nil {
return nil, false
return reflect.StructField{}, false
}
sourceColName := ExtractSourceColumn(colName)
@@ -20,7 +22,7 @@ func getColumnFieldType(model interface{}, colName string) (reflect.Type, bool)
modelType = modelType.Elem()
}
if modelType == nil || modelType.Kind() != reflect.Struct {
return nil, false
return reflect.StructField{}, false
}
for i := 0; i < modelType.NumField(); i++ {
@@ -28,17 +30,28 @@ func getColumnFieldType(model interface{}, colName string) (reflect.Type, bool)
if jsonTag := field.Tag.Get("json"); jsonTag != "" {
if name := jsonTagName(jsonTag); name == sourceColName {
return field.Type, true
return field, true
}
}
if equalFold(field.Name, sourceColName) {
return field.Type, true
return field, true
}
if ToSnakeCase(field.Name) == sourceColName {
return field.Type, true
return field, true
}
}
return nil, false
return reflect.StructField{}, false
}
// getColumnFieldType resolves the reflect.Type of the struct field that backs
// colName (matched by json tag, field name or snake_case), following the same
// rules as GetColumnTypeFromModel.
func getColumnFieldType(model interface{}, colName string) (reflect.Type, bool) {
f, ok := getColumnStructField(model, colName)
if !ok {
return nil, false
}
return f.Type, true
}
func jsonTagName(tag string) string {
@@ -92,3 +105,62 @@ func IsVectorColumn(model interface{}, colName string) bool {
t, ok := getColumnFieldType(model, colName)
return ok && spectypes.IsVectorType(t)
}
var rawMessageType = reflect.TypeOf(json.RawMessage(nil))
// IsJSONColumn reports whether colName is backed by a JSON/JSONB column on the
// model. It recognises the spectypes SqlJSONB wrapper, encoding/json.RawMessage,
// map-typed fields, and fields carrying a bun/gorm `type:json` / `type:jsonb`
// tag. colName should be a bare column name (callers pass the parsed base column
// of a JSON path, not the full "col->>'x'" expression).
func IsJSONColumn(model interface{}, colName string) bool {
f, ok := getColumnStructField(model, colName)
if !ok {
return false
}
ft := f.Type
for ft != nil && ft.Kind() == reflect.Pointer {
ft = ft.Elem()
}
if ft == nil {
return false
}
if spectypes.IsJSONType(ft) {
return true
}
if ft == rawMessageType {
return true
}
if ft.Kind() == reflect.Map {
return true
}
if tagDeclaresJSON(f.Tag.Get("bun")) || tagDeclaresJSON(f.Tag.Get("gorm")) {
return true
}
return false
}
// tagDeclaresJSON reports whether an ORM struct tag declares a json/jsonb column
// type, e.g. `bun:"meta,type:jsonb"` or `gorm:"column:meta;type:json"`.
func tagDeclaresJSON(tag string) bool {
if tag == "" {
return false
}
for _, part := range strings.FieldsFunc(tag, func(r rune) bool {
return r == ',' || r == ';' || r == ' '
}) {
value, found := strings.CutPrefix(strings.TrimSpace(part), "type:")
if !found {
continue
}
value = strings.ToLower(strings.TrimSpace(value))
// Match "json" and "jsonb", including parametrised forms just in case.
if value == "json" || value == "jsonb" ||
strings.HasPrefix(value, "json(") || strings.HasPrefix(value, "jsonb(") {
return true
}
}
return false
}
+59
View File
@@ -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)
}
}
}