Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16af529120 | ||
|
|
7fb343596a | ||
|
|
241bfc2302 | ||
|
|
92d5df9a64 | ||
|
|
b440d50b66 | ||
|
|
052d6f5fac | ||
|
|
9066d36e71 | ||
|
|
76b8321065 | ||
|
|
2b6bb7f948 | ||
|
|
51b63f659e | ||
|
|
ae0efdc008 | ||
|
|
be08c8199f | ||
|
|
5ba20e0581 |
@@ -222,6 +222,7 @@ jobs:
|
|||||||
PKGDIR="relspec_${PKGVER}_${GOARCH}"
|
PKGDIR="relspec_${PKGVER}_${GOARCH}"
|
||||||
mkdir -p "${PKGDIR}/DEBIAN"
|
mkdir -p "${PKGDIR}/DEBIAN"
|
||||||
mkdir -p "${PKGDIR}/usr/bin"
|
mkdir -p "${PKGDIR}/usr/bin"
|
||||||
|
chmod -R 0755 "${PKGDIR}"
|
||||||
|
|
||||||
install -m755 relspec "${PKGDIR}/usr/bin/relspec"
|
install -m755 relspec "${PKGDIR}/usr/bin/relspec"
|
||||||
|
|
||||||
|
|||||||
+17
-5
@@ -16,6 +16,7 @@ import (
|
|||||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/drawdb"
|
"git.warky.dev/wdevs/relspecgo/pkg/readers/drawdb"
|
||||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/json"
|
"git.warky.dev/wdevs/relspecgo/pkg/readers/json"
|
||||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/pgsql"
|
"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/sqlite"
|
||||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/yaml"
|
"git.warky.dev/wdevs/relspecgo/pkg/readers/yaml"
|
||||||
)
|
)
|
||||||
@@ -87,11 +88,11 @@ Examples:
|
|||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
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(&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(&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(&targetPath, "to-path", "", "Target file path (for file-based formats)")
|
||||||
diffCmd.Flags().StringVar(&targetConn, "to-conn", "", "Target connection string (for database 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)
|
fmt.Fprintf(os.Stderr, " ✓ Successfully read database '%s'\n", sourceDB.Name)
|
||||||
sourceTables := 0
|
sourceTables := 0
|
||||||
|
sourceScripts := 0
|
||||||
for _, schema := range sourceDB.Schemas {
|
for _, schema := range sourceDB.Schemas {
|
||||||
sourceTables += len(schema.Tables)
|
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
|
// Read target database
|
||||||
fmt.Fprintf(os.Stderr, "[2/3] Reading target schema...\n")
|
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)
|
fmt.Fprintf(os.Stderr, " ✓ Successfully read database '%s'\n", targetDB.Name)
|
||||||
targetTables := 0
|
targetTables := 0
|
||||||
|
targetScripts := 0
|
||||||
for _, schema := range targetDB.Schemas {
|
for _, schema := range targetDB.Schemas {
|
||||||
targetTables += len(schema.Tables)
|
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
|
// Compare databases
|
||||||
fmt.Fprintf(os.Stderr, "[3/3] Comparing schemas...\n")
|
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.Tables.Missing + summary.Tables.Extra + summary.Tables.Modified +
|
||||||
summary.Columns.Missing + summary.Columns.Extra + summary.Columns.Modified +
|
summary.Columns.Missing + summary.Columns.Extra + summary.Columns.Modified +
|
||||||
summary.Indexes.Missing + summary.Indexes.Extra + summary.Indexes.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, " ✓ Comparison complete\n")
|
||||||
fmt.Fprintf(os.Stderr, " Found: %d difference(s)\n\n", totalDiffs)
|
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})
|
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":
|
case "pgsql", "postgres", "postgresql":
|
||||||
if connString == "" {
|
if connString == "" {
|
||||||
return nil, fmt.Errorf("%s: connection string is required for PostgreSQL format", label)
|
return nil, fmt.Errorf("%s: connection string is required for PostgreSQL format", label)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
printVersionHeader(os.Args[1:])
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
+10
-2
@@ -117,7 +117,7 @@ func init() {
|
|||||||
// Output flags
|
// 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(&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(&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
|
// Merge options
|
||||||
mergeCmd.Flags().BoolVar(&mergeSkipDomains, "skip-domains", false, "Skip domains during merge")
|
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))
|
writer = wtypeorm.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", "", false))
|
||||||
case "sqlite", "sqlite3":
|
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":
|
case "pgsql":
|
||||||
writerOpts := newWriterOptions(filePath, "", flattenSchema, "", "", false)
|
writerOpts := newWriterOptions(filePath, "", flattenSchema, "", "", false)
|
||||||
if connString != "" {
|
if connString != "" {
|
||||||
|
|||||||
+19
-3
@@ -13,6 +13,7 @@ var (
|
|||||||
version = "dev"
|
version = "dev"
|
||||||
buildDate = "unknown"
|
buildDate = "unknown"
|
||||||
prisma7 bool
|
prisma7 bool
|
||||||
|
noVersion bool
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -54,9 +55,6 @@ bidirectional conversion between various database schema formats.
|
|||||||
It reads database schemas from multiple sources (live databases, DBML,
|
It reads database schemas from multiple sources (live databases, DBML,
|
||||||
DCTX, DrawDB, etc.) and writes them to various formats (GORM, Bun,
|
DCTX, DrawDB, etc.) and writes them to various formats (GORM, Bun,
|
||||||
JSON, YAML, SQL, etc.).`,
|
JSON, YAML, SQL, etc.).`,
|
||||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
|
||||||
fmt.Printf("RelSpec %s (built: %s)\n\n", version, buildDate)
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -72,4 +70,22 @@ func init() {
|
|||||||
rootCmd.AddCommand(versionCmd)
|
rootCmd.AddCommand(versionCmd)
|
||||||
rootCmd.AddCommand(reportCmd)
|
rootCmd.AddCommand(reportCmd)
|
||||||
rootCmd.PersistentFlags().BoolVar(&prisma7, "prisma7", false, "Use Prisma 7 generator conventions when reading/writing Prisma schemas")
|
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
@@ -1,6 +1,6 @@
|
|||||||
# Maintainer: Hein (Warky Devs) <hein@warky.dev>
|
# Maintainer: Hein (Warky Devs) <hein@warky.dev>
|
||||||
pkgname=relspec
|
pkgname=relspec
|
||||||
pkgver=1.0.69
|
pkgver=1.0.73
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs."
|
pkgdesc="RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs."
|
||||||
arch=('x86_64' 'aarch64')
|
arch=('x86_64' 'aarch64')
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
Name: relspec
|
Name: relspec
|
||||||
Version: 1.0.69
|
Version: 1.0.73
|
||||||
Release: 1%{?dist}
|
Release: 1%{?dist}
|
||||||
Summary: RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs.
|
Summary: RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package diff
|
package diff
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
@@ -96,6 +97,13 @@ func compareSchemaDetails(source, target *models.Schema) *SchemaChange {
|
|||||||
hasChanges = true
|
hasChanges = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compare scripts
|
||||||
|
scriptDiff := compareScripts(source.Scripts, target.Scripts)
|
||||||
|
if !isEmpty(scriptDiff) {
|
||||||
|
change.Scripts = scriptDiff
|
||||||
|
hasChanges = true
|
||||||
|
}
|
||||||
|
|
||||||
if !hasChanges {
|
if !hasChanges {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -543,6 +551,79 @@ func compareSequenceDetails(source, target *models.Sequence) map[string]any {
|
|||||||
return changes
|
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
|
// Helper function to check if a diff is empty
|
||||||
func isEmpty(v any) bool {
|
func isEmpty(v any) bool {
|
||||||
switch d := v.(type) {
|
switch d := v.(type) {
|
||||||
@@ -560,6 +641,8 @@ func isEmpty(v any) bool {
|
|||||||
return len(d.Missing) == 0 && len(d.Extra) == 0 && len(d.Modified) == 0
|
return len(d.Missing) == 0 && len(d.Extra) == 0 && len(d.Modified) == 0
|
||||||
case *SequenceDiff:
|
case *SequenceDiff:
|
||||||
return len(d.Missing) == 0 && len(d.Extra) == 0 && len(d.Modified) == 0
|
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:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -616,6 +699,11 @@ func ComputeSummary(result *DiffResult) *Summary {
|
|||||||
summary.Sequences.Extra += len(schemaChange.Sequences.Extra)
|
summary.Sequences.Extra += len(schemaChange.Sequences.Extra)
|
||||||
summary.Sequences.Modified += len(schemaChange.Sequences.Modified)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -525,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) {
|
func TestIsEmpty(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -540,6 +612,8 @@ func TestIsEmpty(t *testing.T) {
|
|||||||
{"TableDiff with extra", &TableDiff{Missing: []*models.Table{}, Extra: []*models.Table{{Name: "users"}}, Modified: []*TableChange{}}, false},
|
{"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 ConstraintDiff", &ConstraintDiff{Missing: []*models.Constraint{}, Extra: []*models.Constraint{}, Modified: []*ConstraintChange{}}, true},
|
||||||
{"empty RelationshipDiff", &RelationshipDiff{Missing: []*models.Relationship{}, Extra: []*models.Relationship{}, Modified: []*RelationshipChange{}}, 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 {
|
for _, tt := range tests {
|
||||||
@@ -586,6 +660,26 @@ func TestComputeSummary(t *testing.T) {
|
|||||||
return s.Schemas.Missing == 1 && s.Schemas.Extra == 2 && s.Schemas.Modified == 1
|
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 {
|
for _, tt := range tests {
|
||||||
|
|||||||
+66
-1
@@ -158,6 +158,21 @@ func formatSummary(result *DiffResult, w io.Writer) error {
|
|||||||
fmt.Fprintf(w, "\n")
|
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
|
// Check if there are no differences
|
||||||
if summary.Schemas.Missing == 0 && summary.Schemas.Extra == 0 && summary.Schemas.Modified == 0 &&
|
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 &&
|
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.Constraints.Missing == 0 && summary.Constraints.Extra == 0 && summary.Constraints.Modified == 0 &&
|
||||||
summary.Relationships.Missing == 0 && summary.Relationships.Extra == 0 && summary.Relationships.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.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")
|
fmt.Fprintf(w, "No differences found.\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,6 +464,26 @@ const htmlTemplate = `<!DOCTYPE html>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -588,6 +624,35 @@ const htmlTemplate = `<!DOCTYPE html>
|
|||||||
</ul>
|
</ul>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{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>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -104,6 +104,26 @@ func TestFormatSummary(t *testing.T) {
|
|||||||
},
|
},
|
||||||
wantStr: []string{"Tables:", "Missing: 1", "Extra: 1", "Modified: 1"},
|
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 {
|
for _, tt := range tests {
|
||||||
@@ -237,6 +257,31 @@ func TestFormatHTML(t *testing.T) {
|
|||||||
"text",
|
"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 {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type SchemaChange struct {
|
|||||||
Tables *TableDiff `json:"tables,omitempty"`
|
Tables *TableDiff `json:"tables,omitempty"`
|
||||||
Views *ViewDiff `json:"views,omitempty"`
|
Views *ViewDiff `json:"views,omitempty"`
|
||||||
Sequences *SequenceDiff `json:"sequences,omitempty"`
|
Sequences *SequenceDiff `json:"sequences,omitempty"`
|
||||||
|
Scripts *ScriptDiff `json:"scripts,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableDiff represents differences in tables
|
// TableDiff represents differences in tables
|
||||||
@@ -131,6 +132,21 @@ type SequenceChange struct {
|
|||||||
Changes map[string]any `json:"changes"`
|
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
|
// Summary provides counts for quick overview
|
||||||
type Summary struct {
|
type Summary struct {
|
||||||
Schemas SchemaSummary `json:"schemas"`
|
Schemas SchemaSummary `json:"schemas"`
|
||||||
@@ -141,6 +157,7 @@ type Summary struct {
|
|||||||
Relationships RelationshipSummary `json:"relationships"`
|
Relationships RelationshipSummary `json:"relationships"`
|
||||||
Views ViewSummary `json:"views"`
|
Views ViewSummary `json:"views"`
|
||||||
Sequences SequenceSummary `json:"sequences"`
|
Sequences SequenceSummary `json:"sequences"`
|
||||||
|
Scripts ScriptSummary `json:"scripts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SchemaSummary struct {
|
type SchemaSummary struct {
|
||||||
@@ -190,3 +207,9 @@ type SequenceSummary struct {
|
|||||||
Extra int `json:"extra"`
|
Extra int `json:"extra"`
|
||||||
Modified int `json:"modified"`
|
Modified int `json:"modified"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ScriptSummary struct {
|
||||||
|
Missing int `json:"missing"`
|
||||||
|
Extra int `json:"extra"`
|
||||||
|
Modified int `json:"modified"`
|
||||||
|
}
|
||||||
|
|||||||
+6
-1
@@ -492,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
|
return typeName, length, precision, scale
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestMergeConstraints_NewConstraint(t *testing.T) {
|
||||||
target := &models.Database{
|
target := &models.Database{
|
||||||
Schemas: []*models.Schema{
|
Schemas: []*models.Schema{
|
||||||
|
|||||||
@@ -193,6 +193,28 @@ func IsKnownPGBaseType(baseType string) bool {
|
|||||||
return ok
|
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 {
|
func IsGoType(pTypeName string) bool {
|
||||||
for k := range GoToStdTypes {
|
for k := range GoToStdTypes {
|
||||||
if strings.EqualFold(pTypeName, k) {
|
if strings.EqualFold(pTypeName, k) {
|
||||||
|
|||||||
+70
-15
@@ -434,6 +434,7 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) {
|
|||||||
var currentSchema string
|
var currentSchema string
|
||||||
var inIndexes bool
|
var inIndexes bool
|
||||||
var inTable bool
|
var inTable bool
|
||||||
|
var columnSeq uint
|
||||||
|
|
||||||
tableRegex := regexp.MustCompile(`^Table\s+(.+?)\s*{`)
|
tableRegex := regexp.MustCompile(`^Table\s+(.+?)\s*{`)
|
||||||
refRegex := regexp.MustCompile(`^Ref:\s+(.+)`)
|
refRegex := regexp.MustCompile(`^Ref:\s+(.+)`)
|
||||||
@@ -469,6 +470,7 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) {
|
|||||||
currentTable = models.InitTable(tableName, currentSchema)
|
currentTable = models.InitTable(tableName, currentSchema)
|
||||||
inTable = true
|
inTable = true
|
||||||
inIndexes = false
|
inIndexes = false
|
||||||
|
columnSeq = 0
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,6 +499,17 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) {
|
|||||||
|
|
||||||
// Parse index definition
|
// Parse index definition
|
||||||
if inIndexes && currentTable != nil {
|
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)
|
index := r.parseIndex(line, currentTable.Name, currentSchema)
|
||||||
if index != nil {
|
if index != nil {
|
||||||
currentTable.Indexes[index.Name] = index
|
currentTable.Indexes[index.Name] = index
|
||||||
@@ -516,6 +529,8 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) {
|
|||||||
if inTable && !inIndexes && currentTable != nil {
|
if inTable && !inIndexes && currentTable != nil {
|
||||||
column, constraint := r.parseColumn(line, currentTable.Name, currentSchema)
|
column, constraint := r.parseColumn(line, currentTable.Name, currentSchema)
|
||||||
if column != nil {
|
if column != nil {
|
||||||
|
columnSeq++
|
||||||
|
column.Sequence = columnSeq
|
||||||
currentTable.Columns[column.Name] = column
|
currentTable.Columns[column.Name] = column
|
||||||
}
|
}
|
||||||
if constraint != nil {
|
if constraint != nil {
|
||||||
@@ -743,9 +758,10 @@ func stripWrappingQuotes(s string) string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseIndex parses a DBML index definition
|
// indexLineColumns extracts the column list from an Indexes-block entry,
|
||||||
func (r *Reader) parseIndex(line, tableName, schemaName string) *models.Index {
|
// e.g. "(col1, col2) [attrs]" or "columnname [attrs]", preserving
|
||||||
// Format: (columns) [attributes] OR columnname [attributes]
|
// declaration order.
|
||||||
|
func indexLineColumns(line string) []string {
|
||||||
var columns []string
|
var columns []string
|
||||||
|
|
||||||
// Find the attributes section to avoid parsing parentheses in notes/attributes
|
// 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 {
|
if len(columns) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -786,16 +852,7 @@ func (r *Reader) parseIndex(line, tableName, schemaName string) *models.Index {
|
|||||||
index.Columns = columns
|
index.Columns = columns
|
||||||
|
|
||||||
// Parse attributes
|
// Parse attributes
|
||||||
if strings.Contains(line, "[") && strings.Contains(line, "]") {
|
for _, attr := range indexLineAttrs(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" {
|
if attr == "unique" {
|
||||||
index.Unique = true
|
index.Unique = true
|
||||||
} else if strings.HasPrefix(attr, "name:") {
|
} else if strings.HasPrefix(attr, "name:") {
|
||||||
@@ -806,8 +863,6 @@ func (r *Reader) parseIndex(line, tableName, schemaName string) *models.Index {
|
|||||||
index.Type = strings.Trim(indexType, "'\"")
|
index.Type = strings.Trim(indexType, "'\"")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate name if not provided
|
// Generate name if not provided
|
||||||
if index.Name == "" {
|
if index.Name == "" {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ When `include_audit` is enabled, adds:
|
|||||||
- Constraint actions (CASCADE, RESTRICT, SET NULL)
|
- Constraint actions (CASCADE, RESTRICT, SET NULL)
|
||||||
- Partial indexes
|
- Partial indexes
|
||||||
- Function-based indexes
|
- Function-based indexes
|
||||||
|
- Concurrent index creation (`CREATE INDEX CONCURRENTLY`) via `Index.Concurrent`
|
||||||
- Check constraints with expressions
|
- Check constraints with expressions
|
||||||
|
|
||||||
## Data Types
|
## Data Types
|
||||||
|
|||||||
@@ -652,6 +652,7 @@ func (w *MigrationWriter) generateIndexScripts(model *models.Schema, current *mo
|
|||||||
IndexType: indexType,
|
IndexType: indexType,
|
||||||
Columns: strings.Join(columnExprs, ", "),
|
Columns: strings.Join(columnExprs, ", "),
|
||||||
Unique: modelIndex.Unique,
|
Unique: modelIndex.Unique,
|
||||||
|
Concurrent: modelIndex.Concurrent,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -334,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) {
|
func TestWriteMigration_GinIndexOnTextUsesTrigramOperatorClass(t *testing.T) {
|
||||||
current := models.InitDatabase("testdb")
|
current := models.InitDatabase("testdb")
|
||||||
currentSchema := models.InitSchema("public")
|
currentSchema := models.InitSchema("public")
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ type CreateIndexData struct {
|
|||||||
IndexType string
|
IndexType string
|
||||||
Columns string
|
Columns string
|
||||||
Unique bool
|
Unique bool
|
||||||
|
Concurrent bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateForeignKeyData contains data for create foreign key template
|
// CreateForeignKeyData contains data for create foreign key template
|
||||||
|
|||||||
@@ -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}});
|
ON {{qual_table .SchemaName .TableName}} USING {{.IndexType}} ({{.Columns}});
|
||||||
@@ -1097,8 +1097,13 @@ func (w *Writer) writeIndexes(schema *models.Schema) error {
|
|||||||
whereClause = fmt.Sprintf(" WHERE %s", index.Where)
|
whereClause = fmt.Sprintf(" WHERE %s", index.Where)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(w.writer, "CREATE %sINDEX IF NOT EXISTS %s\n",
|
concurrently := ""
|
||||||
unique, indexName)
|
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",
|
fmt.Fprintf(w.writer, " ON %s USING %s (%s)%s;\n\n",
|
||||||
w.qualTable(schema.SQLName(), table.SQLName()), indexType, strings.Join(columnExprs, ", "), whereClause)
|
w.qualTable(schema.SQLName(), table.SQLName()), indexType, strings.Join(columnExprs, ", "), whereClause)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestWriteDatabase_GinIndexOnTextArrayDoesNotUseTrigramOperatorClass(t *testing.T) {
|
||||||
db := models.InitDatabase("testdb")
|
db := models.InitDatabase("testdb")
|
||||||
schema := models.InitSchema("public")
|
schema := models.InitSchema("public")
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ SQLite DDL (Data Definition Language) writer for RelSpec. Converts database sche
|
|||||||
|
|
||||||
## Features
|
## 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)
|
- **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`
|
- **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`)
|
- **Function Translation** - Converts PostgreSQL functions to SQLite equivalents (e.g., `now()` → `CURRENT_TIMESTAMP`)
|
||||||
- **Boolean Handling** - Maps boolean values to INTEGER (true=1, false=0)
|
- **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
|
- **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
|
## Usage
|
||||||
|
|
||||||
@@ -30,15 +31,26 @@ relspec convert --from dbml --from-path schema.dbml \
|
|||||||
|
|
||||||
### Multi-Schema Databases
|
### 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
|
```bash
|
||||||
# Input has auth.users and public.posts
|
# 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 \
|
relspec convert --from json --from-path multi_schema.json \
|
||||||
--to sqlite --to-path flattened.sql
|
--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
|
## Type Mapping
|
||||||
|
|
||||||
| PostgreSQL Type | SQLite Affinity | Examples |
|
| PostgreSQL Type | SQLite Affinity | Examples |
|
||||||
@@ -87,17 +99,17 @@ CREATE TABLE "users" (
|
|||||||
|
|
||||||
## Foreign Keys
|
## 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
|
```sql
|
||||||
-- Foreign key: fk_posts_user_id
|
CREATE TABLE "posts" (
|
||||||
-- ALTER TABLE "posts" ADD CONSTRAINT "posts_fk_posts_user_id"
|
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
-- FOREIGN KEY ("user_id")
|
"user_id" INTEGER NOT NULL,
|
||||||
-- REFERENCES "users" ("id");
|
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||||
-- Note: Foreign keys should be defined in CREATE TABLE for better SQLite compatibility
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
## Constraints
|
||||||
|
|
||||||
@@ -112,11 +124,10 @@ Generated SQL follows this order:
|
|||||||
|
|
||||||
1. Header comments
|
1. Header comments
|
||||||
2. `PRAGMA foreign_keys = ON;`
|
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
|
4. CREATE INDEX statements
|
||||||
5. CREATE UNIQUE INDEX statements (for unique constraints)
|
5. CREATE UNIQUE INDEX statements (for unique constraints)
|
||||||
6. Check constraint comments
|
6. Check constraint comments
|
||||||
7. Foreign key comments
|
|
||||||
|
|
||||||
## Example
|
## Example
|
||||||
|
|
||||||
@@ -145,7 +156,7 @@ CREATE TABLE public.posts (
|
|||||||
-- SQLite Database Schema
|
-- SQLite Database Schema
|
||||||
-- Database: mydb
|
-- Database: mydb
|
||||||
-- Generated by RelSpec
|
-- 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
|
-- Enable foreign key constraints
|
||||||
PRAGMA foreign_keys = ON;
|
PRAGMA foreign_keys = ON;
|
||||||
@@ -160,22 +171,17 @@ CREATE TABLE "auth_users" (
|
|||||||
|
|
||||||
CREATE UNIQUE INDEX "auth_users_users_username_key" ON "auth_users" ("username");
|
CREATE UNIQUE INDEX "auth_users_users_username_key" ON "auth_users" ("username");
|
||||||
|
|
||||||
-- Schema: public (flattened into table names)
|
CREATE TABLE "posts" (
|
||||||
|
|
||||||
CREATE TABLE "public_posts" (
|
|
||||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
"user_id" INTEGER NOT NULL,
|
"user_id" INTEGER NOT NULL,
|
||||||
"title" TEXT 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
|
## Programmatic Usage
|
||||||
|
|
||||||
```go
|
```go
|
||||||
@@ -208,8 +214,9 @@ func main() {
|
|||||||
|
|
||||||
## Notes
|
## 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
|
- Constraint and index names are prefixed with the flattened table name to avoid collisions
|
||||||
- Generated SQL is compatible with SQLite 3.x
|
- 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
|
- For complex schemas, review and test the generated SQL before use in production
|
||||||
|
|||||||
@@ -44,6 +44,18 @@ type TableTemplateData struct {
|
|||||||
Name string
|
Name string
|
||||||
Columns []*models.Column
|
Columns []*models.Column
|
||||||
PrimaryKey *models.Constraint
|
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
|
// IndexTemplateData contains data for index template
|
||||||
@@ -120,16 +132,6 @@ func (te *TemplateExecutor) ExecuteCreateCheckConstraint(data ConstraintTemplate
|
|||||||
return buf.String(), nil
|
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
|
// Helper functions to build template data from models
|
||||||
|
|
||||||
// BuildTableTemplateData builds TableTemplateData from a models.Table
|
// BuildTableTemplateData builds TableTemplateData from a models.Table
|
||||||
@@ -162,11 +164,35 @@ 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{
|
return TableTemplateData{
|
||||||
Schema: schema,
|
Schema: schema,
|
||||||
Name: table.Name,
|
Name: table.Name,
|
||||||
Columns: columns,
|
Columns: columns,
|
||||||
PrimaryKey: pk,
|
PrimaryKey: pk,
|
||||||
|
ForeignKeys: fks,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}}
|
{{- 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}})
|
PRIMARY KEY ({{range $i, $colName := .PrimaryKey.Columns}}{{if $i}}, {{end}}{{quote_ident $colName}}{{end}})
|
||||||
{{- 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}}
|
||||||
);
|
);
|
||||||
|
|||||||
+117
-50
@@ -1,11 +1,15 @@
|
|||||||
package sqlite
|
package sqlite
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite" // SQLite driver
|
||||||
|
|
||||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
"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 {
|
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 writer io.Writer
|
||||||
var file *os.File
|
var file *os.File
|
||||||
var err error
|
var err error
|
||||||
@@ -52,12 +64,16 @@ func (w *Writer) WriteDatabase(db *models.Database) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
w.writer = writer
|
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
|
// Write header comment
|
||||||
fmt.Fprintf(w.writer, "-- SQLite Database Schema\n")
|
fmt.Fprintf(w.writer, "-- SQLite Database Schema\n")
|
||||||
fmt.Fprintf(w.writer, "-- Database: %s\n", db.Name)
|
fmt.Fprintf(w.writer, "-- Database: %s\n", db.Name)
|
||||||
fmt.Fprintf(w.writer, "-- Generated by RelSpec\n")
|
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
|
// Enable foreign keys
|
||||||
pragma, err := w.executor.ExecutePragmaForeignKeys()
|
pragma, err := w.executor.ExecutePragmaForeignKeys()
|
||||||
@@ -76,48 +92,134 @@ func (w *Writer) WriteDatabase(db *models.Database) error {
|
|||||||
return nil
|
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
|
// WriteSchema writes a single schema as SQLite SQL
|
||||||
func (w *Writer) WriteSchema(schema *models.Schema) error {
|
func (w *Writer) WriteSchema(schema *models.Schema) error {
|
||||||
// SQLite doesn't have schemas, so we just write a comment
|
tableSchema := tableSchemaName(schema.Name)
|
||||||
if 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)
|
fmt.Fprintf(w.writer, "-- Schema: %s (flattened into table names)\n\n", schema.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 1: Create tables
|
// Phase 1: Create tables
|
||||||
for _, table := range schema.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)
|
return fmt.Errorf("failed to write table %s: %w", table.Name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: Create indexes
|
// Phase 2: Create indexes
|
||||||
for _, table := range schema.Tables {
|
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)
|
return fmt.Errorf("failed to write indexes for table %s: %w", table.Name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 3: Create unique constraints (as unique indexes)
|
// Phase 3: Create unique constraints (as unique indexes)
|
||||||
for _, table := range schema.Tables {
|
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)
|
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)
|
// Phase 4: Check constraints (as comments, since SQLite requires them in CREATE TABLE)
|
||||||
for _, table := range schema.Tables {
|
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)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,38 +356,3 @@ func (w *Writer) writeCheckConstraints(schema string, table *models.Table) error
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeForeignKeys writes foreign keys as comments
|
|
||||||
func (w *Writer) writeForeignKeys(schema string, table *models.Table) error {
|
|
||||||
for _, constraint := range sortConstraints(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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -85,8 +85,11 @@ func TestWriteDatabase(t *testing.T) {
|
|||||||
t.Error("Expected CREATE TABLE statement")
|
t.Error("Expected CREATE TABLE statement")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.Contains(output, "\"public_users\"") {
|
if !strings.Contains(output, "\"users\"") {
|
||||||
t.Error("Expected flattened table name public_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") {
|
if !strings.Contains(output, "INTEGER PRIMARY KEY AUTOINCREMENT") {
|
||||||
@@ -322,13 +325,15 @@ func TestWriteSchema_MultiSchema(t *testing.T) {
|
|||||||
|
|
||||||
output := buf.String()
|
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\"") {
|
if !strings.Contains(output, "\"auth_sessions\"") {
|
||||||
t.Error("Expected flattened table name auth_sessions")
|
t.Error("Expected flattened table name auth_sessions")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.Contains(output, "\"public_posts\"") {
|
// ...but the default "public" schema is not, since it's typically the
|
||||||
t.Error("Expected flattened table name public_posts")
|
// 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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -148,6 +148,26 @@ func SanitizeFilename(name string) string {
|
|||||||
// Examples (boolean): "true" → "true"
|
// Examples (boolean): "true" → "true"
|
||||||
// Examples (bigint): "0" → "0"
|
// Examples (bigint): "0" → "0"
|
||||||
// Examples (timestamp): "now()" → "now()" (function call – never quoted)
|
// 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 {
|
func QuoteDefaultValue(value, sqlType string) string {
|
||||||
value = strings.TrimSpace(value)
|
value = strings.TrimSpace(value)
|
||||||
|
|
||||||
@@ -158,6 +178,12 @@ func QuoteDefaultValue(value, sqlType string) string {
|
|||||||
return value
|
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.
|
// Normalise the SQL type: lowercase, strip length/precision suffix.
|
||||||
baseType := strings.ToLower(strings.TrimSpace(sqlType))
|
baseType := strings.ToLower(strings.TrimSpace(sqlType))
|
||||||
if idx := strings.Index(baseType, "("); idx > 0 {
|
if idx := strings.Index(baseType, "("); idx > 0 {
|
||||||
|
|||||||
@@ -41,6 +41,24 @@ func TestQuoteDefaultValue(t *testing.T) {
|
|||||||
sqlType: "timestamptz",
|
sqlType: "timestamptz",
|
||||||
want: "now()",
|
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 {
|
for _, tt := range tests {
|
||||||
|
|||||||
Reference in New Issue
Block a user