Compare commits

...
22 Commits
Author SHA1 Message Date
Hein 16af529120 chore(release): update package version to 1.0.73
Release / test (push) Successful in 1m57s
Release / release (push) Successful in 3m49s
Release / pkg-aur (push) Successful in 1m0s
Release / pkg-rpm (push) Successful in 1m43s
Release / pkg-deb (push) Successful in 1m46s
2026-08-24 12:59:50 +02:00
Hein 7fb343596a fix(dbml): honor composite [pk] in Indexes blocks, preserve column order
A composite [pk] entry inside an Indexes block (e.g. (a, b) [pk]) was
silently dropped: models.Index has no way to represent a primary key,
so the attribute was parsed and ignored, producing neither a PK nor a
meaningful index. It's now converted into a PrimaryKeyConstraint.

Also, Column.Sequence was never set by the DBML reader, so composite
PKs assembled from column-level [pk] attributes fell back to
alphabetical Name sorting instead of declaration order. Columns now
get a per-table sequence counter reflecting the order they were
declared.
2026-08-24 12:59:18 +02:00
Hein 241bfc2302 feat(cli): always print version header first, add --no-version flag
Previously the version banner only printed via PersistentPreRun, which
Cobra skips for --help and bare invocations. It now prints from main()
before Cobra parses anything, so it's the first line for every command.
Suppressible with --no-version; skipped for the version subcommand to
avoid duplicating its own output.
2026-08-24 12:59:14 +02:00
Hein 92d5df9a64 fix(release): chmod deb control dir to fix dpkg-deb permission error
dpkg-deb rejects a control directory with permissions above 0775;
the Gitea runner's umask left mkdir -p at 0777.
2026-08-24 12:59:11 +02:00
Hein b440d50b66 chore(release): update package version to 1.0.72
Release / test (push) Successful in 1m38s
Release / release (push) Successful in 2m37s
Release / pkg-deb (push) Failing after 22s
Release / pkg-aur (push) Successful in 38s
Release / pkg-rpm (push) Successful in 1m33s
2026-08-24 12:06:00 +02:00
Hein 052d6f5fac fix(sqlite): remove unnecessary newline in writeCheckConstraints 2026-08-24 12:05:50 +02:00
Hein 9066d36e71 chore(release): update package version to 1.0.71
Release / release (push) Successful in 2m54s
Release / pkg-deb (push) Failing after 23s
Release / pkg-aur (push) Successful in 1m6s
Release / pkg-rpm (push) Successful in 4m40s
Release / test (push) Successful in 30s
2026-08-24 12:03:41 +02:00
Hein 76b8321065 feat(pgsql): add support for concurrent index creation
* Implemented `Concurrent` field in index model
* Updated index creation template to support `CREATE INDEX CONCURRENTLY`
* Added tests for concurrent index creation in migration writer
2026-08-24 12:03:30 +02:00
Hein 2b6bb7f948 fix(sqlite): emit inline foreign keys, bare-name default schema, direct exec
SQLite can't ALTER TABLE ADD CONSTRAINT, so foreign keys are now written
as inline FOREIGN KEY clauses in CREATE TABLE instead of commented-out
ALTER statements. The default schema (public/main) now produces bare
table names instead of a "public_" prefix; other schemas are still
prefixed to avoid collisions. Also adds direct-to-file execution: the
sqlite writer can now apply generated DDL straight to a .db file via
Metadata["connection_string"], wired into `relspec merge --output-conn`.
2026-08-24 11:52:24 +02:00
warkanum 51b63f659e Merge pull request 'feat: include SQL scripts in schema diff' (#17) from issue-16-migration-script-count into master
Reviewed-on: #17
2026-08-18 11:52:25 +00:00
Hein ae0efdc008 chore(release): update package version to 1.0.70
Release / test (push) Successful in 17s
Release / release (push) Successful in 2m43s
Release / pkg-deb (push) Failing after 37s
Release / pkg-aur (push) Successful in 52s
Release / pkg-rpm (push) Successful in 1m19s
2026-08-18 13:43:04 +02:00
Hein be08c8199f fix(merge,pgsql): treat serial types as their base integer in diffs, unquote bare keyword defaults
Merge conflict detection compared bigserial (DBML) against bigint (live
PostgreSQL read of an existing serial column) as incompatible types, since
serial is sugar over an integer column plus a sequence default and
PostgreSQL always reports back the underlying integer type. Add
SerialUnderlyingType and use it when comparing column types for conflicts.

QuoteDefaultValue also wrapped bare keyword expressions like CURRENT_DATE
in string quotes because they contain no parentheses, unlike function-call
defaults such as now(). Recognize known bare keyword defaults and leave
them unquoted across CREATE TABLE, ALTER TABLE ADD COLUMN, and
ALTER COLUMN SET DEFAULT generation.
2026-08-18 13:42:34 +02:00
SG Command 5ba20e0581 feat: include SQL scripts in schema diff 2026-08-18 00:16:35 +02:00
warkanum 19b592820c chore(release): update package version to 1.0.69
Release / test (push) Successful in 4m9s
Release / release (push) Successful in 2m11s
Release / pkg-deb (push) Failing after 1m46s
Release / pkg-aur (push) Successful in 2m5s
Release / pkg-rpm (push) Successful in 2m36s
2026-08-17 22:36:35 +02:00
warkanum b158a98acc fix(pgsql): strip backticks from column defaults in migration paths
Backtick-wrapped defaults (e.g. from GORM tags like `now()`) were only
stripped in the CREATE TABLE column-definition path, leaving raw
backticks in the ALTER COLUMN ... SET DEFAULT migration statement and
in the migration-generated CREATE TABLE template, producing invalid
SQL. Default-drift comparisons also compared raw values, so a
backtick-wrapped model default never matched the live DB default and
kept re-emitting redundant ALTER statements.
2026-08-17 22:36:14 +02:00
Hein 465db7643c chore(release): update package version to 1.0.68
Release / test (push) Successful in 3m8s
Release / release (push) Successful in 4m18s
Release / pkg-deb (push) Successful in 50s
Release / pkg-aur (push) Successful in 1m2s
Release / pkg-rpm (push) Successful in 2m8s
2026-08-14 16:17:43 +02:00
Hein d84306934a fix(pgsql): handle nullability/type/default drift on existing columns
Existing databases that already ran an old migration kept stale NOT
NULL constraints and mismatched column types/defaults, because the
schema writer only emitted idempotent ADD COLUMN IF NOT EXISTS guards
and never altered columns that already existed.

- Emit guarded ALTER COLUMN ... SET/DROP NOT NULL when a column's
  nullability differs from the model.
- Emit guarded ALTER COLUMN ... TYPE, falling back to renaming the old
  column and adding a fresh one when the in-place conversion fails.
- Emit guarded ALTER COLUMN ... SET/DROP DEFAULT for default drift.
- Collapse the previously duplicated plain/guarded templates so
  WriteSchema (full-schema, live-state-checking) and WriteMigration
  (diff-based) share the same guarded SQL templates and Go helpers
  instead of maintaining the logic twice.
2026-08-14 16:17:17 +02:00
Hein e650406177 chore(release): update package version to 1.0.67
Release / test (push) Successful in 11s
Release / release (push) Successful in 1m49s
Release / pkg-aur (push) Successful in 58s
Release / pkg-rpm (push) Successful in 2m47s
Release / pkg-deb (push) Successful in 2m57s
2026-08-14 14:15:02 +02:00
Hein fc3409f324 feat(migration): add fallback for column type conversion failures
* implement renaming of old column and adding new column on conversion failure
* update templates and tests to support new behavior
2026-08-14 14:14:24 +02:00
Hein 97139723c9 feat(migration): add support for altering column nullability
* Implement ExecuteAlterColumnNullability function
* Create alter_column_nullability template
* Add test for altering column nullability behavior
2026-08-14 14:10:21 +02:00
warkanum d44945b475 chore(release): update package version to 1.0.66
Release / test (push) Successful in 57s
Release / release (push) Successful in 1m42s
Release / pkg-aur (push) Failing after 1m14s
Release / pkg-deb (push) Failing after 1m58s
Release / pkg-rpm (push) Successful in 9m49s
2026-08-10 20:54:56 +02:00
warkanum 3b88c386a1 fix(codegen): sort map iteration to make generated output deterministic
Table.Columns/Constraints/Indexes/Relationships are Go maps, and every
writer, reader, diff, inspector, and merge code path that iterated them
directly was subject to Go's randomized map order, so identical input
could produce different output (or a different in-report violation/diff
order) on every run. Most visibly this showed up as bun/gorm `unique:`
struct tags changing order across consecutive `make models` runs with no
source change.

Fixed by sorting map iteration (by Sequence then Name, or alphabetically
for string-keyed maps) everywhere the order affects generated output or
first-match tie-break logic, across the bun, gorm, sqlite, dbml, drawdb,
pgsql, prisma, graphql, typeorm, drizzle, and dctx writers; the dctx,
prisma, and typeorm readers; the shared models.GetPrimaryKey/
GetForeignKeys helpers; pkg/diff, pkg/inspector, and pkg/merge; and the
TUI column/relationship pickers in pkg/ui.
2026-08-10 20:54:40 +02:00
62 changed files with 2192 additions and 307 deletions
+1
View File
@@ -222,6 +222,7 @@ jobs:
PKGDIR="relspec_${PKGVER}_${GOARCH}"
mkdir -p "${PKGDIR}/DEBIAN"
mkdir -p "${PKGDIR}/usr/bin"
chmod -R 0755 "${PKGDIR}"
install -m755 relspec "${PKGDIR}/usr/bin/relspec"
+17 -5
View File
@@ -16,6 +16,7 @@ import (
"git.warky.dev/wdevs/relspecgo/pkg/readers/drawdb"
"git.warky.dev/wdevs/relspecgo/pkg/readers/json"
"git.warky.dev/wdevs/relspecgo/pkg/readers/pgsql"
"git.warky.dev/wdevs/relspecgo/pkg/readers/sqldir"
"git.warky.dev/wdevs/relspecgo/pkg/readers/sqlite"
"git.warky.dev/wdevs/relspecgo/pkg/readers/yaml"
)
@@ -87,11 +88,11 @@ Examples:
}
func init() {
diffCmd.Flags().StringVar(&sourceType, "from", "", "Source database format (dbml, dctx, drawdb, json, yaml, pgsql)")
diffCmd.Flags().StringVar(&sourceType, "from", "", "Source database format (dbml, dctx, drawdb, json, yaml, pgsql, sqldir)")
diffCmd.Flags().StringVar(&sourcePath, "from-path", "", "Source file path (for file-based formats)")
diffCmd.Flags().StringVar(&sourceConn, "from-conn", "", "Source connection string (for database formats)")
diffCmd.Flags().StringVar(&targetType, "to", "", "Target database format (dbml, dctx, drawdb, json, yaml, pgsql)")
diffCmd.Flags().StringVar(&targetType, "to", "", "Target database format (dbml, dctx, drawdb, json, yaml, pgsql, sqldir)")
diffCmd.Flags().StringVar(&targetPath, "to-path", "", "Target file path (for file-based formats)")
diffCmd.Flags().StringVar(&targetConn, "to-conn", "", "Target connection string (for database formats)")
@@ -129,10 +130,12 @@ func runDiff(cmd *cobra.Command, args []string) error {
fmt.Fprintf(os.Stderr, " ✓ Successfully read database '%s'\n", sourceDB.Name)
sourceTables := 0
sourceScripts := 0
for _, schema := range sourceDB.Schemas {
sourceTables += len(schema.Tables)
sourceScripts += len(schema.Scripts)
}
fmt.Fprintf(os.Stderr, " Found: %d schema(s), %d table(s)\n\n", len(sourceDB.Schemas), sourceTables)
fmt.Fprintf(os.Stderr, " Found: %d schema(s), %d table(s), %d script(s)\n\n", len(sourceDB.Schemas), sourceTables, sourceScripts)
// Read target database
fmt.Fprintf(os.Stderr, "[2/3] Reading target schema...\n")
@@ -151,10 +154,12 @@ func runDiff(cmd *cobra.Command, args []string) error {
fmt.Fprintf(os.Stderr, " ✓ Successfully read database '%s'\n", targetDB.Name)
targetTables := 0
targetScripts := 0
for _, schema := range targetDB.Schemas {
targetTables += len(schema.Tables)
targetScripts += len(schema.Scripts)
}
fmt.Fprintf(os.Stderr, " Found: %d schema(s), %d table(s)\n\n", len(targetDB.Schemas), targetTables)
fmt.Fprintf(os.Stderr, " Found: %d schema(s), %d table(s), %d script(s)\n\n", len(targetDB.Schemas), targetTables, targetScripts)
// Compare databases
fmt.Fprintf(os.Stderr, "[3/3] Comparing schemas...\n")
@@ -165,7 +170,8 @@ func runDiff(cmd *cobra.Command, args []string) error {
summary.Tables.Missing + summary.Tables.Extra + summary.Tables.Modified +
summary.Columns.Missing + summary.Columns.Extra + summary.Columns.Modified +
summary.Indexes.Missing + summary.Indexes.Extra + summary.Indexes.Modified +
summary.Constraints.Missing + summary.Constraints.Extra + summary.Constraints.Modified
summary.Constraints.Missing + summary.Constraints.Extra + summary.Constraints.Modified +
summary.Scripts.Missing + summary.Scripts.Extra + summary.Scripts.Modified
fmt.Fprintf(os.Stderr, " ✓ Comparison complete\n")
fmt.Fprintf(os.Stderr, " Found: %d difference(s)\n\n", totalDiffs)
@@ -249,6 +255,12 @@ func readDatabase(dbType, filePath, connString, label string) (*models.Database,
}
reader = yaml.NewReader(&readers.ReaderOptions{FilePath: filePath})
case "sqldir", "scripts", "scriptdir":
if filePath == "" {
return nil, fmt.Errorf("%s: file path is required for SQL directory format", label)
}
reader = sqldir.NewReader(&readers.ReaderOptions{FilePath: filePath})
case "pgsql", "postgres", "postgresql":
if connString == "" {
return nil, fmt.Errorf("%s: connection string is required for PostgreSQL format", label)
+28
View File
@@ -0,0 +1,28 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestReadDatabaseSupportsSQLDir(t *testing.T) {
tempDir := t.TempDir()
if err := os.WriteFile(filepath.Join(tempDir, "1_001_create_users.sql"), []byte("CREATE TABLE users (id int);"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tempDir, "1_002_seed_users.pgsql"), []byte("INSERT INTO users (id) VALUES (1);"), 0o644); err != nil {
t.Fatal(err)
}
db, err := readDatabase("sqldir", tempDir, "", "source")
if err != nil {
t.Fatalf("readDatabase failed: %v", err)
}
if len(db.Schemas) != 1 {
t.Fatalf("expected 1 schema, got %d", len(db.Schemas))
}
if got := len(db.Schemas[0].Scripts); got != 2 {
t.Fatalf("expected 2 scripts, got %d", got)
}
}
+1
View File
@@ -6,6 +6,7 @@ import (
)
func main() {
printVersionHeader(os.Args[1:])
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
+10 -2
View File
@@ -117,7 +117,7 @@ func init() {
// Output flags
mergeCmd.Flags().StringVar(&mergeOutputType, "output", "", "Output format (required): dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql")
mergeCmd.Flags().StringVar(&mergeOutputPath, "output-path", "", "Output file path (required for file-based formats)")
mergeCmd.Flags().StringVar(&mergeOutputConn, "output-conn", "", "Output connection string (for pgsql)")
mergeCmd.Flags().StringVar(&mergeOutputConn, "output-conn", "", "Output connection string (for pgsql) or database file path (for sqlite, to execute DDL directly instead of writing a .sql file)")
// Merge options
mergeCmd.Flags().BoolVar(&mergeSkipDomains, "skip-domains", false, "Skip domains during merge")
@@ -427,7 +427,15 @@ func writeDatabaseForMerge(dbType, filePath, connString string, db *models.Datab
}
writer = wtypeorm.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", "", false))
case "sqlite", "sqlite3":
writer = wsqlite.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", "", false))
writerOpts := newWriterOptions(filePath, "", flattenSchema, "", "", false)
if connString != "" {
// Execute DDL directly against the SQLite database file instead
// of writing a .sql script.
writerOpts.Metadata = map[string]interface{}{
"connection_string": connString,
}
}
writer = wsqlite.NewWriter(writerOpts)
case "pgsql":
writerOpts := newWriterOptions(filePath, "", flattenSchema, "", "", false)
if connString != "" {
+19 -3
View File
@@ -13,6 +13,7 @@ var (
version = "dev"
buildDate = "unknown"
prisma7 bool
noVersion bool
)
func init() {
@@ -54,9 +55,6 @@ bidirectional conversion between various database schema formats.
It reads database schemas from multiple sources (live databases, DBML,
DCTX, DrawDB, etc.) and writes them to various formats (GORM, Bun,
JSON, YAML, SQL, etc.).`,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
fmt.Printf("RelSpec %s (built: %s)\n\n", version, buildDate)
},
}
func init() {
@@ -72,4 +70,22 @@ func init() {
rootCmd.AddCommand(versionCmd)
rootCmd.AddCommand(reportCmd)
rootCmd.PersistentFlags().BoolVar(&prisma7, "prisma7", false, "Use Prisma 7 generator conventions when reading/writing Prisma schemas")
rootCmd.PersistentFlags().BoolVar(&noVersion, "no-version", false, "Suppress the RelSpec version header")
}
// printVersionHeader prints the "RelSpec <version> (built: <date>)" banner
// that precedes all command output. It is invoked from main() before cobra
// parses/executes anything, so it runs even for --help and bare invocations.
// It is skipped when --no-version is present, or when the version subcommand
// is being run (which prints its own, more detailed output).
func printVersionHeader(args []string) {
for _, a := range args {
if a == "--no-version" {
return
}
}
if len(args) > 0 && args[0] == "version" {
return
}
fmt.Printf("RelSpec %s (built: %s)\n\n", version, buildDate)
}
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Hein (Warky Devs) <hein@warky.dev>
pkgname=relspec
pkgver=1.0.65
pkgver=1.0.73
pkgrel=1
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')
+1 -1
View File
@@ -1,5 +1,5 @@
Name: relspec
Version: 1.0.65
Version: 1.0.73
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.
+132 -16
View File
@@ -1,11 +1,24 @@
package diff
import (
"fmt"
"reflect"
"sort"
"git.warky.dev/wdevs/relspecgo/pkg/models"
)
// sortedKeys returns a map's keys sorted alphabetically, so callers get a
// deterministic iteration order instead of Go's randomized map order.
func sortedKeys[T any](m map[string]T) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// CompareDatabases compares two database models and returns the differences
func CompareDatabases(source, target *models.Database) *DiffResult {
result := &DiffResult{
@@ -34,7 +47,8 @@ func compareSchemas(source, target []*models.Schema) *SchemaDiff {
}
// Find missing and modified schemas
for name, srcSchema := range sourceMap {
for _, name := range sortedKeys(sourceMap) {
srcSchema := sourceMap[name]
if tgtSchema, exists := targetMap[name]; !exists {
diff.Missing = append(diff.Missing, srcSchema)
} else {
@@ -45,7 +59,8 @@ func compareSchemas(source, target []*models.Schema) *SchemaDiff {
}
// Find extra schemas
for name, tgtSchema := range targetMap {
for _, name := range sortedKeys(targetMap) {
tgtSchema := targetMap[name]
if _, exists := sourceMap[name]; !exists {
diff.Extra = append(diff.Extra, tgtSchema)
}
@@ -82,6 +97,13 @@ func compareSchemaDetails(source, target *models.Schema) *SchemaChange {
hasChanges = true
}
// Compare scripts
scriptDiff := compareScripts(source.Scripts, target.Scripts)
if !isEmpty(scriptDiff) {
change.Scripts = scriptDiff
hasChanges = true
}
if !hasChanges {
return nil
}
@@ -106,7 +128,8 @@ func compareTables(source, target []*models.Table) *TableDiff {
}
// Find missing and modified tables
for name, srcTable := range sourceMap {
for _, name := range sortedKeys(sourceMap) {
srcTable := sourceMap[name]
if tgtTable, exists := targetMap[name]; !exists {
diff.Missing = append(diff.Missing, srcTable)
} else {
@@ -117,7 +140,8 @@ func compareTables(source, target []*models.Table) *TableDiff {
}
// Find extra tables
for name, tgtTable := range targetMap {
for _, name := range sortedKeys(targetMap) {
tgtTable := targetMap[name]
if _, exists := sourceMap[name]; !exists {
diff.Extra = append(diff.Extra, tgtTable)
}
@@ -176,7 +200,8 @@ func compareColumns(source, target map[string]*models.Column) *ColumnDiff {
}
// Find missing and modified columns
for name, srcCol := range source {
for _, name := range sortedKeys(source) {
srcCol := source[name]
if tgtCol, exists := target[name]; !exists {
diff.Missing = append(diff.Missing, srcCol)
} else {
@@ -192,7 +217,8 @@ func compareColumns(source, target map[string]*models.Column) *ColumnDiff {
}
// Find extra columns
for name, tgtCol := range target {
for _, name := range sortedKeys(target) {
tgtCol := target[name]
if _, exists := source[name]; !exists {
diff.Extra = append(diff.Extra, tgtCol)
}
@@ -240,7 +266,8 @@ func compareIndexes(source, target map[string]*models.Index) *IndexDiff {
}
// Find missing and modified indexes
for name, srcIdx := range source {
for _, name := range sortedKeys(source) {
srcIdx := source[name]
if tgtIdx, exists := target[name]; !exists {
diff.Missing = append(diff.Missing, srcIdx)
} else {
@@ -256,7 +283,8 @@ func compareIndexes(source, target map[string]*models.Index) *IndexDiff {
}
// Find extra indexes
for name, tgtIdx := range target {
for _, name := range sortedKeys(target) {
tgtIdx := target[name]
if _, exists := source[name]; !exists {
diff.Extra = append(diff.Extra, tgtIdx)
}
@@ -292,7 +320,8 @@ func compareConstraints(source, target map[string]*models.Constraint) *Constrain
}
// Find missing and modified constraints
for name, srcCon := range source {
for _, name := range sortedKeys(source) {
srcCon := source[name]
if tgtCon, exists := target[name]; !exists {
diff.Missing = append(diff.Missing, srcCon)
} else {
@@ -308,7 +337,8 @@ func compareConstraints(source, target map[string]*models.Constraint) *Constrain
}
// Find extra constraints
for name, tgtCon := range target {
for _, name := range sortedKeys(target) {
tgtCon := target[name]
if _, exists := source[name]; !exists {
diff.Extra = append(diff.Extra, tgtCon)
}
@@ -350,7 +380,8 @@ func compareRelationships(source, target map[string]*models.Relationship) *Relat
}
// Find missing and modified relationships
for name, srcRel := range source {
for _, name := range sortedKeys(source) {
srcRel := source[name]
if tgtRel, exists := target[name]; !exists {
diff.Missing = append(diff.Missing, srcRel)
} else {
@@ -366,7 +397,8 @@ func compareRelationships(source, target map[string]*models.Relationship) *Relat
}
// Find extra relationships
for name, tgtRel := range target {
for _, name := range sortedKeys(target) {
tgtRel := target[name]
if _, exists := source[name]; !exists {
diff.Extra = append(diff.Extra, tgtRel)
}
@@ -415,7 +447,8 @@ func compareViews(source, target []*models.View) *ViewDiff {
}
// Find missing and modified views
for name, srcView := range sourceMap {
for _, name := range sortedKeys(sourceMap) {
srcView := sourceMap[name]
if tgtView, exists := targetMap[name]; !exists {
diff.Missing = append(diff.Missing, srcView)
} else {
@@ -431,7 +464,8 @@ func compareViews(source, target []*models.View) *ViewDiff {
}
// Find extra views
for name, tgtView := range targetMap {
for _, name := range sortedKeys(targetMap) {
tgtView := targetMap[name]
if _, exists := sourceMap[name]; !exists {
diff.Extra = append(diff.Extra, tgtView)
}
@@ -468,7 +502,8 @@ func compareSequences(source, target []*models.Sequence) *SequenceDiff {
}
// Find missing and modified sequences
for name, srcSeq := range sourceMap {
for _, name := range sortedKeys(sourceMap) {
srcSeq := sourceMap[name]
if tgtSeq, exists := targetMap[name]; !exists {
diff.Missing = append(diff.Missing, srcSeq)
} else {
@@ -484,7 +519,8 @@ func compareSequences(source, target []*models.Sequence) *SequenceDiff {
}
// Find extra sequences
for name, tgtSeq := range targetMap {
for _, name := range sortedKeys(targetMap) {
tgtSeq := targetMap[name]
if _, exists := sourceMap[name]; !exists {
diff.Extra = append(diff.Extra, tgtSeq)
}
@@ -515,6 +551,79 @@ func compareSequenceDetails(source, target *models.Sequence) map[string]any {
return changes
}
func compareScripts(source, target []*models.Script) *ScriptDiff {
diff := &ScriptDiff{
Missing: make([]*models.Script, 0),
Extra: make([]*models.Script, 0),
Modified: make([]*ScriptChange, 0),
}
sourceMap := make(map[string]*models.Script)
targetMap := make(map[string]*models.Script)
for _, s := range source {
sourceMap[scriptCompareKey(s)] = s
}
for _, s := range target {
targetMap[scriptCompareKey(s)] = s
}
for _, name := range sortedKeys(sourceMap) {
srcScript := sourceMap[name]
if tgtScript, exists := targetMap[name]; !exists {
diff.Missing = append(diff.Missing, srcScript)
} else if changes := compareScriptDetails(srcScript, tgtScript); len(changes) > 0 {
diff.Modified = append(diff.Modified, &ScriptChange{
Name: srcScript.Name,
Source: srcScript,
Target: tgtScript,
Changes: changes,
})
}
}
for _, name := range sortedKeys(targetMap) {
tgtScript := targetMap[name]
if _, exists := sourceMap[name]; !exists {
diff.Extra = append(diff.Extra, tgtScript)
}
}
return diff
}
func scriptCompareKey(script *models.Script) string {
return fmt.Sprintf("%d:%d:%s", script.Priority, script.Sequence, script.SQLName())
}
func compareScriptDetails(source, target *models.Script) map[string]any {
changes := make(map[string]any)
if source.SQL != target.SQL {
changes["sql"] = map[string]string{"source": source.SQL, "target": target.SQL}
}
if source.Rollback != target.Rollback {
changes["rollback"] = map[string]string{"source": source.Rollback, "target": target.Rollback}
}
if !reflect.DeepEqual(source.RunAfter, target.RunAfter) {
changes["run_after"] = map[string][]string{"source": source.RunAfter, "target": target.RunAfter}
}
if source.Schema != target.Schema {
changes["schema"] = map[string]string{"source": source.Schema, "target": target.Schema}
}
if source.Version != target.Version {
changes["version"] = map[string]string{"source": source.Version, "target": target.Version}
}
if source.Priority != target.Priority {
changes["priority"] = map[string]int{"source": source.Priority, "target": target.Priority}
}
if source.Sequence != target.Sequence {
changes["sequence"] = map[string]uint{"source": source.Sequence, "target": target.Sequence}
}
return changes
}
// Helper function to check if a diff is empty
func isEmpty(v any) bool {
switch d := v.(type) {
@@ -532,6 +641,8 @@ func isEmpty(v any) bool {
return len(d.Missing) == 0 && len(d.Extra) == 0 && len(d.Modified) == 0
case *SequenceDiff:
return len(d.Missing) == 0 && len(d.Extra) == 0 && len(d.Modified) == 0
case *ScriptDiff:
return len(d.Missing) == 0 && len(d.Extra) == 0 && len(d.Modified) == 0
default:
return false
}
@@ -588,6 +699,11 @@ func ComputeSummary(result *DiffResult) *Summary {
summary.Sequences.Extra += len(schemaChange.Sequences.Extra)
summary.Sequences.Modified += len(schemaChange.Sequences.Modified)
}
if schemaChange.Scripts != nil {
summary.Scripts.Missing += len(schemaChange.Scripts.Missing)
summary.Scripts.Extra += len(schemaChange.Scripts.Extra)
summary.Scripts.Modified += len(schemaChange.Scripts.Modified)
}
}
}
+135
View File
@@ -1,6 +1,7 @@
package diff
import (
"reflect"
"testing"
"git.warky.dev/wdevs/relspecgo/pkg/models"
@@ -140,6 +141,46 @@ func TestCompareColumns(t *testing.T) {
}
}
// TestCompareColumns_Deterministic verifies that Missing/Extra entries are
// always reported in the same (alphabetical) order across repeated calls,
// instead of following Go's randomized map iteration order over the
// source/target column maps.
func TestCompareColumns_Deterministic(t *testing.T) {
source := map[string]*models.Column{
"zeta": {Name: "zeta", Type: "text"},
"alpha": {Name: "alpha", Type: "text"},
"mu": {Name: "mu", Type: "text"},
}
target := map[string]*models.Column{
"omega": {Name: "omega", Type: "text"},
"delta": {Name: "delta", Type: "text"},
"charlie": {Name: "charlie", Type: "text"},
}
wantMissing := []string{"alpha", "mu", "zeta"}
wantExtra := []string{"charlie", "delta", "omega"}
for i := 0; i < 25; i++ {
got := compareColumns(source, target)
gotMissing := make([]string, len(got.Missing))
for j, c := range got.Missing {
gotMissing[j] = c.Name
}
gotExtra := make([]string, len(got.Extra))
for j, c := range got.Extra {
gotExtra[j] = c.Name
}
if !reflect.DeepEqual(gotMissing, wantMissing) {
t.Fatalf("compareColumns() Missing = %v, want %v (run %d)", gotMissing, wantMissing, i)
}
if !reflect.DeepEqual(gotExtra, wantExtra) {
t.Fatalf("compareColumns() Extra = %v, want %v (run %d)", gotExtra, wantExtra, i)
}
}
}
func TestCompareColumnDetails(t *testing.T) {
tests := []struct {
name string
@@ -484,6 +525,78 @@ func TestCompareSchemas(t *testing.T) {
}
}
func TestCompareScripts(t *testing.T) {
tests := []struct {
name string
source []*models.Script
target []*models.Script
want func(*ScriptDiff) bool
}{
{
name: "identical scripts",
source: []*models.Script{{Name: "create_users", SQL: "CREATE TABLE users (id int);", Priority: 1, Sequence: 1}},
target: []*models.Script{{Name: "create_users", SQL: "CREATE TABLE users (id int);", Priority: 1, Sequence: 1}},
want: func(d *ScriptDiff) bool {
return len(d.Missing) == 0 && len(d.Extra) == 0 && len(d.Modified) == 0
},
},
{
name: "missing script",
source: []*models.Script{{Name: "create_users", SQL: "CREATE TABLE users (id int);"}},
target: []*models.Script{},
want: func(d *ScriptDiff) bool {
return len(d.Missing) == 1 && d.Missing[0].Name == "create_users"
},
},
{
name: "extra script",
source: []*models.Script{},
target: []*models.Script{{Name: "create_users", SQL: "CREATE TABLE users (id int);"}},
want: func(d *ScriptDiff) bool {
return len(d.Extra) == 1 && d.Extra[0].Name == "create_users"
},
},
{
name: "modified script sql",
source: []*models.Script{{Name: "create_users", SQL: "CREATE TABLE users (id int);"}},
target: []*models.Script{{Name: "create_users", SQL: "CREATE TABLE users (id bigint);"}},
want: func(d *ScriptDiff) bool {
return len(d.Modified) == 1 && d.Modified[0].Name == "create_users" && d.Modified[0].Changes["sql"] != nil
},
},
{
name: "different script order is different identity",
source: []*models.Script{{Name: "create_users", SQL: "SELECT 1;", Priority: 1, Sequence: 1}},
target: []*models.Script{{Name: "create_users", SQL: "SELECT 1;", Priority: 2, Sequence: 3}},
want: func(d *ScriptDiff) bool {
return len(d.Missing) == 1 && len(d.Extra) == 1 && len(d.Modified) == 0
},
},
{
name: "same descriptive names remain distinct",
source: []*models.Script{
{Name: "alter_users", SQL: "SELECT 1;", Priority: 1, Sequence: 1},
{Name: "alter_users", SQL: "SELECT 2;", Priority: 1, Sequence: 2},
},
target: []*models.Script{
{Name: "alter_users", SQL: "SELECT 1;", Priority: 1, Sequence: 1},
},
want: func(d *ScriptDiff) bool {
return len(d.Missing) == 1 && d.Missing[0].Sequence == 2
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := compareScripts(tt.source, tt.target)
if !tt.want(got) {
t.Errorf("compareScripts() result doesn't match expectations")
}
})
}
}
func TestIsEmpty(t *testing.T) {
tests := []struct {
name string
@@ -499,6 +612,8 @@ func TestIsEmpty(t *testing.T) {
{"TableDiff with extra", &TableDiff{Missing: []*models.Table{}, Extra: []*models.Table{{Name: "users"}}, Modified: []*TableChange{}}, false},
{"empty ConstraintDiff", &ConstraintDiff{Missing: []*models.Constraint{}, Extra: []*models.Constraint{}, Modified: []*ConstraintChange{}}, true},
{"empty RelationshipDiff", &RelationshipDiff{Missing: []*models.Relationship{}, Extra: []*models.Relationship{}, Modified: []*RelationshipChange{}}, true},
{"empty ScriptDiff", &ScriptDiff{Missing: []*models.Script{}, Extra: []*models.Script{}, Modified: []*ScriptChange{}}, true},
{"ScriptDiff with modified", &ScriptDiff{Missing: []*models.Script{}, Extra: []*models.Script{}, Modified: []*ScriptChange{{Name: "create_users"}}}, false},
}
for _, tt := range tests {
@@ -545,6 +660,26 @@ func TestComputeSummary(t *testing.T) {
return s.Schemas.Missing == 1 && s.Schemas.Extra == 2 && s.Schemas.Modified == 1
},
},
{
name: "scripts with differences",
result: &DiffResult{
Schemas: &SchemaDiff{
Modified: []*SchemaChange{
{
Name: "public",
Scripts: &ScriptDiff{
Missing: []*models.Script{{Name: "missing_script"}},
Extra: []*models.Script{{Name: "extra_script"}, {Name: "seed_data"}},
Modified: []*ScriptChange{{Name: "changed_script"}},
},
},
},
},
},
want: func(s *Summary) bool {
return s.Scripts.Missing == 1 && s.Scripts.Extra == 2 && s.Scripts.Modified == 1
},
},
}
for _, tt := range tests {
+66 -1
View File
@@ -158,6 +158,21 @@ func formatSummary(result *DiffResult, w io.Writer) error {
fmt.Fprintf(w, "\n")
}
// Scripts
if summary.Scripts.Missing > 0 || summary.Scripts.Extra > 0 || summary.Scripts.Modified > 0 {
fmt.Fprintf(w, "Scripts:\n")
if summary.Scripts.Missing > 0 {
fmt.Fprintf(w, " Missing: %d\n", summary.Scripts.Missing)
}
if summary.Scripts.Extra > 0 {
fmt.Fprintf(w, " Extra: %d\n", summary.Scripts.Extra)
}
if summary.Scripts.Modified > 0 {
fmt.Fprintf(w, " Modified: %d\n", summary.Scripts.Modified)
}
fmt.Fprintf(w, "\n")
}
// Check if there are no differences
if summary.Schemas.Missing == 0 && summary.Schemas.Extra == 0 && summary.Schemas.Modified == 0 &&
summary.Tables.Missing == 0 && summary.Tables.Extra == 0 && summary.Tables.Modified == 0 &&
@@ -166,7 +181,8 @@ func formatSummary(result *DiffResult, w io.Writer) error {
summary.Constraints.Missing == 0 && summary.Constraints.Extra == 0 && summary.Constraints.Modified == 0 &&
summary.Relationships.Missing == 0 && summary.Relationships.Extra == 0 && summary.Relationships.Modified == 0 &&
summary.Views.Missing == 0 && summary.Views.Extra == 0 && summary.Views.Modified == 0 &&
summary.Sequences.Missing == 0 && summary.Sequences.Extra == 0 && summary.Sequences.Modified == 0 {
summary.Sequences.Missing == 0 && summary.Sequences.Extra == 0 && summary.Sequences.Modified == 0 &&
summary.Scripts.Missing == 0 && summary.Scripts.Extra == 0 && summary.Scripts.Modified == 0 {
fmt.Fprintf(w, "No differences found.\n")
}
@@ -448,6 +464,26 @@ const htmlTemplate = `<!DOCTYPE html>
</div>
</div>
{{end}}
{{if or .Summary.Scripts.Missing .Summary.Scripts.Extra .Summary.Scripts.Modified}}
<div class="summary-item">
<h3>Scripts</h3>
<div class="count-group">
<div class="count">
<span class="count-label">Missing</span>
<span class="count-value missing">{{.Summary.Scripts.Missing}}</span>
</div>
<div class="count">
<span class="count-label">Extra</span>
<span class="count-value extra">{{.Summary.Scripts.Extra}}</span>
</div>
<div class="count">
<span class="count-label">Modified</span>
<span class="count-value modified">{{.Summary.Scripts.Modified}}</span>
</div>
</div>
</div>
{{end}}
</div>
</div>
@@ -588,6 +624,35 @@ const htmlTemplate = `<!DOCTYPE html>
</ul>
{{end}}
{{end}}
{{if .Scripts}}
{{if .Scripts.Missing}}
<h4>Missing Scripts</h4>
<ul class="item-list">
{{range .Scripts.Missing}}
<li class="missing">{{.Name}}</li>
{{end}}
</ul>
{{end}}
{{if .Scripts.Extra}}
<h4>Extra Scripts</h4>
<ul class="item-list">
{{range .Scripts.Extra}}
<li class="extra">{{.Name}}</li>
{{end}}
</ul>
{{end}}
{{if .Scripts.Modified}}
<h4>Modified Scripts</h4>
<ul class="item-list">
{{range .Scripts.Modified}}
<li class="modified">{{.Name}}</li>
{{end}}
</ul>
{{end}}
{{end}}
</div>
{{end}}
</div>
+45
View File
@@ -104,6 +104,26 @@ func TestFormatSummary(t *testing.T) {
},
wantStr: []string{"Tables:", "Missing: 1", "Extra: 1", "Modified: 1"},
},
{
name: "with script differences",
result: &DiffResult{
Source: "source",
Target: "target",
Schemas: &SchemaDiff{
Modified: []*SchemaChange{
{
Name: "public",
Scripts: &ScriptDiff{
Missing: []*models.Script{{Name: "create_users"}},
Extra: []*models.Script{{Name: "seed_users"}},
Modified: []*ScriptChange{{Name: "add_indexes"}},
},
},
},
},
},
wantStr: []string{"Scripts:", "Missing: 1", "Extra: 1", "Modified: 1"},
},
}
for _, tt := range tests {
@@ -237,6 +257,31 @@ func TestFormatHTML(t *testing.T) {
"text",
},
},
{
name: "with script modifications",
result: &DiffResult{
Source: "source",
Target: "target",
Schemas: &SchemaDiff{
Modified: []*SchemaChange{
{
Name: "public",
Scripts: &ScriptDiff{
Missing: []*models.Script{{Name: "create_users"}},
Extra: []*models.Script{{Name: "seed_users"}},
Modified: []*ScriptChange{{Name: "add_indexes"}},
},
},
},
},
},
wantStr: []string{
"Scripts",
"create_users",
"seed_users",
"add_indexes",
},
},
}
for _, tt := range tests {
+23
View File
@@ -22,6 +22,7 @@ type SchemaChange struct {
Tables *TableDiff `json:"tables,omitempty"`
Views *ViewDiff `json:"views,omitempty"`
Sequences *SequenceDiff `json:"sequences,omitempty"`
Scripts *ScriptDiff `json:"scripts,omitempty"`
}
// TableDiff represents differences in tables
@@ -131,6 +132,21 @@ type SequenceChange struct {
Changes map[string]any `json:"changes"`
}
// ScriptDiff represents differences in migration scripts.
type ScriptDiff struct {
Missing []*models.Script `json:"missing"` // Scripts in source but not in target
Extra []*models.Script `json:"extra"` // Scripts in target but not in source
Modified []*ScriptChange `json:"modified"` // Scripts that exist in both but differ
}
// ScriptChange represents a modified migration script.
type ScriptChange struct {
Name string `json:"name"`
Source *models.Script `json:"source"`
Target *models.Script `json:"target"`
Changes map[string]any `json:"changes"`
}
// Summary provides counts for quick overview
type Summary struct {
Schemas SchemaSummary `json:"schemas"`
@@ -141,6 +157,7 @@ type Summary struct {
Relationships RelationshipSummary `json:"relationships"`
Views ViewSummary `json:"views"`
Sequences SequenceSummary `json:"sequences"`
Scripts ScriptSummary `json:"scripts"`
}
type SchemaSummary struct {
@@ -190,3 +207,9 @@ type SequenceSummary struct {
Extra int `json:"extra"`
Modified int `json:"modified"`
}
type ScriptSummary struct {
Missing int `json:"missing"`
Extra int `json:"extra"`
Modified int `json:"modified"`
}
+10 -2
View File
@@ -2,6 +2,7 @@ package inspector
import (
"fmt"
"sort"
"time"
"git.warky.dev/wdevs/relspecgo/pkg/models"
@@ -54,8 +55,15 @@ func NewInspector(db *models.Database, config *Config) *Inspector {
func (i *Inspector) Inspect() (*InspectorReport, error) {
results := []ValidationResult{}
// Run all enabled validators
for ruleName, rule := range i.config.Rules {
// Run all enabled validators in deterministic (alphabetical) rule-name order
ruleNames := make([]string, 0, len(i.config.Rules))
for ruleName := range i.config.Rules {
ruleNames = append(ruleNames, ruleName)
}
sort.Strings(ruleNames)
for _, ruleName := range ruleNames {
rule := i.config.Rules[ruleName]
if !rule.IsEnabled() {
continue
}
+39
View File
@@ -51,6 +51,45 @@ func TestInspect(t *testing.T) {
}
}
// TestInspect_Deterministic verifies that repeated Inspect() calls against
// the same database and config produce violations in the same order, instead
// of following Go's randomized map iteration order over config.Rules and the
// per-table Columns/Constraints/Indexes maps.
func TestInspect_Deterministic(t *testing.T) {
db := createTestDatabase()
config := GetDefaultConfig()
inspector := NewInspector(db, config)
first, err := inspector.Inspect()
if err != nil {
t.Fatalf("Inspect() returned error: %v", err)
}
wantOrder := make([]string, len(first.Violations))
for i, v := range first.Violations {
wantOrder[i] = v.RuleName + "|" + v.Location
}
for i := 0; i < 25; i++ {
report, err := inspector.Inspect()
if err != nil {
t.Fatalf("Inspect() returned error on run %d: %v", i, err)
}
if len(report.Violations) != len(wantOrder) {
t.Fatalf("run %d: got %d violations, want %d", i, len(report.Violations), len(wantOrder))
}
for j, v := range report.Violations {
got := v.RuleName + "|" + v.Location
if got != wantOrder[j] {
t.Fatalf("run %d: violation[%d] = %q, want %q", i, j, got, wantOrder[j])
}
}
}
}
func TestInspectWithDisabledRules(t *testing.T) {
db := createTestDatabase()
config := GetDefaultConfig()
+9 -2
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"os"
"sort"
"strings"
"time"
)
@@ -199,12 +200,18 @@ func (f *MarkdownFormatter) formatContext(context map[string]interface{}) string
"column": true,
}
for key, value := range context {
keys := make([]string, 0, len(context))
for key := range context {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if skipKeys[key] {
continue
}
parts = append(parts, fmt.Sprintf("%s=%v", key, value))
parts = append(parts, fmt.Sprintf("%s=%v", key, context[key]))
}
return strings.Join(parts, ", ")
+54 -12
View File
@@ -2,12 +2,54 @@ package inspector
import (
"regexp"
"sort"
"strings"
"git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/pgsql"
)
// sortedKeys returns a map's keys sorted alphabetically, so validators report
// violations in a deterministic order instead of Go's randomized map order.
func sortedKeys[T any](m map[string]T) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// sortColumns returns columns sorted by Sequence then Name for deterministic output.
func sortColumns(columns map[string]*models.Column) []*models.Column {
result := make([]*models.Column, 0, len(columns))
for _, col := range columns {
result = append(result, col)
}
sort.Slice(result, func(i, j int) bool {
if result[i].Sequence > 0 && result[j].Sequence > 0 {
return result[i].Sequence < result[j].Sequence
}
return result[i].Name < result[j].Name
})
return result
}
// sortConstraints returns constraints sorted by Sequence then Name for deterministic output.
func sortConstraints(constraints map[string]*models.Constraint) []*models.Constraint {
result := make([]*models.Constraint, 0, len(constraints))
for _, c := range constraints {
result = append(result, c)
}
sort.Slice(result, func(i, j int) bool {
if result[i].Sequence > 0 && result[j].Sequence > 0 {
return result[i].Sequence < result[j].Sequence
}
return result[i].Name < result[j].Name
})
return result
}
// validatePrimaryKeyNaming checks that primary key column names match a pattern
func validatePrimaryKeyNaming(db *models.Database, rule Rule, ruleName string) []ValidationResult {
results := []ValidationResult{}
@@ -18,7 +60,7 @@ func validatePrimaryKeyNaming(db *models.Database, rule Rule, ruleName string) [
for _, schema := range db.Schemas {
for _, table := range schema.Tables {
for _, col := range table.Columns {
for _, col := range sortColumns(table.Columns) {
if col.IsPrimaryKey {
location := formatLocation(schema.Name, table.Name, col.Name)
passed := pattern.MatchString(col.Name)
@@ -49,7 +91,7 @@ func validatePrimaryKeyDatatype(db *models.Database, rule Rule, ruleName string)
for _, schema := range db.Schemas {
for _, table := range schema.Tables {
for _, col := range table.Columns {
for _, col := range sortColumns(table.Columns) {
if col.IsPrimaryKey {
location := formatLocation(schema.Name, table.Name, col.Name)
@@ -84,7 +126,7 @@ func validatePrimaryKeyAutoIncrement(db *models.Database, rule Rule, ruleName st
for _, schema := range db.Schemas {
for _, table := range schema.Tables {
for _, col := range table.Columns {
for _, col := range sortColumns(table.Columns) {
if col.IsPrimaryKey {
location := formatLocation(schema.Name, table.Name, col.Name)
@@ -125,7 +167,7 @@ func validateForeignKeyColumnNaming(db *models.Database, rule Rule, ruleName str
for _, schema := range db.Schemas {
for _, table := range schema.Tables {
// Check foreign key constraints
for _, constraint := range table.Constraints {
for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type == models.ForeignKeyConstraint {
for _, colName := range constraint.Columns {
location := formatLocation(schema.Name, table.Name, colName)
@@ -163,7 +205,7 @@ func validateForeignKeyConstraintNaming(db *models.Database, rule Rule, ruleName
for _, schema := range db.Schemas {
for _, table := range schema.Tables {
for _, constraint := range table.Constraints {
for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type == models.ForeignKeyConstraint {
location := formatLocation(schema.Name, table.Name, "")
passed := pattern.MatchString(constraint.Name)
@@ -209,7 +251,7 @@ func validateForeignKeyIndex(db *models.Database, rule Rule, ruleName string) []
}
// Check if each FK column has an index
for fkCol := range fkColumns {
for _, fkCol := range sortedKeys(fkColumns) {
hasIndex := false
// Check table indexes
@@ -282,7 +324,7 @@ func validateColumnNamingCase(db *models.Database, rule Rule, ruleName string) [
for _, schema := range db.Schemas {
for _, table := range schema.Tables {
for _, col := range table.Columns {
for _, col := range sortColumns(table.Columns) {
location := formatLocation(schema.Name, table.Name, col.Name)
passed := pattern.MatchString(col.Name)
@@ -339,7 +381,7 @@ func validateColumnNameLength(db *models.Database, rule Rule, ruleName string) [
for _, schema := range db.Schemas {
for _, table := range schema.Tables {
for _, col := range table.Columns {
for _, col := range sortColumns(table.Columns) {
location := formatLocation(schema.Name, table.Name, col.Name)
passed := len(col.Name) <= rule.MaxLength
@@ -396,7 +438,7 @@ func validateReservedKeywords(db *models.Database, rule Rule, ruleName string) [
// Check column names
if rule.CheckColumns {
for _, col := range table.Columns {
for _, col := range sortColumns(table.Columns) {
location := formatLocation(schema.Name, table.Name, col.Name)
passed := !keywords[strings.ToUpper(col.Name)]
@@ -479,7 +521,7 @@ func validateOrphanedForeignKey(db *models.Database, rule Rule, ruleName string)
// Check all foreign key constraints
for _, schema := range db.Schemas {
for _, table := range schema.Tables {
for _, constraint := range table.Constraints {
for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type == models.ForeignKeyConstraint {
// Build referenced table key
refSchema := constraint.ReferencedSchema
@@ -522,7 +564,7 @@ func validateCircularDependency(db *models.Database, rule Rule, ruleName string)
for _, table := range schema.Tables {
tableKey := schema.Name + "." + table.Name
for _, constraint := range table.Constraints {
for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type == models.ForeignKeyConstraint {
refSchema := constraint.ReferencedSchema
if refSchema == "" {
@@ -537,7 +579,7 @@ func validateCircularDependency(db *models.Database, rule Rule, ruleName string)
}
// Check for cycles using DFS
for tableKey := range dependencies {
for _, tableKey := range sortedKeys(dependencies) {
visited := make(map[string]bool)
recStack := make(map[string]bool)
+18 -3
View File
@@ -5,6 +5,7 @@ package merge
import (
"fmt"
"sort"
"strconv"
"strings"
@@ -156,8 +157,17 @@ func (r *MergeResult) mergeColumns(table *models.Table, srcTable *models.Table)
existingColumns[colName] = table.Columns[colName]
}
// Merge columns
for colName, srcCol := range srcTable.Columns {
// Merge columns in deterministic (alphabetical) order so that, when a
// TypeConflicts entry is recorded, its position in the report doesn't
// depend on Go's randomized map iteration order.
srcColNames := make([]string, 0, len(srcTable.Columns))
for colName := range srcTable.Columns {
srcColNames = append(srcColNames, colName)
}
sort.Strings(srcColNames)
for _, colName := range srcColNames {
srcCol := srcTable.Columns[colName]
if tgtCol, exists := existingColumns[colName]; !exists {
// Column doesn't exist, add it
newCol := cloneColumn(srcCol)
@@ -482,7 +492,12 @@ func extractTypeParts(col *models.Column) (baseType string, length, precision, s
}
}
typeName = pgsql.NormalizePGType(typeName)
// serial/bigserial/smallserial are sugar over an integer column plus a
// sequence default; PostgreSQL itself reports the underlying integer
// type back for such columns, so treat them as equivalent here to avoid
// spurious conflicts between a DBML "bigserial" source and a live-read
// "bigint" target (or vice versa).
typeName = pgsql.SerialUnderlyingType(typeName)
return typeName, length, precision, scale
}
+44
View File
@@ -196,6 +196,50 @@ func TestMergeColumns_TypeConflictIsDetected(t *testing.T) {
}
}
func TestMergeColumns_SerialVsUnderlyingIntegerIsNotAConflict(t *testing.T) {
target := &models.Database{
Schemas: []*models.Schema{
{
Name: "public",
Tables: []*models.Table{
{
Name: "users",
Schema: "public",
Columns: map[string]*models.Column{
// As reported back by a live PostgreSQL read of an
// existing serial primary key column.
"id": {Name: "id", Type: "bigint"},
},
},
},
},
},
}
source := &models.Database{
Schemas: []*models.Schema{
{
Name: "public",
Tables: []*models.Table{
{
Name: "users",
Schema: "public",
Columns: map[string]*models.Column{
// As declared in a DBML source spec.
"id": {Name: "id", Type: "bigserial"},
},
},
},
},
},
}
result := MergeDatabases(target, source, nil)
if len(result.TypeConflicts) != 0 {
t.Fatalf("Expected no type conflicts for bigserial vs bigint, got %d: %+v", len(result.TypeConflicts), result.TypeConflicts)
}
}
func TestMergeConstraints_NewConstraint(t *testing.T) {
target := &models.Database{
Schemas: []*models.Schema{
+23 -1
View File
@@ -1,6 +1,9 @@
package models
import "fmt"
import (
"fmt"
"sort"
)
// Flat/Denormalized Views
//
@@ -56,6 +59,10 @@ func (d *Database) ToFlatColumns() []*FlatColumn {
}
}
sort.Slice(flatColumns, func(i, j int) bool {
return flatColumns[i].FullyQualifiedName < flatColumns[j].FullyQualifiedName
})
return flatColumns
}
@@ -148,6 +155,10 @@ func (d *Database) ToFlatConstraints() []*FlatConstraint {
}
}
sort.Slice(flatConstraints, func(i, j int) bool {
return flatConstraints[i].FullyQualifiedName < flatConstraints[j].FullyQualifiedName
})
return flatConstraints
}
@@ -198,5 +209,16 @@ func (d *Database) ToFlatRelationships() []*FlatRelationship {
}
}
sort.Slice(flatRelationships, func(i, j int) bool {
a, b := flatRelationships[i], flatRelationships[j]
if a.FromFQN != b.FromFQN {
return a.FromFQN < b.FromFQN
}
if a.RelationshipName != b.RelationshipName {
return a.RelationshipName < b.RelationshipName
}
return a.ToFQN < b.ToFQN
})
return flatRelationships
}
+24 -4
View File
@@ -5,6 +5,7 @@
package models
import (
"sort"
"strings"
"time"
@@ -141,15 +142,28 @@ func (d *Table) SQLName() string {
// GetPrimaryKey returns the primary key column for the table, or nil if none exists.
func (m Table) GetPrimaryKey() *Column {
var pk *Column
for _, column := range m.Columns {
if column.IsPrimaryKey {
return column
if !column.IsPrimaryKey {
continue
}
if pk == nil || columnLess(column, pk) {
pk = column
}
}
return nil
return pk
}
// GetForeignKeys returns all foreign key constraints for the table.
// columnLess reports whether a should sort before b, by Sequence then Name.
func columnLess(a, b *Column) bool {
if a.Sequence > 0 && b.Sequence > 0 {
return a.Sequence < b.Sequence
}
return a.Name < b.Name
}
// GetForeignKeys returns all foreign key constraints for the table, sorted
// deterministically by Sequence then Name.
func (m Table) GetForeignKeys() []*Constraint {
keys := make([]*Constraint, 0)
@@ -158,6 +172,12 @@ func (m Table) GetForeignKeys() []*Constraint {
keys = append(keys, c)
}
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].Sequence > 0 && keys[j].Sequence > 0 {
return keys[i].Sequence < keys[j].Sequence
}
return keys[i].Name < keys[j].Name
})
return keys
}
+22
View File
@@ -193,6 +193,28 @@ func IsKnownPGBaseType(baseType string) bool {
return ok
}
// serialUnderlyingType maps each serial pseudo-type to the integer type
// PostgreSQL actually stores the column as. serial/bigserial/smallserial are
// not real types: they are sugar for an integer column plus a sequence
// default, and pg_catalog (and information_schema) always reports the
// underlying integer type back for such columns.
var serialUnderlyingType = map[string]string{
"serial": "integer",
"bigserial": "bigint",
"smallserial": "smallint",
}
// SerialUnderlyingType returns the underlying integer type for a serial
// pseudo-type (e.g. "bigserial" -> "bigint"). If baseType (after
// NormalizePGType) is not a serial type, it is returned unchanged.
func SerialUnderlyingType(baseType string) string {
normalized := NormalizePGType(baseType)
if underlying, ok := serialUnderlyingType[normalized]; ok {
return underlying
}
return normalized
}
func IsGoType(pTypeName string) bool {
for k := range GoToStdTypes {
if strings.EqualFold(pTypeName, k) {
+78 -23
View File
@@ -434,6 +434,7 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) {
var currentSchema string
var inIndexes bool
var inTable bool
var columnSeq uint
tableRegex := regexp.MustCompile(`^Table\s+(.+?)\s*{`)
refRegex := regexp.MustCompile(`^Ref:\s+(.+)`)
@@ -469,6 +470,7 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) {
currentTable = models.InitTable(tableName, currentSchema)
inTable = true
inIndexes = false
columnSeq = 0
continue
}
@@ -497,6 +499,17 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) {
// Parse index definition
if inIndexes && currentTable != nil {
// A composite `[pk]` entry inside an Indexes block declares the
// table's primary key (DBML's way of expressing multi-column PKs
// that can't be attached to a single column). It must become a
// primary key constraint, not a plain index, or the PK is lost.
if indexLineHasPKAttr(line) {
if constraint := r.parsePrimaryKeyIndex(line, currentTable.Name, currentSchema); constraint != nil {
currentTable.Constraints[constraint.Name] = constraint
}
continue
}
index := r.parseIndex(line, currentTable.Name, currentSchema)
if index != nil {
currentTable.Indexes[index.Name] = index
@@ -516,6 +529,8 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) {
if inTable && !inIndexes && currentTable != nil {
column, constraint := r.parseColumn(line, currentTable.Name, currentSchema)
if column != nil {
columnSeq++
column.Sequence = columnSeq
currentTable.Columns[column.Name] = column
}
if constraint != nil {
@@ -743,9 +758,10 @@ func stripWrappingQuotes(s string) string {
return s
}
// parseIndex parses a DBML index definition
func (r *Reader) parseIndex(line, tableName, schemaName string) *models.Index {
// Format: (columns) [attributes] OR columnname [attributes]
// indexLineColumns extracts the column list from an Indexes-block entry,
// e.g. "(col1, col2) [attrs]" or "columnname [attrs]", preserving
// declaration order.
func indexLineColumns(line string) []string {
var columns []string
// Find the attributes section to avoid parsing parentheses in notes/attributes
@@ -776,6 +792,56 @@ func (r *Reader) parseIndex(line, tableName, schemaName string) *models.Index {
}
}
return columns
}
// indexLineAttrs extracts and splits the bracketed attribute list of an
// Indexes-block entry, e.g. "[pk]" or "[unique, name: 'foo']".
func indexLineAttrs(line string) []string {
attrStart := strings.Index(line, "[")
attrEnd := strings.Index(line, "]")
if attrStart < 0 || attrEnd < 0 || attrStart >= attrEnd {
return nil
}
var attrs []string
for _, attr := range strings.Split(line[attrStart+1:attrEnd], ",") {
attrs = append(attrs, strings.TrimSpace(attr))
}
return attrs
}
// indexLineHasPKAttr reports whether an Indexes-block entry carries a `pk`
// attribute, e.g. "(artifact_id, sha256) [pk]". DBML uses this form to
// declare composite primary keys that can't be attached to a single column.
func indexLineHasPKAttr(line string) bool {
for _, attr := range indexLineAttrs(line) {
if attr == "pk" || attr == "primary key" {
return true
}
}
return false
}
// parsePrimaryKeyIndex converts a composite `[pk]` entry from an Indexes
// block into a primary key constraint, preserving the declared column order.
func (r *Reader) parsePrimaryKeyIndex(line, tableName, schemaName string) *models.Constraint {
columns := indexLineColumns(line)
if len(columns) == 0 {
return nil
}
constraint := models.InitConstraint("pk_"+tableName, models.PrimaryKeyConstraint)
constraint.Schema = schemaName
constraint.Table = tableName
constraint.Columns = columns
return constraint
}
// parseIndex parses a DBML index definition
func (r *Reader) parseIndex(line, tableName, schemaName string) *models.Index {
// Format: (columns) [attributes] OR columnname [attributes]
columns := indexLineColumns(line)
if len(columns) == 0 {
return nil
}
@@ -786,26 +852,15 @@ func (r *Reader) parseIndex(line, tableName, schemaName string) *models.Index {
index.Columns = columns
// Parse attributes
if strings.Contains(line, "[") && strings.Contains(line, "]") {
attrStart := strings.Index(line, "[")
attrEnd := strings.Index(line, "]")
if attrStart < attrEnd {
attrs := line[attrStart+1 : attrEnd]
attrList := strings.Split(attrs, ",")
for _, attr := range attrList {
attr = strings.TrimSpace(attr)
if attr == "unique" {
index.Unique = true
} else if strings.HasPrefix(attr, "name:") {
name := strings.TrimSpace(strings.TrimPrefix(attr, "name:"))
index.Name = strings.Trim(name, "'\"")
} else if strings.HasPrefix(attr, "type:") {
indexType := strings.TrimSpace(strings.TrimPrefix(attr, "type:"))
index.Type = strings.Trim(indexType, "'\"")
}
}
for _, attr := range indexLineAttrs(line) {
if attr == "unique" {
index.Unique = true
} else if strings.HasPrefix(attr, "name:") {
name := strings.TrimSpace(strings.TrimPrefix(attr, "name:"))
index.Name = strings.Trim(name, "'\"")
} else if strings.HasPrefix(attr, "type:") {
indexType := strings.TrimSpace(strings.TrimPrefix(attr, "type:"))
index.Type = strings.Trim(indexType, "'\"")
}
}
+94
View File
@@ -932,3 +932,97 @@ func TestHasCommentedRefs(t *testing.T) {
})
}
}
// TestReader_CompositePKIndex verifies that a composite `[pk]` entry inside
// an Indexes block is turned into a primary key constraint, in declaration
// order, rather than being silently dropped.
func TestReader_CompositePKIndex(t *testing.T) {
dbmlContent := `Table artifact_blob {
artifact_id integer [not null]
sha256 text [not null]
size integer
Indexes {
(artifact_id, sha256) [pk]
}
}
`
dir := t.TempDir()
path := filepath.Join(dir, "composite_pk.dbml")
if err := os.WriteFile(path, []byte(dbmlContent), 0644); err != nil {
t.Fatalf("failed to write fixture: %v", err)
}
reader := NewReader(&readers.ReaderOptions{FilePath: path})
db, err := reader.ReadDatabase()
if err != nil {
t.Fatalf("ReadDatabase() error = %v", err)
}
table := db.Schemas[0].Tables[0]
var pk *models.Constraint
for _, c := range table.Constraints {
if c.Type == models.PrimaryKeyConstraint {
pk = c
break
}
}
if pk == nil {
t.Fatal("expected a primary key constraint, got none")
}
want := []string{"artifact_id", "sha256"}
if len(pk.Columns) != len(want) {
t.Fatalf("expected PK columns %v, got %v", want, pk.Columns)
}
for i, col := range want {
if pk.Columns[i] != col {
t.Errorf("PK column[%d] = %q, want %q (order must match declaration)", i, pk.Columns[i], col)
}
}
// No plain index should be emitted for the pk-only entry.
if len(table.Indexes) != 0 {
t.Errorf("expected no plain indexes from a [pk] Indexes entry, got %v", table.Indexes)
}
}
// TestReader_ColumnPKOrderPreserved verifies that composite primary keys
// declared via column-level [pk] attributes keep declaration order (via
// Column.Sequence) instead of falling back to alphabetical sorting.
func TestReader_ColumnPKOrderPreserved(t *testing.T) {
dbmlContent := `Table snapshot_artifact {
snapshot_id integer [pk, not null]
artifact_id integer [pk, not null]
}
`
dir := t.TempDir()
path := filepath.Join(dir, "column_pk_order.dbml")
if err := os.WriteFile(path, []byte(dbmlContent), 0644); err != nil {
t.Fatalf("failed to write fixture: %v", err)
}
reader := NewReader(&readers.ReaderOptions{FilePath: path})
db, err := reader.ReadDatabase()
if err != nil {
t.Fatalf("ReadDatabase() error = %v", err)
}
table := db.Schemas[0].Tables[0]
snapshotCol, ok := table.Columns["snapshot_id"]
if !ok {
t.Fatal("column 'snapshot_id' not found")
}
artifactCol, ok := table.Columns["artifact_id"]
if !ok {
t.Fatal("column 'artifact_id' not found")
}
if snapshotCol.Sequence == 0 || artifactCol.Sequence == 0 {
t.Fatalf("expected non-zero Sequence values, got snapshot_id=%d artifact_id=%d", snapshotCol.Sequence, artifactCol.Sequence)
}
if snapshotCol.Sequence >= artifactCol.Sequence {
t.Errorf("expected snapshot_id (declared first) to have a lower Sequence than artifact_id, got %d >= %d", snapshotCol.Sequence, artifactCol.Sequence)
}
}
+7
View File
@@ -4,6 +4,7 @@ import (
"encoding/xml"
"fmt"
"os"
"sort"
"strings"
"git.warky.dev/wdevs/relspecgo/pkg/models"
@@ -373,7 +374,13 @@ func (r *Reader) convertKey(dctxKey *models.DCTXKey, table *models.Table, fieldG
if len(columns) == 0 {
if dctxKey.Primary {
// Look for common primary key column patterns
colNames := make([]string, 0, len(table.Columns))
for colName := range table.Columns {
colNames = append(colNames, colName)
}
sort.Strings(colNames)
for _, colName := range colNames {
colNameLower := strings.ToLower(colName)
if strings.HasPrefix(colNameLower, "rid_") || strings.HasSuffix(colNameLower, "id") {
columns = append(columns, colName)
+18 -4
View File
@@ -820,17 +820,31 @@ func (r *Reader) createImplicitJoinTable(model1, model2 string, tableMap map[str
tableMap[joinTableName] = joinTable
}
// getPrimaryKeyColumn returns the primary key column of a table
// getPrimaryKeyColumn returns the primary key column of a table. For tables
// with a composite primary key, the column with the lowest Sequence (or,
// failing that, the alphabetically first Name) is returned deterministically.
func (r *Reader) getPrimaryKeyColumn(table *models.Table) *models.Column {
if table == nil {
return nil
}
var pk *models.Column
for _, col := range table.Columns {
if col.IsPrimaryKey {
return col
if !col.IsPrimaryKey {
continue
}
if pk == nil {
pk = col
continue
}
if col.Sequence > 0 && pk.Sequence > 0 {
if col.Sequence < pk.Sequence {
pk = col
}
} else if col.Name < pk.Name {
pk = col
}
}
return nil
return pk
}
+18 -4
View File
@@ -806,17 +806,31 @@ func (r *Reader) createManyToManyJoinTable(entity1, entity2 string, tableMap map
tableMap[joinTableName] = joinTable
}
// getPrimaryKeyColumn returns the primary key column of a table
// getPrimaryKeyColumn returns the primary key column of a table. For tables
// with a composite primary key, the column with the lowest Sequence (or,
// failing that, the alphabetically first Name) is returned deterministically.
func (r *Reader) getPrimaryKeyColumn(table *models.Table) *models.Column {
if table == nil {
return nil
}
var pk *models.Column
for _, col := range table.Columns {
if col.IsPrimaryKey {
return col
if !col.IsPrimaryKey {
continue
}
if pk == nil {
pk = col
continue
}
if col.Sequence > 0 && pk.Sequence > 0 {
if col.Sequence < pk.Sequence {
pk = col
}
} else if col.Name < pk.Name {
pk = col
}
}
return nil
return pk
}
+2
View File
@@ -2,6 +2,7 @@ package ui
import (
"fmt"
"sort"
"github.com/rivo/tview"
@@ -69,5 +70,6 @@ func getColumnNames(table *models.Table) []string {
for name := range table.Columns {
names = append(names, name)
}
sort.Strings(names)
return names
}
+6 -1
View File
@@ -1,6 +1,10 @@
package ui
import "git.warky.dev/wdevs/relspecgo/pkg/models"
import (
"sort"
"git.warky.dev/wdevs/relspecgo/pkg/models"
)
// Relationship data operations - business logic for relationship management
@@ -111,5 +115,6 @@ func (se *SchemaEditor) GetRelationshipNames(schemaIndex, tableIndex int) []stri
for name := range table.Relationships {
names = append(names, name)
}
sort.Strings(names)
return names
}
+19 -3
View File
@@ -220,8 +220,11 @@ func NewModelData(table *models.Table, schema string, typeMapper *TypeMapper, fl
Prefix: GeneratePrefix(table.Name),
}
// Convert columns to fields (sorted by sequence or name)
columns := sortColumns(table.Columns)
// Find primary key
for _, col := range table.Columns {
for _, col := range columns {
if col.IsPrimaryKey {
// Sanitize column name to remove backticks
safeName := writers.SanitizeStructTagValue(col.Name)
@@ -240,8 +243,6 @@ func NewModelData(table *models.Table, schema string, typeMapper *TypeMapper, fl
}
}
// Convert columns to fields (sorted by sequence or name)
columns := sortColumns(table.Columns)
for _, col := range columns {
field := columnToField(col, table, typeMapper)
// Check for name collision with generated methods and rename if needed
@@ -335,6 +336,21 @@ func sortConstraints(constraints map[string]*models.Constraint) []*models.Constr
return result
}
// sortIndexes sorts indexes by sequence, then by name
func sortIndexes(indexes map[string]*models.Index) []*models.Index {
result := make([]*models.Index, 0, len(indexes))
for _, idx := range indexes {
result = append(result, idx)
}
sort.Slice(result, func(i, j int) bool {
if result[i].Sequence > 0 && result[j].Sequence > 0 {
return result[i].Sequence < result[j].Sequence
}
return result[i].Name < result[j].Name
})
return result
}
// sortColumns sorts columns by sequence, then by name
func sortColumns(columns map[string]*models.Column) []*models.Column {
result := make([]*models.Column, 0, len(columns))
+1 -1
View File
@@ -383,7 +383,7 @@ func (tm *TypeMapper) BuildBunTag(column *models.Column, table *models.Table) st
// Check for indexes (unique indexes should be added to tag)
if table != nil {
for _, index := range table.Indexes {
for _, index := range sortIndexes(table.Indexes) {
if !index.Unique {
continue
}
+37
View File
@@ -836,6 +836,43 @@ func TestTypeMapper_BuildBunTag(t *testing.T) {
}
}
// TestTypeMapper_BuildBunTag_MultipleUniqueIndexesDeterministic verifies that
// when a column belongs to more than one unique index, the "unique:" tag
// fragments always appear in the same order across repeated calls, instead
// of following Go's randomized map iteration order over Table.Indexes.
func TestTypeMapper_BuildBunTag_MultipleUniqueIndexesDeterministic(t *testing.T) {
mapper := NewTypeMapper("", "")
table := &models.Table{
Name: "accounts",
Indexes: map[string]*models.Index{
"idx_z_accounts_email_tenant": {
Name: "idx_z_accounts_email_tenant",
Columns: []string{"email", "tenant_id"},
Unique: true,
},
"idx_a_accounts_email_region": {
Name: "idx_a_accounts_email_region",
Columns: []string{"email", "region_id"},
Unique: true,
},
},
}
column := &models.Column{Name: "email", Type: "varchar", Length: 255, NotNull: true}
first := mapper.BuildBunTag(column, table)
for i := 0; i < 50; i++ {
got := mapper.BuildBunTag(column, table)
if got != first {
t.Fatalf("BuildBunTag() is non-deterministic across calls: %q vs %q", first, got)
}
}
wantOrder := "unique:idx_a_accounts_email_region,unique:idx_z_accounts_email_tenant,"
if !strings.Contains(first, wantOrder) {
t.Errorf("BuildBunTag() = %q, want unique tags sorted by index name: %q", first, wantOrder)
}
}
// TestTypeMapper_BuildBunTag_ArraysAreNativeInEveryMode verifies that array
// columns always use a plain "text[]"-style type and the native Go slice
// type plus an explicit "array" tag, regardless of NullableTypes style
+49 -3
View File
@@ -3,6 +3,7 @@ package dbml
import (
"fmt"
"os"
"sort"
"strings"
"git.warky.dev/wdevs/relspecgo/pkg/models"
@@ -78,7 +79,7 @@ func (w *Writer) databaseToDBML(d *models.Database) string {
sb.WriteString("\n// Relationships\n")
for _, schema := range d.Schemas {
for _, table := range schema.Tables {
for _, constraint := range table.Constraints {
for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type == models.ForeignKeyConstraint {
sb.WriteString(w.constraintToDBML(constraint, table))
}
@@ -112,7 +113,7 @@ func (w *Writer) tableToDBML(t *models.Table) string {
tableName := fmt.Sprintf("%s.%s", t.Schema, t.Name)
fmt.Fprintf(&sb, "Table %s {\n", tableName)
for _, column := range t.Columns {
for _, column := range sortColumns(t.Columns) {
fmt.Fprintf(&sb, " %s %s", column.Name, column.Type)
var attrs []string
@@ -149,7 +150,7 @@ func (w *Writer) tableToDBML(t *models.Table) string {
if len(t.Indexes) > 0 {
sb.WriteString("\n indexes {\n")
for _, index := range t.Indexes {
for _, index := range sortIndexes(t.Indexes) {
var indexAttrs []string
if index.Unique {
indexAttrs = append(indexAttrs, "unique")
@@ -230,3 +231,48 @@ func (w *Writer) constraintToDBML(c *models.Constraint, t *models.Table) string
return refLine + "\n"
}
// sortColumns returns columns sorted by Sequence then Name for deterministic output.
func sortColumns(columns map[string]*models.Column) []*models.Column {
result := make([]*models.Column, 0, len(columns))
for _, col := range columns {
result = append(result, col)
}
sort.Slice(result, func(i, j int) bool {
if result[i].Sequence > 0 && result[j].Sequence > 0 {
return result[i].Sequence < result[j].Sequence
}
return result[i].Name < result[j].Name
})
return result
}
// sortConstraints returns constraints sorted by Sequence then Name for deterministic output.
func sortConstraints(constraints map[string]*models.Constraint) []*models.Constraint {
result := make([]*models.Constraint, 0, len(constraints))
for _, c := range constraints {
result = append(result, c)
}
sort.Slice(result, func(i, j int) bool {
if result[i].Sequence > 0 && result[j].Sequence > 0 {
return result[i].Sequence < result[j].Sequence
}
return result[i].Name < result[j].Name
})
return result
}
// sortIndexes returns indexes sorted by Sequence then Name for deterministic output.
func sortIndexes(indexes map[string]*models.Index) []*models.Index {
result := make([]*models.Index, 0, len(indexes))
for _, idx := range indexes {
result = append(result, idx)
}
sort.Slice(result, func(i, j int) bool {
if result[i].Sequence > 0 && result[j].Sequence > 0 {
return result[i].Sequence < result[j].Sequence
}
return result[i].Name < result[j].Name
})
return result
}
+8 -1
View File
@@ -66,7 +66,14 @@ func (w *Writer) WriteSchema(schema *models.Schema) error {
// Add table-level relationships
for _, table := range tableSlice {
for _, rel := range table.Relationships {
relNames := make([]string, 0, len(table.Relationships))
for name := range table.Relationships {
relNames = append(relNames, name)
}
sort.Strings(relNames)
for _, relName := range relNames {
rel := table.Relationships[relName]
// Check if this relationship is already in the list (avoid duplicates)
isDuplicate := false
for _, existing := range allRelations {
+49 -3
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"os"
"sort"
"git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/writers"
@@ -175,7 +176,7 @@ func (w *Writer) databaseToDrawDB(d *models.Database) *DrawDBSchema {
// Add relationships
for _, schemaModel := range d.Schemas {
for _, table := range schemaModel.Tables {
for _, constraint := range table.Constraints {
for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type == models.ForeignKeyConstraint && constraint.ReferencedTable != "" {
startTableKey := fmt.Sprintf("%s.%s", schemaModel.Name, table.Name)
endTableKey := fmt.Sprintf("%s.%s", constraint.ReferencedSchema, constraint.ReferencedTable)
@@ -306,7 +307,7 @@ func (w *Writer) convertTableToDrawDB(table *models.Table, schemaName string, ta
}
// Add fields
for _, column := range table.Columns {
for _, column := range sortColumns(table.Columns) {
field := &DrawDBField{
ID: fieldID,
Name: column.Name,
@@ -339,7 +340,7 @@ func (w *Writer) convertTableToDrawDB(table *models.Table, schemaName string, ta
// Add indexes
indexID := 0
for _, index := range table.Indexes {
for _, index := range sortIndexes(table.Indexes) {
drawIndex := &DrawDBIndex{
ID: indexID,
Name: index.Name,
@@ -393,3 +394,48 @@ func getColorForIndex(index int) string {
}
return colors[index%len(colors)]
}
// sortColumns returns columns sorted by Sequence then Name for deterministic output.
func sortColumns(columns map[string]*models.Column) []*models.Column {
result := make([]*models.Column, 0, len(columns))
for _, col := range columns {
result = append(result, col)
}
sort.Slice(result, func(i, j int) bool {
if result[i].Sequence > 0 && result[j].Sequence > 0 {
return result[i].Sequence < result[j].Sequence
}
return result[i].Name < result[j].Name
})
return result
}
// sortConstraints returns constraints sorted by Sequence then Name for deterministic output.
func sortConstraints(constraints map[string]*models.Constraint) []*models.Constraint {
result := make([]*models.Constraint, 0, len(constraints))
for _, c := range constraints {
result = append(result, c)
}
sort.Slice(result, func(i, j int) bool {
if result[i].Sequence > 0 && result[j].Sequence > 0 {
return result[i].Sequence < result[j].Sequence
}
return result[i].Name < result[j].Name
})
return result
}
// sortIndexes returns indexes sorted by Sequence then Name for deterministic output.
func sortIndexes(indexes map[string]*models.Index) []*models.Index {
result := make([]*models.Index, 0, len(indexes))
for _, idx := range indexes {
result = append(result, idx)
}
sort.Slice(result, func(i, j int) bool {
if result[i].Sequence > 0 && result[j].Sequence > 0 {
return result[i].Sequence < result[j].Sequence
}
return result[i].Name < result[j].Name
})
return result
}
+35 -3
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"git.warky.dev/wdevs/relspecgo/pkg/models"
@@ -250,7 +251,7 @@ func (w *Writer) buildTableData(table *models.Table, schema *models.Schema, db *
indexColumnFields := make(map[string]bool)
// Add indexes (excluding single-column unique indexes, which are handled inline)
for _, index := range table.Indexes {
for _, index := range sortIndexes(table.Indexes) {
// Skip single-column unique indexes (handled by .unique() modifier)
if index.Unique && len(index.Columns) == 1 {
continue
@@ -270,7 +271,7 @@ func (w *Writer) buildTableData(table *models.Table, schema *models.Schema, db *
}
// Add multi-column unique constraints as unique indexes
for _, constraint := range table.Constraints {
for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type == models.UniqueConstraint && len(constraint.Columns) > 1 {
// Create a unique index for this constraint
indexData := &IndexData{
@@ -316,6 +317,36 @@ func (w *Writer) buildTableData(table *models.Table, schema *models.Schema, db *
return tableData
}
// sortIndexes returns indexes sorted by Sequence then Name for deterministic output.
func sortIndexes(indexes map[string]*models.Index) []*models.Index {
result := make([]*models.Index, 0, len(indexes))
for _, idx := range indexes {
result = append(result, idx)
}
sort.Slice(result, func(i, j int) bool {
if result[i].Sequence > 0 && result[j].Sequence > 0 {
return result[i].Sequence < result[j].Sequence
}
return result[i].Name < result[j].Name
})
return result
}
// sortConstraints returns constraints sorted by Sequence then Name for deterministic output.
func sortConstraints(constraints map[string]*models.Constraint) []*models.Constraint {
result := make([]*models.Constraint, 0, len(constraints))
for _, c := range constraints {
result = append(result, c)
}
sort.Slice(result, func(i, j int) bool {
if result[i].Sequence > 0 && result[j].Sequence > 0 {
return result[i].Sequence < result[j].Sequence
}
return result[i].Name < result[j].Name
})
return result
}
// sortStrings sorts a slice of strings in place
func sortStrings(strs []string) {
for i := 0; i < len(strs); i++ {
@@ -422,7 +453,8 @@ func (w *Writer) getTableEnumNames(table *models.Table, schema *models.Schema, e
enumNames := make([]string, 0)
seen := make(map[string]bool)
for _, col := range table.Columns {
for _, colName := range w.getSortedColumnNames(table) {
col := table.Columns[colName]
if enumMap[col.Type] || enumMap[strings.ToLower(col.Type)] {
// Find the enum in schema
for _, enum := range schema.Enums {
+19 -3
View File
@@ -134,8 +134,11 @@ func NewModelData(table *models.Table, schema string, typeMapper *TypeMapper, fl
Prefix: GeneratePrefix(table.Name),
}
// Convert columns to fields (sorted by sequence or name)
columns := sortColumns(table.Columns)
// Find primary key
for _, col := range table.Columns {
for _, col := range columns {
if col.IsPrimaryKey {
// Sanitize column name to remove backticks
safeName := writers.SanitizeStructTagValue(col.Name)
@@ -153,8 +156,6 @@ func NewModelData(table *models.Table, schema string, typeMapper *TypeMapper, fl
}
}
// Convert columns to fields (sorted by sequence or name)
columns := sortColumns(table.Columns)
for _, col := range columns {
field := columnToField(col, table, typeMapper)
// Check for name collision with generated methods and rename if needed
@@ -248,6 +249,21 @@ func sortConstraints(constraints map[string]*models.Constraint) []*models.Constr
return result
}
// sortIndexes sorts indexes by sequence, then by name
func sortIndexes(indexes map[string]*models.Index) []*models.Index {
result := make([]*models.Index, 0, len(indexes))
for _, idx := range indexes {
result = append(result, idx)
}
sort.Slice(result, func(i, j int) bool {
if result[i].Sequence > 0 && result[j].Sequence > 0 {
return result[i].Sequence < result[j].Sequence
}
return result[i].Name < result[j].Name
})
return result
}
// sortColumns sorts columns by sequence, then by name
func sortColumns(columns map[string]*models.Column) []*models.Column {
result := make([]*models.Column, 0, len(columns))
+2 -2
View File
@@ -415,7 +415,7 @@ func (tm *TypeMapper) BuildGormTag(column *models.Column, table *models.Table) s
// Check for unique constraint
if table != nil {
for _, constraint := range table.Constraints {
for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type == models.UniqueConstraint {
for _, col := range constraint.Columns {
if col == column.Name {
@@ -431,7 +431,7 @@ func (tm *TypeMapper) BuildGormTag(column *models.Column, table *models.Table) s
}
// Check for index
for _, index := range table.Indexes {
for _, index := range sortIndexes(table.Indexes) {
for _, col := range index.Columns {
if col == column.Name {
if index.Unique {
+45
View File
@@ -757,3 +757,48 @@ func TestTypeMapper_BuildGormTag_PreservesExplicitTypeModifiers(t *testing.T) {
t.Fatalf("type modifier appears duplicated in %q", tag)
}
}
// TestTypeMapper_BuildGormTag_MultipleUniqueIndexesDeterministic verifies
// that when a column belongs to a unique constraint and more than one
// unique index, the "uniqueIndex:" tag fragments always appear in the same
// order across repeated calls, instead of following Go's randomized map
// iteration order over Table.Constraints and Table.Indexes.
func TestTypeMapper_BuildGormTag_MultipleUniqueIndexesDeterministic(t *testing.T) {
mapper := NewTypeMapper("")
table := &models.Table{
Name: "accounts",
Constraints: map[string]*models.Constraint{
"uq_z_accounts_email": {
Name: "uq_z_accounts_email",
Type: models.UniqueConstraint,
Columns: []string{"email"},
},
},
Indexes: map[string]*models.Index{
"idx_z_accounts_email_tenant": {
Name: "idx_z_accounts_email_tenant",
Columns: []string{"email", "tenant_id"},
Unique: true,
},
"idx_a_accounts_email_region": {
Name: "idx_a_accounts_email_region",
Columns: []string{"email", "region_id"},
Unique: true,
},
},
}
column := &models.Column{Name: "email", Type: "varchar", Length: 255, NotNull: true}
first := mapper.BuildGormTag(column, table)
for i := 0; i < 50; i++ {
got := mapper.BuildGormTag(column, table)
if got != first {
t.Fatalf("BuildGormTag() is non-deterministic across calls: %q vs %q", first, got)
}
}
wantOrder := "uniqueIndex:uq_z_accounts_email;uniqueIndex:idx_a_accounts_email_region;uniqueIndex:idx_z_accounts_email_tenant"
if !strings.Contains(first, wantOrder) {
t.Errorf("BuildGormTag() = %q, want uniqueIndex tags sorted (constraint before indexes, indexes by name): %q", first, wantOrder)
}
}
+2 -1
View File
@@ -233,7 +233,8 @@ func (w *Writer) tableToGraphQL(table *models.Table, db *models.Database, schema
// Add relation fields
relationFields = w.generateRelationFields(table, db, schema)
// Write fields in order: ID, scalars (sorted), relations (sorted)
// Write fields in order: ID (sorted), scalars (sorted), relations (sorted)
sort.Strings(idFields)
for _, field := range idFields {
sb.WriteString(field + "\n")
}
+1
View File
@@ -169,6 +169,7 @@ When `include_audit` is enabled, adds:
- Constraint actions (CASCADE, RESTRICT, SET NULL)
- Partial indexes
- Function-based indexes
- Concurrent index creation (`CREATE INDEX CONCURRENTLY`) via `Index.Concurrent`
- Check constraints with expressions
## Data Types
+68 -34
View File
@@ -239,7 +239,8 @@ func (w *MigrationWriter) generateDropScripts(model *models.Schema, current *mod
}
// Check each constraint in current database
for constraintName, currentConstraint := range currentTable.Constraints {
for _, currentConstraint := range sortConstraints(currentTable.Constraints) {
constraintName := currentConstraint.Name
modelConstraint, existsInModel := modelTable.Constraints[constraintName]
shouldDrop := false
@@ -252,7 +253,8 @@ func (w *MigrationWriter) generateDropScripts(model *models.Schema, current *mod
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 {
for _, fkConstraint := range sortConstraints(otherTable.Constraints) {
fkName := fkConstraint.Name
if fkConstraint.Type != models.ForeignKeyConstraint {
continue
}
@@ -310,7 +312,8 @@ func (w *MigrationWriter) generateDropScripts(model *models.Schema, current *mod
}
// Check indexes
for indexName, currentIndex := range currentTable.Indexes {
for _, currentIndex := range sortIndexes(currentTable.Indexes) {
indexName := currentIndex.Name
modelIndex, existsInModel := modelTable.Indexes[indexName]
shouldDrop := false
@@ -401,19 +404,12 @@ func (w *MigrationWriter) generateAlterTableScripts(schema *models.Schema, model
}
// Check each model column
for _, modelCol := range modelTable.Columns {
for _, modelCol := range sortColumns(modelTable.Columns) {
currentCol, exists := currentColumns[strings.ToLower(modelCol.Name)]
if !exists {
// Column doesn't exist, add it
defaultVal := ""
if modelCol.Default != nil {
if value, ok := modelCol.Default.(string); ok {
defaultVal = writers.QuoteDefaultValue(value, modelCol.Type)
} else {
defaultVal = fmt.Sprintf("%v", modelCol.Default)
}
}
_, defaultVal := formatColumnDefaultSQL(modelCol)
sql, err := w.executor.ExecuteAddColumn(AddColumnData{
SchemaName: schema.Name,
@@ -439,12 +435,14 @@ func (w *MigrationWriter) generateAlterTableScripts(schema *models.Schema, model
} else if !columnsEqual(modelCol, currentCol) {
// Column exists but properties changed
if !columnTypesEqual(modelCol, currentCol) {
sql, err := w.executor.ExecuteAlterColumnType(AlterColumnTypeData{
SchemaName: schema.Name,
TableName: modelTable.Name,
ColumnName: modelCol.Name,
NewType: effectiveAlterColumnSQLType(modelCol),
UsingExpr: buildAlterColumnUsingExpression(modelCol.Name, effectiveAlterColumnSQLType(modelCol)),
newType := effectiveAlterColumnSQLType(modelCol)
sql, err := w.executor.ExecuteAlterColumnTypeWithCheck(AlterColumnTypeWithCheckData{
SchemaName: schema.Name,
TableName: modelTable.Name,
ColumnName: modelCol.Name,
NewType: newType,
EquivalentTypes: equivalentTypeListSQL(newType),
UsingExpr: buildAlterColumnUsingExpression(modelCol.Name, newType),
})
if err != nil {
return nil, err
@@ -462,18 +460,10 @@ func (w *MigrationWriter) generateAlterTableScripts(schema *models.Schema, model
}
// Check default value changes
if fmt.Sprintf("%v", modelCol.Default) != fmt.Sprintf("%v", currentCol.Default) {
setDefault := modelCol.Default != nil
defaultVal := ""
if setDefault {
if value, ok := modelCol.Default.(string); ok {
defaultVal = writers.QuoteDefaultValue(value, modelCol.Type)
} else {
defaultVal = fmt.Sprintf("%v", modelCol.Default)
}
}
if !columnDefaultsEqual(modelCol.Default, currentCol.Default) {
setDefault, defaultVal := formatColumnDefaultSQL(modelCol)
sql, err := w.executor.ExecuteAlterColumnDefault(AlterColumnDefaultData{
sql, err := w.executor.ExecuteAlterColumnDefaultWithCheck(AlterColumnDefaultWithCheckData{
SchemaName: schema.Name,
TableName: modelTable.Name,
ColumnName: modelCol.Name,
@@ -494,6 +484,29 @@ func (w *MigrationWriter) generateAlterTableScripts(schema *models.Schema, model
}
scripts = append(scripts, script)
}
// Check nullability changes
if modelCol.NotNull != currentCol.NotNull {
sql, err := w.executor.ExecuteAlterColumnNullabilityWithCheck(AlterColumnNullabilityWithCheckData{
SchemaName: schema.Name,
TableName: modelTable.Name,
ColumnName: modelCol.Name,
NotNull: modelCol.NotNull,
})
if err != nil {
return nil, err
}
script := MigrationScript{
ObjectName: fmt.Sprintf("%s.%s.%s", schema.Name, modelTable.Name, modelCol.Name),
ObjectType: "alter column nullability",
Schema: schema.Name,
Priority: 145,
Sequence: len(scripts),
Body: sql,
}
scripts = append(scripts, script)
}
}
}
@@ -518,7 +531,8 @@ func (w *MigrationWriter) generateIndexScripts(model *models.Schema, current *mo
// Process primary keys first - check explicit constraints
foundExplicitPK := false
for constraintName, constraint := range modelTable.Constraints {
for _, constraint := range sortConstraints(modelTable.Constraints) {
constraintName := constraint.Name
if constraint.Type == models.PrimaryKeyConstraint {
foundExplicitPK = true
shouldCreate := true
@@ -603,7 +617,8 @@ func (w *MigrationWriter) generateIndexScripts(model *models.Schema, current *mo
}
// Process indexes
for indexName, modelIndex := range modelTable.Indexes {
for _, modelIndex := range sortIndexes(modelTable.Indexes) {
indexName := modelIndex.Name
// Skip primary key indexes
if strings.HasPrefix(strings.ToLower(indexName), "pk_") {
continue
@@ -637,6 +652,7 @@ func (w *MigrationWriter) generateIndexScripts(model *models.Schema, current *mo
IndexType: indexType,
Columns: strings.Join(columnExprs, ", "),
Unique: modelIndex.Unique,
Concurrent: modelIndex.Concurrent,
})
if err != nil {
return nil, err
@@ -697,7 +713,8 @@ func (w *MigrationWriter) generateForeignKeyScripts(model *models.Schema, curren
currentTable := currentTables[strings.ToLower(modelTable.Name)]
// Process each constraint
for constraintName, constraint := range modelTable.Constraints {
for _, constraint := range sortConstraints(modelTable.Constraints) {
constraintName := constraint.Name
if constraint.Type != models.ForeignKeyConstraint {
continue
}
@@ -787,7 +804,7 @@ func (w *MigrationWriter) generateCommentScripts(model *models.Schema, current *
}
// Column comments
for _, col := range modelTable.Columns {
for _, col := range sortColumns(modelTable.Columns) {
if col.Description != "" {
sql, err := w.executor.ExecuteCommentColumn(CommentColumnData{
SchemaName: model.Name,
@@ -940,7 +957,24 @@ func columnsEqual(col1, col2 *models.Column) bool {
}
return columnTypesEqual(col1, col2) &&
col1.NotNull == col2.NotNull &&
fmt.Sprintf("%v", col1.Default) == fmt.Sprintf("%v", col2.Default)
columnDefaultsEqual(col1.Default, col2.Default)
}
// columnDefaultsEqual compares column defaults for drift detection, stripping
// MySQL-style backticks (e.g. from GORM tags) so a model default of
// "`now()`" is recognised as equal to a live default of "now()".
func columnDefaultsEqual(default1, default2 interface{}) bool {
return normalizeDefaultForCompare(default1) == normalizeDefaultForCompare(default2)
}
func normalizeDefaultForCompare(value interface{}) string {
if value == nil {
return ""
}
if s, ok := value.(string); ok {
return strings.TrimSpace(stripBackticks(s))
}
return fmt.Sprintf("%v", value)
}
func columnTypesEqual(col1, col2 *models.Column) bool {
+123
View File
@@ -136,6 +136,89 @@ func TestWriteMigration_AltersColumnTypeWhenActualTypeDiffers(t *testing.T) {
}
}
func TestWriteMigration_AltersColumnTypeFallsBackToRenameAndAddOnConversionFailure(t *testing.T) {
current := models.InitDatabase("testdb")
currentSchema := models.InitSchema("public")
currentTable := models.InitTable("learnings", "public")
currentDetails := models.InitColumn("details", "learnings", "public")
currentDetails.Type = "varchar(50)"
currentTable.Columns["details"] = currentDetails
currentSchema.Tables = append(currentSchema.Tables, currentTable)
current.Schemas = append(current.Schemas, currentSchema)
model := models.InitDatabase("testdb")
modelSchema := models.InitSchema("public")
modelTable := models.InitTable("learnings", "public")
modelDetails := models.InitColumn("details", "learnings", "public")
modelDetails.Type = "integer"
modelTable.Columns["details"] = modelDetails
modelSchema.Tables = append(modelSchema.Tables, modelTable)
model.Schemas = append(model.Schemas, modelSchema)
var buf bytes.Buffer
writer, err := NewMigrationWriter(&writers.WriterOptions{})
if err != nil {
t.Fatalf("Failed to create writer: %v", err)
}
writer.writer = &buf
if err := writer.WriteMigration(model, current); err != nil {
t.Fatalf("WriteMigration failed: %v", err)
}
output := buf.String()
if !strings.Contains(output, "EXCEPTION WHEN OTHERS THEN") {
t.Fatalf("expected migration to guard the type conversion with an exception handler, got:\n%s", output)
}
if !strings.Contains(output, "RENAME COLUMN details TO %I") {
t.Fatalf("expected migration to rename the old column (derived from the live type) on conversion failure, got:\n%s", output)
}
if !strings.Contains(output, "renamed_column := 'details_' || trim(both '_' from regexp_replace(lower(current_type)") {
t.Fatalf("expected migration to derive the renamed column name from the live type, got:\n%s", output)
}
if !strings.Contains(output, "ADD COLUMN details integer") {
t.Fatalf("expected migration to add a fresh column with the new type on conversion failure, got:\n%s", output)
}
}
func TestWriteMigration_AltersColumnNullabilityWhenNotNullDiffers(t *testing.T) {
current := models.InitDatabase("testdb")
currentSchema := models.InitSchema("public")
currentTable := models.InitTable("service_instance", "public")
currentType := models.InitColumn("rid_service_instance_type", "service_instance", "public")
currentType.Type = "text"
currentType.NotNull = true
currentTable.Columns["rid_service_instance_type"] = currentType
currentSchema.Tables = append(currentSchema.Tables, currentTable)
current.Schemas = append(current.Schemas, currentSchema)
model := models.InitDatabase("testdb")
modelSchema := models.InitSchema("public")
modelTable := models.InitTable("service_instance", "public")
modelType := models.InitColumn("rid_service_instance_type", "service_instance", "public")
modelType.Type = "text"
modelType.NotNull = false
modelTable.Columns["rid_service_instance_type"] = modelType
modelSchema.Tables = append(modelSchema.Tables, modelTable)
model.Schemas = append(model.Schemas, modelSchema)
var buf bytes.Buffer
writer, err := NewMigrationWriter(&writers.WriterOptions{})
if err != nil {
t.Fatalf("Failed to create writer: %v", err)
}
writer.writer = &buf
if err := writer.WriteMigration(model, current); err != nil {
t.Fatalf("WriteMigration failed: %v", err)
}
output := buf.String()
if !strings.Contains(output, "ALTER COLUMN rid_service_instance_type DROP NOT NULL") {
t.Fatalf("expected migration to drop NOT NULL on existing column, got:\n%s", output)
}
}
func TestWriteMigration_UsesStorageTypeForSerialAlterStatements(t *testing.T) {
current := models.InitDatabase("testdb")
currentSchema := models.InitSchema("public")
@@ -251,6 +334,46 @@ func TestWriteMigration_DoesNotAlterEquivalentNormalizedColumnType(t *testing.T)
}
}
func TestWriteMigration_ConcurrentIndex(t *testing.T) {
current := models.InitDatabase("testdb")
currentSchema := models.InitSchema("public")
current.Schemas = append(current.Schemas, currentSchema)
model := models.InitDatabase("testdb")
modelSchema := models.InitSchema("public")
table := models.InitTable("articles", "public")
titleCol := models.InitColumn("title", "articles", "public")
titleCol.Type = "text"
table.Columns["title"] = titleCol
index := &models.Index{
Name: "idx_articles_title",
Columns: []string{"title"},
Concurrent: true,
}
table.Indexes[index.Name] = index
modelSchema.Tables = append(modelSchema.Tables, table)
model.Schemas = append(model.Schemas, modelSchema)
var buf bytes.Buffer
writer, err := NewMigrationWriter(&writers.WriterOptions{})
if err != nil {
t.Fatalf("Failed to create writer: %v", err)
}
writer.writer = &buf
if err := writer.WriteMigration(model, current); err != nil {
t.Fatalf("WriteMigration failed: %v", err)
}
output := buf.String()
if !strings.Contains(output, "CREATE INDEX CONCURRENTLY IF NOT EXISTS") {
t.Fatalf("expected CONCURRENTLY create index statement, got:\n%s", output)
}
}
func TestWriteMigration_GinIndexOnTextUsesTrigramOperatorClass(t *testing.T) {
current := models.InitDatabase("testdb")
currentSchema := models.InitSchema("public")
+41 -27
View File
@@ -89,15 +89,10 @@ type AddColumnData struct {
NotNull bool
}
// AlterColumnTypeData contains data for alter column type template
type AlterColumnTypeData struct {
SchemaName string
TableName string
ColumnName string
NewType string
UsingExpr string
}
// AlterColumnTypeWithCheckData contains data for the guarded alter column
// type template, which only alters existing columns whose live type
// differs from the desired one, and falls back to renaming the old column
// and adding a fresh one when the in-place conversion is not possible.
type AlterColumnTypeWithCheckData struct {
SchemaName string
TableName string
@@ -107,8 +102,10 @@ type AlterColumnTypeWithCheckData struct {
UsingExpr string
}
// AlterColumnDefaultData contains data for alter column default template
type AlterColumnDefaultData struct {
// AlterColumnDefaultWithCheckData contains data for the guarded alter
// column default template, which only alters existing columns whose live
// default differs from the desired one.
type AlterColumnDefaultWithCheckData struct {
SchemaName string
TableName string
ColumnName string
@@ -116,6 +113,16 @@ type AlterColumnDefaultData struct {
DefaultValue string
}
// AlterColumnNullabilityWithCheckData contains data for the guarded alter
// column nullability template, which only alters existing columns whose
// live NOT NULL state differs from the desired one.
type AlterColumnNullabilityWithCheckData struct {
SchemaName string
TableName string
ColumnName string
NotNull bool
}
// CreatePrimaryKeyData contains data for create primary key template
type CreatePrimaryKeyData struct {
SchemaName string
@@ -132,6 +139,7 @@ type CreateIndexData struct {
IndexType string
Columns string
Unique bool
Concurrent bool
}
// CreateForeignKeyData contains data for create foreign key template
@@ -302,16 +310,9 @@ func (te *TemplateExecutor) ExecuteAddColumn(data AddColumnData) (string, error)
return buf.String(), nil
}
// ExecuteAlterColumnType executes the alter column type template
func (te *TemplateExecutor) ExecuteAlterColumnType(data AlterColumnTypeData) (string, error) {
var buf bytes.Buffer
err := te.templates.ExecuteTemplate(&buf, "alter_column_type.tmpl", data)
if err != nil {
return "", fmt.Errorf("failed to execute alter_column_type template: %w", err)
}
return buf.String(), nil
}
// ExecuteAlterColumnTypeWithCheck executes the guarded alter column type
// template shared by the full-schema writer and the diff-based migration
// writer.
func (te *TemplateExecutor) ExecuteAlterColumnTypeWithCheck(data AlterColumnTypeWithCheckData) (string, error) {
var buf bytes.Buffer
err := te.templates.ExecuteTemplate(&buf, "alter_column_type_with_check.tmpl", data)
@@ -321,12 +322,25 @@ func (te *TemplateExecutor) ExecuteAlterColumnTypeWithCheck(data AlterColumnType
return buf.String(), nil
}
// ExecuteAlterColumnDefault executes the alter column default template
func (te *TemplateExecutor) ExecuteAlterColumnDefault(data AlterColumnDefaultData) (string, error) {
// ExecuteAlterColumnDefaultWithCheck executes the guarded alter column
// default template shared by the full-schema writer and the diff-based
// migration writer.
func (te *TemplateExecutor) ExecuteAlterColumnDefaultWithCheck(data AlterColumnDefaultWithCheckData) (string, error) {
var buf bytes.Buffer
err := te.templates.ExecuteTemplate(&buf, "alter_column_default.tmpl", data)
err := te.templates.ExecuteTemplate(&buf, "alter_column_default_with_check.tmpl", data)
if err != nil {
return "", fmt.Errorf("failed to execute alter_column_default template: %w", err)
return "", fmt.Errorf("failed to execute alter_column_default_with_check template: %w", err)
}
return buf.String(), nil
}
// ExecuteAlterColumnNullabilityWithCheck executes the guarded alter column
// nullability template.
func (te *TemplateExecutor) ExecuteAlterColumnNullabilityWithCheck(data AlterColumnNullabilityWithCheckData) (string, error) {
var buf bytes.Buffer
err := te.templates.ExecuteTemplate(&buf, "alter_column_nullability_with_check.tmpl", data)
if err != nil {
return "", fmt.Errorf("failed to execute alter_column_nullability_with_check template: %w", err)
}
return buf.String(), nil
}
@@ -517,7 +531,7 @@ func BuildCreateTableData(schemaName string, table *models.Table) CreateTableDat
}
if col.Default != nil {
if value, ok := col.Default.(string); ok {
colData.Default = writers.QuoteDefaultValue(value, col.Type)
colData.Default = writers.QuoteDefaultValue(stripBackticks(value), col.Type)
} else {
colData.Default = fmt.Sprintf("%v", col.Default)
}
@@ -545,7 +559,7 @@ func BuildAuditFunctionData(
// Build list of audited columns
auditedColumns := make([]*models.Column, 0)
for _, col := range table.Columns {
for _, col := range sortColumns(table.Columns) {
if col.Name == pk.Name {
continue
}
@@ -1,7 +0,0 @@
{{- if .SetDefault -}}
ALTER TABLE {{qual_table .SchemaName .TableName}}
ALTER COLUMN {{quote_ident .ColumnName}} SET DEFAULT {{.DefaultValue}};
{{- else -}}
ALTER TABLE {{qual_table .SchemaName .TableName}}
ALTER COLUMN {{quote_ident .ColumnName}} DROP DEFAULT;
{{- end -}}
@@ -0,0 +1,29 @@
DO $$
DECLARE
current_default text;
BEGIN
SELECT pg_catalog.pg_get_expr(d.adbin, d.adrelid)
INTO current_default
FROM pg_attribute a
JOIN pg_class t ON t.oid = a.attrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
WHERE n.nspname = '{{.SchemaName}}'
AND t.relname = '{{.TableName}}'
AND a.attname = '{{.ColumnName}}'
AND a.attnum > 0
AND NOT a.attisdropped;
{{- if .SetDefault }}
IF current_default IS DISTINCT FROM {{quote .DefaultValue}} THEN
ALTER TABLE {{qual_table .SchemaName .TableName}}
ALTER COLUMN {{quote_ident .ColumnName}} SET DEFAULT {{.DefaultValue}};
END IF;
{{- else }}
IF current_default IS NOT NULL THEN
ALTER TABLE {{qual_table .SchemaName .TableName}}
ALTER COLUMN {{quote_ident .ColumnName}} DROP DEFAULT;
END IF;
{{- end }}
END;
$$;
@@ -0,0 +1,26 @@
DO $$
DECLARE
current_not_null boolean;
BEGIN
SELECT a.attnotnull
INTO current_not_null
FROM pg_attribute a
JOIN pg_class t ON t.oid = a.attrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = '{{.SchemaName}}'
AND t.relname = '{{.TableName}}'
AND a.attname = '{{.ColumnName}}'
AND a.attnum > 0
AND NOT a.attisdropped;
IF current_not_null IS NOT NULL AND current_not_null IS DISTINCT FROM {{.NotNull}} THEN
{{- if .NotNull }}
ALTER TABLE {{qual_table .SchemaName .TableName}}
ALTER COLUMN {{quote_ident .ColumnName}} SET NOT NULL;
{{- else }}
ALTER TABLE {{qual_table .SchemaName .TableName}}
ALTER COLUMN {{quote_ident .ColumnName}} DROP NOT NULL;
{{- end }}
END IF;
END;
$$;
@@ -1,2 +0,0 @@
ALTER TABLE {{qual_table .SchemaName .TableName}}
ALTER COLUMN {{quote_ident .ColumnName}} TYPE {{.NewType}}{{if .UsingExpr}} USING {{.UsingExpr}}{{end}};
@@ -1,6 +1,7 @@
DO $$
DECLARE
current_type text;
renamed_column text;
BEGIN
SELECT pg_catalog.format_type(a.atttypid, a.atttypmod)
INTO current_type
@@ -15,8 +16,15 @@ BEGIN
IF current_type IS NOT NULL
AND current_type <> ALL(ARRAY[{{.EquivalentTypes}}]) THEN
ALTER TABLE {{qual_table .SchemaName .TableName}}
ALTER COLUMN {{quote_ident .ColumnName}} TYPE {{.NewType}}{{if .UsingExpr}} USING {{.UsingExpr}}{{end}};
BEGIN
ALTER TABLE {{qual_table .SchemaName .TableName}}
ALTER COLUMN {{quote_ident .ColumnName}} TYPE {{.NewType}}{{if .UsingExpr}} USING {{.UsingExpr}}{{end}};
EXCEPTION WHEN OTHERS THEN
renamed_column := '{{.ColumnName}}_' || trim(both '_' from regexp_replace(lower(current_type), '[^a-z0-9]+', '_', 'g'));
EXECUTE format('ALTER TABLE {{qual_table .SchemaName .TableName}} RENAME COLUMN {{quote_ident .ColumnName}} TO %I', renamed_column);
ALTER TABLE {{qual_table .SchemaName .TableName}}
ADD COLUMN {{quote_ident .ColumnName}} {{.NewType}};
END;
END IF;
END;
$$;
@@ -1,2 +1,2 @@
CREATE {{if .Unique}}UNIQUE {{end}}INDEX IF NOT EXISTS {{quote_ident .IndexName}}
CREATE {{if .Unique}}UNIQUE {{end}}INDEX {{if .Concurrent}}CONCURRENTLY {{end}}IF NOT EXISTS {{quote_ident .IndexName}}
ON {{qual_table .SchemaName .TableName}} USING {{.IndexType}} ({{.Columns}});
+166 -10
View File
@@ -199,7 +199,7 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro
for _, table := range schema.Tables {
// First check for explicit PrimaryKeyConstraint
var pkConstraint *models.Constraint
for _, constraint := range table.Constraints {
for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type == models.PrimaryKeyConstraint {
pkConstraint = constraint
break
@@ -255,7 +255,7 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro
// Phase 5: Indexes
for _, table := range schema.Tables {
for _, index := range table.Indexes {
for _, index := range sortIndexes(table.Indexes) {
// Skip primary key indexes
if strings.HasSuffix(index.Name, "_pkey") {
continue
@@ -298,7 +298,7 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro
// Phase 5.5: Unique constraints
for _, table := range schema.Tables {
for _, constraint := range table.Constraints {
for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type != models.UniqueConstraint {
continue
}
@@ -321,7 +321,7 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro
// Phase 5.7: Check constraints
for _, table := range schema.Tables {
for _, constraint := range table.Constraints {
for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type != models.CheckConstraint {
continue
}
@@ -344,7 +344,7 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro
// Phase 6: Foreign keys
for _, table := range schema.Tables {
for _, constraint := range table.Constraints {
for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type != models.ForeignKeyConstraint {
continue
}
@@ -394,7 +394,7 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro
statements = append(statements, stmt)
}
for _, column := range table.Columns {
for _, column := range sortColumns(table.Columns) {
if column.Comment != "" {
stmt := fmt.Sprintf("COMMENT ON COLUMN %s.%s IS '%s'",
w.qualTable(schema.SQLName(), table.SQLName()), column.SQLName(), escapeQuote(column.Comment))
@@ -475,6 +475,75 @@ func (w *Writer) GenerateAlterColumnTypeStatements(schema *models.Schema) ([]str
return statements, nil
}
// GenerateAlterColumnDefaultStatements generates guarded ALTER TABLE
// statements to bring existing columns' DEFAULT clause in line with the
// model, safe to run against a database that already has the columns.
func (w *Writer) GenerateAlterColumnDefaultStatements(schema *models.Schema) ([]string, error) {
statements := []string{}
statements = append(statements, fmt.Sprintf("-- Alter column defaults for schema: %s", schema.Name))
for _, table := range schema.Tables {
columns := getSortedColumns(table.Columns)
for _, col := range columns {
setDefault, defaultVal := formatColumnDefaultSQL(col)
stmt, err := w.executor.ExecuteAlterColumnDefaultWithCheck(AlterColumnDefaultWithCheckData{
SchemaName: schema.Name,
TableName: table.Name,
ColumnName: col.Name,
SetDefault: setDefault,
DefaultValue: defaultVal,
})
if err != nil {
return nil, fmt.Errorf("failed to generate alter column default for %s.%s.%s: %w", schema.Name, table.Name, col.Name, err)
}
statements = append(statements, stmt)
}
}
return statements, nil
}
// formatColumnDefaultSQL renders a column's model-level default into the
// SQL literal/expression used by ALTER COLUMN ... SET DEFAULT, shared by
// the full-schema writer and the diff-based migration writer.
func formatColumnDefaultSQL(col *models.Column) (setDefault bool, defaultVal string) {
if col.Default == nil {
return false, ""
}
if value, ok := col.Default.(string); ok {
return true, writers.QuoteDefaultValue(stripBackticks(value), col.Type)
}
return true, fmt.Sprintf("%v", col.Default)
}
// GenerateAlterColumnNullabilityStatements generates guarded ALTER TABLE
// statements to bring existing columns' NOT NULL state in line with the
// model, safe to run against a database that already has the columns.
func (w *Writer) GenerateAlterColumnNullabilityStatements(schema *models.Schema) ([]string, error) {
statements := []string{}
statements = append(statements, fmt.Sprintf("-- Alter column nullability for schema: %s", schema.Name))
for _, table := range schema.Tables {
columns := getSortedColumns(table.Columns)
for _, col := range columns {
stmt, err := w.executor.ExecuteAlterColumnNullabilityWithCheck(AlterColumnNullabilityWithCheckData{
SchemaName: schema.Name,
TableName: table.Name,
ColumnName: col.Name,
NotNull: col.NotNull,
})
if err != nil {
return nil, fmt.Errorf("failed to generate alter column nullability for %s.%s.%s: %w", schema.Name, table.Name, col.Name, err)
}
statements = append(statements, stmt)
}
}
return statements, nil
}
// GenerateAddColumnsForDatabase generates ALTER TABLE ADD COLUMN statements for the entire database
func (w *Writer) GenerateAddColumnsForDatabase(db *models.Database) ([]string, error) {
statements := []string{}
@@ -641,6 +710,14 @@ func (w *Writer) WriteSchema(schema *models.Schema) error {
return err
}
if err := w.writeAlterColumnDefaults(schema); err != nil {
return err
}
if err := w.writeAlterColumnNullability(schema); err != nil {
return err
}
// Phase 4: Create primary keys (priority 160)
if err := w.writePrimaryKeys(schema); err != nil {
return err
@@ -859,6 +936,36 @@ func (w *Writer) writeAlterColumnTypes(schema *models.Schema) error {
return nil
}
func (w *Writer) writeAlterColumnDefaults(schema *models.Schema) error {
fmt.Fprintf(w.writer, "-- Alter column defaults for schema: %s\n", schema.Name)
statements, err := w.GenerateAlterColumnDefaultStatements(schema)
if err != nil {
return err
}
for _, stmt := range statements[1:] {
fmt.Fprint(w.writer, stmt)
fmt.Fprint(w.writer, "\n")
}
return nil
}
func (w *Writer) writeAlterColumnNullability(schema *models.Schema) error {
fmt.Fprintf(w.writer, "-- Alter column nullability for schema: %s\n", schema.Name)
statements, err := w.GenerateAlterColumnNullabilityStatements(schema)
if err != nil {
return err
}
for _, stmt := range statements[1:] {
fmt.Fprint(w.writer, stmt)
fmt.Fprint(w.writer, "\n")
}
return nil
}
// writePrimaryKeys generates ALTER TABLE statements for primary keys
func (w *Writer) writePrimaryKeys(schema *models.Schema) error {
fmt.Fprintf(w.writer, "-- Primary keys for schema: %s\n", schema.Name)
@@ -866,10 +973,9 @@ func (w *Writer) writePrimaryKeys(schema *models.Schema) error {
for _, table := range schema.Tables {
// Find primary key constraint
var pkConstraint *models.Constraint
for name, constraint := range table.Constraints {
for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type == models.PrimaryKeyConstraint {
pkConstraint = constraint
_ = name // Use the name variable
break
}
}
@@ -991,8 +1097,13 @@ func (w *Writer) writeIndexes(schema *models.Schema) error {
whereClause = fmt.Sprintf(" WHERE %s", index.Where)
}
fmt.Fprintf(w.writer, "CREATE %sINDEX IF NOT EXISTS %s\n",
unique, indexName)
concurrently := ""
if index.Concurrent {
concurrently = "CONCURRENTLY "
}
fmt.Fprintf(w.writer, "CREATE %sINDEX %sIF NOT EXISTS %s\n",
unique, concurrently, indexName)
fmt.Fprintf(w.writer, " ON %s USING %s (%s)%s;\n\n",
w.qualTable(schema.SQLName(), table.SQLName()), indexType, strings.Join(columnExprs, ", "), whereClause)
}
@@ -1475,6 +1586,51 @@ func resolveIndexColumn(table *models.Table, colName string) (*models.Column, bo
return nil, false
}
// sortColumns returns columns sorted by Sequence then Name for deterministic output.
func sortColumns(columns map[string]*models.Column) []*models.Column {
result := make([]*models.Column, 0, len(columns))
for _, col := range columns {
result = append(result, col)
}
sort.Slice(result, func(i, j int) bool {
if result[i].Sequence > 0 && result[j].Sequence > 0 {
return result[i].Sequence < result[j].Sequence
}
return result[i].Name < result[j].Name
})
return result
}
// sortConstraints returns constraints sorted by Sequence then Name for deterministic output.
func sortConstraints(constraints map[string]*models.Constraint) []*models.Constraint {
result := make([]*models.Constraint, 0, len(constraints))
for _, c := range constraints {
result = append(result, c)
}
sort.Slice(result, func(i, j int) bool {
if result[i].Sequence > 0 && result[j].Sequence > 0 {
return result[i].Sequence < result[j].Sequence
}
return result[i].Name < result[j].Name
})
return result
}
// sortIndexes returns indexes sorted by Sequence then Name for deterministic output.
func sortIndexes(indexes map[string]*models.Index) []*models.Index {
result := make([]*models.Index, 0, len(indexes))
for _, idx := range indexes {
result = append(result, idx)
}
sort.Slice(result, func(i, j int) bool {
if result[i].Sequence > 0 && result[j].Sequence > 0 {
return result[i].Sequence < result[j].Sequence
}
return result[i].Name < result[j].Name
})
return result
}
// formatStringList formats a list of strings as a SQL-safe comma-separated quoted list
func formatStringList(items []string) string {
quoted := make([]string, len(items))
+173
View File
@@ -87,6 +87,41 @@ func TestWriteDatabase(t *testing.T) {
}
}
func TestWriteDatabase_ConcurrentIndex(t *testing.T) {
db := models.InitDatabase("testdb")
schema := models.InitSchema("public")
table := models.InitTable("users", "public")
emailCol := models.InitColumn("email", "users", "public")
emailCol.Type = "text"
table.Columns["email"] = emailCol
concurrentIndex := &models.Index{
Name: "idx_users_email",
Columns: []string{"email"},
Concurrent: true,
}
table.Indexes["idx_users_email"] = concurrentIndex
schema.Tables = append(schema.Tables, table)
db.Schemas = append(db.Schemas, schema)
var buf bytes.Buffer
writer := NewWriter(&writers.WriterOptions{})
writer.writer = &buf
if err := writer.WriteDatabase(db); err != nil {
t.Fatalf("WriteDatabase failed: %v", err)
}
output := buf.String()
if !strings.Contains(output, "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email") {
t.Errorf("Output missing CONCURRENTLY index creation:\n%s", output)
}
}
func TestWriteDatabase_GinIndexOnTextArrayDoesNotUseTrigramOperatorClass(t *testing.T) {
db := models.InitDatabase("testdb")
schema := models.InitSchema("public")
@@ -1106,6 +1141,144 @@ func TestWriteSchema_EmitsGuardedAlterColumnTypeStatements(t *testing.T) {
}
}
func TestWriteSchema_EmitsGuardedAlterColumnDefaultStatements(t *testing.T) {
db := models.InitDatabase("testdb")
schema := models.InitSchema("public")
table := models.InitTable("agent_skills", "public")
statusCol := models.InitColumn("status", "agent_skills", "public")
statusCol.Type = "text"
statusCol.Default = "active"
table.Columns["status"] = statusCol
schema.Tables = append(schema.Tables, table)
db.Schemas = append(db.Schemas, schema)
var buf bytes.Buffer
writer := NewWriter(&writers.WriterOptions{})
writer.writer = &buf
if err := writer.WriteDatabase(db); err != nil {
t.Fatalf("WriteDatabase failed: %v", err)
}
output := buf.String()
if !strings.Contains(output, "-- Alter column defaults for schema: public") {
t.Fatalf("expected alter column default section, got:\n%s", output)
}
if !strings.Contains(output, "pg_get_expr(d.adbin, d.adrelid)") {
t.Fatalf("expected guarded live-default check, got:\n%s", output)
}
if !strings.Contains(output, "ALTER COLUMN status SET DEFAULT 'active'") {
t.Fatalf("expected guarded SET DEFAULT for status column, got:\n%s", output)
}
}
func TestWriteSchema_AlterColumnDefaultStripsBackticksFromFunctionExpression(t *testing.T) {
db := models.InitDatabase("testdb")
schema := models.InitSchema("public")
table := models.InitTable("agent_skills", "public")
updatedAtCol := models.InitColumn("updatedat", "agent_skills", "public")
updatedAtCol.Type = "timestamp"
updatedAtCol.Default = "`now()`"
table.Columns["updatedat"] = updatedAtCol
schema.Tables = append(schema.Tables, table)
db.Schemas = append(db.Schemas, schema)
var buf bytes.Buffer
writer := NewWriter(&writers.WriterOptions{})
writer.writer = &buf
if err := writer.WriteDatabase(db); err != nil {
t.Fatalf("WriteDatabase failed: %v", err)
}
output := buf.String()
if strings.Contains(output, "`") {
t.Fatalf("expected no backticks in generated SQL, got:\n%s", output)
}
if !strings.Contains(output, "ALTER COLUMN updatedat SET DEFAULT now()") {
t.Fatalf("expected guarded SET DEFAULT now() without backticks, got:\n%s", output)
}
}
func TestWriteSchema_GuardedAlterColumnTypeFallsBackOnConversionFailure(t *testing.T) {
db := models.InitDatabase("testdb")
schema := models.InitSchema("public")
table := models.InitTable("agent_skills", "public")
nameCol := models.InitColumn("name", "agent_skills", "public")
nameCol.Type = "integer"
table.Columns["name"] = nameCol
schema.Tables = append(schema.Tables, table)
db.Schemas = append(db.Schemas, schema)
var buf bytes.Buffer
writer := NewWriter(&writers.WriterOptions{})
writer.writer = &buf
if err := writer.WriteDatabase(db); err != nil {
t.Fatalf("WriteDatabase failed: %v", err)
}
output := buf.String()
if !strings.Contains(output, "EXCEPTION WHEN OTHERS THEN") {
t.Fatalf("expected guarded alter to fall back on conversion failure, got:\n%s", output)
}
if !strings.Contains(output, "renamed_column := 'name_' || trim(both '_' from regexp_replace(lower(current_type)") {
t.Fatalf("expected fallback to derive a renamed column name from the live type, got:\n%s", output)
}
if !strings.Contains(output, "RENAME COLUMN name TO %I") {
t.Fatalf("expected fallback to rename the existing column, got:\n%s", output)
}
if !strings.Contains(output, "ADD COLUMN name integer") {
t.Fatalf("expected fallback to add a fresh column with the new type, got:\n%s", output)
}
}
func TestWriteSchema_EmitsGuardedAlterColumnNullabilityStatements(t *testing.T) {
db := models.InitDatabase("testdb")
schema := models.InitSchema("origin")
table := models.InitTable("service_instance", "origin")
typeCol := models.InitColumn("rid_service_instance_type", "service_instance", "origin")
typeCol.Type = "text"
typeCol.NotNull = false
table.Columns["rid_service_instance_type"] = typeCol
schema.Tables = append(schema.Tables, table)
db.Schemas = append(db.Schemas, schema)
var buf bytes.Buffer
writer := NewWriter(&writers.WriterOptions{})
writer.writer = &buf
if err := writer.WriteDatabase(db); err != nil {
t.Fatalf("WriteDatabase failed: %v", err)
}
output := buf.String()
if !strings.Contains(output, "-- Alter column nullability for schema: origin") {
t.Fatalf("expected alter column nullability section, got:\n%s", output)
}
if !strings.Contains(output, "a.attnotnull") {
t.Fatalf("expected guarded live-nullability check, got:\n%s", output)
}
if !strings.Contains(output, "current_not_null IS DISTINCT FROM false") {
t.Fatalf("expected guard comparing live nullability against desired value, got:\n%s", output)
}
if !strings.Contains(output, "ALTER COLUMN rid_service_instance_type DROP NOT NULL") {
t.Fatalf("expected guarded DROP NOT NULL for nullable column, got:\n%s", output)
}
}
func TestWriteSchema_UsesStorageTypeForSerialAlterStatements(t *testing.T) {
db := models.InitDatabase("testdb")
schema := models.InitSchema("public")
+32 -2
View File
@@ -549,14 +549,14 @@ func (w *Writer) generateBlockAttributes(table *models.Table) string {
}
// @@unique for multi-column unique constraints
for _, constraint := range table.Constraints {
for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type == models.UniqueConstraint && len(constraint.Columns) > 1 {
fmt.Fprintf(&sb, " @@unique([%s])\n", strings.Join(constraint.Columns, ", "))
}
}
// @@index for indexes
for _, index := range table.Indexes {
for _, index := range sortIndexes(table.Indexes) {
if !index.Unique { // Unique indexes are handled by @@unique
fmt.Fprintf(&sb, " @@index([%s])\n", strings.Join(index.Columns, ", "))
}
@@ -564,3 +564,33 @@ func (w *Writer) generateBlockAttributes(table *models.Table) string {
return sb.String()
}
// sortConstraints returns constraints sorted by Sequence then Name for deterministic output.
func sortConstraints(constraints map[string]*models.Constraint) []*models.Constraint {
result := make([]*models.Constraint, 0, len(constraints))
for _, c := range constraints {
result = append(result, c)
}
sort.Slice(result, func(i, j int) bool {
if result[i].Sequence > 0 && result[j].Sequence > 0 {
return result[i].Sequence < result[j].Sequence
}
return result[i].Name < result[j].Name
})
return result
}
// sortIndexes returns indexes sorted by Sequence then Name for deterministic output.
func sortIndexes(indexes map[string]*models.Index) []*models.Index {
result := make([]*models.Index, 0, len(indexes))
for _, idx := range indexes {
result = append(result, idx)
}
sort.Slice(result, func(i, j int) bool {
if result[i].Sequence > 0 && result[j].Sequence > 0 {
return result[i].Sequence < result[j].Sequence
}
return result[i].Name < result[j].Name
})
return result
}
+33 -26
View File
@@ -4,13 +4,14 @@ SQLite DDL (Data Definition Language) writer for RelSpec. Converts database sche
## Features
- **Automatic Schema Flattening** - SQLite doesn't support PostgreSQL-style schemas, so table names are automatically flattened (e.g., `public.users``public_users`)
- **Schema Flattening** - SQLite doesn't support PostgreSQL-style schemas. Non-default schema names are flattened into table name prefixes (e.g., `auth.sessions``auth_sessions`); the default schema (`public`/`main`) is left as bare table names (e.g., `public.users``users`)
- **Type Mapping** - Converts PostgreSQL data types to SQLite type affinities (TEXT, INTEGER, REAL, NUMERIC, BLOB)
- **Auto-Increment Detection** - Automatically converts SERIAL types and auto-increment columns to `INTEGER PRIMARY KEY AUTOINCREMENT`
- **Function Translation** - Converts PostgreSQL functions to SQLite equivalents (e.g., `now()``CURRENT_TIMESTAMP`)
- **Boolean Handling** - Maps boolean values to INTEGER (true=1, false=0)
- **Constraint Generation** - Creates indexes, unique constraints, and documents foreign keys
- **Constraint Generation** - Creates indexes, unique constraints, and inline `FOREIGN KEY` clauses in `CREATE TABLE`
- **Identifier Quoting** - Properly quotes identifiers using double quotes
- **Direct Execution** - Can execute the generated DDL directly against a `.db` file instead of writing a `.sql` script (see below)
## Usage
@@ -30,15 +31,26 @@ relspec convert --from dbml --from-path schema.dbml \
### Multi-Schema Databases
SQLite doesn't support schemas, so multi-schema databases are automatically flattened:
SQLite doesn't support schemas, so multi-schema databases are automatically flattened. The default schema (`public`/`main`) keeps bare table names; other schemas are prefixed to avoid collisions:
```bash
# Input has auth.users and public.posts
# Output will have auth_users and public_posts
# Output will have auth_users and posts
relspec convert --from json --from-path multi_schema.json \
--to sqlite --to-path flattened.sql
```
### Direct Execution Against a Database File
`relspec merge` can execute the generated DDL directly against a SQLite file instead of writing a `.sql` script, by passing the file path as `--output-conn`:
```bash
relspec merge --source dbml --source-path schema.dbml \
--output sqlite --output-conn ./app.db
```
Passing `--output-conn` opens `./app.db` and applies the schema directly; passing `--output-path` instead (or omitting `--output-conn`) writes a `.sql` script as before.
## Type Mapping
| PostgreSQL Type | SQLite Affinity | Examples |
@@ -87,17 +99,17 @@ CREATE TABLE "users" (
## Foreign Keys
Foreign keys are generated as commented-out ALTER TABLE statements for reference:
SQLite has no `ALTER TABLE ADD CONSTRAINT`, so foreign keys are generated as inline `FOREIGN KEY` clauses inside `CREATE TABLE`, exactly as SQLite requires:
```sql
-- Foreign key: fk_posts_user_id
-- ALTER TABLE "posts" ADD CONSTRAINT "posts_fk_posts_user_id"
-- FOREIGN KEY ("user_id")
-- REFERENCES "users" ("id");
-- Note: Foreign keys should be defined in CREATE TABLE for better SQLite compatibility
CREATE TABLE "posts" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"user_id" INTEGER NOT NULL,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
```
For production use, define foreign keys directly in the CREATE TABLE statement or execute the ALTER TABLE commands after creating all tables.
`PRAGMA foreign_keys = ON;` is emitted at the top of the output (and executed first in direct-execution mode) so these constraints are actually enforced.
## Constraints
@@ -112,11 +124,10 @@ Generated SQL follows this order:
1. Header comments
2. `PRAGMA foreign_keys = ON;`
3. CREATE TABLE statements (sorted by schema, then table)
3. CREATE TABLE statements (sorted by schema, then table), with primary keys and foreign keys defined inline
4. CREATE INDEX statements
5. CREATE UNIQUE INDEX statements (for unique constraints)
6. Check constraint comments
7. Foreign key comments
## Example
@@ -145,7 +156,7 @@ CREATE TABLE public.posts (
-- SQLite Database Schema
-- Database: mydb
-- Generated by RelSpec
-- Note: Schema names have been flattened (e.g., public.users -> public_users)
-- Note: SQLite has no schema concept; non-default schema names are flattened into table name prefixes (e.g., auth.sessions -> auth_sessions)
-- Enable foreign key constraints
PRAGMA foreign_keys = ON;
@@ -160,22 +171,17 @@ CREATE TABLE "auth_users" (
CREATE UNIQUE INDEX "auth_users_users_username_key" ON "auth_users" ("username");
-- Schema: public (flattened into table names)
CREATE TABLE "public_posts" (
CREATE TABLE "posts" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"user_id" INTEGER NOT NULL,
"title" TEXT NOT NULL,
"published" INTEGER DEFAULT 0
"published" INTEGER DEFAULT 0,
FOREIGN KEY ("user_id") REFERENCES "auth_users" ("id")
);
-- Foreign key: posts_user_id_fkey
-- ALTER TABLE "public_posts" ADD CONSTRAINT "public_posts_posts_user_id_fkey"
-- FOREIGN KEY ("user_id")
-- REFERENCES "auth_users" ("id");
-- Note: Foreign keys should be defined in CREATE TABLE for better SQLite compatibility
```
Note that `public.posts` becomes bare `posts` (the default schema isn't prefixed), while `auth.users` becomes `auth_users` (a non-default schema is), and the foreign key to `auth_users` is defined inline rather than as a separate statement.
## Programmatic Usage
```go
@@ -208,8 +214,9 @@ func main() {
## Notes
- Schema flattening is **always enabled** for SQLite output (cannot be disabled)
- Schema flattening is **always enabled** for SQLite output (cannot be disabled); the default schema (`public`/`main`) produces bare table names, other schemas are prefixed
- Constraint and index names are prefixed with the flattened table name to avoid collisions
- Generated SQL is compatible with SQLite 3.x
- Foreign key constraints require `PRAGMA foreign_keys = ON;` to be enforced
- Foreign key constraints require `PRAGMA foreign_keys = ON;` to be enforced, which is emitted (and, in direct-execution mode, run) before any `CREATE TABLE`
- Setting `Metadata["connection_string"]` to a `.db` file path (or passing `--output-conn` to `relspec merge`) executes the DDL directly against that file instead of writing a `.sql` script
- For complex schemas, review and test the generated SQL before use in production
+93 -25
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"embed"
"fmt"
"sort"
"text/template"
"git.warky.dev/wdevs/relspecgo/pkg/models"
@@ -39,10 +40,22 @@ func NewTemplateExecutor(opts *writers.WriterOptions) (*TemplateExecutor, error)
// TableTemplateData contains data for table template
type TableTemplateData struct {
Schema string
Name string
Columns []*models.Column
PrimaryKey *models.Constraint
Schema string
Name string
Columns []*models.Column
PrimaryKey *models.Constraint
ForeignKeys []ForeignKeyTemplateData
}
// ForeignKeyTemplateData contains data for an inline FOREIGN KEY clause
type ForeignKeyTemplateData struct {
Name string
Columns []string
ForeignSchema string
ForeignTable string
ForeignColumns []string
OnDelete string
OnUpdate string
}
// IndexTemplateData contains data for index template
@@ -119,29 +132,15 @@ func (te *TemplateExecutor) ExecuteCreateCheckConstraint(data ConstraintTemplate
return buf.String(), nil
}
// ExecuteCreateForeignKey executes the create foreign key template
func (te *TemplateExecutor) ExecuteCreateForeignKey(data ConstraintTemplateData) (string, error) {
var buf bytes.Buffer
err := te.templates.ExecuteTemplate(&buf, "create_foreign_key.tmpl", data)
if err != nil {
return "", fmt.Errorf("failed to execute create_foreign_key template: %w", err)
}
return buf.String(), nil
}
// Helper functions to build template data from models
// BuildTableTemplateData builds TableTemplateData from a models.Table
func BuildTableTemplateData(schema string, table *models.Table) TableTemplateData {
// Get sorted columns
columns := make([]*models.Column, 0, len(table.Columns))
for _, col := range table.Columns {
columns = append(columns, col)
}
columns := sortColumns(table.Columns)
// Find primary key constraint
var pk *models.Constraint
for _, constraint := range table.Constraints {
for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type == models.PrimaryKeyConstraint {
pk = constraint
break
@@ -151,7 +150,7 @@ func BuildTableTemplateData(schema string, table *models.Table) TableTemplateDat
// If no explicit primary key constraint, build one from columns with IsPrimaryKey=true
if pk == nil {
pkCols := []string{}
for _, col := range table.Columns {
for _, col := range columns {
if col.IsPrimaryKey {
pkCols = append(pkCols, col.Name)
}
@@ -165,10 +164,79 @@ func BuildTableTemplateData(schema string, table *models.Table) TableTemplateDat
}
}
// Collect foreign keys for inline FOREIGN KEY clauses
var fks []ForeignKeyTemplateData
for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type != models.ForeignKeyConstraint {
continue
}
refSchema := tableSchemaName(constraint.ReferencedSchema)
if refSchema == "" {
refSchema = schema
}
fks = append(fks, ForeignKeyTemplateData{
Name: constraint.Name,
Columns: constraint.Columns,
ForeignSchema: refSchema,
ForeignTable: constraint.ReferencedTable,
ForeignColumns: constraint.ReferencedColumns,
OnDelete: constraint.OnDelete,
OnUpdate: constraint.OnUpdate,
})
}
return TableTemplateData{
Schema: schema,
Name: table.Name,
Columns: columns,
PrimaryKey: pk,
Schema: schema,
Name: table.Name,
Columns: columns,
PrimaryKey: pk,
ForeignKeys: fks,
}
}
// sortColumns returns columns sorted by Sequence then Name for deterministic output.
func sortColumns(columns map[string]*models.Column) []*models.Column {
result := make([]*models.Column, 0, len(columns))
for _, col := range columns {
result = append(result, col)
}
sort.Slice(result, func(i, j int) bool {
if result[i].Sequence > 0 && result[j].Sequence > 0 {
return result[i].Sequence < result[j].Sequence
}
return result[i].Name < result[j].Name
})
return result
}
// sortConstraints returns constraints sorted by Sequence then Name for deterministic output.
func sortConstraints(constraints map[string]*models.Constraint) []*models.Constraint {
result := make([]*models.Constraint, 0, len(constraints))
for _, c := range constraints {
result = append(result, c)
}
sort.Slice(result, func(i, j int) bool {
if result[i].Sequence > 0 && result[j].Sequence > 0 {
return result[i].Sequence < result[j].Sequence
}
return result[i].Name < result[j].Name
})
return result
}
// sortIndexes returns indexes sorted by Sequence then Name for deterministic output.
func sortIndexes(indexes map[string]*models.Index) []*models.Index {
result := make([]*models.Index, 0, len(indexes))
for _, idx := range indexes {
result = append(result, idx)
}
sort.Slice(result, func(i, j int) bool {
if result[i].Sequence > 0 && result[j].Sequence > 0 {
return result[i].Sequence < result[j].Sequence
}
return result[i].Name < result[j].Name
})
return result
}
@@ -1,6 +0,0 @@
-- Foreign key: {{.Name}}
-- ALTER TABLE {{quote_ident (qualified_table_name .Schema .Table)}} ADD CONSTRAINT {{quote_ident (format_constraint_name .Schema .Table .Name)}}
-- FOREIGN KEY ({{range $i, $col := .Columns}}{{if $i}}, {{end}}{{quote_ident $col}}{{end}})
-- REFERENCES {{quote_ident (qualified_table_name .ForeignSchema .ForeignTable)}} ({{range $i, $col := .ForeignColumns}}{{if $i}}, {{end}}{{quote_ident $col}}{{end}})
-- {{if .OnDelete}}ON DELETE {{.OnDelete}}{{end}}{{if .OnUpdate}} ON UPDATE {{.OnUpdate}}{{end}};
-- Note: Foreign keys should be defined in CREATE TABLE for better SQLite compatibility
@@ -6,4 +6,7 @@ CREATE TABLE {{quote_ident (qualified_table_name .Schema .Name)}} (
{{- if and .PrimaryKey (not $hasAutoIncrement)}}{{if gt (len .Columns) 0}},{{end}}
PRIMARY KEY ({{range $i, $colName := .PrimaryKey.Columns}}{{if $i}}, {{end}}{{quote_ident $colName}}{{end}})
{{- end}}
{{- range .ForeignKeys}},
FOREIGN KEY ({{range $i, $col := .Columns}}{{if $i}}, {{end}}{{quote_ident $col}}{{end}}) REFERENCES {{quote_ident (qualified_table_name .ForeignSchema .ForeignTable)}} ({{range $i, $col := .ForeignColumns}}{{if $i}}, {{end}}{{quote_ident $col}}{{end}}){{if .OnDelete}} ON DELETE {{.OnDelete}}{{end}}{{if .OnUpdate}} ON UPDATE {{.OnUpdate}}{{end}}
{{- end}}
);
+121 -54
View File
@@ -1,11 +1,15 @@
package sqlite
import (
"context"
"database/sql"
"fmt"
"io"
"os"
"strings"
_ "modernc.org/sqlite" // SQLite driver
"git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/writers"
)
@@ -30,8 +34,16 @@ func NewWriter(options *writers.WriterOptions) *Writer {
}
}
// WriteDatabase writes the entire database schema as SQLite SQL
// WriteDatabase writes the entire database schema as SQLite SQL.
//
// If Metadata["connection_string"] is set (a path to a SQLite database file),
// the generated DDL is executed directly against that file instead of being
// written out as a .sql script.
func (w *Writer) WriteDatabase(db *models.Database) error {
if dbPath, ok := w.options.Metadata["connection_string"].(string); ok && dbPath != "" {
return w.executeDatabaseSQL(db, dbPath)
}
var writer io.Writer
var file *os.File
var err error
@@ -52,12 +64,16 @@ func (w *Writer) WriteDatabase(db *models.Database) error {
}
w.writer = writer
return w.writeContent(db)
}
// writeContent writes the header, pragma, and every schema's DDL to w.writer.
func (w *Writer) writeContent(db *models.Database) error {
// Write header comment
fmt.Fprintf(w.writer, "-- SQLite Database Schema\n")
fmt.Fprintf(w.writer, "-- Database: %s\n", db.Name)
fmt.Fprintf(w.writer, "-- Generated by RelSpec\n")
fmt.Fprintf(w.writer, "-- Note: Schema names have been flattened (e.g., public.users -> public_users)\n\n")
fmt.Fprintf(w.writer, "-- Note: SQLite has no schema concept; non-default schema names are flattened into table name prefixes (e.g., auth.sessions -> auth_sessions)\n\n")
// Enable foreign keys
pragma, err := w.executor.ExecutePragmaForeignKeys()
@@ -76,48 +92,134 @@ func (w *Writer) WriteDatabase(db *models.Database) error {
return nil
}
// statementCollector captures each Write call as a single SQL statement (or
// comment line), matching the writer's convention of one Fprintf per statement.
type statementCollector struct {
statements []string
}
func (c *statementCollector) Write(p []byte) (int, error) {
if s := strings.TrimSpace(string(p)); s != "" {
c.statements = append(c.statements, s)
}
return len(p), nil
}
// executeDatabaseSQL generates the DDL for db and executes it directly
// against the SQLite database file at dbPath.
func (w *Writer) executeDatabaseSQL(db *models.Database, dbPath string) error {
collector := &statementCollector{}
w.writer = collector
if err := w.writeContent(db); err != nil {
return fmt.Errorf("failed to generate SQL statements: %w", err)
}
conn, err := sql.Open("sqlite", dbPath)
if err != nil {
return fmt.Errorf("failed to open sqlite database %q: %w", dbPath, err)
}
defer conn.Close()
ctx := context.Background()
ignoreErrors := false
if val, ok := w.options.Metadata["ignore_errors"].(bool); ok {
ignoreErrors = val
}
total, executed := 0, 0
var execErrors []string
for _, stmt := range collector.statements {
if strings.HasPrefix(stmt, "--") {
continue
}
total++
if _, err := conn.ExecContext(ctx, stmt); err != nil {
execErrors = append(execErrors, fmt.Sprintf("statement %d (%s): %v", total, truncateStatement(stmt), err))
if !ignoreErrors {
break
}
continue
}
executed++
}
w.options.Metadata["execution_total"] = total
w.options.Metadata["execution_success"] = executed
w.options.Metadata["execution_failed"] = len(execErrors)
if len(execErrors) > 0 {
return fmt.Errorf("failed to execute %d/%d statement(s) against %q:\n%s", len(execErrors), total, dbPath, strings.Join(execErrors, "\n"))
}
return nil
}
// truncateStatement shortens a SQL statement for error messages.
func truncateStatement(stmt string) string {
const maxLen = 80
stmt = strings.Join(strings.Fields(stmt), " ")
if len(stmt) > maxLen {
return stmt[:maxLen] + "..."
}
return stmt
}
// defaultSchemaNames are treated as "no schema" for SQLite output: SQLite has
// no schema concept, and a lone default schema (e.g. DBML's implicit "public")
// should produce bare table names rather than a "public_" prefix.
var defaultSchemaNames = map[string]bool{
"public": true,
"main": true,
}
// tableSchemaName returns the schema name to use for table/constraint naming,
// collapsing default schema names to "" so they aren't prefixed onto table names.
func tableSchemaName(schema string) string {
if defaultSchemaNames[strings.ToLower(schema)] {
return ""
}
return schema
}
// WriteSchema writes a single schema as SQLite SQL
func (w *Writer) WriteSchema(schema *models.Schema) error {
// SQLite doesn't have schemas, so we just write a comment
if schema.Name != "" {
tableSchema := tableSchemaName(schema.Name)
// SQLite doesn't have schemas, so we just write a comment (skip for the
// default schema, since its tables aren't actually being prefixed)
if tableSchema != "" {
fmt.Fprintf(w.writer, "-- Schema: %s (flattened into table names)\n\n", schema.Name)
}
// Phase 1: Create tables
for _, table := range schema.Tables {
if err := w.writeTable(schema.Name, table); err != nil {
if err := w.writeTable(tableSchema, table); err != nil {
return fmt.Errorf("failed to write table %s: %w", table.Name, err)
}
}
// Phase 2: Create indexes
for _, table := range schema.Tables {
if err := w.writeIndexes(schema.Name, table); err != nil {
if err := w.writeIndexes(tableSchema, table); err != nil {
return fmt.Errorf("failed to write indexes for table %s: %w", table.Name, err)
}
}
// Phase 3: Create unique constraints (as unique indexes)
for _, table := range schema.Tables {
if err := w.writeUniqueConstraints(schema.Name, table); err != nil {
if err := w.writeUniqueConstraints(tableSchema, table); err != nil {
return fmt.Errorf("failed to write unique constraints for table %s: %w", table.Name, err)
}
}
// Phase 4: Check constraints (as comments, since SQLite requires them in CREATE TABLE)
for _, table := range schema.Tables {
if err := w.writeCheckConstraints(schema.Name, table); err != nil {
if err := w.writeCheckConstraints(tableSchema, table); err != nil {
return fmt.Errorf("failed to write check constraints for table %s: %w", table.Name, err)
}
}
// Phase 5: Foreign keys (as comments for compatibility)
for _, table := range schema.Tables {
if err := w.writeForeignKeys(schema.Name, table); err != nil {
return fmt.Errorf("failed to write foreign keys for table %s: %w", table.Name, err)
}
}
return nil
}
@@ -143,7 +245,7 @@ func (w *Writer) writeTable(schema string, table *models.Table) error {
// writeIndexes writes indexes for a table
func (w *Writer) writeIndexes(schema string, table *models.Table) error {
for _, index := range table.Indexes {
for _, index := range sortIndexes(table.Indexes) {
// Skip primary key indexes
if strings.HasSuffix(index.Name, "_pkey") {
continue
@@ -174,7 +276,7 @@ func (w *Writer) writeIndexes(schema string, table *models.Table) error {
// writeUniqueConstraints writes unique constraints as unique indexes
func (w *Writer) writeUniqueConstraints(schema string, table *models.Table) error {
for _, constraint := range table.Constraints {
for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type != models.UniqueConstraint {
continue
}
@@ -195,7 +297,7 @@ func (w *Writer) writeUniqueConstraints(schema string, table *models.Table) erro
}
// Also handle unique indexes from the Indexes map
for _, index := range table.Indexes {
for _, index := range sortIndexes(table.Indexes) {
if !index.Unique {
continue
}
@@ -232,7 +334,7 @@ func (w *Writer) writeUniqueConstraints(schema string, table *models.Table) erro
// writeCheckConstraints writes check constraints as comments
func (w *Writer) writeCheckConstraints(schema string, table *models.Table) error {
for _, constraint := range table.Constraints {
for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type != models.CheckConstraint {
continue
}
@@ -254,38 +356,3 @@ func (w *Writer) writeCheckConstraints(schema string, table *models.Table) error
return nil
}
// writeForeignKeys writes foreign keys as comments
func (w *Writer) writeForeignKeys(schema string, table *models.Table) error {
for _, constraint := range table.Constraints {
if constraint.Type != models.ForeignKeyConstraint {
continue
}
refSchema := constraint.ReferencedSchema
if refSchema == "" {
refSchema = schema
}
data := ConstraintTemplateData{
Schema: schema,
Table: table.Name,
Name: constraint.Name,
Columns: constraint.Columns,
ForeignSchema: refSchema,
ForeignTable: constraint.ReferencedTable,
ForeignColumns: constraint.ReferencedColumns,
OnDelete: constraint.OnDelete,
OnUpdate: constraint.OnUpdate,
}
sql, err := w.executor.ExecuteCreateForeignKey(data)
if err != nil {
return fmt.Errorf("failed to execute create foreign key template: %w", err)
}
fmt.Fprintf(w.writer, "%s\n", sql)
}
return nil
}
+10 -5
View File
@@ -85,8 +85,11 @@ func TestWriteDatabase(t *testing.T) {
t.Error("Expected CREATE TABLE statement")
}
if !strings.Contains(output, "\"public_users\"") {
t.Error("Expected flattened table name public_users")
if !strings.Contains(output, "\"users\"") {
t.Error("Expected bare table name users (default schema should not be prefixed)")
}
if strings.Contains(output, "\"public_users\"") {
t.Error("Did not expect flattened table name public_users for the default public schema")
}
if !strings.Contains(output, "INTEGER PRIMARY KEY AUTOINCREMENT") {
@@ -322,13 +325,15 @@ func TestWriteSchema_MultiSchema(t *testing.T) {
output := buf.String()
// Check for flattened table names from both schemas
// Non-default schemas are still prefixed to avoid name collisions...
if !strings.Contains(output, "\"auth_sessions\"") {
t.Error("Expected flattened table name auth_sessions")
}
if !strings.Contains(output, "\"public_posts\"") {
t.Error("Expected flattened table name public_posts")
// ...but the default "public" schema is not, since it's typically the
// only schema and bare names read better (and match e.g. DBML output).
if !strings.Contains(output, "\"posts\"") {
t.Error("Expected bare table name posts")
}
}
+7 -1
View File
@@ -531,7 +531,13 @@ func (w *Writer) generateInverseRelations(table *models.Table, schema *models.Sc
// generateManyToManyRelations generates @ManyToMany fields
func (w *Writer) generateManyToManyRelations(table *models.Table, schema *models.Schema, joinTables map[string]bool, sb *strings.Builder) {
for joinTableName := range joinTables {
joinTableNames := make([]string, 0, len(joinTables))
for name := range joinTables {
joinTableNames = append(joinTableNames, name)
}
sort.Strings(joinTableNames)
for _, joinTableName := range joinTableNames {
joinTable := w.findTable(joinTableName, schema)
if joinTable == nil {
continue
+26
View File
@@ -148,6 +148,26 @@ func SanitizeFilename(name string) string {
// Examples (boolean): "true" → "true"
// Examples (bigint): "0" → "0"
// Examples (timestamp): "now()" → "now()" (function call never quoted)
// bareKeywordDefaults are PostgreSQL default-value keywords that are
// expressions, not string literals, even though they contain no
// parentheses (e.g. "CURRENT_DATE" rather than "now()"). They must never be
// wrapped in quotes.
var bareKeywordDefaults = map[string]bool{
"current_date": true,
"current_time": true,
"current_timestamp": true,
"localtime": true,
"localtimestamp": true,
"current_user": true,
"session_user": true,
"current_role": true,
"current_catalog": true,
"current_schema": true,
"null": true,
"true": true,
"false": true,
}
func QuoteDefaultValue(value, sqlType string) string {
value = strings.TrimSpace(value)
@@ -158,6 +178,12 @@ func QuoteDefaultValue(value, sqlType string) string {
return value
}
// Bare keyword expressions (e.g. CURRENT_DATE) are never quoted,
// regardless of column type.
if bareKeywordDefaults[strings.ToLower(value)] {
return value
}
// Normalise the SQL type: lowercase, strip length/precision suffix.
baseType := strings.ToLower(strings.TrimSpace(sqlType))
if idx := strings.Index(baseType, "("); idx > 0 {
+18
View File
@@ -41,6 +41,24 @@ func TestQuoteDefaultValue(t *testing.T) {
sqlType: "timestamptz",
want: "now()",
},
{
name: "bare keyword default CURRENT_DATE is not quoted",
value: "CURRENT_DATE",
sqlType: "date",
want: "CURRENT_DATE",
},
{
name: "bare keyword default is case insensitive",
value: "current_timestamp",
sqlType: "timestamptz",
want: "current_timestamp",
},
{
name: "bare keyword default localtime is not quoted",
value: "LOCALTIME",
sqlType: "time",
want: "LOCALTIME",
},
}
for _, tt := range tests {