diff --git a/cmd/relspec/convert.go b/cmd/relspec/convert.go index 807b4b4..9be6658 100644 --- a/cmd/relspec/convert.go +++ b/cmd/relspec/convert.go @@ -52,6 +52,7 @@ var ( convertPackageName string convertSchemaFilter string convertFlattenSchema bool + convertNullableTypes string ) var convertCmd = &cobra.Command{ @@ -175,6 +176,7 @@ func init() { convertCmd.Flags().StringVar(&convertPackageName, "package", "", "Package name (for code generation formats like gorm/bun)") convertCmd.Flags().StringVar(&convertSchemaFilter, "schema", "", "Filter to a specific schema by name (required for formats like dctx that only support single schemas)") convertCmd.Flags().BoolVar(&convertFlattenSchema, "flatten-schema", false, "Flatten schema.table names to schema_table (useful for databases like SQLite that do not support schemas)") + convertCmd.Flags().StringVar(&convertNullableTypes, "types", "", "Nullable type package for code-gen writers (bun/gorm): 'resolvespec' (default) or 'stdlib' (database/sql)") err := convertCmd.MarkFlagRequired("from") if err != nil { @@ -241,7 +243,7 @@ func runConvert(cmd *cobra.Command, args []string) error { fmt.Fprintf(os.Stderr, " Schema: %s\n", convertSchemaFilter) } - if err := writeDatabase(db, convertTargetType, convertTargetPath, convertPackageName, convertSchemaFilter, convertFlattenSchema); err != nil { + if err := writeDatabase(db, convertTargetType, convertTargetPath, convertPackageName, convertSchemaFilter, convertFlattenSchema, convertNullableTypes); err != nil { return fmt.Errorf("failed to write target: %w", err) } @@ -381,13 +383,14 @@ func readDatabaseForConvert(dbType, filePath, connString string) (*models.Databa return db, nil } -func writeDatabase(db *models.Database, dbType, outputPath, packageName, schemaFilter string, flattenSchema bool) error { +func writeDatabase(db *models.Database, dbType, outputPath, packageName, schemaFilter string, flattenSchema bool, nullableTypes string) error { var writer writers.Writer writerOpts := &writers.WriterOptions{ OutputPath: outputPath, PackageName: packageName, FlattenSchema: flattenSchema, + NullableTypes: nullableTypes, } switch strings.ToLower(dbType) { diff --git a/cmd/relspec/split.go b/cmd/relspec/split.go index 2101768..876ac73 100644 --- a/cmd/relspec/split.go +++ b/cmd/relspec/split.go @@ -22,6 +22,7 @@ var ( splitDatabaseName string splitExcludeSchema string splitExcludeTables string + splitNullableTypes string ) var splitCmd = &cobra.Command{ @@ -110,6 +111,7 @@ func init() { splitCmd.Flags().StringVar(&splitTables, "tables", "", "Comma-separated list of table names to include (case-insensitive)") splitCmd.Flags().StringVar(&splitExcludeSchema, "exclude-schema", "", "Comma-separated list of schema names to exclude") splitCmd.Flags().StringVar(&splitExcludeTables, "exclude-tables", "", "Comma-separated list of table names to exclude (case-insensitive)") + splitCmd.Flags().StringVar(&splitNullableTypes, "types", "", "Nullable type package for code-gen writers (bun/gorm): 'resolvespec' (default) or 'stdlib' (database/sql)") err := splitCmd.MarkFlagRequired("from") if err != nil { @@ -185,6 +187,7 @@ func runSplit(cmd *cobra.Command, args []string) error { splitPackageName, "", // no schema filter for split false, // no flatten-schema for split + splitNullableTypes, ) if err != nil { return fmt.Errorf("failed to write output: %w", err) diff --git a/pkg/readers/pgsql/queries.go b/pkg/readers/pgsql/queries.go index e43d417..5087e7a 100644 --- a/pkg/readers/pgsql/queries.go +++ b/pkg/readers/pgsql/queries.go @@ -252,7 +252,7 @@ func (r *Reader) queryColumns(schemaName string) (map[string]map[string]*models. column.AutoIncrement = true column.Default = defaultVal } else { - column.Default = defaultVal + column.Default = normalizePostgresDefault(defaultVal) } } @@ -613,3 +613,30 @@ func (r *Reader) parseIndexDefinition(indexName, tableName, schema, indexDef str return index, nil } + +// normalizePostgresDefault converts a raw PostgreSQL column_default expression into the +// unquoted string value that the model convention expects. PostgreSQL stores string +// literal defaults as 'value' or 'value'::type (e.g. '{}'::text[]), while every other +// reader stores the bare value so the writer can re-quote it correctly. +func normalizePostgresDefault(defaultVal string) string { + if !strings.HasPrefix(defaultVal, "'") { + return defaultVal + } + // Decode the SQL string literal: skip the leading quote, unescape '' → ', stop at + // the first unescaped closing quote (any trailing ::cast is ignored). + rest := defaultVal[1:] + var buf strings.Builder + for i := 0; i < len(rest); i++ { + if rest[i] == '\'' { + if i+1 < len(rest) && rest[i+1] == '\'' { + buf.WriteByte('\'') + i++ + } else { + break + } + } else { + buf.WriteByte(rest[i]) + } + } + return buf.String() +} diff --git a/pkg/writers/bun/README.md b/pkg/writers/bun/README.md index a30f5cc..29f0d8d 100644 --- a/pkg/writers/bun/README.md +++ b/pkg/writers/bun/README.md @@ -46,54 +46,67 @@ func main() { ### CLI Examples ```bash -# Generate Bun models from PostgreSQL database -relspec --input pgsql \ - --conn "postgres://localhost/mydb" \ - --output bun \ - --out-file models.go \ - --package models +# Generate Bun models from a DBML schema (default: resolvespec types) +relspec convert --from dbml --from-path schema.dbml \ + --to bun --to-path models.go --package models -# Convert GORM models to Bun -relspec --input gorm --in-file gorm_models.go --output bun --out-file bun_models.go +# Use standard library database/sql nullable types instead of resolvespec +relspec convert --from dbml --from-path schema.dbml \ + --to bun --to-path models.go --package models \ + --types stdlib -# Multi-file output -relspec --input json --in-file schema.json --output bun --out-file models/ +# Explicitly select resolvespec types (same as omitting --types) +relspec convert --from pgsql --from-conn "postgres://localhost/mydb" \ + --to bun --to-path models.go --package models \ + --types resolvespec + +# Multi-file output (one file per table) +relspec convert --from json --from-path schema.json \ + --to bun --to-path models/ --package models ``` -## Generated Code Example +## Generated Code Examples + +### Default — resolvespec types (`--types resolvespec`) ```go package models import ( - "time" - "database/sql" + resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes" "github.com/uptrace/bun" ) type User struct { bun.BaseModel `bun:"table:users,alias:u"` - ID int64 `bun:"id,pk,autoincrement" json:"id"` - Username string `bun:"username,notnull,unique" json:"username"` - Email string `bun:"email,notnull" json:"email"` - Bio sql.NullString `bun:"bio" json:"bio,omitempty"` - CreatedAt time.Time `bun:"created_at,notnull,default:now()" json:"created_at"` - - // Relationships - Posts []*Post `bun:"rel:has-many,join:id=user_id" json:"posts,omitempty"` + ID int64 `bun:"id,type:uuid,pk," json:"id"` + Username string `bun:"username,type:text,notnull," json:"username"` + Email resolvespec_common.SqlString `bun:"email,type:text,nullzero," json:"email"` + Tags resolvespec_common.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"` + CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"` } +``` -type Post struct { - bun.BaseModel `bun:"table:posts,alias:p"` +### Standard library — `--types stdlib` - ID int64 `bun:"id,pk" json:"id"` - UserID int64 `bun:"user_id,notnull" json:"user_id"` - Title string `bun:"title,notnull" json:"title"` - Content sql.NullString `bun:"content" json:"content,omitempty"` +```go +package models - // Belongs to - User *User `bun:"rel:belongs-to,join:user_id=id" json:"user,omitempty"` +import ( + "database/sql" + "time" + "github.com/uptrace/bun" +) + +type User struct { + bun.BaseModel `bun:"table:users,alias:u"` + + ID string `bun:"id,type:uuid,pk," json:"id"` + Username string `bun:"username,type:text,notnull," json:"username"` + Email sql.NullString `bun:"email,type:text,nullzero," json:"email"` + Tags []string `bun:"tags,type:text[],default:'{}',notnull," json:"tags"` + CreatedAt time.Time `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"` } ``` @@ -111,19 +124,68 @@ type Post struct { ## Type Mapping -| SQL Type | Go Type | Nullable Type | -|----------|---------|---------------| -| bigint | int64 | sql.NullInt64 | -| integer | int | sql.NullInt32 | -| varchar, text | string | sql.NullString | -| boolean | bool | sql.NullBool | -| timestamp | time.Time | sql.NullTime | -| numeric | float64 | sql.NullFloat64 | +The nullable type package is selected with `--types` (or `WriterOptions.NullableTypes`). + +| SQL Type | NOT NULL (both) | Nullable — resolvespec | Nullable — stdlib | +|---|---|---|---| +| `bigint` | `int64` | `SqlInt64` | `sql.NullInt64` | +| `integer` | `int32` | `SqlInt32` | `sql.NullInt32` | +| `smallint` | `int16` | `SqlInt16` | `sql.NullInt16` | +| `text`, `varchar` | `string` | `SqlString` | `sql.NullString` | +| `boolean` | `bool` | `SqlBool` | `sql.NullBool` | +| `timestamp`, `timestamptz` | `time.Time`* | `SqlTimeStamp` | `sql.NullTime` | +| `numeric`, `decimal` | `float64` | `SqlFloat64` | `sql.NullFloat64` | +| `uuid` | `string` | `SqlUUID` | `sql.NullString` | +| `jsonb` | `string` | `SqlJSONB` | `sql.NullString` | +| `text[]` | `SqlStringArray` | `SqlStringArray` | `[]string` | +| `integer[]` | `SqlInt32Array` | `SqlInt32Array` | `[]int32` | +| `uuid[]` | `SqlUUIDArray` | `SqlUUIDArray` | `[]string` | +| `vector` | `SqlVector` | `SqlVector` | `[]float32` | + +\* In resolvespec mode, NOT NULL timestamps use `SqlTimeStamp` (not `time.Time`) unless the base type is a simple integer or boolean. In stdlib mode, NOT NULL timestamps use `time.Time`. + +## Writer Options + +### NullableTypes + +Controls which Go package is used for nullable column types. Set via the `--types` CLI flag or `WriterOptions.NullableTypes`: + +```go +// Use resolvespec types (default — omit NullableTypes or set to "resolvespec") +options := &writers.WriterOptions{ + OutputPath: "models.go", + PackageName: "models", + NullableTypes: writers.NullableTypeResolveSpec, +} + +// Use standard library database/sql types +options := &writers.WriterOptions{ + OutputPath: "models.go", + PackageName: "models", + NullableTypes: writers.NullableTypeStdlib, +} +``` + +### Metadata Options + +```go +options := &writers.WriterOptions{ + OutputPath: "models.go", + PackageName: "models", + Metadata: map[string]any{ + "multi_file": true, // Enable multi-file mode + "populate_refs": true, // Populate RefDatabase/RefSchema + "generate_get_id_str": true, // Generate GetIDStr() methods + }, +} +``` ## Notes - Model names are derived from table names (singularized, PascalCase) - Table aliases are auto-generated from table names +- Nullable columns use `resolvespec_common.SqlString`, `resolvespec_common.SqlTimeStamp`, etc. by default; pass `--types stdlib` to use `sql.NullString`, `sql.NullTime`, etc. instead +- Array columns use `resolvespec_common.SqlStringArray`, `resolvespec_common.SqlInt32Array`, etc. by default; `--types stdlib` produces plain Go slices (`[]string`, `[]int32`, …) - Multi-file mode: one file per table named `sql_{schema}_{table}.go` - Generated code is auto-formatted - JSON tags are automatically added diff --git a/pkg/writers/bun/type_mapper.go b/pkg/writers/bun/type_mapper.go index e7c136b..ec08d88 100644 --- a/pkg/writers/bun/type_mapper.go +++ b/pkg/writers/bun/type_mapper.go @@ -11,30 +11,43 @@ import ( // TypeMapper handles type conversions between SQL and Go types for Bun type TypeMapper struct { - // Package alias for sql_types import sqlTypesAlias string + typeStyle string // writers.NullableTypeResolveSpec | writers.NullableTypeStdlib } -// NewTypeMapper creates a new TypeMapper with default settings -func NewTypeMapper() *TypeMapper { +// NewTypeMapper creates a new TypeMapper. +// typeStyle should be writers.NullableTypeResolveSpec or writers.NullableTypeStdlib; +// an empty string defaults to resolvespec. +func NewTypeMapper(typeStyle string) *TypeMapper { + if typeStyle == "" { + typeStyle = writers.NullableTypeResolveSpec + } return &TypeMapper{ sqlTypesAlias: "resolvespec_common", + typeStyle: typeStyle, } } -// SQLTypeToGoType converts a SQL type to its Go equivalent -// Uses ResolveSpec common package types (all are nullable by default in Bun) +// SQLTypeToGoType converts a SQL type to its Go equivalent. func (tm *TypeMapper) SQLTypeToGoType(sqlType string, notNull bool) string { - // Normalize SQL type (lowercase, remove length/precision) + // Array types are handled separately for both styles. + if pgsql.IsArrayType(sqlType) { + return tm.arrayGoType(tm.extractBaseType(sqlType)) + } + baseType := tm.extractBaseType(sqlType) - // For Bun, we typically use resolvespec_common types for most fields - // unless they're explicitly NOT NULL and we want to avoid null handling + if tm.typeStyle == writers.NullableTypeStdlib { + if notNull { + return tm.rawGoType(baseType) + } + return tm.stdlibNullableGoType(baseType) + } + + // resolvespec (default): use base Go types only for simple NOT NULL fields. if notNull && tm.isSimpleType(baseType) { return tm.baseGoType(baseType) } - - // Use resolvespec_common types for nullable fields return tm.bunGoType(baseType) } @@ -154,6 +167,9 @@ func (tm *TypeMapper) bunGoType(sqlType string) string { // Other "money": tm.sqlTypesAlias + ".SqlFloat64", + + // pgvector + "vector": tm.sqlTypesAlias + ".SqlVector", } if goType, ok := typeMap[sqlType]; ok { @@ -164,6 +180,123 @@ func (tm *TypeMapper) bunGoType(sqlType string) string { return tm.sqlTypesAlias + ".SqlString" } +// arrayGoType returns the Go type for a PostgreSQL array column. +// The baseElemType is the canonical base type (e.g. "text", "integer"). +func (tm *TypeMapper) arrayGoType(baseElemType string) string { + if tm.typeStyle == writers.NullableTypeStdlib { + return tm.stdlibArrayGoType(baseElemType) + } + typeMap := map[string]string{ + "text": tm.sqlTypesAlias + ".SqlStringArray", "varchar": tm.sqlTypesAlias + ".SqlStringArray", + "char": tm.sqlTypesAlias + ".SqlStringArray", "character": tm.sqlTypesAlias + ".SqlStringArray", + "citext": tm.sqlTypesAlias + ".SqlStringArray", "bpchar": tm.sqlTypesAlias + ".SqlStringArray", + "inet": tm.sqlTypesAlias + ".SqlStringArray", "cidr": tm.sqlTypesAlias + ".SqlStringArray", + "macaddr": tm.sqlTypesAlias + ".SqlStringArray", + "json": tm.sqlTypesAlias + ".SqlStringArray", "jsonb": tm.sqlTypesAlias + ".SqlStringArray", + "integer": tm.sqlTypesAlias + ".SqlInt32Array", "int": tm.sqlTypesAlias + ".SqlInt32Array", + "int4": tm.sqlTypesAlias + ".SqlInt32Array", "serial": tm.sqlTypesAlias + ".SqlInt32Array", + "smallint": tm.sqlTypesAlias + ".SqlInt16Array", "int2": tm.sqlTypesAlias + ".SqlInt16Array", + "smallserial": tm.sqlTypesAlias + ".SqlInt16Array", + "bigint": tm.sqlTypesAlias + ".SqlInt64Array", "int8": tm.sqlTypesAlias + ".SqlInt64Array", + "bigserial": tm.sqlTypesAlias + ".SqlInt64Array", + "real": tm.sqlTypesAlias + ".SqlFloat32Array", "float4": tm.sqlTypesAlias + ".SqlFloat32Array", + "double precision": tm.sqlTypesAlias + ".SqlFloat64Array", "float8": tm.sqlTypesAlias + ".SqlFloat64Array", + "numeric": tm.sqlTypesAlias + ".SqlFloat64Array", "decimal": tm.sqlTypesAlias + ".SqlFloat64Array", + "money": tm.sqlTypesAlias + ".SqlFloat64Array", + "boolean": tm.sqlTypesAlias + ".SqlBoolArray", "bool": tm.sqlTypesAlias + ".SqlBoolArray", + "uuid": tm.sqlTypesAlias + ".SqlUUIDArray", + } + if goType, ok := typeMap[baseElemType]; ok { + return goType + } + return tm.sqlTypesAlias + ".SqlStringArray" +} + +// rawGoType returns the plain Go type for a NOT NULL column in stdlib mode. +func (tm *TypeMapper) rawGoType(sqlType string) string { + typeMap := map[string]string{ + "integer": "int32", "int": "int32", "int4": "int32", "serial": "int32", + "smallint": "int16", "int2": "int16", "smallserial": "int16", + "bigint": "int64", "int8": "int64", "bigserial": "int64", + "boolean": "bool", "bool": "bool", + "real": "float32", "float4": "float32", + "double precision": "float64", "float8": "float64", + "numeric": "float64", "decimal": "float64", "money": "float64", + "text": "string", "varchar": "string", "char": "string", + "character": "string", "citext": "string", "bpchar": "string", + "inet": "string", "cidr": "string", "macaddr": "string", + "uuid": "string", "json": "string", "jsonb": "string", + "timestamp": "time.Time", + "timestamp without time zone": "time.Time", + "timestamp with time zone": "time.Time", + "timestamptz": "time.Time", + "date": "time.Time", + "time": "time.Time", + "time without time zone": "time.Time", + "time with time zone": "time.Time", + "timetz": "time.Time", + "bytea": "[]byte", + "vector": "[]float32", + } + if goType, ok := typeMap[sqlType]; ok { + return goType + } + return "string" +} + +// stdlibNullableGoType returns the database/sql nullable type for a column. +func (tm *TypeMapper) stdlibNullableGoType(sqlType string) string { + typeMap := map[string]string{ + "integer": "sql.NullInt32", "int": "sql.NullInt32", "int4": "sql.NullInt32", "serial": "sql.NullInt32", + "smallint": "sql.NullInt16", "int2": "sql.NullInt16", "smallserial": "sql.NullInt16", + "bigint": "sql.NullInt64", "int8": "sql.NullInt64", "bigserial": "sql.NullInt64", + "boolean": "sql.NullBool", "bool": "sql.NullBool", + "real": "sql.NullFloat64", "float4": "sql.NullFloat64", + "double precision": "sql.NullFloat64", "float8": "sql.NullFloat64", + "numeric": "sql.NullFloat64", "decimal": "sql.NullFloat64", "money": "sql.NullFloat64", + "text": "sql.NullString", "varchar": "sql.NullString", "char": "sql.NullString", + "character": "sql.NullString", "citext": "sql.NullString", "bpchar": "sql.NullString", + "inet": "sql.NullString", "cidr": "sql.NullString", "macaddr": "sql.NullString", + "uuid": "sql.NullString", "json": "sql.NullString", "jsonb": "sql.NullString", + "timestamp": "sql.NullTime", + "timestamp without time zone": "sql.NullTime", + "timestamp with time zone": "sql.NullTime", + "timestamptz": "sql.NullTime", + "date": "sql.NullTime", + "time": "sql.NullTime", + "time without time zone": "sql.NullTime", + "time with time zone": "sql.NullTime", + "timetz": "sql.NullTime", + "bytea": "[]byte", + "vector": "[]float32", + } + if goType, ok := typeMap[sqlType]; ok { + return goType + } + return "sql.NullString" +} + +// stdlibArrayGoType returns a plain Go slice type for array columns in stdlib mode. +func (tm *TypeMapper) stdlibArrayGoType(baseElemType string) string { + typeMap := map[string]string{ + "text": "[]string", "varchar": "[]string", "char": "[]string", + "character": "[]string", "citext": "[]string", "bpchar": "[]string", + "inet": "[]string", "cidr": "[]string", "macaddr": "[]string", + "uuid": "[]string", "json": "[]string", "jsonb": "[]string", + "integer": "[]int32", "int": "[]int32", "int4": "[]int32", "serial": "[]int32", + "smallint": "[]int16", "int2": "[]int16", "smallserial": "[]int16", + "bigint": "[]int64", "int8": "[]int64", "bigserial": "[]int64", + "real": "[]float32", "float4": "[]float32", + "double precision": "[]float64", "float8": "[]float64", + "numeric": "[]float64", "decimal": "[]float64", "money": "[]float64", + "boolean": "[]bool", "bool": "[]bool", + } + if goType, ok := typeMap[baseElemType]; ok { + return goType + } + return "[]string" +} + // BuildBunTag generates a complete Bun tag string for a column // Bun format: bun:"column_name,type:type_name,pk,default:value" func (tm *TypeMapper) BuildBunTag(column *models.Column, table *models.Table) string { @@ -286,11 +419,20 @@ func (tm *TypeMapper) NeedsFmtImport(generateGetIDStr bool) bool { return generateGetIDStr } -// GetSQLTypesImport returns the import path for sql_types (ResolveSpec common) +// GetSQLTypesImport returns the import path for the ResolveSpec spectypes package. func (tm *TypeMapper) GetSQLTypesImport() string { return "github.com/bitechdev/ResolveSpec/pkg/spectypes" } +// GetNullableTypeImportLine returns the full Go import line for the nullable type +// package (ready to pass to AddImport). Returns empty string when no import is needed. +func (tm *TypeMapper) GetNullableTypeImportLine() string { + if tm.typeStyle == writers.NullableTypeStdlib { + return "\"database/sql\"" + } + return fmt.Sprintf("%s \"%s\"", tm.sqlTypesAlias, tm.GetSQLTypesImport()) +} + // GetBunImport returns the import path for Bun func (tm *TypeMapper) GetBunImport() string { return "github.com/uptrace/bun" diff --git a/pkg/writers/bun/writer.go b/pkg/writers/bun/writer.go index f77ec41..3e8afd7 100644 --- a/pkg/writers/bun/writer.go +++ b/pkg/writers/bun/writer.go @@ -24,7 +24,7 @@ type Writer struct { func NewWriter(options *writers.WriterOptions) *Writer { w := &Writer{ options: options, - typeMapper: NewTypeMapper(), + typeMapper: NewTypeMapper(options.NullableTypes), config: LoadMethodConfigFromMetadata(options.Metadata), } @@ -80,8 +80,8 @@ func (w *Writer) writeSingleFile(db *models.Database) error { // Add bun import (always needed) templateData.AddImport(fmt.Sprintf("\"%s\"", w.typeMapper.GetBunImport())) - // Add resolvespec_common import (always needed for nullable types) - templateData.AddImport(fmt.Sprintf("resolvespec_common \"%s\"", w.typeMapper.GetSQLTypesImport())) + // Add nullable types import (resolvespec or stdlib depending on options) + templateData.AddImport(w.typeMapper.GetNullableTypeImportLine()) // Collect all models for _, schema := range db.Schemas { @@ -177,8 +177,8 @@ func (w *Writer) writeMultiFile(db *models.Database) error { // Add bun import templateData.AddImport(fmt.Sprintf("\"%s\"", w.typeMapper.GetBunImport())) - // Add resolvespec_common import - templateData.AddImport(fmt.Sprintf("resolvespec_common \"%s\"", w.typeMapper.GetSQLTypesImport())) + // Add nullable types import (resolvespec or stdlib depending on options) + templateData.AddImport(w.typeMapper.GetNullableTypeImportLine()) // Create model data modelData := NewModelData(table, schema.Name, w.typeMapper, w.options.FlattenSchema) diff --git a/pkg/writers/bun/writer_test.go b/pkg/writers/bun/writer_test.go index 287b122..0e5ba60 100644 --- a/pkg/writers/bun/writer_test.go +++ b/pkg/writers/bun/writer_test.go @@ -556,7 +556,7 @@ func TestWriter_FieldNameCollision(t *testing.T) { } func TestTypeMapper_SQLTypeToGoType_Bun(t *testing.T) { - mapper := NewTypeMapper() + mapper := NewTypeMapper("") tests := []struct { sqlType string @@ -587,7 +587,7 @@ func TestTypeMapper_SQLTypeToGoType_Bun(t *testing.T) { } func TestTypeMapper_BuildBunTag(t *testing.T) { - mapper := NewTypeMapper() + mapper := NewTypeMapper("") tests := []struct { name string @@ -700,7 +700,7 @@ func TestTypeMapper_BuildBunTag(t *testing.T) { } func TestTypeMapper_BuildBunTag_PreservesExplicitTypeModifiers(t *testing.T) { - mapper := NewTypeMapper() + mapper := NewTypeMapper("") col := &models.Column{ Name: "embedding", diff --git a/pkg/writers/gorm/README.md b/pkg/writers/gorm/README.md index d73ac86..6a7aa15 100644 --- a/pkg/writers/gorm/README.md +++ b/pkg/writers/gorm/README.md @@ -48,22 +48,23 @@ func main() { ### CLI Examples ```bash -# Generate GORM models from PostgreSQL database (single file) -relspec --input pgsql \ - --conn "postgres://localhost/mydb" \ - --output gorm \ - --out-file models.go \ - --package models +# Generate GORM models from a DBML schema (default: resolvespec types) +relspec convert --from dbml --from-path schema.dbml \ + --to gorm --to-path models.go --package models -# Generate GORM models with multi-file output (one file per table) -relspec --input json \ - --in-file schema.json \ - --output gorm \ - --out-file models/ \ - --package models +# Use standard library database/sql nullable types instead of resolvespec +relspec convert --from dbml --from-path schema.dbml \ + --to gorm --to-path models.go --package models \ + --types stdlib -# Convert DBML to GORM models -relspec --input dbml --in-file schema.dbml --output gorm --out-file models.go +# Explicitly select resolvespec types (same as omitting --types) +relspec convert --from pgsql --from-conn "postgres://localhost/mydb" \ + --to gorm --to-path models.go --package models \ + --types resolvespec + +# Multi-file output (one file per table) +relspec convert --from json --from-path schema.json \ + --to gorm --to-path models/ --package models ``` ## Output Modes @@ -86,58 +87,86 @@ relspec --input pgsql --conn "..." --output gorm --out-file models/ Files are named: `sql_{schema}_{table}.go` -## Generated Code Example +## Generated Code Examples + +### Default — resolvespec types (`--types resolvespec`) ```go package models import ( - "time" - sql_types "git.warky.dev/wdevs/sql_types" + sql_types "github.com/bitechdev/ResolveSpec/pkg/spectypes" ) type ModelUser struct { - ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement" json:"id"` - Username string `gorm:"column:username;type:varchar(50);not null;uniqueIndex" json:"username"` - Email string `gorm:"column:email;type:varchar(100);not null" json:"email"` - CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:now()" json:"created_at"` - - // Relationships - Pos []*ModelPost `gorm:"foreignKey:UserID;references:ID;constraint:OnDelete:CASCADE" json:"pos,omitempty"` + ID string `gorm:"column:id;type:uuid;primaryKey" json:"id"` + Username string `gorm:"column:username;type:text;not null" json:"username"` + Email sql_types.SqlString `gorm:"column:email;type:text" json:"email,omitempty"` + Tags sql_types.SqlStringArray `gorm:"column:tags;type:text[];not null;default:'{}'" json:"tags"` + CreatedAt sql_types.SqlTimeStamp `gorm:"column:created_at;type:timestamptz;not null;default:now()" json:"created_at"` } func (ModelUser) TableName() string { return "public.users" } +``` -type ModelPost struct { - ID int64 `gorm:"column:id;type:bigint;primaryKey" json:"id"` - UserID int64 `gorm:"column:user_id;type:bigint;not null" json:"user_id"` - Title string `gorm:"column:title;type:varchar(200);not null" json:"title"` - Content sql_types.SqlString `gorm:"column:content;type:text" json:"content,omitempty"` +### Standard library — `--types stdlib` - // Belongs to - Use *ModelUser `gorm:"foreignKey:UserID;references:ID" json:"use,omitempty"` +```go +package models + +import ( + "database/sql" + "time" +) + +type ModelUser struct { + ID string `gorm:"column:id;type:uuid;primaryKey" json:"id"` + Username string `gorm:"column:username;type:text;not null" json:"username"` + Email sql.NullString `gorm:"column:email;type:text" json:"email,omitempty"` + Tags []string `gorm:"column:tags;type:text[];not null;default:'{}'" json:"tags"` + CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;default:now()" json:"created_at"` } -func (ModelPost) TableName() string { - return "public.posts" +func (ModelUser) TableName() string { + return "public.users" } ``` ## Writer Options +### NullableTypes + +Controls which Go package is used for nullable column types. Set via the `--types` CLI flag or `WriterOptions.NullableTypes`: + +```go +// Use resolvespec types (default — omit NullableTypes or set to "resolvespec") +options := &writers.WriterOptions{ + OutputPath: "models.go", + PackageName: "models", + NullableTypes: writers.NullableTypeResolveSpec, +} + +// Use standard library database/sql types +options := &writers.WriterOptions{ + OutputPath: "models.go", + PackageName: "models", + NullableTypes: writers.NullableTypeStdlib, +} +``` + ### Metadata Options -Configure the writer behavior using metadata in `WriterOptions`: +Configure additional writer behavior using metadata in `WriterOptions`: ```go options := &writers.WriterOptions{ OutputPath: "models.go", PackageName: "models", - Metadata: map[string]interface{}{ - "multi_file": true, // Enable multi-file mode - "populate_refs": true, // Populate RefDatabase/RefSchema + Metadata: map[string]any{ + "multi_file": true, // Enable multi-file mode + "populate_refs": true, // Populate RefDatabase/RefSchema "generate_get_id_str": true, // Generate GetIDStr() methods }, } @@ -145,18 +174,23 @@ options := &writers.WriterOptions{ ## Type Mapping -| SQL Type | Go Type | Notes | -|----------|---------|-------| -| bigint, int8 | int64 | - | -| integer, int, int4 | int | - | -| smallint, int2 | int16 | - | -| varchar, text | string | Not nullable | -| varchar, text (nullable) | sql_types.SqlString | Nullable | -| boolean, bool | bool | - | -| timestamp, timestamptz | time.Time | - | -| numeric, decimal | float64 | - | -| uuid | string | - | -| json, jsonb | string | - | +The nullable type package is selected with `--types` (or `WriterOptions.NullableTypes`). + +| SQL Type | NOT NULL — both | Nullable — resolvespec | Nullable — stdlib | +|---|---|---|---| +| `bigint` | `int64` | `SqlInt64` | `sql.NullInt64` | +| `integer` | `int32` | `SqlInt32` | `sql.NullInt32` | +| `smallint` | `int16` | `SqlInt16` | `sql.NullInt16` | +| `text`, `varchar` | `string` | `SqlString` | `sql.NullString` | +| `boolean` | `bool` | `SqlBool` | `sql.NullBool` | +| `timestamp`, `timestamptz` | `time.Time` | `SqlTimeStamp` | `sql.NullTime` | +| `numeric`, `decimal` | `float64` | `SqlFloat64` | `sql.NullFloat64` | +| `uuid` | `string` | `SqlUUID` | `sql.NullString` | +| `jsonb` | `string` | `SqlString` | `sql.NullString` | +| `text[]` | `SqlStringArray` | `SqlStringArray` | `[]string` | +| `integer[]` | `SqlInt32Array` | `SqlInt32Array` | `[]int32` | +| `uuid[]` | `SqlUUIDArray` | `SqlUUIDArray` | `[]string` | +| `vector` | `SqlVector` | `SqlVector` | `[]float32` | ## Relationship Generation @@ -170,7 +204,8 @@ The writer automatically generates relationship fields: ## Notes - Model names are prefixed with "Model" (e.g., `ModelUser`) -- Nullable columns use `sql_types.SqlString`, `sql_types.SqlInt64`, etc. +- Nullable columns use `sql_types.SqlString`, `sql_types.SqlInt64`, etc. by default; pass `--types stdlib` to use `sql.NullString`, `sql.NullInt64`, etc. instead +- Array columns use `sql_types.SqlStringArray`, `sql_types.SqlInt32Array`, etc. by default; `--types stdlib` produces plain Go slices (`[]string`, `[]int32`, …) - Generated code is auto-formatted with `go fmt` - JSON tags are automatically added - Supports schema-qualified table names in `TableName()` method diff --git a/pkg/writers/gorm/type_mapper.go b/pkg/writers/gorm/type_mapper.go index a61e643..9c20e29 100644 --- a/pkg/writers/gorm/type_mapper.go +++ b/pkg/writers/gorm/type_mapper.go @@ -11,29 +11,43 @@ import ( // TypeMapper handles type conversions between SQL and Go types type TypeMapper struct { - // Package alias for sql_types import sqlTypesAlias string + typeStyle string // writers.NullableTypeResolveSpec | writers.NullableTypeStdlib } -// NewTypeMapper creates a new TypeMapper with default settings -func NewTypeMapper() *TypeMapper { +// NewTypeMapper creates a new TypeMapper. +// typeStyle should be writers.NullableTypeResolveSpec or writers.NullableTypeStdlib; +// an empty string defaults to resolvespec. +func NewTypeMapper(typeStyle string) *TypeMapper { + if typeStyle == "" { + typeStyle = writers.NullableTypeResolveSpec + } return &TypeMapper{ sqlTypesAlias: "sql_types", + typeStyle: typeStyle, } } -// SQLTypeToGoType converts a SQL type to its Go equivalent -// Handles nullable types using ResolveSpec sql_types package +// SQLTypeToGoType converts a SQL type to its Go equivalent. func (tm *TypeMapper) SQLTypeToGoType(sqlType string, notNull bool) string { - // Normalize SQL type (lowercase, remove length/precision) + // Array types are handled separately for both styles. + if pgsql.IsArrayType(sqlType) { + return tm.arrayGoType(tm.extractBaseType(sqlType)) + } + baseType := tm.extractBaseType(sqlType) - // If not null, use base Go types + if tm.typeStyle == writers.NullableTypeStdlib { + if notNull { + return tm.rawGoType(baseType) + } + return tm.stdlibNullableGoType(baseType) + } + + // resolvespec (default) if notNull { return tm.baseGoType(baseType) } - - // For nullable fields, use sql_types return tm.nullableGoType(baseType) } @@ -106,6 +120,9 @@ func (tm *TypeMapper) baseGoType(sqlType string) string { // Other "money": "float64", + + // pgvector — always uses SqlVector even when NOT NULL + "vector": tm.sqlTypesAlias + ".SqlVector", } if goType, ok := typeMap[sqlType]; ok { @@ -179,6 +196,9 @@ func (tm *TypeMapper) nullableGoType(sqlType string) string { // Other "money": tm.sqlTypesAlias + ".SqlFloat64", + + // pgvector + "vector": tm.sqlTypesAlias + ".SqlVector", } if goType, ok := typeMap[sqlType]; ok { @@ -189,6 +209,123 @@ func (tm *TypeMapper) nullableGoType(sqlType string) string { return tm.sqlTypesAlias + ".SqlString" } +// arrayGoType returns the Go type for a PostgreSQL array column. +// The baseElemType is the canonical base type (e.g. "text", "integer"). +func (tm *TypeMapper) arrayGoType(baseElemType string) string { + if tm.typeStyle == writers.NullableTypeStdlib { + return tm.stdlibArrayGoType(baseElemType) + } + typeMap := map[string]string{ + "text": tm.sqlTypesAlias + ".SqlStringArray", "varchar": tm.sqlTypesAlias + ".SqlStringArray", + "char": tm.sqlTypesAlias + ".SqlStringArray", "character": tm.sqlTypesAlias + ".SqlStringArray", + "citext": tm.sqlTypesAlias + ".SqlStringArray", "bpchar": tm.sqlTypesAlias + ".SqlStringArray", + "inet": tm.sqlTypesAlias + ".SqlStringArray", "cidr": tm.sqlTypesAlias + ".SqlStringArray", + "macaddr": tm.sqlTypesAlias + ".SqlStringArray", + "json": tm.sqlTypesAlias + ".SqlStringArray", "jsonb": tm.sqlTypesAlias + ".SqlStringArray", + "integer": tm.sqlTypesAlias + ".SqlInt32Array", "int": tm.sqlTypesAlias + ".SqlInt32Array", + "int4": tm.sqlTypesAlias + ".SqlInt32Array", "serial": tm.sqlTypesAlias + ".SqlInt32Array", + "smallint": tm.sqlTypesAlias + ".SqlInt16Array", "int2": tm.sqlTypesAlias + ".SqlInt16Array", + "smallserial": tm.sqlTypesAlias + ".SqlInt16Array", + "bigint": tm.sqlTypesAlias + ".SqlInt64Array", "int8": tm.sqlTypesAlias + ".SqlInt64Array", + "bigserial": tm.sqlTypesAlias + ".SqlInt64Array", + "real": tm.sqlTypesAlias + ".SqlFloat32Array", "float4": tm.sqlTypesAlias + ".SqlFloat32Array", + "double precision": tm.sqlTypesAlias + ".SqlFloat64Array", "float8": tm.sqlTypesAlias + ".SqlFloat64Array", + "numeric": tm.sqlTypesAlias + ".SqlFloat64Array", "decimal": tm.sqlTypesAlias + ".SqlFloat64Array", + "money": tm.sqlTypesAlias + ".SqlFloat64Array", + "boolean": tm.sqlTypesAlias + ".SqlBoolArray", "bool": tm.sqlTypesAlias + ".SqlBoolArray", + "uuid": tm.sqlTypesAlias + ".SqlUUIDArray", + } + if goType, ok := typeMap[baseElemType]; ok { + return goType + } + return tm.sqlTypesAlias + ".SqlStringArray" +} + +// rawGoType returns the plain Go type for a NOT NULL column in stdlib mode. +func (tm *TypeMapper) rawGoType(sqlType string) string { + typeMap := map[string]string{ + "integer": "int32", "int": "int32", "int4": "int32", "serial": "int32", + "smallint": "int16", "int2": "int16", "smallserial": "int16", + "bigint": "int64", "int8": "int64", "bigserial": "int64", + "boolean": "bool", "bool": "bool", + "real": "float32", "float4": "float32", + "double precision": "float64", "float8": "float64", + "numeric": "float64", "decimal": "float64", "money": "float64", + "text": "string", "varchar": "string", "char": "string", + "character": "string", "citext": "string", "bpchar": "string", + "inet": "string", "cidr": "string", "macaddr": "string", + "uuid": "string", "json": "string", "jsonb": "string", + "timestamp": "time.Time", + "timestamp without time zone": "time.Time", + "timestamp with time zone": "time.Time", + "timestamptz": "time.Time", + "date": "time.Time", + "time": "time.Time", + "time without time zone": "time.Time", + "time with time zone": "time.Time", + "timetz": "time.Time", + "bytea": "[]byte", + "vector": "[]float32", + } + if goType, ok := typeMap[sqlType]; ok { + return goType + } + return "string" +} + +// stdlibNullableGoType returns the database/sql nullable type for a column. +func (tm *TypeMapper) stdlibNullableGoType(sqlType string) string { + typeMap := map[string]string{ + "integer": "sql.NullInt32", "int": "sql.NullInt32", "int4": "sql.NullInt32", "serial": "sql.NullInt32", + "smallint": "sql.NullInt16", "int2": "sql.NullInt16", "smallserial": "sql.NullInt16", + "bigint": "sql.NullInt64", "int8": "sql.NullInt64", "bigserial": "sql.NullInt64", + "boolean": "sql.NullBool", "bool": "sql.NullBool", + "real": "sql.NullFloat64", "float4": "sql.NullFloat64", + "double precision": "sql.NullFloat64", "float8": "sql.NullFloat64", + "numeric": "sql.NullFloat64", "decimal": "sql.NullFloat64", "money": "sql.NullFloat64", + "text": "sql.NullString", "varchar": "sql.NullString", "char": "sql.NullString", + "character": "sql.NullString", "citext": "sql.NullString", "bpchar": "sql.NullString", + "inet": "sql.NullString", "cidr": "sql.NullString", "macaddr": "sql.NullString", + "uuid": "sql.NullString", "json": "sql.NullString", "jsonb": "sql.NullString", + "timestamp": "sql.NullTime", + "timestamp without time zone": "sql.NullTime", + "timestamp with time zone": "sql.NullTime", + "timestamptz": "sql.NullTime", + "date": "sql.NullTime", + "time": "sql.NullTime", + "time without time zone": "sql.NullTime", + "time with time zone": "sql.NullTime", + "timetz": "sql.NullTime", + "bytea": "[]byte", + "vector": "[]float32", + } + if goType, ok := typeMap[sqlType]; ok { + return goType + } + return "sql.NullString" +} + +// stdlibArrayGoType returns a plain Go slice type for array columns in stdlib mode. +func (tm *TypeMapper) stdlibArrayGoType(baseElemType string) string { + typeMap := map[string]string{ + "text": "[]string", "varchar": "[]string", "char": "[]string", + "character": "[]string", "citext": "[]string", "bpchar": "[]string", + "inet": "[]string", "cidr": "[]string", "macaddr": "[]string", + "uuid": "[]string", "json": "[]string", "jsonb": "[]string", + "integer": "[]int32", "int": "[]int32", "int4": "[]int32", "serial": "[]int32", + "smallint": "[]int16", "int2": "[]int16", "smallserial": "[]int16", + "bigint": "[]int64", "int8": "[]int64", "bigserial": "[]int64", + "real": "[]float32", "float4": "[]float32", + "double precision": "[]float64", "float8": "[]float64", + "numeric": "[]float64", "decimal": "[]float64", "money": "[]float64", + "boolean": "[]bool", "bool": "[]bool", + } + if goType, ok := typeMap[baseElemType]; ok { + return goType + } + return "[]string" +} + // BuildGormTag generates a complete GORM tag string for a column func (tm *TypeMapper) BuildGormTag(column *models.Column, table *models.Table) string { var parts []string @@ -330,7 +467,16 @@ func (tm *TypeMapper) NeedsFmtImport(generateGetIDStr bool) bool { return generateGetIDStr } -// GetSQLTypesImport returns the import path for sql_types +// GetSQLTypesImport returns the import path for the ResolveSpec spectypes package. func (tm *TypeMapper) GetSQLTypesImport() string { return "github.com/bitechdev/ResolveSpec/pkg/spectypes" } + +// GetNullableTypeImportLine returns the full Go import line for the nullable type +// package (ready to pass to AddImport). Returns empty string when no import is needed. +func (tm *TypeMapper) GetNullableTypeImportLine() string { + if tm.typeStyle == writers.NullableTypeStdlib { + return "\"database/sql\"" + } + return fmt.Sprintf("%s \"%s\"", tm.sqlTypesAlias, tm.GetSQLTypesImport()) +} diff --git a/pkg/writers/gorm/writer.go b/pkg/writers/gorm/writer.go index a91796c..f4a80fe 100644 --- a/pkg/writers/gorm/writer.go +++ b/pkg/writers/gorm/writer.go @@ -24,7 +24,7 @@ type Writer struct { func NewWriter(options *writers.WriterOptions) *Writer { w := &Writer{ options: options, - typeMapper: NewTypeMapper(), + typeMapper: NewTypeMapper(options.NullableTypes), config: LoadMethodConfigFromMetadata(options.Metadata), } @@ -77,8 +77,8 @@ func (w *Writer) writeSingleFile(db *models.Database) error { packageName := w.getPackageName() templateData := NewTemplateData(packageName, w.config) - // Add sql_types import (always needed for nullable types) - templateData.AddImport(fmt.Sprintf("sql_types \"%s\"", w.typeMapper.GetSQLTypesImport())) + // Add nullable types import (resolvespec or stdlib depending on options) + templateData.AddImport(w.typeMapper.GetNullableTypeImportLine()) // Collect all models for _, schema := range db.Schemas { @@ -171,8 +171,8 @@ func (w *Writer) writeMultiFile(db *models.Database) error { // Create template data for this single table templateData := NewTemplateData(packageName, w.config) - // Add sql_types import - templateData.AddImport(fmt.Sprintf("sql_types \"%s\"", w.typeMapper.GetSQLTypesImport())) + // Add nullable types import (resolvespec or stdlib depending on options) + templateData.AddImport(w.typeMapper.GetNullableTypeImportLine()) // Create model data modelData := NewModelData(table, schema.Name, w.typeMapper, w.options.FlattenSchema) diff --git a/pkg/writers/gorm/writer_test.go b/pkg/writers/gorm/writer_test.go index b90a67a..8bf3d90 100644 --- a/pkg/writers/gorm/writer_test.go +++ b/pkg/writers/gorm/writer_test.go @@ -643,7 +643,7 @@ func TestNameConverter_Pluralize(t *testing.T) { } func TestTypeMapper_SQLTypeToGoType(t *testing.T) { - mapper := NewTypeMapper() + mapper := NewTypeMapper("") tests := []struct { sqlType string @@ -671,7 +671,7 @@ func TestTypeMapper_SQLTypeToGoType(t *testing.T) { } func TestTypeMapper_BuildGormTag_PreservesExplicitTypeModifiers(t *testing.T) { - mapper := NewTypeMapper() + mapper := NewTypeMapper("") col := &models.Column{ Name: "embedding", diff --git a/pkg/writers/writer.go b/pkg/writers/writer.go index 78b5183..86f92d9 100644 --- a/pkg/writers/writer.go +++ b/pkg/writers/writer.go @@ -20,6 +20,18 @@ type Writer interface { WriteTable(table *models.Table) error } +// NullableType constants control which Go package is used for nullable column types +// in code-generation writers (Bun, GORM). +const ( + // NullableTypeResolveSpec uses github.com/bitechdev/ResolveSpec/pkg/spectypes + // (SqlString, SqlInt32, SqlVector, SqlStringArray, …). This is the default. + NullableTypeResolveSpec = "resolvespec" + + // NullableTypeStdlib uses the standard library database/sql nullable types + // (sql.NullString, sql.NullInt32, …) and plain Go slices for arrays. + NullableTypeStdlib = "stdlib" +) + // WriterOptions contains common options for writers type WriterOptions struct { // OutputPath is the path where the output should be written @@ -33,6 +45,12 @@ type WriterOptions struct { // Useful for databases like SQLite that do not support schemas. FlattenSchema bool + // NullableTypes selects the Go type package used for nullable columns in + // code-generation writers (bun, gorm). Accepted values: + // "resolvespec" (default) — github.com/bitechdev/ResolveSpec/pkg/spectypes + // "stdlib" — database/sql (sql.NullString, sql.NullInt32, …) + NullableTypes string + // Additional options can be added here as needed Metadata map[string]interface{} }