mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-09-13 03:42:36 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user