fix(json-columns): skip JSON select columns with no model scan target

Requesting a JSON sub-field (e.g. jsonvalue->'product'->>'cost') that has
no matching bun scanonly field on the model made the whole read fail with
"bun: ModelX does not have column Y", since bun scans SELECT results
straight into the typed model struct.

Add reflection.HasColumn to check whether the model can actually receive
a given column (including scanonly fields, walking embedded structs), and
gate the JSON select-column expression on it in ApplySelectColumns
(shared by websocketspec/mqttspec) and the resolvespec/restheadspec
handlers. When there's no scan target, drop just that column with a
warning instead of erroring the whole request.
This commit is contained in:
Hein
2026-09-28 12:30:56 +02:00
parent 6687a7a5cd
commit b46f889fbb
6 changed files with 133 additions and 0 deletions
+10
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/bitechdev/ResolveSpec/pkg/logger"
"github.com/bitechdev/ResolveSpec/pkg/reflection" "github.com/bitechdev/ResolveSpec/pkg/reflection"
) )
@@ -72,6 +73,15 @@ func ResolveJSONColumnExpr(model interface{}, tableAlias, token string) (expr st
func ApplySelectColumns(query SelectQuery, model interface{}, tableAlias string, columns []string) SelectQuery { func ApplySelectColumns(query SelectQuery, model interface{}, tableAlias string, columns []string) SelectQuery {
for _, col := range columns { for _, col := range columns {
if expr, args, alias, ok := ResolveJSONColumnExpr(model, tableAlias, col); ok { if expr, args, alias, ok := ResolveJSONColumnExpr(model, tableAlias, col); ok {
if !reflection.HasColumn(model, alias) {
// No matching scan target on the model (e.g. no
// `bun:"<alias>,scanonly"` field declared for this JSON
// path) - bun would fail to scan the row with "does not
// have column X". Drop the expression rather than erroring;
// the rest of the requested columns still get selected.
logger.Warn("Skipping JSON select column %q: model has no scan target for alias %q", col, alias)
continue
}
query = query.ColumnExpr(expr+" AS "+QuoteIdent(alias), args...) query = query.ColumnExpr(expr+" AS "+QuoteIdent(alias), args...)
continue continue
} }
+41
View File
@@ -2,6 +2,7 @@ package common
import ( import (
"reflect" "reflect"
"strings"
"testing" "testing"
"github.com/bitechdev/ResolveSpec/pkg/spectypes" "github.com/bitechdev/ResolveSpec/pkg/spectypes"
@@ -162,3 +163,43 @@ func TestBuildJSONFilterCondition_QualifiedAndInjectionSafe(t *testing.T) {
t.Errorf("args = %#v", args) t.Errorf("args = %#v", args)
} }
} }
// selectCapQuery is a minimal SelectQuery that records Column/ColumnExpr calls
// so ApplySelectColumns' behaviour can be asserted without a real DB.
type selectCapQuery struct {
SelectQuery
columns []string
columnExprs []string
}
func (m *selectCapQuery) Column(cols ...string) SelectQuery {
m.columns = append(m.columns, cols...)
return m
}
func (m *selectCapQuery) ColumnExpr(q string, args ...interface{}) SelectQuery {
m.columnExprs = append(m.columnExprs, q)
return m
}
// jsonSelectModel has a real JSON column (Data) but only ONE pre-declared
// scanonly field for a computed JSON path ("data_city"); "data_age" has no
// matching scan target.
type jsonSelectModel struct {
ID int64 `json:"id" bun:"id,pk"`
Data spectypes.SqlJSONB `json:"data" bun:"data"`
DataCity string `json:"-" bun:"data_city,scanonly"`
}
func TestApplySelectColumns_SkipsJSONColumnWithoutScanTarget(t *testing.T) {
m := jsonSelectModel{}
q := &selectCapQuery{}
ApplySelectColumns(q, m, "", []string{"id", "data.city", "data.age"})
if !reflect.DeepEqual(q.columns, []string{"id"}) {
t.Errorf("columns = %#v, want [id]", q.columns)
}
if len(q.columnExprs) != 1 || !strings.Contains(q.columnExprs[0], `AS "data_city"`) {
t.Errorf("columnExprs = %#v, want exactly one expr aliased data_city", q.columnExprs)
}
}
+57
View File
@@ -439,6 +439,63 @@ func GetSQLModelColumns(model any) []string {
return columns return columns
} }
// HasColumn reports whether the model has a struct field that bun/gorm would
// scan a column named columnName into. Unlike GetSQLModelColumns, this
// includes scanonly fields (e.g. a `bun:"jsonvalue_product_cost,scanonly"`
// field added specifically to receive a computed/JSON-path SELECT expression)
// since those are legitimate scan targets even though they are not writable.
// Matching is case-insensitive against the resolved bun/gorm/json column name
// and against the bare Go field name.
func HasColumn(model any, columnName string) bool {
if columnName == "" {
return false
}
modelType := reflect.TypeOf(model)
for modelType != nil && (modelType.Kind() == reflect.Pointer || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
modelType = modelType.Elem()
}
if modelType == nil || modelType.Kind() != reflect.Struct {
return false
}
return hasColumnInType(modelType, columnName)
}
func hasColumnInType(typ reflect.Type, columnName string) bool {
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
if !field.IsExported() {
continue
}
bunTag := field.Tag.Get("bun")
gormTag := field.Tag.Get("gorm")
if field.Anonymous {
fieldType := field.Type
if fieldType.Kind() == reflect.Pointer {
fieldType = fieldType.Elem()
}
if fieldType.Kind() == reflect.Struct {
if hasColumnInType(fieldType, columnName) {
return true
}
continue
}
}
if bunTag == "-" || gormTag == "-" {
continue
}
if strings.EqualFold(getColumnNameFromField(field), columnName) || strings.EqualFold(field.Name, columnName) {
return true
}
}
return false
}
// collectSQLColumnsFromType recursively collects SQL column names from a struct type // collectSQLColumnsFromType recursively collects SQL column names from a struct type
// scanOnlyEmbedded indicates if we're inside a scan-only embedded struct // scanOnlyEmbedded indicates if we're inside a scan-only embedded struct
func collectSQLColumnsFromType(typ reflect.Type, columns *[]string, scanOnlyEmbedded bool) { func collectSQLColumnsFromType(typ reflect.Type, columns *[]string, scanOnlyEmbedded bool) {
+17
View File
@@ -411,6 +411,23 @@ func TestGetModelColumnsWithEmbedded(t *testing.T) {
} }
} }
func TestHasColumn(t *testing.T) {
m := ModelWithEmbedded{}
for _, col := range []string{"name", "description", "rid_base", "created_at", "cql1", "cql2"} {
if !HasColumn(m, col) {
t.Errorf("HasColumn(%q) = false, want true", col)
}
}
if HasColumn(m, "nonexistent_column") {
t.Error("HasColumn(nonexistent_column) = true, want false")
}
if HasColumn(m, "") {
t.Error("HasColumn(\"\") = true, want false")
}
}
func TestIsColumnWritableWithEmbedded(t *testing.T) { func TestIsColumnWritableWithEmbedded(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
+4
View File
@@ -349,6 +349,10 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
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 { if expr, jargs, alias, ok := common.ResolveJSONColumnExpr(model, "", col); ok {
if !reflection.HasColumn(model, alias) {
logger.Warn("Skipping JSON select column %q: model has no scan target for alias %q", col, alias)
continue
}
query = query.ColumnExpr(expr+" AS "+common.QuoteIdent(alias), jargs...) query = query.ColumnExpr(expr+" AS "+common.QuoteIdent(alias), jargs...)
continue continue
} }
+4
View File
@@ -530,6 +530,10 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
// JSON sub-field selection (data->>'x', data.x, data#>>'{a,b}'): // JSON sub-field selection (data->>'x', data.x, data#>>'{a,b}'):
// emit a parameterised expression aliased to a stable name. // emit a parameterised expression aliased to a stable name.
if expr, jargs, alias, ok := common.ResolveJSONColumnExpr(model, selectAlias, col); ok { if expr, jargs, alias, ok := common.ResolveJSONColumnExpr(model, selectAlias, col); ok {
if !reflection.HasColumn(model, alias) {
logger.Warn("Skipping JSON select column %q: model has no scan target for alias %q", col, alias)
continue
}
query = query.ColumnExpr(expr+" AS "+common.QuoteIdent(alias), jargs...) query = query.ColumnExpr(expr+" AS "+common.QuoteIdent(alias), jargs...)
continue continue
} }