mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-08-29 04:22:37 +00:00
fix(spec): unwrap SQL wrapper metadata types
This commit is contained in:
+31
-3
@@ -85,13 +85,19 @@ func buildModelInfo(schema, entity string, model interface{}) modelInfo {
|
|||||||
|
|
||||||
// Skip relation fields (slice or user-defined struct that isn't time.Time).
|
// Skip relation fields (slice or user-defined struct that isn't time.Time).
|
||||||
fieldType, found := modelType.FieldByName(d.Name)
|
fieldType, found := modelType.FieldByName(d.Name)
|
||||||
|
var unwrappedType reflect.Type
|
||||||
|
isSQLType := false
|
||||||
if found {
|
if found {
|
||||||
ft := fieldType.Type
|
ft := fieldType.Type
|
||||||
if ft.Kind() == reflect.Pointer {
|
if sqlType, ok := unwrapSQLType(ft); ok {
|
||||||
|
unwrappedType = sqlType
|
||||||
|
ft = sqlType
|
||||||
|
isSQLType = true
|
||||||
|
} else if ft.Kind() == reflect.Pointer {
|
||||||
ft = ft.Elem()
|
ft = ft.Elem()
|
||||||
}
|
}
|
||||||
isUserStruct := ft.Kind() == reflect.Struct && ft.Name() != "Time" && ft.PkgPath() != ""
|
isUserStruct := ft.Kind() == reflect.Struct && ft.Name() != "Time" && ft.PkgPath() != ""
|
||||||
if ft.Kind() == reflect.Slice || isUserStruct {
|
if !isSQLType && (ft.Kind() == reflect.Slice || isUserStruct) {
|
||||||
info.relationNames = append(info.relationNames, jsonName)
|
info.relationNames = append(info.relationNames, jsonName)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -104,6 +110,9 @@ func buildModelInfo(schema, entity string, model interface{}) modelInfo {
|
|||||||
|
|
||||||
// Derive Go type name, unwrapping pointer if needed.
|
// Derive Go type name, unwrapping pointer if needed.
|
||||||
goType := d.DataType
|
goType := d.DataType
|
||||||
|
if isSQLType {
|
||||||
|
goType = unwrappedType.Name()
|
||||||
|
}
|
||||||
if goType == "" && found {
|
if goType == "" && found {
|
||||||
ft := fieldType.Type
|
ft := fieldType.Type
|
||||||
for ft.Kind() == reflect.Pointer {
|
for ft.Kind() == reflect.Pointer {
|
||||||
@@ -125,7 +134,7 @@ func buildModelInfo(schema, entity string, model interface{}) modelInfo {
|
|||||||
isPrimary: isPrimary,
|
isPrimary: isPrimary,
|
||||||
isUnique: d.SQLKey == "unique" || d.SQLKey == "uniqueindex",
|
isUnique: d.SQLKey == "unique" || d.SQLKey == "uniqueindex",
|
||||||
isFK: d.SQLKey == "foreign_key",
|
isFK: d.SQLKey == "foreign_key",
|
||||||
nullable: d.Nullable,
|
nullable: isSQLType || d.Nullable,
|
||||||
}
|
}
|
||||||
info.columns = append(info.columns, ci)
|
info.columns = append(info.columns, ci)
|
||||||
}
|
}
|
||||||
@@ -134,6 +143,25 @@ func buildModelInfo(schema, entity string, model interface{}) modelInfo {
|
|||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// unwrapSQLType returns the value type wrapped by a spectypes SQL value. These
|
||||||
|
// types are scalar columns even when their Go representation is a struct or a
|
||||||
|
// slice (for example, SqlNull[string] and SqlJSONB).
|
||||||
|
func unwrapSQLType(t reflect.Type) (reflect.Type, bool) {
|
||||||
|
for t.Kind() == reflect.Pointer {
|
||||||
|
t = t.Elem()
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.PkgPath() != "github.com/bitechdev/ResolveSpec/pkg/spectypes" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if t.Kind() == reflect.Struct {
|
||||||
|
if value, ok := t.FieldByName("Val"); ok {
|
||||||
|
return value.Type, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
|
||||||
// fieldJSONName returns the JSON tag name for a struct field, falling back to the field name.
|
// fieldJSONName returns the JSON tag name for a struct field, falling back to the field name.
|
||||||
func fieldJSONName(modelType reflect.Type, fieldName string) string {
|
func fieldJSONName(modelType reflect.Type, fieldName string) string {
|
||||||
field, ok := modelType.FieldByName(fieldName)
|
field, ok := modelType.FieldByName(fieldName)
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package resolvemcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildModelInfo_UnwrapsSQLTypes(t *testing.T) {
|
||||||
|
type related struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
type model struct {
|
||||||
|
Name spectypes.SqlString `gorm:"column:name" json:"name"`
|
||||||
|
Metadata spectypes.SqlJSONB `gorm:"column:metadata;type:jsonb" json:"metadata"`
|
||||||
|
Related related `json:"related"`
|
||||||
|
}
|
||||||
|
|
||||||
|
info := buildModelInfo("public", "models", model{})
|
||||||
|
columns := make(map[string]columnInfo, len(info.columns))
|
||||||
|
for _, column := range info.columns {
|
||||||
|
columns[column.jsonName] = column
|
||||||
|
}
|
||||||
|
|
||||||
|
if column, ok := columns["name"]; !ok || column.goType != "string" || !column.nullable {
|
||||||
|
t.Errorf("expected name SQL wrapper column, got %+v", column)
|
||||||
|
}
|
||||||
|
if column, ok := columns["metadata"]; !ok || !column.nullable {
|
||||||
|
t.Errorf("expected metadata SQL wrapper column, got %+v", column)
|
||||||
|
}
|
||||||
|
if len(info.relationNames) != 1 || info.relationNames[0] != "related" {
|
||||||
|
t.Errorf("expected only related to be a relation, got %v", info.relationNames)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2073,16 +2073,23 @@ func (h *Handler) generateMetadata(schema, entity string, model interface{}) *co
|
|||||||
jsonName = field.Name
|
jsonName = field.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
if field.Type.Kind() == reflect.Slice ||
|
columnField := field
|
||||||
(field.Type.Kind() == reflect.Struct && field.Type.Name() != "Time") {
|
isSQLType := false
|
||||||
|
if unwrappedType, ok := unwrapSQLType(field.Type); ok {
|
||||||
|
columnField.Type = unwrappedType
|
||||||
|
isSQLType = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isSQLType && (columnField.Type.Kind() == reflect.Slice ||
|
||||||
|
(columnField.Type.Kind() == reflect.Struct && columnField.Type.Name() != "Time")) {
|
||||||
metadata.Relations = append(metadata.Relations, jsonName)
|
metadata.Relations = append(metadata.Relations, jsonName)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
column := common.Column{
|
column := common.Column{
|
||||||
Name: jsonName,
|
Name: jsonName,
|
||||||
Type: getColumnType(field),
|
Type: getColumnType(columnField),
|
||||||
IsNullable: isNullable(field),
|
IsNullable: isSQLType || isNullable(field),
|
||||||
IsPrimary: strings.Contains(gormTag, "primaryKey"),
|
IsPrimary: strings.Contains(gormTag, "primaryKey"),
|
||||||
IsUnique: strings.Contains(gormTag, "unique") || strings.Contains(gormTag, "uniqueIndex"),
|
IsUnique: strings.Contains(gormTag, "unique") || strings.Contains(gormTag, "uniqueIndex"),
|
||||||
HasIndex: strings.Contains(gormTag, "index") || strings.Contains(gormTag, "uniqueIndex"),
|
HasIndex: strings.Contains(gormTag, "index") || strings.Contains(gormTag, "uniqueIndex"),
|
||||||
@@ -2173,6 +2180,31 @@ func getColumnType(field reflect.StructField) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// unwrapSQLType returns the value type wrapped by a spectypes SQL value. These
|
||||||
|
// types represent columns, even when their Go representation is a struct or a
|
||||||
|
// slice (for example, SqlNull[string] and SqlJSONB).
|
||||||
|
func unwrapSQLType(t reflect.Type) (reflect.Type, bool) {
|
||||||
|
for t.Kind() == reflect.Pointer {
|
||||||
|
t = t.Elem()
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.PkgPath() != "github.com/bitechdev/ResolveSpec/pkg/spectypes" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// SqlNull aliases and the date/time wrappers expose their actual value via
|
||||||
|
// Val. FieldByName also resolves Val through the embedded SqlNull field.
|
||||||
|
if t.Kind() == reflect.Struct {
|
||||||
|
if value, ok := t.FieldByName("Val"); ok {
|
||||||
|
return value.Type, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SqlJSONB has no wrapper field, but remains a scalar SQL value rather than
|
||||||
|
// a relation.
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
|
||||||
func isNullable(field reflect.StructField) bool {
|
func isNullable(field reflect.StructField) bool {
|
||||||
// Check if it's a pointer type
|
// Check if it's a pointer type
|
||||||
if field.Type.Kind() == reflect.Pointer {
|
if field.Type.Kind() == reflect.Pointer {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/bitechdev/ResolveSpec/pkg/common"
|
"github.com/bitechdev/ResolveSpec/pkg/common"
|
||||||
|
"github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewHandler(t *testing.T) {
|
func TestNewHandler(t *testing.T) {
|
||||||
@@ -202,6 +203,48 @@ func TestGetColumnType(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGenerateMetadata_UnwrapsSQLTypes(t *testing.T) {
|
||||||
|
type related struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
type model struct {
|
||||||
|
Name spectypes.SqlString `json:"name"`
|
||||||
|
Count spectypes.SqlInt64 `json:"count"`
|
||||||
|
CreatedAt spectypes.SqlTimeStamp `json:"created_at"`
|
||||||
|
Metadata spectypes.SqlJSONB `json:"metadata" gorm:"type:jsonb"`
|
||||||
|
Related related `json:"related"`
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := NewHandler(nil, nil).generateMetadata("public", "models", model{})
|
||||||
|
columns := make(map[string]common.Column, len(metadata.Columns))
|
||||||
|
for _, column := range metadata.Columns {
|
||||||
|
columns[column.Name] = column
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, wantType := range map[string]string{
|
||||||
|
"name": "string",
|
||||||
|
"count": "bigint",
|
||||||
|
"created_at": "timestamp",
|
||||||
|
"metadata": "jsonb",
|
||||||
|
} {
|
||||||
|
column, ok := columns[name]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("expected %q to be a metadata column", name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if column.Type != wantType {
|
||||||
|
t.Errorf("%q: expected type %q, got %q", name, wantType, column.Type)
|
||||||
|
}
|
||||||
|
if !column.IsNullable {
|
||||||
|
t.Errorf("%q: expected SQL wrapper to be nullable", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(metadata.Relations) != 1 || metadata.Relations[0] != "related" {
|
||||||
|
t.Errorf("expected only related to be a relation, got %v", metadata.Relations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestIsNullable(t *testing.T) {
|
func TestIsNullable(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/bitechdev/ResolveSpec/pkg/common"
|
"github.com/bitechdev/ResolveSpec/pkg/common"
|
||||||
|
"github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||||
)
|
)
|
||||||
|
|
||||||
// detailTestModel is a simple model with gorm column/type tags for detail format tests.
|
// detailTestModel is a simple model with gorm column/type tags for detail format tests.
|
||||||
@@ -207,3 +208,30 @@ func TestBuildDetailFields_SkipsRelations(t *testing.T) {
|
|||||||
t.Errorf("expected 2 scalar fields (id, name), got %d", len(fields))
|
t.Errorf("expected 2 scalar fields (id, name), got %d", len(fields))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildDetailFields_UnwrapsSQLTypes(t *testing.T) {
|
||||||
|
type model struct {
|
||||||
|
Name spectypes.SqlString `gorm:"column:name" json:"name"`
|
||||||
|
CreatedAt spectypes.SqlTimeStamp `gorm:"column:created_at" json:"created_at"`
|
||||||
|
Metadata spectypes.SqlJSONB `gorm:"column:metadata;type:jsonb" json:"metadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := (&Handler{}).buildDetailFields(model{})
|
||||||
|
byName := make(map[string]string, len(fields))
|
||||||
|
for _, field := range fields {
|
||||||
|
byName[field.Name] = field.DataType
|
||||||
|
if !field.Nullable {
|
||||||
|
t.Errorf("%q: expected SQL wrapper to be nullable", field.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, want := range map[string]string{
|
||||||
|
"name": "string",
|
||||||
|
"created_at": "unknown",
|
||||||
|
"metadata": "unknown",
|
||||||
|
} {
|
||||||
|
if got := byName[name]; got != want {
|
||||||
|
t.Errorf("%q: expected type %q, got %q", name, want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+46
-12
@@ -2551,10 +2551,18 @@ func (h *Handler) generateMetadata(schema, entity string, model interface{}) *co
|
|||||||
jsonName = field.Name
|
jsonName = field.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is a relation field (slice or struct, but not time.Time)
|
columnType := field.Type
|
||||||
if field.Type.Kind() == reflect.Slice ||
|
isSQLType := false
|
||||||
(field.Type.Kind() == reflect.Struct && field.Type.Name() != "Time") ||
|
if unwrappedType, ok := unwrapSQLType(field.Type); ok {
|
||||||
(field.Type.Kind() == reflect.Pointer && field.Type.Elem().Kind() == reflect.Struct && field.Type.Elem().Name() != "Time") {
|
columnType = unwrappedType
|
||||||
|
isSQLType = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a relation field (slice or struct, but not time.Time).
|
||||||
|
// spectypes SQL values are columns even when their Go representation is a
|
||||||
|
// struct or a slice.
|
||||||
|
if !isSQLType && (columnType.Kind() == reflect.Slice ||
|
||||||
|
(columnType.Kind() == reflect.Struct && columnType.Name() != "Time")) {
|
||||||
metadata.Relations = append(metadata.Relations, jsonName)
|
metadata.Relations = append(metadata.Relations, jsonName)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -2575,8 +2583,8 @@ func (h *Handler) generateMetadata(schema, entity string, model interface{}) *co
|
|||||||
|
|
||||||
column := common.Column{
|
column := common.Column{
|
||||||
Name: columnName,
|
Name: columnName,
|
||||||
Type: h.getColumnType(field.Type),
|
Type: h.getColumnType(columnType),
|
||||||
IsNullable: h.isNullable(field),
|
IsNullable: isSQLType || h.isNullable(field),
|
||||||
IsPrimary: strings.Contains(gormTag, "primaryKey") || strings.Contains(gormTag, "primary_key"),
|
IsPrimary: strings.Contains(gormTag, "primaryKey") || strings.Contains(gormTag, "primary_key"),
|
||||||
IsUnique: strings.Contains(gormTag, "unique"),
|
IsUnique: strings.Contains(gormTag, "unique"),
|
||||||
HasIndex: strings.Contains(gormTag, "index"),
|
HasIndex: strings.Contains(gormTag, "index"),
|
||||||
@@ -2607,6 +2615,27 @@ func (h *Handler) getColumnType(t reflect.Type) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// unwrapSQLType returns the value type wrapped by a spectypes SQL value. These
|
||||||
|
// types represent columns, even when their Go representation is a struct or a
|
||||||
|
// slice (for example, SqlNull[string] and SqlJSONB).
|
||||||
|
func unwrapSQLType(t reflect.Type) (reflect.Type, bool) {
|
||||||
|
for t.Kind() == reflect.Pointer {
|
||||||
|
t = t.Elem()
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.PkgPath() != "github.com/bitechdev/ResolveSpec/pkg/spectypes" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.Kind() == reflect.Struct {
|
||||||
|
if value, ok := t.FieldByName("Val"); ok {
|
||||||
|
return value.Type, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) isNullable(field reflect.StructField) bool {
|
func (h *Handler) isNullable(field reflect.StructField) bool {
|
||||||
return field.Type.Kind() == reflect.Pointer
|
return field.Type.Kind() == reflect.Pointer
|
||||||
}
|
}
|
||||||
@@ -2705,13 +2734,18 @@ func (h *Handler) buildDetailFields(model interface{}) []reflection.ModelFieldDe
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip relation fields (slices, structs that aren't time.Time, ptrs to struct)
|
// Skip relation fields (slices and structs that aren't time.Time). spectypes
|
||||||
|
// SQL values are columns, not relations.
|
||||||
ft := field.Type
|
ft := field.Type
|
||||||
if ft.Kind() == reflect.Pointer {
|
isSQLType := false
|
||||||
|
if unwrappedType, ok := unwrapSQLType(ft); ok {
|
||||||
|
ft = unwrappedType
|
||||||
|
isSQLType = true
|
||||||
|
} else if ft.Kind() == reflect.Pointer {
|
||||||
ft = ft.Elem()
|
ft = ft.Elem()
|
||||||
}
|
}
|
||||||
if ft.Kind() == reflect.Slice ||
|
if !isSQLType && (ft.Kind() == reflect.Slice ||
|
||||||
(ft.Kind() == reflect.Struct && ft.Name() != "Time") {
|
(ft.Kind() == reflect.Struct && ft.Name() != "Time")) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2739,7 +2773,7 @@ func (h *Handler) buildDetailFields(model interface{}) []reflection.ModelFieldDe
|
|||||||
sqlKey = "unique"
|
sqlKey = "unique"
|
||||||
}
|
}
|
||||||
|
|
||||||
nullable := field.Type.Kind() == reflect.Pointer
|
nullable := isSQLType || field.Type.Kind() == reflect.Pointer
|
||||||
if strings.Contains(gormLower, "not null") {
|
if strings.Contains(gormLower, "not null") {
|
||||||
nullable = false
|
nullable = false
|
||||||
} else if strings.Contains(gormLower, "nullable") || strings.Contains(gormLower, ",null") {
|
} else if strings.Contains(gormLower, "nullable") || strings.Contains(gormLower, ",null") {
|
||||||
@@ -2748,7 +2782,7 @@ func (h *Handler) buildDetailFields(model interface{}) []reflection.ModelFieldDe
|
|||||||
|
|
||||||
fields = append(fields, reflection.ModelFieldDetail{
|
fields = append(fields, reflection.ModelFieldDetail{
|
||||||
Name: jsonName,
|
Name: jsonName,
|
||||||
DataType: h.getColumnType(field.Type),
|
DataType: h.getColumnType(ft),
|
||||||
SQLName: sqlName,
|
SQLName: sqlName,
|
||||||
SQLDataType: sqlDataType,
|
SQLDataType: sqlDataType,
|
||||||
SQLKey: sqlKey,
|
SQLKey: sqlKey,
|
||||||
|
|||||||
Reference in New Issue
Block a user