Compare commits

...

7 Commits

Author SHA1 Message Date
warkanum bb7ceb37fe chore(release): update package version to 1.0.58
Release / test (push) Successful in -32m53s
Release / release (push) Successful in -20m53s
Release / pkg-deb (push) Successful in -31m34s
Release / pkg-rpm (push) Successful in -31m3s
Release / pkg-aur (push) Successful in -11m7s
2026-05-19 19:26:45 +02:00
warkanum 6a759ef3d1 fix(mssql): correct order of MSSQL type mappings 2026-05-19 19:26:30 +02:00
warkanum cb735f0754 feat(sqlite): add SQLite type mapping and conversion functions
* Implement SQLiteToCanonicalTypes for type mapping
* Add ConvertSQLiteToCanonical and ConvertCanonicalToSQLite functions
* Update mapDataType to utilize new conversion logic
2026-05-19 19:26:09 +02:00
warkanum 80fb49bc5e refactor(datatypes): remove redundant normalization function 2026-05-19 19:12:54 +02:00
warkanum 9190df81dd feat(merge): enhance type conflict detection for columns
* Introduced extractTypeParts function to handle embedded dimensions in type strings.
* Updated columnTypeConflict to utilize new type extraction logic.
* Improved PostgreSQL type normalization and handling in various components.
2026-05-19 19:12:27 +02:00
Hein 9235ef5e08 chore(release): update package version to 1.0.57
Release / test (push) Successful in -32m7s
Release / release (push) Successful in -27m58s
Release / pkg-deb (push) Successful in -30m52s
Release / pkg-aur (push) Successful in -29m5s
Release / pkg-rpm (push) Successful in -29m58s
2026-05-07 14:45:02 +02:00
Hein b91d6b33b5 feat(writer): add continue-on-error option for SQL writers
* Introduce ContinueOnError option to WriterOptions
* Update writer functions to support continue-on-error behavior
* Modify migration and database writing to handle continue-on-error
2026-05-07 14:44:36 +02:00
22 changed files with 687 additions and 313 deletions
+5 -3
View File
@@ -53,6 +53,7 @@ var (
convertSchemaFilter string convertSchemaFilter string
convertFlattenSchema bool convertFlattenSchema bool
convertNullableTypes string convertNullableTypes string
convertContinueOnError bool
) )
var convertCmd = &cobra.Command{ var convertCmd = &cobra.Command{
@@ -177,6 +178,7 @@ func init() {
convertCmd.Flags().StringVar(&convertSchemaFilter, "schema", "", "Filter to a specific schema by name (required for formats like dctx that only support single schemas)") 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().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)") convertCmd.Flags().StringVar(&convertNullableTypes, "types", "", "Nullable type package for code-gen writers (bun/gorm): 'resolvespec' (default) or 'stdlib' (database/sql)")
convertCmd.Flags().BoolVar(&convertContinueOnError, "continue-on-error", false, "Prepend \\set ON_ERROR_STOP off to generated SQL so psql continues past errors (pgsql output only)")
err := convertCmd.MarkFlagRequired("from") err := convertCmd.MarkFlagRequired("from")
if err != nil { if err != nil {
@@ -243,7 +245,7 @@ func runConvert(cmd *cobra.Command, args []string) error {
fmt.Fprintf(os.Stderr, " Schema: %s\n", convertSchemaFilter) fmt.Fprintf(os.Stderr, " Schema: %s\n", convertSchemaFilter)
} }
if err := writeDatabase(db, convertTargetType, convertTargetPath, convertPackageName, convertSchemaFilter, convertFlattenSchema, convertNullableTypes); err != nil { if err := writeDatabase(db, convertTargetType, convertTargetPath, convertPackageName, convertSchemaFilter, convertFlattenSchema, convertNullableTypes, convertContinueOnError); err != nil {
return fmt.Errorf("failed to write target: %w", err) return fmt.Errorf("failed to write target: %w", err)
} }
@@ -383,10 +385,10 @@ func readDatabaseForConvert(dbType, filePath, connString string) (*models.Databa
return db, nil return db, nil
} }
func writeDatabase(db *models.Database, dbType, outputPath, packageName, schemaFilter string, flattenSchema bool, nullableTypes string) error { func writeDatabase(db *models.Database, dbType, outputPath, packageName, schemaFilter string, flattenSchema bool, nullableTypes string, continueOnError bool) error {
var writer writers.Writer var writer writers.Writer
writerOpts := newWriterOptions(outputPath, packageName, flattenSchema, nullableTypes) writerOpts := newWriterOptions(outputPath, packageName, flattenSchema, nullableTypes, continueOnError)
switch strings.ToLower(dbType) { switch strings.ToLower(dbType) {
case "dbml": case "dbml":
+13 -13
View File
@@ -323,31 +323,31 @@ func writeDatabaseForEdit(dbType, filePath, connString string, db *models.Databa
switch strings.ToLower(dbType) { switch strings.ToLower(dbType) {
case "dbml": case "dbml":
writer = wdbml.NewWriter(newWriterOptions(filePath, "", false, "")) writer = wdbml.NewWriter(newWriterOptions(filePath, "", false, "", false))
case "dctx": case "dctx":
writer = wdctx.NewWriter(newWriterOptions(filePath, "", false, "")) writer = wdctx.NewWriter(newWriterOptions(filePath, "", false, "", false))
case "drawdb": case "drawdb":
writer = wdrawdb.NewWriter(newWriterOptions(filePath, "", false, "")) writer = wdrawdb.NewWriter(newWriterOptions(filePath, "", false, "", false))
case "graphql": case "graphql":
writer = wgraphql.NewWriter(newWriterOptions(filePath, "", false, "")) writer = wgraphql.NewWriter(newWriterOptions(filePath, "", false, "", false))
case "json": case "json":
writer = wjson.NewWriter(newWriterOptions(filePath, "", false, "")) writer = wjson.NewWriter(newWriterOptions(filePath, "", false, "", false))
case "yaml": case "yaml":
writer = wyaml.NewWriter(newWriterOptions(filePath, "", false, "")) writer = wyaml.NewWriter(newWriterOptions(filePath, "", false, "", false))
case "gorm": case "gorm":
writer = wgorm.NewWriter(newWriterOptions(filePath, "", false, "")) writer = wgorm.NewWriter(newWriterOptions(filePath, "", false, "", false))
case "bun": case "bun":
writer = wbun.NewWriter(newWriterOptions(filePath, "", false, "")) writer = wbun.NewWriter(newWriterOptions(filePath, "", false, "", false))
case "drizzle": case "drizzle":
writer = wdrizzle.NewWriter(newWriterOptions(filePath, "", false, "")) writer = wdrizzle.NewWriter(newWriterOptions(filePath, "", false, "", false))
case "prisma": case "prisma":
writer = wprisma.NewWriter(newWriterOptions(filePath, "", false, "")) writer = wprisma.NewWriter(newWriterOptions(filePath, "", false, "", false))
case "typeorm": case "typeorm":
writer = wtypeorm.NewWriter(newWriterOptions(filePath, "", false, "")) writer = wtypeorm.NewWriter(newWriterOptions(filePath, "", false, "", false))
case "sqlite", "sqlite3": case "sqlite", "sqlite3":
writer = wsqlite.NewWriter(newWriterOptions(filePath, "", false, "")) writer = wsqlite.NewWriter(newWriterOptions(filePath, "", false, "", false))
case "pgsql": case "pgsql":
writer = wpgsql.NewWriter(newWriterOptions(filePath, "", false, "")) writer = wpgsql.NewWriter(newWriterOptions(filePath, "", false, "", false))
default: default:
return fmt.Errorf("%s: unsupported format: %s", label, dbType) return fmt.Errorf("%s: unsupported format: %s", label, dbType)
} }
+13 -13
View File
@@ -375,61 +375,61 @@ func writeDatabaseForMerge(dbType, filePath, connString string, db *models.Datab
if filePath == "" { if filePath == "" {
return fmt.Errorf("%s: file path is required for DBML format", label) return fmt.Errorf("%s: file path is required for DBML format", label)
} }
writer = wdbml.NewWriter(newWriterOptions(filePath, "", flattenSchema, "")) writer = wdbml.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", false))
case "dctx": case "dctx":
if filePath == "" { if filePath == "" {
return fmt.Errorf("%s: file path is required for DCTX format", label) return fmt.Errorf("%s: file path is required for DCTX format", label)
} }
writer = wdctx.NewWriter(newWriterOptions(filePath, "", flattenSchema, "")) writer = wdctx.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", false))
case "drawdb": case "drawdb":
if filePath == "" { if filePath == "" {
return fmt.Errorf("%s: file path is required for DrawDB format", label) return fmt.Errorf("%s: file path is required for DrawDB format", label)
} }
writer = wdrawdb.NewWriter(newWriterOptions(filePath, "", flattenSchema, "")) writer = wdrawdb.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", false))
case "graphql": case "graphql":
if filePath == "" { if filePath == "" {
return fmt.Errorf("%s: file path is required for GraphQL format", label) return fmt.Errorf("%s: file path is required for GraphQL format", label)
} }
writer = wgraphql.NewWriter(newWriterOptions(filePath, "", flattenSchema, "")) writer = wgraphql.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", false))
case "json": case "json":
if filePath == "" { if filePath == "" {
return fmt.Errorf("%s: file path is required for JSON format", label) return fmt.Errorf("%s: file path is required for JSON format", label)
} }
writer = wjson.NewWriter(newWriterOptions(filePath, "", flattenSchema, "")) writer = wjson.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", false))
case "yaml": case "yaml":
if filePath == "" { if filePath == "" {
return fmt.Errorf("%s: file path is required for YAML format", label) return fmt.Errorf("%s: file path is required for YAML format", label)
} }
writer = wyaml.NewWriter(newWriterOptions(filePath, "", flattenSchema, "")) writer = wyaml.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", false))
case "gorm": case "gorm":
if filePath == "" { if filePath == "" {
return fmt.Errorf("%s: file path is required for GORM format", label) return fmt.Errorf("%s: file path is required for GORM format", label)
} }
writer = wgorm.NewWriter(newWriterOptions(filePath, "", flattenSchema, "")) writer = wgorm.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", false))
case "bun": case "bun":
if filePath == "" { if filePath == "" {
return fmt.Errorf("%s: file path is required for Bun format", label) return fmt.Errorf("%s: file path is required for Bun format", label)
} }
writer = wbun.NewWriter(newWriterOptions(filePath, "", flattenSchema, "")) writer = wbun.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", false))
case "drizzle": case "drizzle":
if filePath == "" { if filePath == "" {
return fmt.Errorf("%s: file path is required for Drizzle format", label) return fmt.Errorf("%s: file path is required for Drizzle format", label)
} }
writer = wdrizzle.NewWriter(newWriterOptions(filePath, "", flattenSchema, "")) writer = wdrizzle.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", false))
case "prisma": case "prisma":
if filePath == "" { if filePath == "" {
return fmt.Errorf("%s: file path is required for Prisma format", label) return fmt.Errorf("%s: file path is required for Prisma format", label)
} }
writer = wprisma.NewWriter(newWriterOptions(filePath, "", flattenSchema, "")) writer = wprisma.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", false))
case "typeorm": case "typeorm":
if filePath == "" { if filePath == "" {
return fmt.Errorf("%s: file path is required for TypeORM format", label) return fmt.Errorf("%s: file path is required for TypeORM format", label)
} }
writer = wtypeorm.NewWriter(newWriterOptions(filePath, "", flattenSchema, "")) writer = wtypeorm.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", false))
case "sqlite", "sqlite3": case "sqlite", "sqlite3":
writer = wsqlite.NewWriter(newWriterOptions(filePath, "", flattenSchema, "")) writer = wsqlite.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", false))
case "pgsql": case "pgsql":
writerOpts := newWriterOptions(filePath, "", flattenSchema, "") writerOpts := newWriterOptions(filePath, "", flattenSchema, "", false)
if connString != "" { if connString != "" {
writerOpts.Metadata = map[string]interface{}{ writerOpts.Metadata = map[string]interface{}{
"connection_string": connString, "connection_string": connString,
+2 -1
View File
@@ -13,12 +13,13 @@ func newReaderOptions(filePath, connString string) *readers.ReaderOptions {
} }
} }
func newWriterOptions(outputPath, packageName string, flattenSchema bool, nullableTypes string) *writers.WriterOptions { func newWriterOptions(outputPath, packageName string, flattenSchema bool, nullableTypes string, continueOnError bool) *writers.WriterOptions {
return &writers.WriterOptions{ return &writers.WriterOptions{
OutputPath: outputPath, OutputPath: outputPath,
PackageName: packageName, PackageName: packageName,
FlattenSchema: flattenSchema, FlattenSchema: flattenSchema,
NullableTypes: nullableTypes, NullableTypes: nullableTypes,
Prisma7: prisma7, Prisma7: prisma7,
ContinueOnError: continueOnError,
} }
} }
+1
View File
@@ -188,6 +188,7 @@ func runSplit(cmd *cobra.Command, args []string) error {
"", // no schema filter for split "", // no schema filter for split
false, // no flatten-schema for split false, // no flatten-schema for split
splitNullableTypes, splitNullableTypes,
false, // no continue-on-error for split
) )
if err != nil { if err != nil {
return fmt.Errorf("failed to write output: %w", err) return fmt.Errorf("failed to write output: %w", err)
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Hein (Warky Devs) <hein@warky.dev> # Maintainer: Hein (Warky Devs) <hein@warky.dev>
pkgname=relspec pkgname=relspec
pkgver=1.0.56 pkgver=1.0.58
pkgrel=1 pkgrel=1
pkgdesc="RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs." pkgdesc="RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs."
arch=('x86_64' 'aarch64') arch=('x86_64' 'aarch64')
+1 -1
View File
@@ -1,5 +1,5 @@
Name: relspec Name: relspec
Version: 1.0.56 Version: 1.0.58
Release: 1%{?dist} Release: 1%{?dist}
Summary: RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs. Summary: RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs.
+156
View File
@@ -0,0 +1,156 @@
package mariadb
import "strings"
// MariaDBToCanonicalTypes maps MariaDB/MySQL type names to canonical types.
var MariaDBToCanonicalTypes = map[string]string{
// Integer types
"tinyint": "int8",
"smallint": "int16",
"mediumint": "int",
"int": "int",
"integer": "int",
"int2": "int16",
"int4": "int",
"int8": "int64",
"bigint": "int64",
// Boolean (TINYINT(1) alias)
"boolean": "bool",
"bool": "bool",
"bit": "bool",
// Float types
"float": "float32",
"double": "float64",
"real": "float64",
"double precision": "float64",
// Decimal types
"decimal": "decimal",
"numeric": "decimal",
"dec": "decimal",
"fixed": "decimal",
// String types
"char": "string",
"character": "string",
"varchar": "string",
"nchar": "string",
"nvarchar": "string",
"tinytext": "text",
"text": "text",
"mediumtext": "text",
"longtext": "text",
// Binary/blob types
"binary": "bytea",
"varbinary": "bytea",
"tinyblob": "bytea",
"blob": "bytea",
"mediumblob": "bytea",
"longblob": "bytea",
// Date/time types
"date": "date",
"time": "time",
"datetime": "timestamp",
"timestamp": "timestamp",
"year": "int",
// Other types
"json": "json",
"enum": "string",
"set": "string",
"uuid": "uuid",
}
// CanonicalToMariaDBTypes maps canonical types to MariaDB/MySQL types.
var CanonicalToMariaDBTypes = map[string]string{
"bool": "TINYINT(1)",
"int8": "TINYINT",
"int16": "SMALLINT",
"int": "INT",
"int32": "INT",
"int64": "BIGINT",
"uint": "INT UNSIGNED",
"uint8": "TINYINT UNSIGNED",
"uint16": "SMALLINT UNSIGNED",
"uint32": "INT UNSIGNED",
"uint64": "BIGINT UNSIGNED",
"float32": "FLOAT",
"float64": "DOUBLE",
"decimal": "DECIMAL",
"string": "VARCHAR(255)",
"text": "TEXT",
"date": "DATE",
"time": "TIME",
"timestamp": "DATETIME",
"timestamptz": "DATETIME",
"uuid": "CHAR(36)",
"json": "JSON",
"jsonb": "JSON",
"bytea": "BLOB",
}
// MariaDBTypeSynonyms maps MariaDB/MySQL type aliases to their canonical MariaDB name.
var MariaDBTypeSynonyms = map[string]string{
"integer": "int",
"int2": "smallint",
"int4": "int",
"int8": "bigint",
"double precision": "double",
"character": "char",
"dec": "decimal",
"fixed": "decimal",
"numeric": "decimal",
"boolean": "tinyint",
"bool": "tinyint",
}
// NormalizeMariaDBType maps a MariaDB/MySQL base type (no dimension parameters)
// to its canonical MariaDB form. Unknown types are returned as-is (lowercased).
func NormalizeMariaDBType(baseType string) string {
lower := strings.ToLower(strings.TrimSpace(baseType))
if canonical, ok := MariaDBTypeSynonyms[lower]; ok {
return canonical
}
return lower
}
// ConvertMariaDBToCanonical converts a MariaDB/MySQL type name to the canonical type.
// Strips dimension parameters and normalizes aliases. Defaults to "string".
func ConvertMariaDBToCanonical(mariadbType string) string {
base := strings.ToLower(strings.TrimSpace(mariadbType))
if idx := strings.Index(base, "("); idx >= 0 {
base = strings.TrimSpace(base[:idx])
}
if canonical, ok := MariaDBToCanonicalTypes[base]; ok {
return canonical
}
// Prefix match for composite types (e.g., "unsigned bigint")
for key, canonical := range MariaDBToCanonicalTypes {
if strings.HasPrefix(base, key) {
return canonical
}
}
return "string"
}
// ConvertCanonicalToMariaDB converts a canonical type to a MariaDB/MySQL type.
// Defaults to VARCHAR(255) for unrecognised types.
func ConvertCanonicalToMariaDB(canonicalType string) string {
lower := strings.ToLower(strings.TrimSpace(canonicalType))
if idx := strings.Index(lower, "("); idx >= 0 {
lower = strings.TrimSpace(lower[:idx])
}
if mariadbType, ok := CanonicalToMariaDBTypes[lower]; ok {
return mariadbType
}
// Prefix fallback
for canonical, mariadb := range CanonicalToMariaDBTypes {
if strings.HasPrefix(lower, canonical) {
return mariadb
}
}
return "VARCHAR(255)"
}
+34 -6
View File
@@ -5,9 +5,11 @@ package merge
import ( import (
"fmt" "fmt"
"strconv"
"strings" "strings"
"git.warky.dev/wdevs/relspecgo/pkg/models" "git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/pgsql"
) )
// MergeResult represents the result of a merge operation // MergeResult represents the result of a merge operation
@@ -449,14 +451,40 @@ func columnTypeConflict(target, source *models.Column) bool {
return false return false
} }
return normalizeType(target.Type) != normalizeType(source.Type) || tType, tLen, tPrec, tScale := extractTypeParts(target)
target.Length != source.Length || sType, sLen, sPrec, sScale := extractTypeParts(source)
target.Precision != source.Precision ||
target.Scale != source.Scale return tType != sType || tLen != sLen || tPrec != sPrec || tScale != sScale
} }
func normalizeType(value string) string { // extractTypeParts returns the canonical base type and dimensions for a column,
return strings.ToLower(strings.TrimSpace(value)) // handling the case where dimensions are embedded in the type string (e.g. "char(2)")
// rather than stored in the separate Length/Precision/Scale fields.
func extractTypeParts(col *models.Column) (baseType string, length, precision, scale int) {
typeName := strings.ToLower(strings.TrimSpace(col.Type))
length, precision, scale = col.Length, col.Precision, col.Scale
if idx := strings.Index(typeName, "("); idx >= 0 {
inner := strings.TrimRight(strings.TrimSpace(typeName[idx+1:]), ")")
typeName = strings.TrimSpace(typeName[:idx])
parts := strings.Split(inner, ",")
if len(parts) == 2 {
if p, err := strconv.Atoi(strings.TrimSpace(parts[0])); err == nil && p > 0 && precision == 0 {
precision = p
}
if s, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil && s > 0 && scale == 0 {
scale = s
}
} else if len(parts) == 1 {
if l, err := strconv.Atoi(strings.TrimSpace(parts[0])); err == nil && l > 0 && length == 0 && precision == 0 {
length = l
}
}
}
typeName = pgsql.NormalizePGType(typeName)
return typeName, length, precision, scale
} }
func describeColumnType(col *models.Column) string { func describeColumnType(col *models.Column) string {
+76 -32
View File
@@ -2,32 +2,73 @@ package mssql
import "strings" import "strings"
// CanonicalToMSSQLTypes maps canonical types to MSSQL types // CanonicalToMSSQLTypes maps canonical types to MSSQL types.
// Accepts both Go canonical names ("int", "string") and SQL canonical names
// ("integer", "varchar") so the writer handles input from any reader.
var CanonicalToMSSQLTypes = map[string]string{ var CanonicalToMSSQLTypes = map[string]string{
// Boolean — Go and SQL canonical
"bool": "BIT", "bool": "BIT",
"boolean": "BIT",
// Integer — Go canonical
"int8": "TINYINT", "int8": "TINYINT",
"int16": "SMALLINT", "int16": "SMALLINT",
"int": "INT", "int": "INT",
"int32": "INT", "int32": "INT",
"int64": "BIGINT", "int64": "BIGINT",
"uint": "BIGINT", "uint": "BIGINT",
"uint8": "SMALLINT", "uint8": "TINYINT",
"uint16": "INT", "uint16": "SMALLINT",
"uint32": "BIGINT", "uint32": "BIGINT",
"uint64": "BIGINT", "uint64": "BIGINT",
// Integer — SQL canonical (serial types map to base integer; IDENTITY is set via AutoIncrement)
"integer": "INT",
"smallint": "SMALLINT",
"bigint": "BIGINT",
"tinyint": "TINYINT",
"serial": "INT",
"smallserial": "SMALLINT",
"bigserial": "BIGINT",
// Float — Go canonical
"float32": "REAL", "float32": "REAL",
"float64": "FLOAT", "float64": "FLOAT",
// Float — SQL canonical
"real": "REAL",
"double precision": "FLOAT",
"double": "FLOAT",
// Decimal/numeric
"decimal": "NUMERIC", "decimal": "NUMERIC",
"numeric": "NUMERIC",
"money": "MONEY",
// String — Go canonical
"string": "NVARCHAR(255)", "string": "NVARCHAR(255)",
"text": "NVARCHAR(MAX)", "text": "NVARCHAR(MAX)",
// String — SQL canonical
"varchar": "NVARCHAR(255)",
"char": "NCHAR",
"nvarchar": "NVARCHAR(255)",
"nchar": "NCHAR",
"citext": "NVARCHAR(MAX)",
// Date/time
"date": "DATE", "date": "DATE",
"time": "TIME", "time": "TIME",
"timetz": "DATETIMEOFFSET",
"timestamp": "DATETIME2", "timestamp": "DATETIME2",
"timestamptz": "DATETIMEOFFSET", "timestamptz": "DATETIMEOFFSET",
"datetime": "DATETIME2",
"interval": "NVARCHAR(50)",
// UUID
"uuid": "UNIQUEIDENTIFIER", "uuid": "UNIQUEIDENTIFIER",
// JSON — MSSQL has no native JSON type; stored as NVARCHAR(MAX)
"json": "NVARCHAR(MAX)", "json": "NVARCHAR(MAX)",
"jsonb": "NVARCHAR(MAX)", "jsonb": "NVARCHAR(MAX)",
// Binary
"bytea": "VARBINARY(MAX)", "bytea": "VARBINARY(MAX)",
"blob": "VARBINARY(MAX)",
// Network/geo types — no MSSQL native equivalent
"xml": "XML",
"inet": "NVARCHAR(45)",
"cidr": "NVARCHAR(43)",
"macaddr": "NVARCHAR(17)",
} }
// MSSQLToCanonicalTypes maps MSSQL types to canonical types // MSSQLToCanonicalTypes maps MSSQL types to canonical types
@@ -68,47 +109,50 @@ var MSSQLToCanonicalTypes = map[string]string{
"geometry": "string", "geometry": "string",
} }
// ConvertCanonicalToMSSQL converts a canonical type to MSSQL type // MSSQLTypeSynonyms maps MSSQL type aliases to their canonical MSSQL name.
var MSSQLTypeSynonyms = map[string]string{
"integer": "int",
"dec": "decimal",
"float(n)": "float",
}
// NormalizeMSSQLType maps an MSSQL base type (no dimension parameters) to its
// canonical MSSQL form. Unknown types are returned as-is (lowercased).
func NormalizeMSSQLType(baseType string) string {
lower := strings.ToLower(strings.TrimSpace(baseType))
if canonical, ok := MSSQLTypeSynonyms[lower]; ok {
return canonical
}
return lower
}
// ConvertCanonicalToMSSQL converts a canonical type (Go or SQL) to an MSSQL type.
// Strips dimension parameters before lookup. Defaults to NVARCHAR(255) for unknown types.
func ConvertCanonicalToMSSQL(canonicalType string) string { func ConvertCanonicalToMSSQL(canonicalType string) string {
// Check direct mapping base := strings.ToLower(strings.TrimSpace(canonicalType))
if mssqlType, exists := CanonicalToMSSQLTypes[strings.ToLower(canonicalType)]; exists { if idx := strings.Index(base, "("); idx >= 0 {
base = strings.TrimSpace(base[:idx])
}
base = strings.TrimSuffix(base, "[]")
if mssqlType, exists := CanonicalToMSSQLTypes[base]; exists {
return mssqlType return mssqlType
} }
// Try to find by prefix
lowerType := strings.ToLower(canonicalType)
for canonical, mssql := range CanonicalToMSSQLTypes {
if strings.HasPrefix(lowerType, canonical) {
return mssql
}
}
// Default to NVARCHAR
return "NVARCHAR(255)" return "NVARCHAR(255)"
} }
// ConvertMSSQLToCanonical converts an MSSQL type to canonical type // ConvertMSSQLToCanonical converts an MSSQL type to the canonical type.
// Strips dimension parameters before lookup. Defaults to "string" for unknown types.
func ConvertMSSQLToCanonical(mssqlType string) string { func ConvertMSSQLToCanonical(mssqlType string) string {
// Extract base type (remove parentheses and parameters) base := strings.ToLower(strings.TrimSpace(mssqlType))
baseType := mssqlType if idx := strings.Index(base, "("); idx >= 0 {
if idx := strings.Index(baseType, "("); idx != -1 { base = strings.TrimSpace(base[:idx])
baseType = baseType[:idx]
} }
baseType = strings.TrimSpace(baseType)
// Check direct mapping if canonicalType, exists := MSSQLToCanonicalTypes[base]; exists {
if canonicalType, exists := MSSQLToCanonicalTypes[strings.ToLower(baseType)]; exists {
return canonicalType return canonicalType
} }
// Try to find by prefix
lowerType := strings.ToLower(baseType)
for mssql, canonical := range MSSQLToCanonicalTypes {
if strings.HasPrefix(lowerType, mssql) {
return canonical
}
}
// Default to string
return "string" return "string"
} }
+58
View File
@@ -45,6 +45,7 @@ var GoToStdTypes = map[string]string{
"sqldate": "date", "sqldate": "date",
"sqltime": "time", "sqltime": "time",
"sqltimestamp": "timestamp", "sqltimestamp": "timestamp",
"time.Time": "timestamp",
} }
var GoToPGSQLTypes = map[string]string{ var GoToPGSQLTypes = map[string]string{
@@ -90,6 +91,7 @@ var GoToPGSQLTypes = map[string]string{
"sqldate": "date", "sqldate": "date",
"sqltime": "time", "sqltime": "time",
"sqltimestamp": "timestamp", "sqltimestamp": "timestamp",
"time.Time": "timestamp",
"citext": "citext", "citext": "citext",
} }
@@ -135,6 +137,62 @@ func ConvertSQLType(anytype string) string {
return anytype return anytype
} }
// PGTypeCanonical maps PostgreSQL type aliases and synonyms to their canonical base name.
// Input should be a base type (no dimension parameters, lowercase).
var PGTypeCanonical = map[string]string{
// integer aliases
"int": "integer",
"int4": "integer",
"int2": "smallint",
"int8": "bigint",
// float aliases
"float4": "real",
"float8": "double precision",
// bool alias
"bool": "boolean",
// char aliases
"character": "char",
"character varying": "varchar",
"bpchar": "char",
// timestamp aliases
"timestamp without time zone": "timestamp",
"timestamp with time zone": "timestamptz",
// time aliases
"time without time zone": "time",
"time with time zone": "timetz",
// decimal alias
"decimal": "numeric",
}
// knownPGBaseTypes is the set of canonical PostgreSQL base types (no aliases).
var knownPGBaseTypes = map[string]struct{}{
"integer": {}, "bigint": {}, "smallint": {},
"serial": {}, "bigserial": {}, "smallserial": {},
"numeric": {}, "real": {}, "double precision": {}, "money": {},
"varchar": {}, "char": {}, "text": {}, "citext": {},
"boolean": {},
"date": {}, "time": {}, "timetz": {}, "timestamp": {}, "timestamptz": {}, "interval": {},
"uuid": {}, "json": {}, "jsonb": {}, "bytea": {},
"inet": {}, "cidr": {}, "macaddr": {}, "xml": {},
}
// NormalizePGType maps a PostgreSQL base type (no dimension parameters) to its
// canonical form. Unknown types are returned as-is (lowercased).
func NormalizePGType(baseType string) string {
lower := strings.ToLower(strings.TrimSpace(baseType))
if canonical, ok := PGTypeCanonical[lower]; ok {
return canonical
}
return lower
}
// IsKnownPGBaseType reports whether the given name (after NormalizePGType) is a
// recognized built-in PostgreSQL type. Custom types (e.g. vector, postgis) return false.
func IsKnownPGBaseType(baseType string) bool {
_, ok := knownPGBaseTypes[strings.ToLower(strings.TrimSpace(baseType))]
return ok
}
func IsGoType(pTypeName string) bool { func IsGoType(pTypeName string) bool {
for k := range GoToStdTypes { for k := range GoToStdTypes {
if strings.EqualFold(pTypeName, k) { if strings.EqualFold(pTypeName, k) {
+8
View File
@@ -270,8 +270,16 @@ func (r *Reader) queryColumns(schemaName string) (map[string]map[string]*models.
} }
if numPrecision != nil { if numPrecision != nil {
// For integer and serial types, numeric_precision is a bit-width (32, 64, 16)
// not a user-visible column parameter. Only store precision for types where
// it represents actual decimal/scale precision (numeric, decimal, float).
switch column.Type {
case "integer", "bigint", "smallint", "serial", "bigserial", "smallserial":
// skip — bit-width, not a column parameter
default:
column.Precision = *numPrecision column.Precision = *numPrecision
} }
}
if numScale != nil { if numScale != nil {
column.Scale = *numScale column.Scale = *numScale
+28 -62
View File
@@ -259,12 +259,14 @@ func (r *Reader) close() {
} }
} }
// mapDataType maps PostgreSQL data types while preserving exact type text when available. // mapDataType maps a PostgreSQL data type to its canonical RelSpec name.
// For known built-in types, dimensions are stripped from the type string (they are
// stored separately in column.Length/Precision/Scale). For custom types (e.g.
// vector(1536), postgis geometries), the full formatted type is preserved.
func (r *Reader) mapDataType(pgType, udtName, formattedType string, hasNextval bool) string { func (r *Reader) mapDataType(pgType, udtName, formattedType string, hasNextval bool) string {
normalizedPGType := strings.ToLower(strings.TrimSpace(pgType)) normalizedPGType := strings.ToLower(strings.TrimSpace(pgType))
// If the column has a nextval default, it's likely a serial type // Detect serial types from nextval defaults before anything else.
// Map to the appropriate serial type instead of the base integer type
if hasNextval { if hasNextval {
switch normalizedPGType { switch normalizedPGType {
case "integer", "int", "int4": case "integer", "int", "int4":
@@ -276,73 +278,38 @@ func (r *Reader) mapDataType(pgType, udtName, formattedType string, hasNextval b
} }
} }
// Prefer the database-provided formatted type; this preserves arrays/custom
// types/modifiers like text[], vector(1536), numeric(10,2), etc.
if strings.TrimSpace(formattedType) != "" {
return formattedType
}
// information_schema reports arrays generically as "ARRAY" with udt_name like "_text". // information_schema reports arrays generically as "ARRAY" with udt_name like "_text".
if strings.EqualFold(pgType, "ARRAY") && strings.HasPrefix(udtName, "_") && len(udtName) > 1 { if strings.EqualFold(pgType, "ARRAY") && strings.HasPrefix(udtName, "_") && len(udtName) > 1 {
return udtName[1:] + "[]" return udtName[1:] + "[]"
} }
// Map common PostgreSQL types // Use the database-formatted type when available. For known built-in types, strip
typeMap := map[string]string{ // embedded dimensions (they are stored in column.Length/Precision/Scale separately).
"integer": "integer", // For unknown/custom types, keep the full formatted string (e.g. vector(1536)).
"bigint": "bigint", if strings.TrimSpace(formattedType) != "" {
"smallint": "smallint", lower := strings.ToLower(strings.TrimSpace(formattedType))
"int": "integer", isArray := strings.HasSuffix(lower, "[]")
"int2": "smallint", base := strings.TrimSuffix(lower, "[]")
"int4": "integer", if idx := strings.Index(base, "("); idx >= 0 {
"int8": "bigint", base = strings.TrimSpace(base[:idx])
"serial": "serial", }
"bigserial": "bigserial", canonical := pgsql.NormalizePGType(base)
"smallserial": "smallserial", if pgsql.IsKnownPGBaseType(canonical) {
"numeric": "numeric", if isArray {
"decimal": "decimal", return canonical + "[]"
"real": "real", }
"double precision": "double precision", return canonical
"float4": "real", }
"float8": "double precision", return formattedType
"money": "money",
"character varying": "varchar",
"varchar": "varchar",
"character": "char",
"char": "char",
"text": "text",
"boolean": "boolean",
"bool": "boolean",
"date": "date",
"time": "time",
"time without time zone": "time",
"time with time zone": "timetz",
"timestamp": "timestamp",
"timestamp without time zone": "timestamp",
"timestamp with time zone": "timestamptz",
"timestamptz": "timestamptz",
"interval": "interval",
"uuid": "uuid",
"json": "json",
"jsonb": "jsonb",
"bytea": "bytea",
"inet": "inet",
"cidr": "cidr",
"macaddr": "macaddr",
"xml": "xml",
} }
// Try mapped type first // Fall back to normalizing the information_schema type name directly.
if mapped, exists := typeMap[normalizedPGType]; exists { canonical := pgsql.NormalizePGType(normalizedPGType)
return mapped if pgsql.IsKnownPGBaseType(canonical) {
return canonical
} }
// Use pgsql utilities if available // Return UDT name for custom types.
if pgsql.ValidSQLType(pgType) {
return pgsql.GetSQLType(pgType)
}
// Return UDT name for custom types (including array fallback when needed)
if udtName != "" { if udtName != "" {
if strings.HasPrefix(udtName, "_") && len(udtName) > 1 { if strings.HasPrefix(udtName, "_") && len(udtName) > 1 {
return udtName[1:] + "[]" return udtName[1:] + "[]"
@@ -350,7 +317,6 @@ func (r *Reader) mapDataType(pgType, udtName, formattedType string, hasNextval b
return udtName return udtName
} }
// Default to the original type
return pgType return pgType
} }
+1 -1
View File
@@ -198,7 +198,7 @@ func TestMapDataType(t *testing.T) {
{"unknown_type", "custom", "", "custom"}, // Should return UDT name {"unknown_type", "custom", "", "custom"}, // Should return UDT name
{"ARRAY", "_text", "", "text[]"}, {"ARRAY", "_text", "", "text[]"},
{"USER-DEFINED", "vector", "vector(1536)", "vector(1536)"}, {"USER-DEFINED", "vector", "vector(1536)", "vector(1536)"},
{"character varying", "varchar", "character varying(255)", "character varying(255)"}, {"character varying", "varchar", "character varying(255)", "varchar"},
} }
for _, tt := range tests { for _, tt := range tests {
+3 -52
View File
@@ -10,6 +10,7 @@ import (
"git.warky.dev/wdevs/relspecgo/pkg/models" "git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/readers" "git.warky.dev/wdevs/relspecgo/pkg/readers"
sqlitepkg "git.warky.dev/wdevs/relspecgo/pkg/sqlite"
) )
// Reader implements the readers.Reader interface for SQLite databases // Reader implements the readers.Reader interface for SQLite databases
@@ -183,59 +184,9 @@ func (r *Reader) close() {
} }
} }
// mapDataType maps SQLite data types to canonical types // mapDataType maps SQLite data types to canonical types.
func (r *Reader) mapDataType(sqliteType string) string { func (r *Reader) mapDataType(sqliteType string) string {
// SQLite has a flexible type system, but we map common types return sqlitepkg.ConvertSQLiteToCanonical(sqliteType)
typeMap := map[string]string{
"INTEGER": "int",
"INT": "int",
"TINYINT": "int8",
"SMALLINT": "int16",
"MEDIUMINT": "int",
"BIGINT": "int64",
"UNSIGNED BIG INT": "uint64",
"INT2": "int16",
"INT8": "int64",
"REAL": "float64",
"DOUBLE": "float64",
"DOUBLE PRECISION": "float64",
"FLOAT": "float32",
"NUMERIC": "decimal",
"DECIMAL": "decimal",
"BOOLEAN": "bool",
"BOOL": "bool",
"DATE": "date",
"DATETIME": "timestamp",
"TIMESTAMP": "timestamp",
"TEXT": "string",
"VARCHAR": "string",
"CHAR": "string",
"CHARACTER": "string",
"VARYING CHARACTER": "string",
"NCHAR": "string",
"NVARCHAR": "string",
"CLOB": "text",
"BLOB": "bytea",
}
// Try exact match first
if mapped, exists := typeMap[sqliteType]; exists {
return mapped
}
// Try case-insensitive match for common types
sqliteTypeUpper := sqliteType
if len(sqliteType) > 0 {
// Extract base type (e.g., "VARCHAR(255)" -> "VARCHAR")
for baseType := range typeMap {
if len(sqliteTypeUpper) >= len(baseType) && sqliteTypeUpper[:len(baseType)] == baseType {
return typeMap[baseType]
}
}
}
// Default to string for unknown types
return "string"
} }
// deriveRelationship creates a relationship from a foreign key constraint // deriveRelationship creates a relationship from a foreign key constraint
+152
View File
@@ -0,0 +1,152 @@
package sqlite
import "strings"
// SQLiteToCanonicalTypes maps SQLite type names to canonical types.
// SQLite has type affinity rules; this maps common type names including
// MySQL/PostgreSQL types that users write in SQLite schemas.
var SQLiteToCanonicalTypes = map[string]string{
// Integer affinity
"integer": "int",
"int": "int",
"tinyint": "int8",
"smallint": "int16",
"mediumint": "int",
"bigint": "int64",
"unsigned big int": "uint64",
"int2": "int16",
"int8": "int64",
// Real affinity
"real": "float64",
"double": "float64",
"double precision": "float64",
"float": "float32",
// Numeric affinity
"numeric": "decimal",
"decimal": "decimal",
// Boolean (stored as integer in SQLite)
"boolean": "bool",
"bool": "bool",
// Date/time (stored as text in SQLite)
"date": "date",
"datetime": "timestamp",
"timestamp": "timestamp",
// Text affinity
"text": "string",
"varchar": "string",
"char": "string",
"character": "string",
"varying character": "string",
"nchar": "string",
"nvarchar": "string",
"clob": "text",
// Blob affinity
"blob": "bytea",
}
// CanonicalToSQLiteAffinity maps type names to SQLite type affinity names.
// Accepts both Go canonical names ("int", "string") and SQL canonical names
// ("integer", "varchar") so the writer handles input from any reader.
// The five SQLite type affinities are TEXT, INTEGER, REAL, NUMERIC, BLOB.
var CanonicalToSQLiteAffinity = map[string]string{
// INTEGER affinity — Go canonical
"int": "INTEGER",
"int8": "INTEGER",
"int16": "INTEGER",
"int32": "INTEGER",
"int64": "INTEGER",
"uint": "INTEGER",
"uint8": "INTEGER",
"uint16": "INTEGER",
"uint32": "INTEGER",
"uint64": "INTEGER",
"bool": "INTEGER",
// INTEGER affinity — SQL canonical
"integer": "INTEGER",
"smallint": "INTEGER",
"bigint": "INTEGER",
"serial": "INTEGER",
"smallserial": "INTEGER",
"bigserial": "INTEGER",
"boolean": "INTEGER",
"tinyint": "INTEGER",
"mediumint": "INTEGER",
// REAL affinity — Go canonical
"float32": "REAL",
"float64": "REAL",
// REAL affinity — SQL canonical
"real": "REAL",
"float": "REAL",
"double": "REAL",
"double precision": "REAL",
// NUMERIC affinity
"decimal": "NUMERIC",
"numeric": "NUMERIC",
"money": "NUMERIC",
"smallmoney": "NUMERIC",
// BLOB affinity
"bytea": "BLOB",
"blob": "BLOB",
// TEXT affinity — Go canonical
"string": "TEXT",
"text": "TEXT",
// TEXT affinity — SQL canonical
"varchar": "TEXT",
"char": "TEXT",
"nvarchar": "TEXT",
"nchar": "TEXT",
"citext": "TEXT",
"date": "TEXT",
"time": "TEXT",
"timetz": "TEXT",
"timestamp": "TEXT",
"timestamptz": "TEXT",
"datetime": "TEXT",
"uuid": "TEXT",
"json": "TEXT",
"jsonb": "TEXT",
"xml": "TEXT",
"inet": "TEXT",
"cidr": "TEXT",
"macaddr": "TEXT",
}
// ConvertSQLiteToCanonical converts a SQLite type name to the canonical type.
// Strips dimension parameters (e.g. VARCHAR(255) → string) and handles
// SQLite's flexible affinity rules. Defaults to "string" for unknown types.
func ConvertSQLiteToCanonical(sqliteType string) string {
base := strings.ToUpper(strings.TrimSpace(sqliteType))
if idx := strings.Index(base, "("); idx >= 0 {
base = strings.TrimSpace(base[:idx])
}
lower := strings.ToLower(base)
if canonical, ok := SQLiteToCanonicalTypes[lower]; ok {
return canonical
}
// Prefix match for types like "VARYING CHARACTER(255)"
for key, canonical := range SQLiteToCanonicalTypes {
if strings.HasPrefix(lower, key) {
return canonical
}
}
return "string"
}
// ConvertCanonicalToSQLite converts a canonical type (or any SQL type) to its
// SQLite type affinity. Defaults to TEXT for unrecognised types.
func ConvertCanonicalToSQLite(canonicalType string) string {
normalized := strings.ToLower(strings.TrimSpace(canonicalType))
if idx := strings.Index(normalized, "("); idx >= 0 {
normalized = strings.TrimSpace(normalized[:idx])
}
normalized = strings.TrimSuffix(normalized, "[]")
if affinity, ok := CanonicalToSQLiteAffinity[normalized]; ok {
return affinity
}
return "TEXT"
}
+69 -17
View File
@@ -144,7 +144,11 @@ func (w *MigrationWriter) WriteMigration(model *models.Database, current *models
// Write header // Write header
fmt.Fprintf(w.writer, "-- PostgreSQL Migration Script\n") fmt.Fprintf(w.writer, "-- PostgreSQL Migration Script\n")
fmt.Fprintf(w.writer, "-- Generated by RelSpec\n") fmt.Fprintf(w.writer, "-- Generated by RelSpec\n")
fmt.Fprintf(w.writer, "-- Source: %s -> %s\n\n", current.Name, model.Name) fmt.Fprintf(w.writer, "-- Source: %s -> %s\n", current.Name, model.Name)
if w.options.ContinueOnError {
fmt.Fprintf(w.writer, "\\set ON_ERROR_STOP off\n")
}
fmt.Fprintf(w.writer, "\n")
// Write scripts // Write scripts
for _, script := range scripts { for _, script := range scripts {
@@ -171,13 +175,15 @@ func (w *MigrationWriter) generateSchemaScripts(model *models.Schema, current *m
}) })
} }
// Phase 1: Drop constraints and indexes that changed (Priority 11-50) // Phase 1: Drop constraints and indexes that changed (Priority 5-50)
var droppedFKs map[string]bool
if current != nil { if current != nil {
dropScripts, err := w.generateDropScripts(model, current) dropScripts, dropped, err := w.generateDropScripts(model, current)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to generate drop scripts: %w", err) return nil, fmt.Errorf("failed to generate drop scripts: %w", err)
} }
scripts = append(scripts, dropScripts...) scripts = append(scripts, dropScripts...)
droppedFKs = dropped
} }
// Phase 3: Create/Alter tables and columns (Priority 100-145) // Phase 3: Create/Alter tables and columns (Priority 100-145)
@@ -195,7 +201,7 @@ func (w *MigrationWriter) generateSchemaScripts(model *models.Schema, current *m
scripts = append(scripts, indexScripts...) scripts = append(scripts, indexScripts...)
// Phase 5: Create foreign keys (Priority 195) // Phase 5: Create foreign keys (Priority 195)
fkScripts, err := w.generateForeignKeyScripts(model, current) fkScripts, err := w.generateForeignKeyScripts(model, current, droppedFKs)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to generate foreign key scripts: %w", err) return nil, fmt.Errorf("failed to generate foreign key scripts: %w", err)
} }
@@ -211,9 +217,12 @@ func (w *MigrationWriter) generateSchemaScripts(model *models.Schema, current *m
return scripts, nil return scripts, nil
} }
// generateDropScripts generates DROP scripts using templates // generateDropScripts generates DROP scripts using templates.
func (w *MigrationWriter) generateDropScripts(model *models.Schema, current *models.Schema) ([]MigrationScript, error) { // Returns the scripts and a set of FK constraint keys (schema.table.name) that were
// explicitly dropped because their referenced PK was being dropped, so they can be force-recreated.
func (w *MigrationWriter) generateDropScripts(model *models.Schema, current *models.Schema) ([]MigrationScript, map[string]bool, error) {
scripts := make([]MigrationScript, 0) scripts := make([]MigrationScript, 0)
droppedFKs := make(map[string]bool)
// Build map of model tables for quick lookup // Build map of model tables for quick lookup
modelTables := make(map[string]*models.Table) modelTables := make(map[string]*models.Table)
@@ -240,6 +249,44 @@ func (w *MigrationWriter) generateDropScripts(model *models.Schema, current *mod
shouldDrop = true shouldDrop = true
} }
if shouldDrop && currentConstraint.Type == models.PrimaryKeyConstraint {
// Drop FK constraints that depend on this PK before dropping the PK itself.
for _, otherTable := range current.Tables {
for fkName, fkConstraint := range otherTable.Constraints {
if fkConstraint.Type != models.ForeignKeyConstraint {
continue
}
refTable := fkConstraint.ReferencedTable
refSchema := fkConstraint.ReferencedSchema
if refSchema == "" {
refSchema = current.Name
}
if strings.EqualFold(refTable, currentTable.Name) && strings.EqualFold(refSchema, current.Name) {
fkKey := fmt.Sprintf("%s.%s.%s", current.Name, otherTable.Name, fkName)
if !droppedFKs[fkKey] {
droppedFKs[fkKey] = true
sql, err := w.executor.ExecuteDropConstraint(DropConstraintData{
SchemaName: current.Name,
TableName: otherTable.Name,
ConstraintName: fkName,
})
if err != nil {
return nil, nil, err
}
scripts = append(scripts, MigrationScript{
ObjectName: fkKey,
ObjectType: "drop constraint",
Schema: current.Name,
Priority: 5,
Sequence: len(scripts),
Body: sql,
})
}
}
}
}
}
if shouldDrop { if shouldDrop {
sql, err := w.executor.ExecuteDropConstraint(DropConstraintData{ sql, err := w.executor.ExecuteDropConstraint(DropConstraintData{
SchemaName: current.Name, SchemaName: current.Name,
@@ -247,7 +294,7 @@ func (w *MigrationWriter) generateDropScripts(model *models.Schema, current *mod
ConstraintName: constraintName, ConstraintName: constraintName,
}) })
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
script := MigrationScript{ script := MigrationScript{
@@ -279,7 +326,7 @@ func (w *MigrationWriter) generateDropScripts(model *models.Schema, current *mod
IndexName: indexName, IndexName: indexName,
}) })
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
script := MigrationScript{ script := MigrationScript{
@@ -295,7 +342,7 @@ func (w *MigrationWriter) generateDropScripts(model *models.Schema, current *mod
} }
} }
return scripts, nil return scripts, droppedFKs, nil
} }
// generateTableScripts generates CREATE/ALTER TABLE scripts using templates // generateTableScripts generates CREATE/ALTER TABLE scripts using templates
@@ -631,8 +678,10 @@ func buildIndexColumnExpressions(table *models.Table, index *models.Index, index
return columnExprs return columnExprs
} }
// generateForeignKeyScripts generates ADD CONSTRAINT FOREIGN KEY scripts using templates // generateForeignKeyScripts generates ADD CONSTRAINT FOREIGN KEY scripts using templates.
func (w *MigrationWriter) generateForeignKeyScripts(model *models.Schema, current *models.Schema) ([]MigrationScript, error) { // forceRecreate is a set of FK constraint keys (schema.table.name) that must be recreated
// even if unchanged, because their referenced PK was dropped and recreated.
func (w *MigrationWriter) generateForeignKeyScripts(model *models.Schema, current *models.Schema, forceRecreate map[string]bool) ([]MigrationScript, error) {
scripts := make([]MigrationScript, 0) scripts := make([]MigrationScript, 0)
// Build map of current tables // Build map of current tables
@@ -653,13 +702,16 @@ func (w *MigrationWriter) generateForeignKeyScripts(model *models.Schema, curren
continue continue
} }
shouldCreate := true fkKey := fmt.Sprintf("%s.%s.%s", model.Name, modelTable.Name, constraintName)
shouldCreate := forceRecreate[fkKey]
if currentTable != nil { if !shouldCreate {
if currentConstraint, exists := currentTable.Constraints[constraintName]; exists { if currentTable == nil {
if constraintsEqual(constraint, currentConstraint) { shouldCreate = true
shouldCreate = false } else if currentConstraint, exists := currentTable.Constraints[constraintName]; !exists {
} shouldCreate = true
} else if !constraintsEqual(constraint, currentConstraint) {
shouldCreate = true
} }
} }
+5 -15
View File
@@ -5,6 +5,8 @@ import (
"regexp" "regexp"
"strings" "strings"
"unicode" "unicode"
"git.warky.dev/wdevs/relspecgo/pkg/pgsql"
) )
// TemplateFunctions returns a map of custom template functions // TemplateFunctions returns a map of custom template functions
@@ -162,24 +164,12 @@ func quoteIdent(s string) string {
// Type conversion functions // Type conversion functions
// goTypeToSQL converts Go type to PostgreSQL type // goTypeToSQL converts Go type to PostgreSQL type using the shared pgsql type map.
func goTypeToSQL(goType string) string { func goTypeToSQL(goType string) string {
typeMap := map[string]string{ if sqlType, ok := pgsql.GoToPGSQLTypes[goType]; ok {
"string": "text",
"int": "integer",
"int32": "integer",
"int64": "bigint",
"float32": "real",
"float64": "double precision",
"bool": "boolean",
"time.Time": "timestamp",
"[]byte": "bytea",
}
if sqlType, ok := typeMap[goType]; ok {
return sqlType return sqlType
} }
return "text" // Default return "text"
} }
// sqlTypeToGo converts PostgreSQL type to Go type // sqlTypeToGo converts PostgreSQL type to Go type
@@ -31,7 +31,7 @@ BEGIN
IF current_pk_name IS NOT NULL IF current_pk_name IS NOT NULL
AND NOT current_pk_matches AND NOT current_pk_matches
AND current_pk_name IN ({{.AutoGenNames}}) THEN AND current_pk_name IN ({{.AutoGenNames}}) THEN
EXECUTE 'ALTER TABLE {{qual_table .SchemaName .TableName}} DROP CONSTRAINT ' || quote_ident(current_pk_name); EXECUTE 'ALTER TABLE {{qual_table .SchemaName .TableName}} DROP CONSTRAINT ' || quote_ident(current_pk_name) || ' CASCADE';
END IF; END IF;
-- Add the desired primary key only when no matching primary key already exists. -- Add the desired primary key only when no matching primary key already exists.
+5 -1
View File
@@ -99,7 +99,11 @@ func (w *Writer) WriteDatabase(db *models.Database) error {
// Write header comment // Write header comment
fmt.Fprintf(w.writer, "-- PostgreSQL Database Schema\n") fmt.Fprintf(w.writer, "-- PostgreSQL Database Schema\n")
fmt.Fprintf(w.writer, "-- Database: %s\n", db.Name) fmt.Fprintf(w.writer, "-- Database: %s\n", db.Name)
fmt.Fprintf(w.writer, "-- Generated by RelSpec\n\n") fmt.Fprintf(w.writer, "-- Generated by RelSpec\n")
if w.options.ContinueOnError {
fmt.Fprintf(w.writer, "\\set ON_ERROR_STOP off\n")
}
fmt.Fprintf(w.writer, "\n")
// Process each schema in the database // Process each schema in the database
for _, schema := range db.Schemas { for _, schema := range db.Schemas {
+17 -60
View File
@@ -2,9 +2,11 @@ package sqlite
import ( import (
"strings" "strings"
sqlitepkg "git.warky.dev/wdevs/relspecgo/pkg/sqlite"
) )
// SQLite type affinities // SQLite type affinity constants
const ( const (
TypeText = "TEXT" TypeText = "TEXT"
TypeInteger = "INTEGER" TypeInteger = "INTEGER"
@@ -13,72 +15,27 @@ const (
TypeBlob = "BLOB" TypeBlob = "BLOB"
) )
// MapPostgreSQLType maps PostgreSQL data types to SQLite type affinities // MapTypeToSQLite maps any SQL or Go canonical type to a SQLite type affinity.
// Handles input from any reader (PostgreSQL, MSSQL, SQLite, Go canonical).
func MapTypeToSQLite(colType string) string {
return sqlitepkg.ConvertCanonicalToSQLite(colType)
}
// MapPostgreSQLType is an alias for MapTypeToSQLite kept for compatibility.
//
// Deprecated: use MapTypeToSQLite.
func MapPostgreSQLType(pgType string) string { func MapPostgreSQLType(pgType string) string {
// Normalize the type return MapTypeToSQLite(pgType)
normalized := strings.ToLower(strings.TrimSpace(pgType))
// Remove array notation if present
normalized = strings.TrimSuffix(normalized, "[]")
// Remove precision/scale if present
if idx := strings.Index(normalized, "("); idx != -1 {
normalized = normalized[:idx]
}
// Map to SQLite type affinity
switch normalized {
// TEXT affinity
case "varchar", "character varying", "text", "char", "character",
"citext", "uuid", "timestamp", "timestamptz", "timestamp with time zone",
"timestamp without time zone", "date", "time", "timetz", "time with time zone",
"time without time zone", "json", "jsonb", "xml", "inet", "cidr", "macaddr":
return TypeText
// INTEGER affinity
case "int", "int2", "int4", "int8", "integer", "smallint", "bigint",
"serial", "smallserial", "bigserial", "boolean", "bool":
return TypeInteger
// REAL affinity
case "real", "float", "float4", "float8", "double precision":
return TypeReal
// NUMERIC affinity
case "numeric", "decimal", "money":
return TypeNumeric
// BLOB affinity
case "bytea", "blob":
return TypeBlob
default:
// Default to TEXT for unknown types
return TypeText
}
} }
// IsIntegerType checks if a column type should be treated as integer // IsIntegerType reports whether a column type maps to SQLite INTEGER affinity.
func IsIntegerType(colType string) bool { func IsIntegerType(colType string) bool {
normalized := strings.ToLower(strings.TrimSpace(colType)) return MapTypeToSQLite(colType) == TypeInteger
normalized = strings.TrimSuffix(normalized, "[]")
if idx := strings.Index(normalized, "("); idx != -1 {
normalized = normalized[:idx]
}
switch normalized {
case "int", "int2", "int4", "int8", "integer", "smallint", "bigint",
"serial", "smallserial", "bigserial":
return true
default:
return false
}
} }
// MapBooleanValue converts PostgreSQL boolean literals to SQLite (0/1) // MapBooleanValue converts common boolean literals to SQLite integers (1/0).
func MapBooleanValue(value string) string { func MapBooleanValue(value string) string {
normalized := strings.ToLower(strings.TrimSpace(value)) switch strings.ToLower(strings.TrimSpace(value)) {
switch normalized {
case "true", "t", "yes", "y", "1": case "true", "t", "yes", "y", "1":
return "1" return "1"
case "false", "f", "no", "n", "0": case "false", "f", "no", "n", "0":
+4
View File
@@ -54,6 +54,10 @@ type WriterOptions struct {
// Prisma7 enables Prisma 7-specific output for Prisma writers. // Prisma7 enables Prisma 7-specific output for Prisma writers.
Prisma7 bool Prisma7 bool
// ContinueOnError instructs SQL writers to prepend `\set ON_ERROR_STOP off`
// to their output so that psql continues past errors instead of stopping.
ContinueOnError bool
// Additional options can be added here as needed // Additional options can be added here as needed
Metadata map[string]interface{} Metadata map[string]interface{}
} }