diff --git a/pkg/common/json_condition.go b/pkg/common/json_condition.go index 2809b96..efabe31 100644 --- a/pkg/common/json_condition.go +++ b/pkg/common/json_condition.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/bitechdev/ResolveSpec/pkg/logger" "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 { for _, col := range columns { 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:",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...) continue } diff --git a/pkg/common/json_condition_test.go b/pkg/common/json_condition_test.go index 920931b..2775856 100644 --- a/pkg/common/json_condition_test.go +++ b/pkg/common/json_condition_test.go @@ -2,6 +2,7 @@ package common import ( "reflect" + "strings" "testing" "github.com/bitechdev/ResolveSpec/pkg/spectypes" @@ -162,3 +163,43 @@ func TestBuildJSONFilterCondition_QualifiedAndInjectionSafe(t *testing.T) { 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) + } +} diff --git a/pkg/reflection/model_utils.go b/pkg/reflection/model_utils.go index 1d9fb26..dc0ccbc 100644 --- a/pkg/reflection/model_utils.go +++ b/pkg/reflection/model_utils.go @@ -439,6 +439,63 @@ func GetSQLModelColumns(model any) []string { 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 // scanOnlyEmbedded indicates if we're inside a scan-only embedded struct func collectSQLColumnsFromType(typ reflect.Type, columns *[]string, scanOnlyEmbedded bool) { diff --git a/pkg/reflection/model_utils_test.go b/pkg/reflection/model_utils_test.go index 99ade55..2ef7706 100644 --- a/pkg/reflection/model_utils_test.go +++ b/pkg/reflection/model_utils_test.go @@ -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) { tests := []struct { name string diff --git a/pkg/resolvespec/handler.go b/pkg/resolvespec/handler.go index 4211fe0..5bc7f25 100644 --- a/pkg/resolvespec/handler.go +++ b/pkg/resolvespec/handler.go @@ -349,6 +349,10 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st logger.Debug("Selecting columns: %v", options.Columns) for _, col := range options.Columns { 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...) continue } diff --git a/pkg/restheadspec/handler.go b/pkg/restheadspec/handler.go index e3349ef..d04040d 100644 --- a/pkg/restheadspec/handler.go +++ b/pkg/restheadspec/handler.go @@ -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}'): // emit a parameterised expression aliased to a stable name. 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...) continue }