diff --git a/pkg/resolvemcp/tools.go b/pkg/resolvemcp/tools.go index 584da31..1e7a44e 100644 --- a/pkg/resolvemcp/tools.go +++ b/pkg/resolvemcp/tools.go @@ -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). fieldType, found := modelType.FieldByName(d.Name) + var unwrappedType reflect.Type + isSQLType := false if found { 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() } 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) continue } @@ -104,6 +110,9 @@ func buildModelInfo(schema, entity string, model interface{}) modelInfo { // Derive Go type name, unwrapping pointer if needed. goType := d.DataType + if isSQLType { + goType = unwrappedType.Name() + } if goType == "" && found { ft := fieldType.Type for ft.Kind() == reflect.Pointer { @@ -125,7 +134,7 @@ func buildModelInfo(schema, entity string, model interface{}) modelInfo { isPrimary: isPrimary, isUnique: d.SQLKey == "unique" || d.SQLKey == "uniqueindex", isFK: d.SQLKey == "foreign_key", - nullable: d.Nullable, + nullable: isSQLType || d.Nullable, } info.columns = append(info.columns, ci) } @@ -134,6 +143,25 @@ func buildModelInfo(schema, entity string, model interface{}) modelInfo { 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. func fieldJSONName(modelType reflect.Type, fieldName string) string { field, ok := modelType.FieldByName(fieldName) diff --git a/pkg/resolvemcp/tools_test.go b/pkg/resolvemcp/tools_test.go new file mode 100644 index 0000000..81a2113 --- /dev/null +++ b/pkg/resolvemcp/tools_test.go @@ -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) + } +} diff --git a/pkg/resolvespec/handler.go b/pkg/resolvespec/handler.go index d5a78a9..85bb5f7 100644 --- a/pkg/resolvespec/handler.go +++ b/pkg/resolvespec/handler.go @@ -2073,16 +2073,23 @@ func (h *Handler) generateMetadata(schema, entity string, model interface{}) *co jsonName = field.Name } - if field.Type.Kind() == reflect.Slice || - (field.Type.Kind() == reflect.Struct && field.Type.Name() != "Time") { + columnField := field + 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) continue } column := common.Column{ Name: jsonName, - Type: getColumnType(field), - IsNullable: isNullable(field), + Type: getColumnType(columnField), + IsNullable: isSQLType || isNullable(field), IsPrimary: strings.Contains(gormTag, "primaryKey"), IsUnique: strings.Contains(gormTag, "unique") || 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 { // Check if it's a pointer type if field.Type.Kind() == reflect.Pointer { diff --git a/pkg/resolvespec/handler_test.go b/pkg/resolvespec/handler_test.go index b4100f0..cd5f035 100644 --- a/pkg/resolvespec/handler_test.go +++ b/pkg/resolvespec/handler_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/bitechdev/ResolveSpec/pkg/common" + "github.com/bitechdev/ResolveSpec/pkg/spectypes" ) 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) { tests := []struct { name string diff --git a/pkg/restheadspec/detail_response_test.go b/pkg/restheadspec/detail_response_test.go index a1785aa..a5e1934 100644 --- a/pkg/restheadspec/detail_response_test.go +++ b/pkg/restheadspec/detail_response_test.go @@ -5,6 +5,7 @@ import ( "testing" "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. @@ -207,3 +208,30 @@ func TestBuildDetailFields_SkipsRelations(t *testing.T) { 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) + } + } +} diff --git a/pkg/restheadspec/handler.go b/pkg/restheadspec/handler.go index e213891..c2c9018 100644 --- a/pkg/restheadspec/handler.go +++ b/pkg/restheadspec/handler.go @@ -2551,10 +2551,18 @@ func (h *Handler) generateMetadata(schema, entity string, model interface{}) *co jsonName = field.Name } - // Check if this is a relation field (slice or struct, but not time.Time) - if field.Type.Kind() == reflect.Slice || - (field.Type.Kind() == reflect.Struct && field.Type.Name() != "Time") || - (field.Type.Kind() == reflect.Pointer && field.Type.Elem().Kind() == reflect.Struct && field.Type.Elem().Name() != "Time") { + columnType := field.Type + isSQLType := false + if unwrappedType, ok := unwrapSQLType(field.Type); ok { + 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) continue } @@ -2575,8 +2583,8 @@ func (h *Handler) generateMetadata(schema, entity string, model interface{}) *co column := common.Column{ Name: columnName, - Type: h.getColumnType(field.Type), - IsNullable: h.isNullable(field), + Type: h.getColumnType(columnType), + IsNullable: isSQLType || h.isNullable(field), IsPrimary: strings.Contains(gormTag, "primaryKey") || strings.Contains(gormTag, "primary_key"), IsUnique: strings.Contains(gormTag, "unique"), 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 { return field.Type.Kind() == reflect.Pointer } @@ -2705,13 +2734,18 @@ func (h *Handler) buildDetailFields(model interface{}) []reflection.ModelFieldDe 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 - 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() } - if ft.Kind() == reflect.Slice || - (ft.Kind() == reflect.Struct && ft.Name() != "Time") { + if !isSQLType && (ft.Kind() == reflect.Slice || + (ft.Kind() == reflect.Struct && ft.Name() != "Time")) { continue } @@ -2739,7 +2773,7 @@ func (h *Handler) buildDetailFields(model interface{}) []reflection.ModelFieldDe sqlKey = "unique" } - nullable := field.Type.Kind() == reflect.Pointer + nullable := isSQLType || field.Type.Kind() == reflect.Pointer if strings.Contains(gormLower, "not null") { nullable = false } 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{ Name: jsonName, - DataType: h.getColumnType(field.Type), + DataType: h.getColumnType(ft), SQLName: sqlName, SQLDataType: sqlDataType, SQLKey: sqlKey,