fix: contain generated files and assets and harden SQL rendering
This commit is contained in:
+17
-28
@@ -94,6 +94,11 @@ func (w *Writer) WriteTable(t *models.Table) error {
|
||||
}
|
||||
|
||||
func (w *Writer) writeTable(s *models.Schema, t *models.Table) error {
|
||||
_, err := io.WriteString(w.writer, tableStatement(s, t))
|
||||
return err
|
||||
}
|
||||
|
||||
func tableStatement(s *models.Schema, t *models.Table) string {
|
||||
name := t.Name
|
||||
if s != nil {
|
||||
name = fmt.Sprintf("%s.%s", quote(s.Name), quote(t.Name))
|
||||
@@ -155,32 +160,27 @@ func (w *Writer) writeTable(s *models.Schema, t *models.Table) error {
|
||||
defs = append(defs, fmt.Sprintf(" CONSTRAINT %s UNIQUE (%s)", quote(c.Name), quoted(c.Columns)))
|
||||
}
|
||||
}
|
||||
sql := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (\n%s\n) ENGINE=InnoDB;\n\n", name, strings.Join(defs, ",\n"))
|
||||
if _, err := io.WriteString(w.writer, sql); err != nil {
|
||||
return err
|
||||
return fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (\n%s\n) ENGINE=InnoDB;\n\n", name, strings.Join(defs, ",\n"))
|
||||
}
|
||||
|
||||
func databaseStatements(db *models.Database) []string {
|
||||
var statements []string
|
||||
for _, schema := range db.Schemas {
|
||||
for _, table := range schema.Tables {
|
||||
statements = append(statements, tableStatement(schema, table))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return statements
|
||||
}
|
||||
|
||||
func (w *Writer) execute(dbm *models.Database, conn string) error {
|
||||
var b strings.Builder
|
||||
old := w.writer
|
||||
w.writer = &b
|
||||
if err := w.writeDatabaseDDL(dbm); err != nil {
|
||||
w.writer = old
|
||||
return err
|
||||
}
|
||||
w.writer = old
|
||||
statements := databaseStatements(dbm)
|
||||
db, err := sql.Open("mysql", conn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
for _, stmt := range strings.Split(b.String(), ";\n") {
|
||||
stmt = stripComments(strings.TrimSpace(stmt))
|
||||
if stmt == "" {
|
||||
continue
|
||||
}
|
||||
for _, stmt := range statements {
|
||||
if _, err := db.ExecContext(context.Background(), stmt); err != nil {
|
||||
return fmt.Errorf("failed to execute SQL: %w", err)
|
||||
}
|
||||
@@ -188,17 +188,6 @@ func (w *Writer) execute(dbm *models.Database, conn string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func stripComments(sqlText string) string {
|
||||
lines := strings.Split(sqlText, "\n")
|
||||
kept := lines[:0]
|
||||
for _, line := range lines {
|
||||
if !strings.HasPrefix(strings.TrimSpace(line), "--") {
|
||||
kept = append(kept, line)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(kept, "\n"))
|
||||
}
|
||||
|
||||
func quote(s string) string { return "`" + strings.ReplaceAll(s, "`", "``") + "`" }
|
||||
func quoted(xs []string) string {
|
||||
out := make([]string, len(xs))
|
||||
|
||||
@@ -9,13 +9,6 @@ import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
)
|
||||
|
||||
func TestStripComments(t *testing.T) {
|
||||
got := stripComments("-- header\nCREATE TABLE `users` (\n `id` INT\n);")
|
||||
if strings.HasPrefix(got, "--") || !strings.HasPrefix(got, "CREATE TABLE") {
|
||||
t.Fatalf("stripComments() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriterGeneratesMySQLDDL(t *testing.T) {
|
||||
db := models.InitDatabase("app")
|
||||
s := models.InitSchema("app")
|
||||
@@ -42,3 +35,26 @@ func TestWriterGeneratesMySQLDDL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseStatementsKeepEmbeddedDelimiters(t *testing.T) {
|
||||
db := models.InitDatabase("app")
|
||||
s := models.InitSchema("app")
|
||||
db.Schemas = append(db.Schemas, s)
|
||||
for _, name := range []string{"one", "two"} {
|
||||
table := models.InitTable(name, s.Name)
|
||||
col := models.InitColumn("text", name, s.Name)
|
||||
col.Type = "text"
|
||||
col.Default = "first;\n-- data, not a comment\nlast"
|
||||
table.Columns[col.Name] = col
|
||||
s.Tables = append(s.Tables, table)
|
||||
}
|
||||
statements := databaseStatements(db)
|
||||
if len(statements) != 2 {
|
||||
t.Fatalf("got %d statements", len(statements))
|
||||
}
|
||||
for _, stmt := range statements {
|
||||
if !strings.Contains(stmt, "first;\n-- data, not a comment\nlast") {
|
||||
t.Fatalf("default corrupted: %s", stmt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,28 @@ The PostgreSQL Writer converts RelSpec's internal database model representation
|
||||
- Includes audit triggers (optional)
|
||||
- Handles PostgreSQL-specific data types
|
||||
|
||||
## Identifier and value handling
|
||||
|
||||
Generated identifiers are always double-quoted, including reserved words, and
|
||||
retain the model's case by default. Supply raw model names, without SQL quoting.
|
||||
Identifiers containing double quotes are escaped by doubling them; NUL is rejected
|
||||
before database/file output. Catalog checks, comments, audit values, and regclass
|
||||
names use SQL literal quoting; generated procedural/function bodies choose dollar
|
||||
delimiters absent from their rendered content. Names in generated line comments
|
||||
cannot create new SQL lines.
|
||||
|
||||
Use CLI `--lowercase`, job option `lowercase_identifiers: true`, or PostgreSQL
|
||||
`WriterOptions.LowercaseIdentifiers: true` to lowercase model names and structured
|
||||
references. The transformation copies the model and rejects name collisions. Other
|
||||
library writers can use `transform.LowercaseIdentifiers` before writing.
|
||||
|
||||
SQL expressions are executable input: types, defaults, generated expressions,
|
||||
checks, index expressions/predicates, dialect directive clauses, audit user
|
||||
expressions, and `pgsql_using` / `pgsql_preflight` are not sanitized or case-folded.
|
||||
Adjust identifier references inside these expressions yourself when lowercasing.
|
||||
Enum values, comments, and other data retain their case. Migration matching respects
|
||||
identifier case; `"Users"` and `"users"` are distinct objects.
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
@@ -22,8 +22,9 @@ func TestCurrentColumnHasDescription(t *testing.T) {
|
||||
want bool
|
||||
}{
|
||||
{"nil table", nil, &models.Column{Name: "email", Description: "x"}, false},
|
||||
{"match ignoring case and whitespace", table, &models.Column{Name: "email", Description: "the email"}, true},
|
||||
{"different description", table, &models.Column{Name: "email", Description: "other"}, false},
|
||||
{"match ignoring whitespace", table, &models.Column{Name: "Email", Description: "the email"}, true},
|
||||
{"different description", table, &models.Column{Name: "Email", Description: "other"}, false},
|
||||
{"different identifier case", table, &models.Column{Name: "email", Description: "the email"}, false},
|
||||
{"column missing", table, &models.Column{Name: "age", Description: "x"}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
@@ -41,12 +42,12 @@ func TestExecuteCommentColumn(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := te.ExecuteCommentColumn(CommentColumnData{
|
||||
SchemaName: "public", TableName: "users", ColumnName: "email", Comment: "it''s",
|
||||
SchemaName: "public", TableName: "users", ColumnName: "email", Comment: "it's",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(got, "COMMENT ON COLUMN") || !strings.Contains(got, "public.users") ||
|
||||
if !strings.Contains(got, "COMMENT ON COLUMN") || !strings.Contains(got, "\"public\".\"users\"") ||
|
||||
!strings.Contains(got, "email") || !strings.Contains(got, "IS 'it''s';") {
|
||||
t.Errorf("unexpected output: %s", got)
|
||||
}
|
||||
|
||||
@@ -57,8 +57,8 @@ func TestDiffStatements(t *testing.T) {
|
||||
current: func() *models.Database {
|
||||
return diffTestDB("public", map[string]map[string]string{"users": {"id": "integer"}})
|
||||
},
|
||||
wantContain: []string{"CREATE TABLE IF NOT EXISTS public.posts"},
|
||||
wantAbsent: []string{"public.users"},
|
||||
wantContain: []string{"CREATE TABLE IF NOT EXISTS \"public\".\"posts\""},
|
||||
wantAbsent: []string{"\"public\".\"users\""},
|
||||
},
|
||||
{
|
||||
name: "missing column is added and existing columns are skipped",
|
||||
@@ -68,7 +68,7 @@ func TestDiffStatements(t *testing.T) {
|
||||
current: func() *models.Database {
|
||||
return diffTestDB("public", map[string]map[string]string{"users": {"id": "integer"}})
|
||||
},
|
||||
wantContain: []string{"ADD COLUMN IF NOT EXISTS email text"},
|
||||
wantContain: []string{"ADD COLUMN IF NOT EXISTS \"email\" text"},
|
||||
wantAbsent: []string{"COLUMN id"},
|
||||
},
|
||||
{
|
||||
@@ -79,7 +79,7 @@ func TestDiffStatements(t *testing.T) {
|
||||
current: func() *models.Database {
|
||||
return diffTestDB("public", map[string]map[string]string{"users": {"id": "integer"}})
|
||||
},
|
||||
wantContain: []string{"ALTER COLUMN id TYPE bigint"},
|
||||
wantContain: []string{"ALTER COLUMN \"id\" TYPE bigint"},
|
||||
},
|
||||
{
|
||||
name: "missing non-public schema is created before its tables",
|
||||
@@ -88,8 +88,8 @@ func TestDiffStatements(t *testing.T) {
|
||||
},
|
||||
current: func() *models.Database { return models.InitDatabase("testdb") },
|
||||
wantContain: []string{
|
||||
"CREATE SCHEMA IF NOT EXISTS app",
|
||||
"CREATE TABLE IF NOT EXISTS app.users",
|
||||
"CREATE SCHEMA IF NOT EXISTS \"app\"",
|
||||
"CREATE TABLE IF NOT EXISTS \"app\".\"users\"",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -149,7 +149,7 @@ func TestDiffStatements_NewTableCreatesPrimaryKeySequence(t *testing.T) {
|
||||
id := models.InitColumn("id", "users", "public")
|
||||
id.Type = "integer"
|
||||
id.IsPrimaryKey = true
|
||||
id.Default = "nextval('public.users_id_seq'::regclass)"
|
||||
id.Default = "nextval('\"public\".\"users_id_seq\"'::regclass)"
|
||||
table.Columns["id"] = id
|
||||
schema.Tables = append(schema.Tables, table)
|
||||
model.Schemas = append(model.Schemas, schema)
|
||||
@@ -160,7 +160,7 @@ func TestDiffStatements_NewTableCreatesPrimaryKeySequence(t *testing.T) {
|
||||
t.Fatalf("diffStatements failed: %v", err)
|
||||
}
|
||||
out := diffJoin(stmts)
|
||||
if !strings.Contains(out, "CREATE SEQUENCE IF NOT EXISTS public.users_id_seq") {
|
||||
if !strings.Contains(out, "CREATE SEQUENCE IF NOT EXISTS \"public\".\"users_id_seq\"") {
|
||||
t.Fatalf("expected sequence creation, got:\n%s", out)
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ func TestDiffStatements_NewIndexAndChangedIndexRecreated(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("diffStatements failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(diffJoin(stmts), "CREATE INDEX IF NOT EXISTS idx_users_lookup") {
|
||||
if !strings.Contains(diffJoin(stmts), "CREATE INDEX IF NOT EXISTS \"idx_users_lookup\"") {
|
||||
t.Fatalf("expected index creation, got:\n%s", diffJoin(stmts))
|
||||
}
|
||||
|
||||
@@ -329,7 +329,7 @@ func TestDiffStatements_ChangedPrimaryKeyIsRecreated(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("diffStatements failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(diffJoin(stmts), "DROP CONSTRAINT IF EXISTS pk_public_users") {
|
||||
if !strings.Contains(diffJoin(stmts), "DROP CONSTRAINT IF EXISTS \"pk_public_users\"") {
|
||||
t.Fatalf("expected old primary key to be dropped, got:\n%s", diffJoin(stmts))
|
||||
}
|
||||
}
|
||||
@@ -345,7 +345,7 @@ func TestDiffStatements_ChangedCommentEmitted(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("diffStatements failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(diffJoin(stmts), "COMMENT ON TABLE public.users IS 'new'") {
|
||||
if !strings.Contains(diffJoin(stmts), "COMMENT ON TABLE \"public\".\"users\" IS 'new'") {
|
||||
t.Fatalf("expected changed comment, got:\n%s", diffJoin(stmts))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ func TestPgDirectives_WriteDatabasePath(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
") PARTITION BY RANGE (created_at) TABLESPACE fast_data",
|
||||
"GENERATED ALWAYS AS IDENTITY",
|
||||
"WITH (fillfactor=90) TABLESPACE idx_space",
|
||||
"WITH (fillfactor=90) TABLESPACE \"idx_space\"",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("missing %q in:\n%s", want, out)
|
||||
@@ -78,7 +78,7 @@ func TestPgDirectives_WriteSchemaPath(t *testing.T) {
|
||||
if !strings.Contains(out, ") PARTITION BY RANGE (created_at) TABLESPACE fast_data;") {
|
||||
t.Errorf("table suffix missing from WriteSchema path:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "WITH (fillfactor=90) TABLESPACE idx_space") {
|
||||
if !strings.Contains(out, "WITH (fillfactor=90) TABLESPACE \"idx_space\"") {
|
||||
t.Errorf("index clauses missing from WriteSchema path:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,8 +88,8 @@ func TestGeneratedColumns_FullDDL(t *testing.T) {
|
||||
}
|
||||
out := diffJoin(stmts)
|
||||
for _, s := range []string{
|
||||
"full_name text GENERATED ALWAYS AS (first || ' ' || last) STORED",
|
||||
"seq bigint GENERATED ALWAYS AS IDENTITY NOT NULL",
|
||||
"\"full_name\" text GENERATED ALWAYS AS (first || ' ' || last) STORED",
|
||||
"\"seq\" bigint GENERATED ALWAYS AS IDENTITY NOT NULL",
|
||||
} {
|
||||
if !strings.Contains(out, s) {
|
||||
t.Errorf("missing %q:\n%s", s, out)
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package pgsql
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/transform"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
)
|
||||
|
||||
// prepareModel rejects NUL before creating files or executing SQL. SQL names
|
||||
// cannot contain NUL; pgx's identifier sanitizer would otherwise remove it.
|
||||
func prepareModel(db *models.Database, lower bool) (*models.Database, error) {
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("database is required")
|
||||
}
|
||||
seen := map[any]bool{}
|
||||
var validate func(reflect.Value) error
|
||||
validate = func(v reflect.Value) error {
|
||||
switch v.Kind() {
|
||||
case reflect.Pointer:
|
||||
if v.IsNil() || v.Type().Elem().PkgPath() != reflect.TypeOf(models.Database{}).PkgPath() {
|
||||
return nil
|
||||
}
|
||||
if seen[v.Interface()] {
|
||||
return nil
|
||||
}
|
||||
seen[v.Interface()] = true
|
||||
return validate(v.Elem())
|
||||
case reflect.Struct:
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
if v.Type().Field(i).Name == "Metadata" {
|
||||
continue
|
||||
}
|
||||
if err := validate(v.Field(i)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case reflect.String:
|
||||
if strings.ContainsRune(v.String(), 0) {
|
||||
return fmt.Errorf("PostgreSQL model contains a NUL byte")
|
||||
}
|
||||
case reflect.Slice:
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
if err := validate(v.Index(i)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case reflect.Map:
|
||||
if v.Type().Elem().Kind() == reflect.Pointer {
|
||||
iter := v.MapRange()
|
||||
for iter.Next() {
|
||||
if err := validate(iter.Value()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := validate(reflect.ValueOf(db)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if lower {
|
||||
return transform.LowercaseIdentifiers(db)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func (w *Writer) prepareSchema(schema *models.Schema) (*models.Schema, error) {
|
||||
db, err := prepareModel(&models.Database{Schemas: []*models.Schema{schema}}, w.options.LowercaseIdentifiers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db.Schemas[0], nil
|
||||
}
|
||||
|
||||
// regclassLastName decodes quoted identifiers in a qualified regclass spelling.
|
||||
func regclassLastName(text string) string {
|
||||
var name strings.Builder
|
||||
quoted := false
|
||||
for i := 0; i < len(text); i++ {
|
||||
c := text[i]
|
||||
if c == '"' {
|
||||
if quoted && i+1 < len(text) && text[i+1] == '"' {
|
||||
name.WriteByte('"')
|
||||
i++
|
||||
} else {
|
||||
quoted = !quoted
|
||||
}
|
||||
continue
|
||||
}
|
||||
if c == '.' && !quoted {
|
||||
name.Reset()
|
||||
continue
|
||||
}
|
||||
name.WriteByte(c)
|
||||
}
|
||||
return name.String()
|
||||
}
|
||||
|
||||
// quoteDefaultValue keeps explicit SQL expressions intact and uses the same
|
||||
// backslash-safe literal quoting as other PostgreSQL value contexts.
|
||||
func quoteDefaultValue(value, sqlType string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if len(trimmed) >= 3 && (trimmed[0] == 'E' || trimmed[0] == 'e') && trimmed[1] == '\'' && trimmed[len(trimmed)-1] == '\'' {
|
||||
return trimmed
|
||||
}
|
||||
formatted := writers.QuoteDefaultValue(value, sqlType)
|
||||
if formatted == "'"+strings.ReplaceAll(trimmed, "'", "''")+"'" {
|
||||
return quote(trimmed)
|
||||
}
|
||||
return formatted
|
||||
}
|
||||
@@ -61,6 +61,16 @@ func (w *MigrationWriter) GenerateScripts(model, current *models.Database) ([]Mi
|
||||
current = models.InitDatabase(model.Name)
|
||||
}
|
||||
|
||||
var err error
|
||||
model, err = prepareModel(model, w.options.LowercaseIdentifiers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current, err = prepareModel(current, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if audit is configured in metadata
|
||||
var auditConfig *AuditConfig
|
||||
if w.options.Metadata != nil {
|
||||
@@ -93,7 +103,7 @@ func (w *MigrationWriter) GenerateScripts(model, current *models.Database) ([]Mi
|
||||
if cs == nil {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(cs.Name, modelSchema.Name) {
|
||||
if cs.Name == modelSchema.Name {
|
||||
currentSchema = cs
|
||||
break
|
||||
}
|
||||
@@ -166,7 +176,7 @@ func (w *MigrationWriter) WriteMigration(model, current *models.Database) error
|
||||
// Write header
|
||||
fmt.Fprintf(w.writer, "-- PostgreSQL Migration Script\n")
|
||||
fmt.Fprintf(w.writer, "-- Generated by RelSpec\n")
|
||||
fmt.Fprintf(w.writer, "-- Source: %s -> %s\n", current.Name, model.Name)
|
||||
fmt.Fprintf(w.writer, "-- Source: %s -> %s\n", commentText(current.Name), commentText(model.Name))
|
||||
if w.options.ContinueOnError {
|
||||
fmt.Fprintf(w.writer, "\\set ON_ERROR_STOP off\n")
|
||||
}
|
||||
@@ -175,7 +185,7 @@ func (w *MigrationWriter) WriteMigration(model, current *models.Database) error
|
||||
// Write scripts
|
||||
for _, script := range scripts {
|
||||
fmt.Fprintf(w.writer, "-- Priority: %d | Type: %s | Object: %s\n",
|
||||
script.Priority, script.ObjectType, script.ObjectName)
|
||||
script.Priority, commentText(script.ObjectType), commentText(script.ObjectName))
|
||||
fmt.Fprintf(w.writer, "%s\n\n", script.Body)
|
||||
}
|
||||
|
||||
@@ -256,12 +266,12 @@ func (w *MigrationWriter) generateDropScripts(model, current *models.Schema) ([]
|
||||
// Build map of model tables for quick lookup
|
||||
modelTables := make(map[string]*models.Table)
|
||||
for _, table := range model.Tables {
|
||||
modelTables[strings.ToLower(table.Name)] = table
|
||||
modelTables[table.Name] = table
|
||||
}
|
||||
|
||||
// Find constraints to drop
|
||||
for _, currentTable := range current.Tables {
|
||||
modelTable, existsInModel := modelTables[strings.ToLower(currentTable.Name)]
|
||||
modelTable, existsInModel := modelTables[currentTable.Name]
|
||||
|
||||
if !existsInModel {
|
||||
continue
|
||||
@@ -296,7 +306,7 @@ func (w *MigrationWriter) generateDropScripts(model, current *models.Schema) ([]
|
||||
if refSchema == "" {
|
||||
refSchema = current.Name
|
||||
}
|
||||
if strings.EqualFold(refTable, currentTable.Name) && strings.EqualFold(refSchema, current.Name) {
|
||||
if (refTable == currentTable.Name) && (refSchema == current.Name) {
|
||||
fkKey := fmt.Sprintf("%s.%s.%s", current.Name, otherTable.Name, fkName)
|
||||
if !droppedFKs[fkKey] {
|
||||
droppedFKs[fkKey] = true
|
||||
@@ -395,13 +405,13 @@ func (w *MigrationWriter) generateTableScripts(model, current *models.Schema) ([
|
||||
currentTables := make(map[string]*models.Table)
|
||||
if current != nil {
|
||||
for _, table := range current.Tables {
|
||||
currentTables[strings.ToLower(table.Name)] = table
|
||||
currentTables[table.Name] = table
|
||||
}
|
||||
}
|
||||
|
||||
// Process each model table
|
||||
for _, modelTable := range model.Tables {
|
||||
currentTable, exists := currentTables[strings.ToLower(modelTable.Name)]
|
||||
currentTable, exists := currentTables[modelTable.Name]
|
||||
|
||||
if !exists {
|
||||
// Table doesn't exist, create it
|
||||
@@ -439,12 +449,12 @@ func (w *MigrationWriter) generateAlterTableScripts(schema *models.Schema, model
|
||||
// Build map of current columns
|
||||
currentColumns := make(map[string]*models.Column)
|
||||
for name, col := range currentTable.Columns {
|
||||
currentColumns[strings.ToLower(name)] = col
|
||||
currentColumns[name] = col
|
||||
}
|
||||
|
||||
// Check each model column
|
||||
for _, modelCol := range sortColumns(modelTable.Columns) {
|
||||
currentCol, exists := currentColumns[strings.ToLower(modelCol.Name)]
|
||||
currentCol, exists := currentColumns[modelCol.Name]
|
||||
|
||||
if !exists {
|
||||
// Column doesn't exist, add it
|
||||
@@ -574,13 +584,13 @@ func (w *MigrationWriter) generateIndexScripts(model, current *models.Schema) ([
|
||||
currentTables := make(map[string]*models.Table)
|
||||
if current != nil {
|
||||
for _, table := range current.Tables {
|
||||
currentTables[strings.ToLower(table.Name)] = table
|
||||
currentTables[table.Name] = table
|
||||
}
|
||||
}
|
||||
|
||||
// Process each model table
|
||||
for _, modelTable := range model.Tables {
|
||||
currentTable := currentTables[strings.ToLower(modelTable.Name)]
|
||||
currentTable := currentTables[modelTable.Name]
|
||||
|
||||
// Process primary keys first - check explicit constraints
|
||||
foundExplicitPK := false
|
||||
@@ -603,7 +613,7 @@ func (w *MigrationWriter) generateIndexScripts(model, current *models.Schema) ([
|
||||
SchemaName: model.Name,
|
||||
TableName: modelTable.Name,
|
||||
ConstraintName: constraintName,
|
||||
Columns: strings.Join(constraint.Columns, ", "),
|
||||
Columns: quotedIdentifiers(constraint.Columns),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -627,12 +637,12 @@ func (w *MigrationWriter) generateIndexScripts(model, current *models.Schema) ([
|
||||
pkColumns := []string{}
|
||||
for _, col := range modelTable.Columns {
|
||||
if col.IsPrimaryKey {
|
||||
pkColumns = append(pkColumns, col.SQLName())
|
||||
pkColumns = append(pkColumns, col.Name)
|
||||
}
|
||||
}
|
||||
if len(pkColumns) > 0 {
|
||||
sort.Strings(pkColumns)
|
||||
constraintName := fmt.Sprintf("pk_%s_%s", model.SQLName(), modelTable.SQLName())
|
||||
constraintName := fmt.Sprintf("pk_%s_%s", model.Name, modelTable.Name)
|
||||
shouldCreate := true
|
||||
|
||||
if currentTable != nil {
|
||||
@@ -650,7 +660,7 @@ func (w *MigrationWriter) generateIndexScripts(model, current *models.Schema) ([
|
||||
SchemaName: model.Name,
|
||||
TableName: modelTable.Name,
|
||||
ConstraintName: constraintName,
|
||||
Columns: strings.Join(pkColumns, ", "),
|
||||
Columns: quotedIdentifiers(pkColumns),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -750,7 +760,7 @@ func buildIndexColumnExpressionsFiltered(table *models.Table, index *models.Inde
|
||||
continue
|
||||
}
|
||||
|
||||
colExpr := col.SQLName()
|
||||
colExpr := quoteIdent(col.Name)
|
||||
if opClass := indexOperatorClassForColumn(col, indexType, index.Comment); opClass != "" {
|
||||
colExpr = fmt.Sprintf("%s %s", colExpr, opClass)
|
||||
}
|
||||
@@ -767,12 +777,12 @@ func (w *MigrationWriter) generateUniqueConstraintScripts(model, current *models
|
||||
currentTables := make(map[string]*models.Table)
|
||||
if current != nil {
|
||||
for _, table := range current.Tables {
|
||||
currentTables[strings.ToLower(table.Name)] = table
|
||||
currentTables[table.Name] = table
|
||||
}
|
||||
}
|
||||
|
||||
for _, modelTable := range model.Tables {
|
||||
currentTable := currentTables[strings.ToLower(modelTable.Name)]
|
||||
currentTable := currentTables[modelTable.Name]
|
||||
|
||||
for _, constraint := range sortConstraints(modelTable.Constraints) {
|
||||
if constraint.Type != models.UniqueConstraint {
|
||||
@@ -788,7 +798,7 @@ func (w *MigrationWriter) generateUniqueConstraintScripts(model, current *models
|
||||
columnExprs := make([]string, 0, len(constraint.Columns))
|
||||
for _, colName := range constraint.Columns {
|
||||
if col, ok := modelTable.Columns[colName]; ok {
|
||||
columnExprs = append(columnExprs, col.SQLName())
|
||||
columnExprs = append(columnExprs, quoteIdent(col.Name))
|
||||
}
|
||||
}
|
||||
if len(columnExprs) == 0 {
|
||||
@@ -838,7 +848,7 @@ func adoptableUniqueIndex(table *models.Table, constraint *models.Constraint) st
|
||||
return ""
|
||||
}
|
||||
for i, col := range index.Columns {
|
||||
if !strings.EqualFold(col, constraint.Columns[i]) {
|
||||
if !(col == constraint.Columns[i]) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -855,13 +865,13 @@ func (w *MigrationWriter) generateForeignKeyScripts(model, current *models.Schem
|
||||
currentTables := make(map[string]*models.Table)
|
||||
if current != nil {
|
||||
for _, table := range current.Tables {
|
||||
currentTables[strings.ToLower(table.Name)] = table
|
||||
currentTables[table.Name] = table
|
||||
}
|
||||
}
|
||||
|
||||
// Process each model table
|
||||
for _, modelTable := range model.Tables {
|
||||
currentTable := currentTables[strings.ToLower(modelTable.Name)]
|
||||
currentTable := currentTables[modelTable.Name]
|
||||
|
||||
// Process each constraint
|
||||
for _, constraint := range sortConstraints(modelTable.Constraints) {
|
||||
@@ -904,10 +914,10 @@ func (w *MigrationWriter) generateForeignKeyScripts(model, current *models.Schem
|
||||
SchemaName: model.Name,
|
||||
TableName: modelTable.Name,
|
||||
ConstraintName: constraintName,
|
||||
SourceColumns: strings.Join(constraint.Columns, ", "),
|
||||
SourceColumns: quotedIdentifiers(constraint.Columns),
|
||||
TargetSchema: constraint.ReferencedSchema,
|
||||
TargetTable: constraint.ReferencedTable,
|
||||
TargetColumns: strings.Join(constraint.ReferencedColumns, ", "),
|
||||
TargetColumns: quotedIdentifiers(constraint.ReferencedColumns),
|
||||
OnDelete: onDelete,
|
||||
OnUpdate: onUpdate,
|
||||
Deferral: deferral,
|
||||
@@ -939,20 +949,20 @@ func (w *MigrationWriter) generateCommentScripts(model, current *models.Schema)
|
||||
currentTables := make(map[string]*models.Table)
|
||||
if current != nil {
|
||||
for _, table := range current.Tables {
|
||||
currentTables[strings.ToLower(table.Name)] = table
|
||||
currentTables[table.Name] = table
|
||||
}
|
||||
}
|
||||
|
||||
// Process each model table
|
||||
for _, modelTable := range model.Tables {
|
||||
currentTable := currentTables[strings.ToLower(modelTable.Name)]
|
||||
currentTable := currentTables[modelTable.Name]
|
||||
|
||||
// Table comment (skipped when the live table already carries the same comment)
|
||||
if modelTable.Description != "" && (currentTable == nil || strings.TrimSpace(currentTable.Description) != strings.TrimSpace(modelTable.Description)) {
|
||||
sql, err := w.executor.ExecuteCommentTable(CommentTableData{
|
||||
SchemaName: model.Name,
|
||||
TableName: modelTable.Name,
|
||||
Comment: escapeQuote(modelTable.Description),
|
||||
Comment: modelTable.Description,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -976,7 +986,7 @@ func (w *MigrationWriter) generateCommentScripts(model, current *models.Schema)
|
||||
SchemaName: model.Name,
|
||||
TableName: modelTable.Name,
|
||||
ColumnName: col.Name,
|
||||
Comment: escapeQuote(col.Description),
|
||||
Comment: col.Description,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1229,21 +1239,21 @@ func constraintsEqual(c1, c2 *models.Constraint) bool {
|
||||
return false
|
||||
}
|
||||
for i, col := range c1.Columns {
|
||||
if !strings.EqualFold(col, c2.Columns[i]) {
|
||||
if !(col == c2.Columns[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// For foreign keys, also compare referenced table and columns
|
||||
if c1.Type == models.ForeignKeyConstraint {
|
||||
if !strings.EqualFold(c1.ReferencedTable, c2.ReferencedTable) {
|
||||
if !(c1.ReferencedTable == c2.ReferencedTable) {
|
||||
return false
|
||||
}
|
||||
if len(c1.ReferencedColumns) != len(c2.ReferencedColumns) {
|
||||
return false
|
||||
}
|
||||
for i, col := range c1.ReferencedColumns {
|
||||
if !strings.EqualFold(col, c2.ReferencedColumns[i]) {
|
||||
if !(col == c2.ReferencedColumns[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1273,7 +1283,7 @@ func indexesEqual(idx1, idx2 *models.Index) bool {
|
||||
return false
|
||||
}
|
||||
for i, col := range idx1.Columns {
|
||||
if !strings.EqualFold(col, idx2.Columns[i]) {
|
||||
if !(col == idx2.Columns[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1327,7 +1337,7 @@ func currentColumnHasDescription(currentTable *models.Table, col *models.Column)
|
||||
return false
|
||||
}
|
||||
for _, cc := range currentTable.Columns {
|
||||
if strings.EqualFold(cc.Name, col.Name) {
|
||||
if cc.Name == col.Name {
|
||||
return strings.TrimSpace(cc.Description) == strings.TrimSpace(col.Description)
|
||||
}
|
||||
}
|
||||
@@ -1355,7 +1365,7 @@ func primaryKeyColumnsMatch(modelTable *models.Table, current *models.Constraint
|
||||
return false
|
||||
}
|
||||
for i, col := range modelCols {
|
||||
if !strings.EqualFold(col, current.Columns[i]) {
|
||||
if !(col == current.Columns[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ func TestWriteMigration_ArrayDefault(t *testing.T) {
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "tags text[] DEFAULT '{}' NOT NULL") {
|
||||
if !strings.Contains(output, "\"tags\" text[] DEFAULT '{}' NOT NULL") {
|
||||
t.Fatalf("expected normalized array default in migration, got:\n%s", output)
|
||||
}
|
||||
if strings.Contains(output, "'''{}'''") {
|
||||
@@ -128,10 +128,10 @@ func TestWriteMigration_AltersColumnTypeWhenActualTypeDiffers(t *testing.T) {
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "ALTER TABLE public.learnings") || !strings.Contains(output, "ALTER COLUMN details TYPE text") {
|
||||
if !strings.Contains(output, "ALTER TABLE \"public\".\"learnings\"") || !strings.Contains(output, "ALTER COLUMN \"details\" TYPE text") {
|
||||
t.Fatalf("expected migration to alter mismatched column type, got:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, `ALTER COLUMN details TYPE text USING details::text;`) {
|
||||
if !strings.Contains(output, `ALTER COLUMN "details" TYPE text USING "details"::text;`) {
|
||||
t.Fatalf("expected migration type alter to include USING cast, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
@@ -167,7 +167,7 @@ func TestWriteMigration_AltersColumnTypePreservesConversionFailure(t *testing.T)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "ALTER COLUMN details TYPE integer USING details::integer;") {
|
||||
if !strings.Contains(output, "ALTER COLUMN \"details\" TYPE integer USING \"details\"::integer;") {
|
||||
t.Fatalf("expected explicit type conversion, got:\n%s", output)
|
||||
}
|
||||
for _, unsafe := range []string{"EXCEPTION WHEN OTHERS", "renamed_column", "RENAME COLUMN"} {
|
||||
@@ -210,7 +210,7 @@ func TestWriteMigration_AltersColumnNullabilityWhenNotNullDiffers(t *testing.T)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "ALTER COLUMN rid_service_instance_type DROP NOT NULL") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -331,13 +331,13 @@ func TestWriteMigration_UsesStorageTypeForSerialAlterStatements(t *testing.T) {
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "ALTER COLUMN id TYPE bigint") {
|
||||
if !strings.Contains(output, "ALTER COLUMN \"id\" TYPE bigint") {
|
||||
t.Fatalf("expected serial alter to use bigint storage type, got:\n%s", output)
|
||||
}
|
||||
if strings.Contains(output, "ALTER COLUMN id TYPE bigserial;") {
|
||||
if strings.Contains(output, "ALTER COLUMN \"id\" TYPE bigserial;") {
|
||||
t.Fatalf("did not expect invalid bigserial alter statement, got:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, `ALTER COLUMN id TYPE bigint USING id::bigint;`) {
|
||||
if !strings.Contains(output, `ALTER COLUMN "id" TYPE bigint USING "id"::bigint;`) {
|
||||
t.Fatalf("expected serial alter to include USING cast, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
@@ -373,7 +373,7 @@ func TestWriteMigration_ArrayAlterIncludesUsingCast(t *testing.T) {
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, `ALTER COLUMN tags TYPE text[] USING tags::text[];`) {
|
||||
if !strings.Contains(output, `ALTER COLUMN "tags" TYPE text[] USING "tags"::text[];`) {
|
||||
t.Fatalf("expected array alter to include USING cast, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
@@ -410,7 +410,7 @@ func TestWriteMigration_DoesNotAlterEquivalentNormalizedColumnType(t *testing.T)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if strings.Contains(output, "ALTER COLUMN email TYPE") {
|
||||
if strings.Contains(output, "ALTER COLUMN \"email\" TYPE") {
|
||||
t.Fatalf("did not expect alter type for equivalent normalized types, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
@@ -493,7 +493,7 @@ func TestWriteMigration_GinIndexOnTextUsesTrigramOperatorClass(t *testing.T) {
|
||||
if !strings.Contains(output, "CREATE EXTENSION IF NOT EXISTS pg_trgm;") {
|
||||
t.Fatalf("expected trigram extension for text GIN migration index, got:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "USING gin (title gin_trgm_ops)") {
|
||||
if !strings.Contains(output, "USING gin (\"title\" gin_trgm_ops)") {
|
||||
t.Fatalf("expected GIN text index to include gin_trgm_ops, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
@@ -514,7 +514,7 @@ func TestWriteMigration_GinIndexOnQuotedTextColumnUsesTrigramOperatorClass(t *te
|
||||
index := &models.Index{
|
||||
Name: "idx_agent_personas_name_gin",
|
||||
Type: "gin",
|
||||
Columns: []string{`"name"`},
|
||||
Columns: []string{"name"},
|
||||
}
|
||||
table.Indexes[index.Name] = index
|
||||
|
||||
@@ -533,7 +533,7 @@ func TestWriteMigration_GinIndexOnQuotedTextColumnUsesTrigramOperatorClass(t *te
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "USING gin (name gin_trgm_ops)") {
|
||||
if !strings.Contains(output, "USING gin (\"name\" gin_trgm_ops)") {
|
||||
t.Fatalf("expected quoted text column GIN index to include gin_trgm_ops, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
@@ -573,7 +573,7 @@ func TestWriteMigration_GinIndexOnTextArrayDoesNotUseTrigramOperatorClass(t *tes
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "USING gin (tags array_ops)") {
|
||||
if !strings.Contains(output, "USING gin (\"tags\" array_ops)") {
|
||||
t.Fatalf("expected GIN array index with array_ops, got:\n%s", output)
|
||||
}
|
||||
if strings.Contains(output, "gin_trgm_ops") {
|
||||
@@ -616,7 +616,7 @@ func TestWriteMigration_GinIndexOnJSONBUsesJSONBOperatorClass(t *testing.T) {
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "USING gin (details jsonb_ops)") {
|
||||
if !strings.Contains(output, "USING gin (\"details\" jsonb_ops)") {
|
||||
t.Fatalf("expected GIN jsonb index to include jsonb_ops, got:\n%s", output)
|
||||
}
|
||||
if strings.Contains(output, "gin_trgm_ops") {
|
||||
@@ -660,7 +660,7 @@ func TestWriteMigration_GinIndexOnJSONBIgnoresIncompatibleTrigramOperatorClass(t
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "USING gin (details jsonb_ops)") {
|
||||
if !strings.Contains(output, "USING gin (\"details\" jsonb_ops)") {
|
||||
t.Fatalf("expected incompatible trigram hint on jsonb to fall back to jsonb_ops, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
@@ -721,20 +721,20 @@ func TestWriteMigration_WithAudit(t *testing.T) {
|
||||
t.Logf("Generated migration with audit:\n%s", output)
|
||||
|
||||
// Verify audit tables
|
||||
if !strings.Contains(output, "CREATE TABLE IF NOT EXISTS public.atevent") {
|
||||
if !strings.Contains(output, "CREATE TABLE IF NOT EXISTS \"public\".atevent") {
|
||||
t.Error("Migration missing atevent table")
|
||||
}
|
||||
if !strings.Contains(output, "CREATE TABLE IF NOT EXISTS public.atdetail") {
|
||||
if !strings.Contains(output, "CREATE TABLE IF NOT EXISTS \"public\".atdetail") {
|
||||
t.Error("Migration missing atdetail table")
|
||||
}
|
||||
|
||||
// Verify audit function
|
||||
if !strings.Contains(output, "CREATE OR REPLACE FUNCTION public.tf_audit_users()") {
|
||||
if !strings.Contains(output, "CREATE OR REPLACE FUNCTION \"public\".\"tf_audit_users\"()") {
|
||||
t.Error("Migration missing audit function")
|
||||
}
|
||||
|
||||
// Verify audit trigger
|
||||
if !strings.Contains(output, "CREATE TRIGGER t_audit_users") {
|
||||
if !strings.Contains(output, "CREATE TRIGGER \"t_audit_users\"") {
|
||||
t.Error("Migration missing audit trigger")
|
||||
}
|
||||
|
||||
@@ -766,13 +766,13 @@ func TestTemplateExecutor_CreateTable(t *testing.T) {
|
||||
|
||||
t.Logf("Generated SQL:\n%s", sql)
|
||||
|
||||
if !strings.Contains(sql, "CREATE TABLE IF NOT EXISTS public.test_table") {
|
||||
if !strings.Contains(sql, "CREATE TABLE IF NOT EXISTS \"public\".\"test_table\"") {
|
||||
t.Error("SQL missing CREATE TABLE statement")
|
||||
}
|
||||
if !strings.Contains(sql, "id integer NOT NULL") {
|
||||
if !strings.Contains(sql, "\"id\" integer NOT NULL") {
|
||||
t.Error("SQL missing id column definition")
|
||||
}
|
||||
if !strings.Contains(sql, "name text DEFAULT 'unknown'") {
|
||||
if !strings.Contains(sql, "\"name\" text DEFAULT 'unknown'") {
|
||||
t.Error("SQL missing name column definition")
|
||||
}
|
||||
}
|
||||
@@ -810,7 +810,7 @@ func TestTemplateExecutor_AuditFunction(t *testing.T) {
|
||||
|
||||
t.Logf("Generated SQL:\n%s", sql)
|
||||
|
||||
if !strings.Contains(sql, "CREATE OR REPLACE FUNCTION public.tf_audit_users()") {
|
||||
if !strings.Contains(sql, "CREATE OR REPLACE FUNCTION \"public\".\"tf_audit_users\"()") {
|
||||
t.Error("SQL missing function definition")
|
||||
}
|
||||
if !strings.Contains(sql, "IF TG_OP = 'INSERT'") {
|
||||
@@ -983,8 +983,8 @@ func TestWriteMigration_VectorAndPostGISIndexes(t *testing.T) {
|
||||
"CREATE EXTENSION IF NOT EXISTS vector;",
|
||||
"vector(1536)",
|
||||
"geometry(Point,4326)",
|
||||
"USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100)",
|
||||
"USING gist (location)",
|
||||
"USING ivfflat (\"embedding\" vector_cosine_ops) WITH (lists = 100)",
|
||||
"USING gist (\"location\")",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("expected migration to contain %q, got:\n%s", want, output)
|
||||
@@ -1181,7 +1181,7 @@ func TestWriteMigration_UniqueConstraintAdoptsSameNamedModelIndex(t *testing.T)
|
||||
if got := strings.Contains(output, "UNIQUE USING INDEX"); got != tt.wantAdopt {
|
||||
t.Errorf("USING INDEX present = %v, want %v; output:\n%s", got, tt.wantAdopt, output)
|
||||
}
|
||||
if got := strings.Contains(output, "UNIQUE (guid)") || strings.Contains(output, "UNIQUE (\"guid\")"); got == tt.wantAdopt {
|
||||
if got := strings.Contains(output, "UNIQUE (\"guid\")") || strings.Contains(output, "UNIQUE (\"guid\")"); got == tt.wantAdopt {
|
||||
t.Errorf("column-list ADD CONSTRAINT present = %v, want %v; output:\n%s", got, !tt.wantAdopt, output)
|
||||
}
|
||||
if got := strings.Contains(output, "DROP INDEX"); got != tt.wantDrop {
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
package pgsql
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
)
|
||||
|
||||
func TestQuotedNamesAndLowercaseOption(t *testing.T) {
|
||||
db := models.InitDatabase("DB\nSELECT 'header';")
|
||||
s := models.InitSchema("MySchema")
|
||||
tbl := models.InitTable("Select", "MySchema")
|
||||
col := models.InitColumn("Key", "Select", "MySchema")
|
||||
col.Type = "text"
|
||||
col.Default = "Keep CASE"
|
||||
tbl.Columns[col.Name] = col
|
||||
s.Tables = append(s.Tables, tbl)
|
||||
db.Schemas = append(db.Schemas, s)
|
||||
for _, lower := range []bool{false, true} {
|
||||
w := NewWriter(&writers.WriterOptions{LowercaseIdentifiers: lower})
|
||||
stmts, err := w.GenerateDatabaseStatements(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := strings.Join(stmts, "\n")
|
||||
want := `"MySchema"."Select"`
|
||||
if lower {
|
||||
want = `"myschema"."select"`
|
||||
}
|
||||
if !strings.Contains(out, want) || !strings.Contains(out, "'Keep CASE'") || strings.Contains(out, "\nSELECT 'header';") {
|
||||
t.Fatalf("%s", out)
|
||||
}
|
||||
}
|
||||
if db.Name != "DB\nSELECT 'header';" || tbl.Name != "Select" {
|
||||
t.Fatal("input changed")
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "out.sql")
|
||||
if err := os.WriteFile(path, []byte("keep"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
col.Name = "bad\x00name"
|
||||
if err := NewWriter(&writers.WriterOptions{OutputPath: path}).WriteDatabase(db); err == nil {
|
||||
t.Fatal("expected NUL rejection")
|
||||
}
|
||||
data, _ := os.ReadFile(path)
|
||||
if string(data) != "keep" {
|
||||
t.Fatal("output changed before validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllBlockTemplatesAvoidDelimiterCollisions(t *testing.T) {
|
||||
te, err := NewTemplateExecutor(false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name := "名'\"\\\n$$ $body$ $relspec_0$ $relspec_1$"
|
||||
cases := []struct {
|
||||
name string
|
||||
data any
|
||||
}{
|
||||
{"alter_column_default_with_check.tmpl", AlterColumnDefaultWithCheckData{SchemaName: name, TableName: name, ColumnName: name, SetDefault: true, DefaultValue: quote(name)}},
|
||||
{"alter_column_nullability_with_check.tmpl", AlterColumnNullabilityWithCheckData{SchemaName: name, TableName: name, ColumnName: name}},
|
||||
{"create_check_constraint.tmpl", CreateCheckConstraintData{SchemaName: name, TableName: name, ConstraintName: name, Expression: "true"}},
|
||||
{"create_foreign_key_with_check.tmpl", CreateForeignKeyWithCheckData{SchemaName: name, TableName: name, ConstraintName: name}},
|
||||
{"create_primary_key.tmpl", CreatePrimaryKeyData{SchemaName: name, TableName: name, ConstraintName: name}},
|
||||
{"create_primary_key_with_autogen_check.tmpl", CreatePrimaryKeyWithAutoGenCheckData{SchemaName: name, TableName: name, ConstraintName: name}},
|
||||
{"create_unique_constraint.tmpl", CreateUniqueConstraintData{SchemaName: name, TableName: name, ConstraintName: name}},
|
||||
{"set_sequence_value.tmpl", SetSequenceValueData{SchemaName: name, TableName: name, SequenceName: name, ColumnName: name}},
|
||||
{"audit_function.tmpl", AuditFunctionData{SchemaName: name, FunctionName: name, TableName: name, PrimaryKey: name, AuditSchema: name}},
|
||||
{"audit_trigger.tmpl", AuditTriggerData{SchemaName: name, TableName: name, TriggerName: name, FunctionName: name}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, err := te.execute(tc.name, tc.data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Count(out, "$relspec_2$") != 2 || !strings.Contains(out, quoteIdent(name)) || !strings.Contains(out, quote(name)) {
|
||||
t.Fatalf("%s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLive_AdversarialNamesAndAudit(t *testing.T) {
|
||||
connString := liveWriterConn(t)
|
||||
base, conn := liveWriterSchema(t, connString)
|
||||
ctx := context.Background()
|
||||
schemaName := base + "'\"\\\n名$relspec_0$"
|
||||
tableName := "select"
|
||||
key := "Key'\"\\\n$relspec_0$"
|
||||
valueName := "名'\"\\\n$body$"
|
||||
comment := "Comment ' \\ $$ $relspec_0$\nUnicode 名"
|
||||
t.Cleanup(func() { _, _ = conn.Exec(ctx, "DROP SCHEMA IF EXISTS "+quoteIdent(schemaName)+" CASCADE") })
|
||||
db := models.InitDatabase("test\nSELECT invalid_header;")
|
||||
s := models.InitSchema(schemaName)
|
||||
tbl := models.InitTable(tableName, schemaName)
|
||||
id := models.InitColumn(key, tableName, schemaName)
|
||||
id.Type = "integer"
|
||||
id.IsPrimaryKey = true
|
||||
id.NotNull = true
|
||||
tbl.Columns[key] = id
|
||||
value := models.InitColumn(valueName, tableName, schemaName)
|
||||
value.Type = "text"
|
||||
value.Default = quote(comment)
|
||||
value.Description = comment
|
||||
value.Comment = comment
|
||||
tbl.Columns[valueName] = value
|
||||
tbl.Description = comment
|
||||
tbl.Comment = comment
|
||||
tbl.Constraints["unique"] = &models.Constraint{Name: "unique'\"\\\n$relspec_0$", Type: models.UniqueConstraint, Columns: []string{valueName}}
|
||||
tbl.Constraints["check"] = &models.Constraint{Name: "check'\"\\\n$relspec_0$", Type: models.CheckConstraint, Expression: quoteIdent(key) + ">0"}
|
||||
tbl.Indexes["index"] = &models.Index{Name: "index'\"\\\n$relspec_0$", Type: "btree", Columns: []string{valueName}}
|
||||
child := models.InitTable("from", schemaName)
|
||||
child.Columns[key] = &models.Column{Name: key, Type: "integer"}
|
||||
child.Constraints["fk"] = &models.Constraint{Name: "foreign'\"\\\n$relspec_0$", Type: models.ForeignKeyConstraint, Columns: []string{key}, ReferencedSchema: schemaName, ReferencedTable: tableName, ReferencedColumns: []string{key}}
|
||||
s.Tables = []*models.Table{tbl, child}
|
||||
db.Schemas = []*models.Schema{s}
|
||||
w := NewWriter(&writers.WriterOptions{})
|
||||
var output bytes.Buffer
|
||||
w.writer = &output
|
||||
if err := w.WriteDatabase(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := conn.Exec(ctx, output.String()); err != nil {
|
||||
t.Fatalf("file SQL: %v\n%s", err, output.String())
|
||||
}
|
||||
if _, err := conn.Exec(ctx, "SET standard_conforming_strings=off"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
statements, err := w.GenerateDatabaseStatements(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, stmt := range statements {
|
||||
if _, err := conn.Exec(ctx, stmt); err != nil {
|
||||
t.Fatalf("statement: %v\n%s", err, stmt)
|
||||
}
|
||||
}
|
||||
target := w.qualTable(schemaName, tableName)
|
||||
if _, err := conn.Exec(ctx, "INSERT INTO "+target+" ("+quoteIdent(key)+") VALUES (1)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got string
|
||||
if err := conn.QueryRow(ctx, "SELECT "+quoteIdent(valueName)+" FROM "+target).Scan(&got); err != nil || got != comment {
|
||||
t.Fatalf("default: %q %v", got, err)
|
||||
}
|
||||
if err := conn.QueryRow(ctx, `SELECT col_description(c.oid,a.attnum) FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace JOIN pg_attribute a ON a.attrelid=c.oid WHERE n.nspname=$1 AND c.relname=$2 AND a.attname=$3`, schemaName, tableName, valueName).Scan(&got); err != nil || got != comment {
|
||||
t.Fatalf("comment: %q %v", got, err)
|
||||
}
|
||||
// Migration templates must preserve the same identifier and literal boundaries.
|
||||
current := models.InitDatabase("current")
|
||||
currentSchema := models.InitSchema(schemaName)
|
||||
currentTable := models.InitTable(tableName, schemaName)
|
||||
currentValue := *value
|
||||
currentValue.Type = "text"
|
||||
currentTable.Columns[valueName] = ¤tValue
|
||||
currentID := *id
|
||||
currentTable.Columns[key] = ¤tID
|
||||
currentTable.Constraints = tbl.Constraints
|
||||
currentTable.Indexes = tbl.Indexes
|
||||
currentSchema.Tables = []*models.Table{currentTable, child}
|
||||
current.Schemas = []*models.Schema{currentSchema}
|
||||
value.Type = "varchar"
|
||||
value.Length = 200
|
||||
migration, err := NewMigrationWriter(&writers.WriterOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var migrationOutput bytes.Buffer
|
||||
migration.writer = &migrationOutput
|
||||
if err := migration.WriteMigration(db, current); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := conn.Exec(ctx, migrationOutput.String()); err != nil {
|
||||
t.Fatalf("migration: %v\n%s", err, migrationOutput.String())
|
||||
}
|
||||
if err := conn.QueryRow(ctx, "SELECT "+quoteIdent(valueName)+" FROM "+target+" /* after migration */").Scan(&got); err != nil || got != comment {
|
||||
t.Fatalf("migration value: %q %v", got, err)
|
||||
}
|
||||
te := w.executor
|
||||
sequenceName := "Seq'\"\\\n$relspec_0$"
|
||||
sequenceSQL, err := te.ExecuteCreateSequence(CreateSequenceData{SchemaName: schemaName, SequenceName: sequenceName, Increment: 1, MinValue: 1, MaxValue: 1000, StartValue: 1, CacheSize: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := conn.Exec(ctx, sequenceSQL); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setvalSQL, err := te.ExecuteSetSequenceValue(SetSequenceValueData{SchemaName: schemaName, TableName: tableName, SequenceName: sequenceName, ColumnName: key})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := conn.Exec(ctx, setvalSQL); err != nil {
|
||||
t.Fatalf("sequence: %v\n%s", err, setvalSQL)
|
||||
}
|
||||
var next int
|
||||
if err := conn.QueryRow(ctx, "SELECT nextval($1::regclass)", w.qualTable(schemaName, sequenceName)).Scan(&next); err != nil || next != 2 {
|
||||
t.Fatalf("sequence value: %d %v", next, err)
|
||||
}
|
||||
autoTable := w.qualTable(schemaName, "auto")
|
||||
if _, err := conn.Exec(ctx, "CREATE TABLE "+autoTable+" (old integer PRIMARY KEY, new integer)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pkSQL, err := te.ExecuteCreatePrimaryKeyWithAutoGenCheck(CreatePrimaryKeyWithAutoGenCheckData{SchemaName: schemaName, TableName: "auto", ConstraintName: "new'key", AutoGenNames: formatStringList([]string{"auto_pkey"}), Columns: quotedIdentifiers([]string{"new"}), ColumnNames: formatStringList([]string{"new"})})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := conn.Exec(ctx, pkSQL); err != nil {
|
||||
t.Fatalf("primary key: %v\n%s", err, pkSQL)
|
||||
}
|
||||
|
||||
auditTables, err := te.ExecuteAuditTables(AuditTablesData{AuditSchema: schemaName})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := conn.Exec(ctx, auditTables); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := &TableAuditConfig{AuditInsert: true, AuditUpdate: true, AuditDelete: true, TablePrefix: comment}
|
||||
functionData := BuildAuditFunctionData(schemaName, tbl, id, config, schemaName, "current_user")
|
||||
function, err := te.ExecuteAuditFunction(functionData)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := conn.Exec(ctx, function); err != nil {
|
||||
t.Fatalf("audit function: %v\n%s", err, function)
|
||||
}
|
||||
trigger, err := te.ExecuteAuditTrigger(AuditTriggerData{SchemaName: schemaName, TableName: tableName, FunctionName: functionData.FunctionName, TriggerName: "trigger'\"\\\n$relspec_0$", Events: "INSERT OR UPDATE OR DELETE"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := conn.Exec(ctx, trigger); err != nil {
|
||||
t.Fatalf("audit trigger: %v\n%s", err, trigger)
|
||||
}
|
||||
for _, sql := range []string{"INSERT INTO " + target + " VALUES (2, 'second')", "UPDATE " + target + " SET " + quoteIdent(valueName) + "='third' WHERE " + quoteIdent(key) + "=2", "DELETE FROM " + target + " WHERE " + quoteIdent(key) + "=2"} {
|
||||
if _, err := conn.Exec(ctx, sql); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var count int
|
||||
if err := conn.QueryRow(ctx, "SELECT count(*) FROM "+w.qualTable(schemaName, "atevent")+" WHERE tableprefix=$1", comment).Scan(&count); err != nil || count != 3 {
|
||||
t.Fatalf("audit events: %d %v", count, err)
|
||||
}
|
||||
}
|
||||
@@ -110,7 +110,7 @@ func TestDiffStatements_ExistingTableNewSequenceIsSetPastData(t *testing.T) {
|
||||
t.Fatalf("diffStatements failed: %v", err)
|
||||
}
|
||||
out := diffJoin(stmts)
|
||||
seqIdx := strings.Index(out, "CREATE SEQUENCE IF NOT EXISTS identity.login_event_id_login_event_seq")
|
||||
seqIdx := strings.Index(out, "CREATE SEQUENCE IF NOT EXISTS \"identity\".\"login_event_id_login_event_seq\"")
|
||||
setvalIdx := strings.Index(out, "setval(")
|
||||
if seqIdx < 0 || setvalIdx < 0 || seqIdx > setvalIdx {
|
||||
t.Fatalf("expected sequence creation before setval, got:\n%s", out)
|
||||
|
||||
@@ -131,7 +131,7 @@ func TestFirstBareIdentAndHelpers(t *testing.T) {
|
||||
"abc,def": "abc",
|
||||
"abc;": "abc",
|
||||
"\n abc\tdef": "abc",
|
||||
`"a b" c`: `"a`,
|
||||
`"a b" c`: `"a b"`,
|
||||
" tbl (x int)": "tbl",
|
||||
}
|
||||
for in, want := range bare {
|
||||
|
||||
@@ -24,6 +24,7 @@ func TemplateFunctions() map[string]interface{} {
|
||||
"escape": escape,
|
||||
"safe_identifier": safeIdentifier,
|
||||
"quote_ident": quoteIdent,
|
||||
"comment_text": commentText,
|
||||
|
||||
// Type conversion
|
||||
"goTypeToSQL": goTypeToSQL,
|
||||
@@ -99,6 +100,9 @@ func indent(spaces int, text string) string {
|
||||
|
||||
// quote quotes a string value for SQL (escapes single quotes)
|
||||
func quote(s string) string {
|
||||
if strings.Contains(s, "\\") {
|
||||
return sqlLiteral(s)
|
||||
}
|
||||
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
|
||||
}
|
||||
|
||||
@@ -125,41 +129,15 @@ func safeIdentifier(s string) string {
|
||||
return strings.ToLower(safe)
|
||||
}
|
||||
|
||||
// quoteIdent quotes a PostgreSQL identifier if necessary
|
||||
// Identifiers need quoting if they:
|
||||
// - Start with a digit
|
||||
// - Contain special characters
|
||||
// - Are reserved keywords
|
||||
// - Contain uppercase letters (to preserve case)
|
||||
func quoteIdent(s string) string {
|
||||
if s == "" {
|
||||
return `""`
|
||||
// quoteIdent always quotes an identifier, preserving case and reserved words.
|
||||
func quoteIdent(s string) string { return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` }
|
||||
|
||||
func quotedIdentifiers(names []string) string {
|
||||
out := make([]string, len(names))
|
||||
for i, name := range names {
|
||||
out[i] = quoteIdent(name)
|
||||
}
|
||||
|
||||
// Check if quoting is needed
|
||||
needsQuoting := unicode.IsDigit(rune(s[0]))
|
||||
|
||||
// Starts with digit
|
||||
|
||||
// Contains uppercase letters or special characters
|
||||
for _, r := range s {
|
||||
if unicode.IsUpper(r) {
|
||||
needsQuoting = true
|
||||
break
|
||||
}
|
||||
if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' {
|
||||
needsQuoting = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if needsQuoting {
|
||||
// Escape double quotes by doubling them
|
||||
escaped := strings.ReplaceAll(s, `"`, `""`)
|
||||
return `"` + escaped + `"`
|
||||
}
|
||||
|
||||
return s
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
|
||||
// Type conversion functions
|
||||
|
||||
@@ -106,7 +106,7 @@ func TestQuoteIdent(t *testing.T) {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"valid_name", "valid_name"},
|
||||
{"valid_name", `"valid_name"`},
|
||||
{"ValidName", `"ValidName"`},
|
||||
{"123column", `"123column"`},
|
||||
{"215162_fk_constraint", `"215162_fk_constraint"`},
|
||||
@@ -114,8 +114,8 @@ func TestQuoteIdent(t *testing.T) {
|
||||
{"user@domain", `"user@domain"`},
|
||||
{`"quoted"`, `"""quoted"""`},
|
||||
{"", `""`},
|
||||
{"lowercase", "lowercase"},
|
||||
{"with_underscore", "with_underscore"},
|
||||
{"lowercase", `"lowercase"`},
|
||||
{"with_underscore", `"with_underscore"`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
+67
-156
@@ -8,7 +8,6 @@ import (
|
||||
"text/template"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
)
|
||||
|
||||
//go:embed templates/*.tmpl
|
||||
@@ -29,7 +28,7 @@ func NewTemplateExecutor(flattenSchema bool) (*TemplateExecutor, error) {
|
||||
}
|
||||
|
||||
// qual_table returns a quoted, schema-qualified identifier.
|
||||
// With flatten=false: "schema"."table" (or unquoted equivalents).
|
||||
// With flatten=false: "schema"."table".
|
||||
// With flatten=true: "schema_table".
|
||||
funcMap["qual_table"] = func(schema, name string) string {
|
||||
if schema == "" {
|
||||
@@ -41,20 +40,20 @@ func NewTemplateExecutor(flattenSchema bool) (*TemplateExecutor, error) {
|
||||
return quoteIdent(schema) + "." + quoteIdent(name)
|
||||
}
|
||||
|
||||
// qual_table_raw is the same as qual_table but without identifier quoting.
|
||||
funcMap["qual_table_raw"] = func(schema, name string) string {
|
||||
if schema == "" {
|
||||
return name
|
||||
}
|
||||
if flattenSchema {
|
||||
return schema + "_" + name
|
||||
}
|
||||
return schema + "." + name
|
||||
}
|
||||
|
||||
tmpl, err := template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.tmpl")
|
||||
funcMap["dollar_delimiter"] = func() string { return "$$" }
|
||||
tmpl := template.New("").Funcs(funcMap)
|
||||
entries, err := templateFS.ReadDir("templates")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse templates: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
source, err := templateFS.ReadFile("templates/" + entry.Name())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := tmpl.New(entry.Name()).Parse(string(source)); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", entry.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
return &TemplateExecutor{
|
||||
@@ -62,6 +61,31 @@ func NewTemplateExecutor(flattenSchema bool) (*TemplateExecutor, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// execute chooses a delimiter absent from rendered data, including SQL expressions.
|
||||
// Only explicit template calls emit delimiters; user text is never rewritten.
|
||||
func (te *TemplateExecutor) execute(name string, data any) (string, error) {
|
||||
for n := 0; ; n++ {
|
||||
delimiter := fmt.Sprintf("$relspec_%d$", n)
|
||||
count := 0
|
||||
tmpl, err := te.templates.Clone()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmpl.Funcs(template.FuncMap{"dollar_delimiter": func() string { count++; return delimiter }})
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
|
||||
return "", fmt.Errorf("execute %s: %w", name, err)
|
||||
}
|
||||
rendered := buf.String()
|
||||
if strings.ContainsRune(rendered, 0) {
|
||||
return "", fmt.Errorf("%s contains a NUL byte", name)
|
||||
}
|
||||
if strings.Count(rendered, delimiter) == count {
|
||||
return rendered, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Template data structures
|
||||
|
||||
// CreateTableData contains data for create table template
|
||||
@@ -305,230 +329,117 @@ type CreatePrimaryKeyWithAutoGenCheckData struct {
|
||||
|
||||
// ExecuteCreateTable executes the create table template
|
||||
func (te *TemplateExecutor) ExecuteCreateTable(data CreateTableData) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
err := te.templates.ExecuteTemplate(&buf, "create_table.tmpl", data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to execute create_table template: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
return te.execute("create_table.tmpl", data)
|
||||
}
|
||||
|
||||
// ExecuteAddColumn executes the add column template
|
||||
func (te *TemplateExecutor) ExecuteAddColumn(data AddColumnData) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
err := te.templates.ExecuteTemplate(&buf, "add_column.tmpl", data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to execute add_column template: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
return te.execute("add_column.tmpl", data)
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to execute alter_column_type_with_check template: %w", err)
|
||||
}
|
||||
body := strings.TrimSpace(buf.String())
|
||||
body = strings.TrimPrefix(body, "DO $$")
|
||||
body = strings.TrimSuffix(body, "$$;")
|
||||
return dollarBlock(strings.TrimSpace(body), "relspec_alter_type"), nil
|
||||
return te.execute("alter_column_type_with_check.tmpl", data)
|
||||
}
|
||||
|
||||
// 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_with_check.tmpl", data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to execute alter_column_default_with_check template: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
return te.execute("alter_column_default_with_check.tmpl", data)
|
||||
}
|
||||
|
||||
// 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
|
||||
return te.execute("alter_column_nullability_with_check.tmpl", data)
|
||||
}
|
||||
|
||||
// ExecuteCreatePrimaryKey executes the create primary key template
|
||||
func (te *TemplateExecutor) ExecuteCreatePrimaryKey(data CreatePrimaryKeyData) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
err := te.templates.ExecuteTemplate(&buf, "create_primary_key.tmpl", data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to execute create_primary_key template: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
return te.execute("create_primary_key.tmpl", data)
|
||||
}
|
||||
|
||||
// ExecuteCreateIndex executes the create index template
|
||||
func (te *TemplateExecutor) ExecuteCreateIndex(data CreateIndexData) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
err := te.templates.ExecuteTemplate(&buf, "create_index.tmpl", data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to execute create_index template: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
return te.execute("create_index.tmpl", data)
|
||||
}
|
||||
|
||||
// ExecuteCreateForeignKey executes the create foreign key template
|
||||
func (te *TemplateExecutor) ExecuteCreateForeignKey(data CreateForeignKeyData) (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
|
||||
return te.execute("create_foreign_key.tmpl", data)
|
||||
}
|
||||
|
||||
// ExecuteDropConstraint executes the drop constraint template
|
||||
func (te *TemplateExecutor) ExecuteDropConstraint(data DropConstraintData) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
err := te.templates.ExecuteTemplate(&buf, "drop_constraint.tmpl", data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to execute drop_constraint template: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
return te.execute("drop_constraint.tmpl", data)
|
||||
}
|
||||
|
||||
// ExecuteDropIndex executes the drop index template
|
||||
func (te *TemplateExecutor) ExecuteDropIndex(data DropIndexData) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
err := te.templates.ExecuteTemplate(&buf, "drop_index.tmpl", data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to execute drop_index template: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
return te.execute("drop_index.tmpl", data)
|
||||
}
|
||||
|
||||
// ExecuteCommentTable executes the table comment template
|
||||
func (te *TemplateExecutor) ExecuteCommentTable(data CommentTableData) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
err := te.templates.ExecuteTemplate(&buf, "comment_table.tmpl", data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to execute comment_table template: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
return te.execute("comment_table.tmpl", data)
|
||||
}
|
||||
|
||||
// ExecuteCommentColumn executes the column comment template
|
||||
func (te *TemplateExecutor) ExecuteCommentColumn(data CommentColumnData) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
err := te.templates.ExecuteTemplate(&buf, "comment_column.tmpl", data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to execute comment_column template: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
return te.execute("comment_column.tmpl", data)
|
||||
}
|
||||
|
||||
// ExecuteAuditTables executes the audit tables template
|
||||
func (te *TemplateExecutor) ExecuteAuditTables(data AuditTablesData) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
err := te.templates.ExecuteTemplate(&buf, "audit_tables.tmpl", data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to execute audit_tables template: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
return te.execute("audit_tables.tmpl", data)
|
||||
}
|
||||
|
||||
// ExecuteAuditFunction executes the audit function template
|
||||
func (te *TemplateExecutor) ExecuteAuditFunction(data AuditFunctionData) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
err := te.templates.ExecuteTemplate(&buf, "audit_function.tmpl", data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to execute audit_function template: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
return te.execute("audit_function.tmpl", data)
|
||||
}
|
||||
|
||||
// ExecuteAuditTrigger executes the audit trigger template
|
||||
func (te *TemplateExecutor) ExecuteAuditTrigger(data AuditTriggerData) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
err := te.templates.ExecuteTemplate(&buf, "audit_trigger.tmpl", data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to execute audit_trigger template: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
return te.execute("audit_trigger.tmpl", data)
|
||||
}
|
||||
|
||||
// ExecuteCreateUniqueConstraint executes the create unique constraint template
|
||||
func (te *TemplateExecutor) ExecuteCreateUniqueConstraint(data CreateUniqueConstraintData) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
err := te.templates.ExecuteTemplate(&buf, "create_unique_constraint.tmpl", data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to execute create_unique_constraint template: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
return te.execute("create_unique_constraint.tmpl", data)
|
||||
}
|
||||
|
||||
// ExecuteCreateCheckConstraint executes the create check constraint template
|
||||
func (te *TemplateExecutor) ExecuteCreateCheckConstraint(data CreateCheckConstraintData) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
err := te.templates.ExecuteTemplate(&buf, "create_check_constraint.tmpl", data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to execute create_check_constraint template: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
return te.execute("create_check_constraint.tmpl", data)
|
||||
}
|
||||
|
||||
// ExecuteCreateForeignKeyWithCheck executes the create foreign key with check template
|
||||
func (te *TemplateExecutor) ExecuteCreateForeignKeyWithCheck(data CreateForeignKeyWithCheckData) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
err := te.templates.ExecuteTemplate(&buf, "create_foreign_key_with_check.tmpl", data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to execute create_foreign_key_with_check template: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
return te.execute("create_foreign_key_with_check.tmpl", data)
|
||||
}
|
||||
|
||||
// ExecuteSetSequenceValue executes the set sequence value template
|
||||
func (te *TemplateExecutor) ExecuteSetSequenceValue(data SetSequenceValueData) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
err := te.templates.ExecuteTemplate(&buf, "set_sequence_value.tmpl", data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to execute set_sequence_value template: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
return te.execute("set_sequence_value.tmpl", data)
|
||||
}
|
||||
|
||||
// ExecuteCreateSequence executes the create sequence template
|
||||
func (te *TemplateExecutor) ExecuteCreateSequence(data CreateSequenceData) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
err := te.templates.ExecuteTemplate(&buf, "create_sequence.tmpl", data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to execute create_sequence template: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
return te.execute("create_sequence.tmpl", data)
|
||||
}
|
||||
|
||||
// ExecuteAddColumnWithCheck executes the add column with check template
|
||||
func (te *TemplateExecutor) ExecuteAddColumnWithCheck(data AddColumnWithCheckData) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
err := te.templates.ExecuteTemplate(&buf, "add_column_with_check.tmpl", data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to execute add_column_with_check template: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
return te.execute("add_column_with_check.tmpl", data)
|
||||
}
|
||||
|
||||
// ExecuteCreatePrimaryKeyWithAutoGenCheck executes the create primary key with auto-generated key check template
|
||||
func (te *TemplateExecutor) ExecuteCreatePrimaryKeyWithAutoGenCheck(data CreatePrimaryKeyWithAutoGenCheckData) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
err := te.templates.ExecuteTemplate(&buf, "create_primary_key_with_autogen_check.tmpl", data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to execute create_primary_key_with_autogen_check template: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
return te.execute("create_primary_key_with_autogen_check.tmpl", data)
|
||||
}
|
||||
|
||||
// Helper functions to build template data from models
|
||||
@@ -561,7 +472,7 @@ func BuildCreateTableData(schemaName string, table *models.Table) CreateTableDat
|
||||
colData.Generated, colData.Identity = columnGenerationClauses(col)
|
||||
if col.Default != nil && colData.Generated == "" && colData.Identity == "" {
|
||||
if value, ok := col.Default.(string); ok {
|
||||
colData.Default = writers.QuoteDefaultValue(stripBackticks(value), col.Type)
|
||||
colData.Default = quoteDefaultValue(stripBackticks(value), col.Type)
|
||||
} else {
|
||||
colData.Default = fmt.Sprintf("%v", col.Default)
|
||||
}
|
||||
@@ -612,7 +523,7 @@ func BuildAuditFunctionData(
|
||||
updateComparisons := make([]string, 0)
|
||||
for _, col := range auditedColumns {
|
||||
updateComparisons = append(updateComparisons,
|
||||
fmt.Sprintf("old.%s IS DISTINCT FROM new.%s", col.Name, col.Name))
|
||||
fmt.Sprintf("old.%s IS DISTINCT FROM new.%s", quoteIdent(col.Name), quoteIdent(col.Name)))
|
||||
}
|
||||
updateCondition := strings.Join(updateComparisons, " OR ")
|
||||
|
||||
@@ -627,8 +538,8 @@ func BuildAuditFunctionData(
|
||||
}
|
||||
}
|
||||
|
||||
oldValue := fmt.Sprintf("old.%s::text", col.Name)
|
||||
newValue := fmt.Sprintf("new.%s::text", col.Name)
|
||||
oldValue := fmt.Sprintf("old.%s::text", quoteIdent(col.Name))
|
||||
newValue := fmt.Sprintf("new.%s::text", quoteIdent(col.Name))
|
||||
|
||||
if isEncrypted {
|
||||
oldValue = "'****************'"
|
||||
@@ -653,7 +564,7 @@ func BuildAuditFunctionData(
|
||||
}
|
||||
}
|
||||
|
||||
oldValue := fmt.Sprintf("old.%s::text", col.Name)
|
||||
oldValue := fmt.Sprintf("old.%s::text", quoteIdent(col.Name))
|
||||
if isEncrypted {
|
||||
oldValue = "'****************'"
|
||||
}
|
||||
@@ -666,7 +577,7 @@ func BuildAuditFunctionData(
|
||||
|
||||
tablePrefix := "NULL"
|
||||
if config.TablePrefix != "" {
|
||||
tablePrefix = fmt.Sprintf("'%s'", config.TablePrefix)
|
||||
tablePrefix = quote(config.TablePrefix)
|
||||
}
|
||||
|
||||
return AuditFunctionData{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
DO $$
|
||||
DO {{dollar_delimiter}}
|
||||
DECLARE
|
||||
current_default text;
|
||||
BEGIN
|
||||
@@ -8,9 +8,9 @@ BEGIN
|
||||
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}}'
|
||||
WHERE n.nspname = {{quote .SchemaName}}
|
||||
AND t.relname = {{quote .TableName}}
|
||||
AND a.attname = {{quote .ColumnName}}
|
||||
AND a.attnum > 0
|
||||
AND NOT a.attisdropped;
|
||||
|
||||
@@ -26,4 +26,4 @@ BEGIN
|
||||
END IF;
|
||||
{{- end }}
|
||||
END;
|
||||
$$;
|
||||
{{dollar_delimiter}};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
DO $$
|
||||
DO {{dollar_delimiter}}
|
||||
DECLARE
|
||||
current_not_null boolean;
|
||||
BEGIN
|
||||
@@ -7,9 +7,9 @@ BEGIN
|
||||
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}}'
|
||||
WHERE n.nspname = {{quote .SchemaName}}
|
||||
AND t.relname = {{quote .TableName}}
|
||||
AND a.attname = {{quote .ColumnName}}
|
||||
AND a.attnum > 0
|
||||
AND NOT a.attisdropped;
|
||||
|
||||
@@ -23,4 +23,4 @@ BEGIN
|
||||
{{- end }}
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
{{dollar_delimiter}};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
DO $$
|
||||
DO {{dollar_delimiter}}
|
||||
DECLARE
|
||||
current_type text;
|
||||
BEGIN
|
||||
@@ -19,4 +19,4 @@ BEGIN
|
||||
ALTER COLUMN {{quote_ident .ColumnName}} TYPE {{.NewType}}{{if .UsingExpr}} USING {{.UsingExpr}}{{end}};
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
{{dollar_delimiter}};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
CREATE OR REPLACE FUNCTION {{qual_table_raw .SchemaName .FunctionName}}()
|
||||
CREATE OR REPLACE FUNCTION {{qual_table .SchemaName .FunctionName}}()
|
||||
RETURNS trigger AS
|
||||
$body$
|
||||
{{dollar_delimiter}}
|
||||
DECLARE
|
||||
m_funcname text = '{{.FunctionName}}';
|
||||
m_funcname text = {{quote .FunctionName}};
|
||||
m_user text;
|
||||
m_atevent integer;
|
||||
BEGIN
|
||||
@@ -23,8 +23,8 @@ BEGIN
|
||||
{{- if .AuditInsert}}
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
-- Record INSERT
|
||||
INSERT INTO {{.AuditSchema}}.atevent (tablename, tableprefix, rid_parent, changeuser, changedate, changetime, actionx)
|
||||
VALUES ('{{.TableName}}', {{.TablePrefix}}, new.{{.PrimaryKey}}, m_user, CURRENT_DATE, CURRENT_TIME, 1)
|
||||
INSERT INTO {{quote_ident .AuditSchema}}.atevent (tablename, tableprefix, rid_parent, changeuser, changedate, changetime, actionx)
|
||||
VALUES ({{quote .TableName}}, {{.TablePrefix}}, new.{{quote_ident .PrimaryKey}}, m_user, CURRENT_DATE, CURRENT_TIME, 1)
|
||||
RETURNING rid_atevent INTO m_atevent;
|
||||
{{- end}}
|
||||
|
||||
@@ -32,15 +32,15 @@ BEGIN
|
||||
ELSIF TG_OP = 'UPDATE' THEN
|
||||
-- Check if any audited columns changed
|
||||
IF ({{.UpdateCondition}}) THEN
|
||||
INSERT INTO {{.AuditSchema}}.atevent (tablename, tableprefix, rid_parent, changeuser, changedate, changetime, actionx)
|
||||
VALUES ('{{.TableName}}', {{.TablePrefix}}, new.{{.PrimaryKey}}, m_user, CURRENT_DATE, CURRENT_TIME, 2)
|
||||
INSERT INTO {{quote_ident .AuditSchema}}.atevent (tablename, tableprefix, rid_parent, changeuser, changedate, changetime, actionx)
|
||||
VALUES ({{quote .TableName}}, {{.TablePrefix}}, new.{{quote_ident .PrimaryKey}}, m_user, CURRENT_DATE, CURRENT_TIME, 2)
|
||||
RETURNING rid_atevent INTO m_atevent;
|
||||
|
||||
-- Record column changes
|
||||
{{- range .UpdateColumns}}
|
||||
IF (old.{{.Name}} IS DISTINCT FROM new.{{.Name}}) THEN
|
||||
INSERT INTO {{$.AuditSchema}}.atdetail(rid_atevent, datacolumn, changedfrom, changedto)
|
||||
VALUES (m_atevent, '{{.Name}}', substr({{.OldValue}}, 1, 1000), substr({{.NewValue}}, 1, 1000));
|
||||
IF (old.{{quote_ident .Name}} IS DISTINCT FROM new.{{quote_ident .Name}}) THEN
|
||||
INSERT INTO {{quote_ident $.AuditSchema}}.atdetail(rid_atevent, datacolumn, changedfrom, changedto)
|
||||
VALUES (m_atevent, {{quote .Name}}, substr({{.OldValue}}, 1, 1000), substr({{.NewValue}}, 1, 1000));
|
||||
END IF;
|
||||
{{- end}}
|
||||
END IF;
|
||||
@@ -49,14 +49,14 @@ BEGIN
|
||||
{{- if .AuditDelete}}
|
||||
ELSIF TG_OP = 'DELETE' THEN
|
||||
-- Record DELETE
|
||||
INSERT INTO {{.AuditSchema}}.atevent (tablename, tableprefix, rid_parent, rid_deletedparent, changeuser, changedate, changetime, actionx)
|
||||
VALUES ('{{.TableName}}', {{.TablePrefix}}, old.{{.PrimaryKey}}, old.{{.PrimaryKey}}, m_user, CURRENT_DATE, CURRENT_TIME, 3)
|
||||
INSERT INTO {{quote_ident .AuditSchema}}.atevent (tablename, tableprefix, rid_parent, rid_deletedparent, changeuser, changedate, changetime, actionx)
|
||||
VALUES ({{quote .TableName}}, {{.TablePrefix}}, old.{{quote_ident .PrimaryKey}}, old.{{quote_ident .PrimaryKey}}, m_user, CURRENT_DATE, CURRENT_TIME, 3)
|
||||
RETURNING rid_atevent INTO m_atevent;
|
||||
|
||||
-- Record deleted column values
|
||||
{{- range .DeleteColumns}}
|
||||
INSERT INTO {{$.AuditSchema}}.atdetail(rid_atevent, datacolumn, changedfrom, changedto)
|
||||
VALUES (m_atevent, '{{.Name}}', substr({{.OldValue}}, 1, 1000), NULL);
|
||||
INSERT INTO {{quote_ident $.AuditSchema}}.atdetail(rid_atevent, datacolumn, changedfrom, changedto)
|
||||
VALUES (m_atevent, {{quote .Name}}, substr({{.OldValue}}, 1, 1000), NULL);
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
END IF;
|
||||
@@ -76,9 +76,9 @@ EXCEPTION
|
||||
RAISE WARNING 'Audit function % failed: %', m_funcname, SQLERRM;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$body$
|
||||
{{dollar_delimiter}}
|
||||
LANGUAGE plpgsql
|
||||
VOLATILE
|
||||
SECURITY DEFINER;
|
||||
|
||||
COMMENT ON FUNCTION {{qual_table_raw .SchemaName .FunctionName}}() IS 'Audit trigger function for table {{qual_table_raw .SchemaName .TableName}}';
|
||||
COMMENT ON FUNCTION {{qual_table .SchemaName .FunctionName}}() IS {{quote (printf "Audit trigger function for table %s" (qual_table .SchemaName .TableName))}};
|
||||
@@ -1,5 +1,5 @@
|
||||
-- Audit Event Header Table
|
||||
CREATE TABLE IF NOT EXISTS {{.AuditSchema}}.atevent (
|
||||
CREATE TABLE IF NOT EXISTS {{quote_ident .AuditSchema}}.atevent (
|
||||
rid_atevent serial PRIMARY KEY,
|
||||
tablename text NOT NULL,
|
||||
tableprefix text,
|
||||
@@ -12,38 +12,38 @@ CREATE TABLE IF NOT EXISTS {{.AuditSchema}}.atevent (
|
||||
CONSTRAINT ck_atevent_action CHECK (actionx IN (1, 2, 3))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_atevent_tablename ON {{.AuditSchema}}.atevent(tablename);
|
||||
CREATE INDEX IF NOT EXISTS idx_atevent_rid_parent ON {{.AuditSchema}}.atevent(rid_parent);
|
||||
CREATE INDEX IF NOT EXISTS idx_atevent_changedate ON {{.AuditSchema}}.atevent(changedate);
|
||||
CREATE INDEX IF NOT EXISTS idx_atevent_changeuser ON {{.AuditSchema}}.atevent(changeuser);
|
||||
CREATE INDEX IF NOT EXISTS idx_atevent_tablename ON {{quote_ident .AuditSchema}}.atevent(tablename);
|
||||
CREATE INDEX IF NOT EXISTS idx_atevent_rid_parent ON {{quote_ident .AuditSchema}}.atevent(rid_parent);
|
||||
CREATE INDEX IF NOT EXISTS idx_atevent_changedate ON {{quote_ident .AuditSchema}}.atevent(changedate);
|
||||
CREATE INDEX IF NOT EXISTS idx_atevent_changeuser ON {{quote_ident .AuditSchema}}.atevent(changeuser);
|
||||
|
||||
COMMENT ON TABLE {{.AuditSchema}}.atevent IS 'Audit trail header table - tracks all data changes';
|
||||
COMMENT ON COLUMN {{.AuditSchema}}.atevent.rid_atevent IS 'Audit event ID';
|
||||
COMMENT ON COLUMN {{.AuditSchema}}.atevent.tablename IS 'Name of the table that was modified';
|
||||
COMMENT ON COLUMN {{.AuditSchema}}.atevent.rid_parent IS 'Primary key value of the modified record';
|
||||
COMMENT ON COLUMN {{.AuditSchema}}.atevent.rid_deletedparent IS 'Parent reference for deleted records';
|
||||
COMMENT ON COLUMN {{.AuditSchema}}.atevent.changeuser IS 'User who made the change';
|
||||
COMMENT ON COLUMN {{.AuditSchema}}.atevent.changedate IS 'Date of change';
|
||||
COMMENT ON COLUMN {{.AuditSchema}}.atevent.changetime IS 'Time of change';
|
||||
COMMENT ON COLUMN {{.AuditSchema}}.atevent.actionx IS 'Action type: 1=INSERT, 2=UPDATE, 3=DELETE';
|
||||
COMMENT ON TABLE {{quote_ident .AuditSchema}}.atevent IS 'Audit trail header table - tracks all data changes';
|
||||
COMMENT ON COLUMN {{quote_ident .AuditSchema}}.atevent.rid_atevent IS 'Audit event ID';
|
||||
COMMENT ON COLUMN {{quote_ident .AuditSchema}}.atevent.tablename IS 'Name of the table that was modified';
|
||||
COMMENT ON COLUMN {{quote_ident .AuditSchema}}.atevent.rid_parent IS 'Primary key value of the modified record';
|
||||
COMMENT ON COLUMN {{quote_ident .AuditSchema}}.atevent.rid_deletedparent IS 'Parent reference for deleted records';
|
||||
COMMENT ON COLUMN {{quote_ident .AuditSchema}}.atevent.changeuser IS 'User who made the change';
|
||||
COMMENT ON COLUMN {{quote_ident .AuditSchema}}.atevent.changedate IS 'Date of change';
|
||||
COMMENT ON COLUMN {{quote_ident .AuditSchema}}.atevent.changetime IS 'Time of change';
|
||||
COMMENT ON COLUMN {{quote_ident .AuditSchema}}.atevent.actionx IS 'Action type: 1=INSERT, 2=UPDATE, 3=DELETE';
|
||||
|
||||
-- Audit Event Detail Table
|
||||
CREATE TABLE IF NOT EXISTS {{.AuditSchema}}.atdetail (
|
||||
CREATE TABLE IF NOT EXISTS {{quote_ident .AuditSchema}}.atdetail (
|
||||
rid_atdetail serial PRIMARY KEY,
|
||||
rid_atevent integer NOT NULL,
|
||||
datacolumn text NOT NULL,
|
||||
changedfrom text,
|
||||
changedto text,
|
||||
CONSTRAINT fk_atdetail_atevent FOREIGN KEY (rid_atevent)
|
||||
REFERENCES {{.AuditSchema}}.atevent(rid_atevent) ON DELETE CASCADE
|
||||
REFERENCES {{quote_ident .AuditSchema}}.atevent(rid_atevent) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_atdetail_rid_atevent ON {{.AuditSchema}}.atdetail(rid_atevent);
|
||||
CREATE INDEX IF NOT EXISTS idx_atdetail_datacolumn ON {{.AuditSchema}}.atdetail(datacolumn);
|
||||
CREATE INDEX IF NOT EXISTS idx_atdetail_rid_atevent ON {{quote_ident .AuditSchema}}.atdetail(rid_atevent);
|
||||
CREATE INDEX IF NOT EXISTS idx_atdetail_datacolumn ON {{quote_ident .AuditSchema}}.atdetail(datacolumn);
|
||||
|
||||
COMMENT ON TABLE {{.AuditSchema}}.atdetail IS 'Audit trail detail table - stores individual column changes';
|
||||
COMMENT ON COLUMN {{.AuditSchema}}.atdetail.rid_atdetail IS 'Audit detail ID';
|
||||
COMMENT ON COLUMN {{.AuditSchema}}.atdetail.rid_atevent IS 'Reference to audit event';
|
||||
COMMENT ON COLUMN {{.AuditSchema}}.atdetail.datacolumn IS 'Name of the column that changed';
|
||||
COMMENT ON COLUMN {{.AuditSchema}}.atdetail.changedfrom IS 'Old value before change';
|
||||
COMMENT ON COLUMN {{.AuditSchema}}.atdetail.changedto IS 'New value after change';
|
||||
COMMENT ON TABLE {{quote_ident .AuditSchema}}.atdetail IS 'Audit trail detail table - stores individual column changes';
|
||||
COMMENT ON COLUMN {{quote_ident .AuditSchema}}.atdetail.rid_atdetail IS 'Audit detail ID';
|
||||
COMMENT ON COLUMN {{quote_ident .AuditSchema}}.atdetail.rid_atevent IS 'Reference to audit event';
|
||||
COMMENT ON COLUMN {{quote_ident .AuditSchema}}.atdetail.datacolumn IS 'Name of the column that changed';
|
||||
COMMENT ON COLUMN {{quote_ident .AuditSchema}}.atdetail.changedfrom IS 'Old value before change';
|
||||
COMMENT ON COLUMN {{quote_ident .AuditSchema}}.atdetail.changedto IS 'New value after change';
|
||||
@@ -1,16 +1,16 @@
|
||||
DO $$
|
||||
DO {{dollar_delimiter}}
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_trigger
|
||||
WHERE tgname = '{{.TriggerName}}'
|
||||
AND tgrelid = '{{qual_table_raw .SchemaName .TableName}}'::regclass
|
||||
WHERE tgname = {{quote .TriggerName}}
|
||||
AND tgrelid = {{quote (qual_table .SchemaName .TableName)}}::regclass
|
||||
) THEN
|
||||
CREATE TRIGGER {{.TriggerName}}
|
||||
CREATE TRIGGER {{quote_ident .TriggerName}}
|
||||
AFTER {{.Events}}
|
||||
ON {{qual_table_raw .SchemaName .TableName}}
|
||||
ON {{qual_table .SchemaName .TableName}}
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION {{qual_table_raw .SchemaName .FunctionName}}();
|
||||
EXECUTE FUNCTION {{qual_table .SchemaName .FunctionName}}();
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
{{dollar_delimiter}};
|
||||
@@ -1,39 +1,39 @@
|
||||
{{/* Base constraint template */}}
|
||||
{{- define "constraint_base" -}}
|
||||
ALTER TABLE {{qual_table_raw .SchemaName .TableName}}
|
||||
ADD CONSTRAINT {{.ConstraintName}}
|
||||
ALTER TABLE {{qual_table .SchemaName .TableName}}
|
||||
ADD CONSTRAINT {{quote_ident .ConstraintName}}
|
||||
{{block "constraint_definition" .}}{{end}};
|
||||
{{- end -}}
|
||||
|
||||
{{/* Drop constraint with check */}}
|
||||
{{- define "drop_constraint_safe" -}}
|
||||
DO $$
|
||||
DO {{dollar_delimiter}}
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE table_schema = '{{.SchemaName}}'
|
||||
AND table_name = '{{.TableName}}'
|
||||
AND constraint_name = '{{.ConstraintName}}'
|
||||
WHERE table_schema = {{quote .SchemaName}}
|
||||
AND table_name = {{quote .TableName}}
|
||||
AND constraint_name = {{quote .ConstraintName}}
|
||||
) THEN
|
||||
ALTER TABLE {{qual_table_raw .SchemaName .TableName}}
|
||||
DROP CONSTRAINT {{.ConstraintName}};
|
||||
ALTER TABLE {{qual_table .SchemaName .TableName}}
|
||||
DROP CONSTRAINT {{quote_ident .ConstraintName}};
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
{{dollar_delimiter}};
|
||||
{{- end -}}
|
||||
|
||||
{{/* Add constraint with existence check */}}
|
||||
{{- define "add_constraint_safe" -}}
|
||||
DO $$
|
||||
DO {{dollar_delimiter}}
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE table_schema = '{{.SchemaName}}'
|
||||
AND table_name = '{{.TableName}}'
|
||||
AND constraint_name = '{{.ConstraintName}}'
|
||||
WHERE table_schema = {{quote .SchemaName}}
|
||||
AND table_name = {{quote .TableName}}
|
||||
AND constraint_name = {{quote .ConstraintName}}
|
||||
) THEN
|
||||
{{template "constraint_base" .}}
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
{{dollar_delimiter}};
|
||||
{{- end -}}
|
||||
@@ -1,23 +1,23 @@
|
||||
{{/* Base DDL template with common structure */}}
|
||||
{{- define "ddl_header" -}}
|
||||
-- DDL Operation: {{.Operation}}
|
||||
-- Schema: {{.Schema}}
|
||||
-- Object: {{.ObjectName}}
|
||||
-- DDL Operation: {{comment_text .Operation}}
|
||||
-- Schema: {{comment_text .Schema}}
|
||||
-- Object: {{comment_text .ObjectName}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "ddl_footer" -}}
|
||||
-- End of {{.Operation}}
|
||||
-- End of {{comment_text .Operation}}
|
||||
{{- end -}}
|
||||
|
||||
{{/* Base ALTER TABLE structure */}}
|
||||
{{- define "alter_table_base" -}}
|
||||
ALTER TABLE {{qual_table_raw .SchemaName .TableName}}
|
||||
ALTER TABLE {{qual_table .SchemaName .TableName}}
|
||||
{{block "alter_operation" .}}{{end}};
|
||||
{{- end -}}
|
||||
|
||||
{{/* Common existence check pattern */}}
|
||||
{{- define "exists_check" -}}
|
||||
DO $$
|
||||
DO {{dollar_delimiter}}
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
{{block "exists_query" .}}{{end}}
|
||||
@@ -25,10 +25,10 @@ BEGIN
|
||||
{{block "create_statement" .}}{{end}}
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
{{dollar_delimiter}};
|
||||
{{- end -}}
|
||||
|
||||
{{/* Common drop pattern */}}
|
||||
{{- define "drop_if_exists" -}}
|
||||
{{block "drop_type" .}}{{end}} IF EXISTS {{qual_table_raw .SchemaName .ObjectName}};
|
||||
{{block "drop_type" .}}{{end}} IF EXISTS {{qual_table .SchemaName .ObjectName}};
|
||||
{{- end -}}
|
||||
@@ -1 +1 @@
|
||||
COMMENT ON COLUMN {{qual_table .SchemaName .TableName}}.{{quote_ident .ColumnName}} IS '{{.Comment}}';
|
||||
COMMENT ON COLUMN {{qual_table .SchemaName .TableName}}.{{quote_ident .ColumnName}} IS {{quote .Comment}};
|
||||
@@ -1 +1 @@
|
||||
COMMENT ON TABLE {{qual_table .SchemaName .TableName}} IS '{{.Comment}}';
|
||||
COMMENT ON TABLE {{qual_table .SchemaName .TableName}} IS {{quote .Comment}};
|
||||
@@ -1,12 +1,12 @@
|
||||
DO $$
|
||||
DO {{dollar_delimiter}}
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE table_schema = '{{.SchemaName}}'
|
||||
AND table_name = '{{.TableName}}'
|
||||
AND constraint_name = '{{.ConstraintName}}'
|
||||
WHERE table_schema = {{quote .SchemaName}}
|
||||
AND table_name = {{quote .TableName}}
|
||||
AND constraint_name = {{quote .ConstraintName}}
|
||||
) THEN
|
||||
ALTER TABLE {{qual_table .SchemaName .TableName}} ADD CONSTRAINT {{quote_ident .ConstraintName}} CHECK ({{.Expression}});
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
{{dollar_delimiter}};
|
||||
@@ -1,10 +1,10 @@
|
||||
DO $$
|
||||
DO {{dollar_delimiter}}
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE table_schema = '{{.SchemaName}}'
|
||||
AND table_name = '{{.TableName}}'
|
||||
AND constraint_name = '{{.ConstraintName}}'
|
||||
WHERE table_schema = {{quote .SchemaName}}
|
||||
AND table_name = {{quote .TableName}}
|
||||
AND constraint_name = {{quote .ConstraintName}}
|
||||
) THEN
|
||||
ALTER TABLE {{qual_table .SchemaName .TableName}}
|
||||
ADD CONSTRAINT {{quote_ident .ConstraintName}}
|
||||
@@ -15,4 +15,4 @@ BEGIN
|
||||
{{.Deferral}}{{end}};
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
{{dollar_delimiter}};
|
||||
@@ -1,13 +1,13 @@
|
||||
DO $$
|
||||
DO {{dollar_delimiter}}
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE table_schema = '{{.SchemaName}}'
|
||||
AND table_name = '{{.TableName}}'
|
||||
AND constraint_name = '{{.ConstraintName}}'
|
||||
WHERE table_schema = {{quote .SchemaName}}
|
||||
AND table_name = {{quote .TableName}}
|
||||
AND constraint_name = {{quote .ConstraintName}}
|
||||
) THEN
|
||||
ALTER TABLE {{qual_table .SchemaName .TableName}}
|
||||
ADD CONSTRAINT {{quote_ident .ConstraintName}} PRIMARY KEY ({{.Columns}});
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
{{dollar_delimiter}};
|
||||
@@ -1,4 +1,4 @@
|
||||
DO $$
|
||||
DO {{dollar_delimiter}}
|
||||
DECLARE
|
||||
current_pk_name text;
|
||||
current_pk_matches boolean := false;
|
||||
@@ -16,22 +16,22 @@ BEGIN
|
||||
ON a.attrelid = t.oid
|
||||
AND a.attnum = cols.attnum
|
||||
WHERE c.contype = 'p'
|
||||
AND n.nspname = '{{.SchemaName}}'
|
||||
AND t.relname = '{{.TableName}}'
|
||||
AND n.nspname = {{quote .SchemaName}}
|
||||
AND t.relname = {{quote .TableName}}
|
||||
ORDER BY cols.ord
|
||||
),
|
||||
ARRAY[]::text[]
|
||||
) = ARRAY[{{.ColumnNames}}]
|
||||
INTO current_pk_name, current_pk_matches
|
||||
FROM information_schema.table_constraints tc
|
||||
WHERE tc.table_schema = '{{.SchemaName}}'
|
||||
AND tc.table_name = '{{.TableName}}'
|
||||
WHERE tc.table_schema = {{quote .SchemaName}}
|
||||
AND tc.table_name = {{quote .TableName}}
|
||||
AND tc.constraint_type = 'PRIMARY KEY';
|
||||
|
||||
IF current_pk_name IS NOT NULL
|
||||
AND NOT current_pk_matches
|
||||
AND current_pk_name IN ({{.AutoGenNames}}) THEN
|
||||
EXECUTE 'ALTER TABLE {{qual_table .SchemaName .TableName}} DROP CONSTRAINT ' || quote_ident(current_pk_name) || ' CASCADE';
|
||||
EXECUTE {{quote (printf "ALTER TABLE %s DROP CONSTRAINT " (qual_table .SchemaName .TableName))}} || quote_ident(current_pk_name) || ' CASCADE';
|
||||
END IF;
|
||||
|
||||
-- Add the desired primary key only when no matching primary key already exists.
|
||||
@@ -40,4 +40,4 @@ BEGIN
|
||||
ALTER TABLE {{qual_table .SchemaName .TableName}} ADD CONSTRAINT {{quote_ident .ConstraintName}} PRIMARY KEY ({{.Columns}});
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
{{dollar_delimiter}};
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
DO $$
|
||||
DO {{dollar_delimiter}}
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE table_schema = '{{.SchemaName}}'
|
||||
AND table_name = '{{.TableName}}'
|
||||
AND constraint_name = '{{.ConstraintName}}'
|
||||
WHERE table_schema = {{quote .SchemaName}}
|
||||
AND table_name = {{quote .TableName}}
|
||||
AND constraint_name = {{quote .ConstraintName}}
|
||||
) THEN
|
||||
ALTER TABLE {{qual_table .SchemaName .TableName}} ADD CONSTRAINT {{quote_ident .ConstraintName}} UNIQUE {{if .UsingIndex}}USING INDEX {{quote_ident .UsingIndex}}{{else}}({{.Columns}}){{end}};
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
{{dollar_delimiter}};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
{{/* Column definition fragment */}}
|
||||
{{- define "column_definition" -}}
|
||||
{{.Name}} {{.Type}}
|
||||
{{quote_ident .Name}} {{.Type}}
|
||||
{{- if .Generated}} GENERATED ALWAYS AS ({{.Generated}}) STORED{{end}}
|
||||
{{- if .Identity}} GENERATED {{.Identity}} AS IDENTITY{{end}}
|
||||
{{- if and .Default (not .Generated) (not .Identity)}} DEFAULT {{.Default}}{{end}}
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
{{/* Qualified table name */}}
|
||||
{{- define "qualified_table" -}}
|
||||
{{qual_table_raw .SchemaName .TableName}}
|
||||
{{qual_table .SchemaName .TableName}}
|
||||
{{- end -}}
|
||||
|
||||
{{/* Index method clause */}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
DO $$
|
||||
DO {{dollar_delimiter}}
|
||||
DECLARE
|
||||
m_cnt bigint;
|
||||
m_next bigint;
|
||||
@@ -6,8 +6,8 @@ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_class c
|
||||
INNER JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE c.relname = '{{.SequenceName}}'
|
||||
AND n.nspname = '{{.SchemaName}}'
|
||||
WHERE c.relname = {{quote .SequenceName}}
|
||||
AND n.nspname = {{quote .SchemaName}}
|
||||
AND c.relkind = 'S'
|
||||
) THEN
|
||||
SELECT COALESCE(MAX({{quote_ident .ColumnName}}), 0) + 1
|
||||
@@ -20,8 +20,8 @@ BEGIN
|
||||
|
||||
-- Only move the sequence forward; never rewind one that is already past the data.
|
||||
IF m_cnt > m_next THEN
|
||||
PERFORM setval('{{qual_table_raw .SchemaName .SequenceName}}'::regclass, m_cnt, false);
|
||||
PERFORM setval({{quote (qual_table .SchemaName .SequenceName)}}::regclass, m_cnt, false);
|
||||
END IF;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
{{dollar_delimiter}};
|
||||
@@ -20,7 +20,7 @@ func TestConversionMetadataValidationAndBlockDelimiters(t *testing.T) {
|
||||
delete(col.Metadata, key)
|
||||
}
|
||||
}
|
||||
col.Metadata["pgsql_using"] = "length('$relspec_alter_type_0$ $relspec_preflight_0$')"
|
||||
col.Metadata["pgsql_using"] = "length('$relspec_0$ $relspec_preflight_0$')"
|
||||
data, err := conversionData("public", "accounts", col)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -33,7 +33,7 @@ func TestConversionMetadataValidationAndBlockDelimiters(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(alter, "DO $relspec_alter_type_1$") {
|
||||
if !strings.HasPrefix(alter, "DO $relspec_1$") {
|
||||
t.Fatalf("unsafe alter delimiter: %s", alter)
|
||||
}
|
||||
preflight := preflightColumnType(data, false)
|
||||
|
||||
+186
-123
@@ -37,6 +37,9 @@ type ExecutionError = writers.ExecutionError
|
||||
|
||||
// NewWriter creates a new PostgreSQL SQL writer
|
||||
func NewWriter(options *writers.WriterOptions) *Writer {
|
||||
if options == nil {
|
||||
options = &writers.WriterOptions{}
|
||||
}
|
||||
executor, _ := NewTemplateExecutor(options.FlattenSchema)
|
||||
return &Writer{
|
||||
options: options,
|
||||
@@ -46,11 +49,22 @@ func NewWriter(options *writers.WriterOptions) *Writer {
|
||||
|
||||
// qualTable returns a schema-qualified name using the writer's FlattenSchema setting.
|
||||
func (w *Writer) qualTable(schema, name string) string {
|
||||
return writers.QualifiedTableName(schema, name, w.options.FlattenSchema)
|
||||
if schema == "" {
|
||||
return quoteIdent(name)
|
||||
}
|
||||
if w.options.FlattenSchema {
|
||||
return quoteIdent(schema + "_" + name)
|
||||
}
|
||||
return quoteIdent(schema) + "." + quoteIdent(name)
|
||||
}
|
||||
|
||||
// WriteDatabase writes the entire database schema as SQL
|
||||
func (w *Writer) WriteDatabase(db *models.Database) error {
|
||||
var preparationErr error
|
||||
db, preparationErr = prepareModel(db, w.options.LowercaseIdentifiers)
|
||||
if preparationErr != nil {
|
||||
return preparationErr
|
||||
}
|
||||
// Check if we should execute SQL directly on a database
|
||||
if connString, ok := w.options.Metadata["connection_string"].(string); ok && connString != "" {
|
||||
return w.executeDatabaseSQL(db, connString)
|
||||
@@ -79,7 +93,7 @@ func (w *Writer) WriteDatabase(db *models.Database) error {
|
||||
|
||||
// Write header comment
|
||||
fmt.Fprintf(w.writer, "-- PostgreSQL Database Schema\n")
|
||||
fmt.Fprintf(w.writer, "-- Database: %s\n", db.Name)
|
||||
fmt.Fprintf(w.writer, "-- Database: %s\n", commentText(db.Name))
|
||||
fmt.Fprintf(w.writer, "-- Generated by RelSpec\n")
|
||||
if w.options.ContinueOnError {
|
||||
fmt.Fprintf(w.writer, "\\set ON_ERROR_STOP off\n")
|
||||
@@ -110,11 +124,16 @@ func (w *Writer) WriteDatabase(db *models.Database) error {
|
||||
// GenerateDatabaseStatements generates SQL statements as a list for the entire database
|
||||
// Returns a slice of SQL statements that can be executed independently
|
||||
func (w *Writer) GenerateDatabaseStatements(db *models.Database) ([]string, error) {
|
||||
var preparationErr error
|
||||
db, preparationErr = prepareModel(db, w.options.LowercaseIdentifiers)
|
||||
if preparationErr != nil {
|
||||
return nil, preparationErr
|
||||
}
|
||||
statements := []string{}
|
||||
|
||||
// Add header comment
|
||||
statements = append(statements, "-- PostgreSQL Database Schema")
|
||||
statements = append(statements, fmt.Sprintf("-- Database: %s", db.Name))
|
||||
statements = append(statements, fmt.Sprintf("-- Database: %s", commentText(db.Name)))
|
||||
statements = append(statements, "-- Generated by RelSpec")
|
||||
|
||||
for _, schema := range db.Schemas {
|
||||
@@ -161,7 +180,7 @@ func (w *Writer) primaryKeySequenceStatement(schema *models.Schema, table *model
|
||||
}
|
||||
|
||||
return fmt.Sprintf("CREATE SEQUENCE IF NOT EXISTS %s\n INCREMENT 1\n MINVALUE 1\n MAXVALUE 9223372036854775807\n START 1\n CACHE 1",
|
||||
w.qualTable(schema.SQLName(), seqName))
|
||||
w.qualTable(schema.Name, seqName))
|
||||
}
|
||||
|
||||
// primaryKeySetvalStatement returns the statement that moves a table's primary key
|
||||
@@ -182,6 +201,11 @@ func (w *Writer) primaryKeySetvalStatement(schema *models.Schema, table *models.
|
||||
|
||||
// GenerateSchemaStatements generates SQL statements as a list for a single schema
|
||||
func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, error) {
|
||||
var err error
|
||||
schema, err = w.prepareSchema(schema)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
preflights, err := w.generateTypePreflights(schema)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -199,8 +223,8 @@ func (w *Writer) generateSchemaStatements(schema *models.Schema) ([]string, erro
|
||||
|
||||
// Phase 1: Create schema (skip entirely when flattening)
|
||||
if schema.Name != "public" && !w.options.FlattenSchema {
|
||||
statements = append(statements, fmt.Sprintf("-- Schema: %s", schema.Name))
|
||||
statements = append(statements, fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s", schema.SQLName()))
|
||||
statements = append(statements, fmt.Sprintf("-- Schema: %s", commentText(schema.Name)))
|
||||
statements = append(statements, fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s", quoteIdent(schema.Name)))
|
||||
}
|
||||
|
||||
for _, extension := range requiredExtensions(schema) {
|
||||
@@ -258,14 +282,14 @@ func (w *Writer) generateSchemaStatements(schema *models.Schema) ([]string, erro
|
||||
pkCols := []string{}
|
||||
for _, col := range table.Columns {
|
||||
if col.IsPrimaryKey {
|
||||
pkCols = append(pkCols, col.SQLName())
|
||||
pkCols = append(pkCols, col.Name)
|
||||
}
|
||||
}
|
||||
if len(pkCols) > 0 {
|
||||
// Sort for consistent output
|
||||
sort.Strings(pkCols)
|
||||
pkColumns = pkCols
|
||||
pkName = fmt.Sprintf("pk_%s_%s", schema.SQLName(), table.SQLName())
|
||||
pkName = fmt.Sprintf("pk_%s_%s", schema.Name, table.Name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,7 +306,7 @@ func (w *Writer) generateSchemaStatements(schema *models.Schema) ([]string, erro
|
||||
TableName: table.Name,
|
||||
ConstraintName: pkName,
|
||||
AutoGenNames: formatStringList(autoGenPKNames),
|
||||
Columns: strings.Join(pkColumns, ", "),
|
||||
Columns: quotedIdentifiers(pkColumns),
|
||||
ColumnNames: formatStringList(pkColumns),
|
||||
}
|
||||
|
||||
@@ -322,7 +346,7 @@ func (w *Writer) generateSchemaStatements(schema *models.Schema) ([]string, erro
|
||||
|
||||
tablespaceClause := ""
|
||||
if ts := pgIndexDirectiveTablespace(index); ts != "" {
|
||||
tablespaceClause = fmt.Sprintf(" TABLESPACE %s", ts)
|
||||
tablespaceClause = fmt.Sprintf(" TABLESPACE %s", quoteIdent(ts))
|
||||
}
|
||||
|
||||
whereClause := ""
|
||||
@@ -331,7 +355,7 @@ func (w *Writer) generateSchemaStatements(schema *models.Schema) ([]string, erro
|
||||
}
|
||||
|
||||
stmt := fmt.Sprintf("CREATE %sINDEX IF NOT EXISTS %s ON %s USING %s (%s)%s%s%s",
|
||||
uniqueStr, quoteIdentifier(index.Name), w.qualTable(schema.SQLName(), table.SQLName()), indexType, strings.Join(columnExprs, ", "), withClause, tablespaceClause, whereClause)
|
||||
uniqueStr, quoteIdentifier(index.Name), w.qualTable(schema.Name, table.Name), indexType, strings.Join(columnExprs, ", "), withClause, tablespaceClause, whereClause)
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
}
|
||||
@@ -348,7 +372,7 @@ func (w *Writer) generateSchemaStatements(schema *models.Schema) ([]string, erro
|
||||
SchemaName: schema.Name,
|
||||
TableName: table.Name,
|
||||
ConstraintName: constraint.Name,
|
||||
Columns: strings.Join(constraint.Columns, ", "),
|
||||
Columns: quotedIdentifiers(constraint.Columns),
|
||||
UsingIndex: adoptableUniqueIndex(table, constraint),
|
||||
}
|
||||
|
||||
@@ -410,10 +434,10 @@ func (w *Writer) generateSchemaStatements(schema *models.Schema) ([]string, erro
|
||||
SchemaName: schema.Name,
|
||||
TableName: table.Name,
|
||||
ConstraintName: constraint.Name,
|
||||
SourceColumns: strings.Join(constraint.Columns, ", "),
|
||||
SourceColumns: quotedIdentifiers(constraint.Columns),
|
||||
TargetSchema: refSchema,
|
||||
TargetTable: constraint.ReferencedTable,
|
||||
TargetColumns: strings.Join(constraint.ReferencedColumns, ", "),
|
||||
TargetColumns: quotedIdentifiers(constraint.ReferencedColumns),
|
||||
OnDelete: onDelete,
|
||||
OnUpdate: onUpdate,
|
||||
Deferral: constraint.DeferralClause(),
|
||||
@@ -441,15 +465,15 @@ func (w *Writer) generateSchemaStatements(schema *models.Schema) ([]string, erro
|
||||
// Phase 7: Comments
|
||||
for _, table := range schema.Tables {
|
||||
if table.Comment != "" {
|
||||
stmt := fmt.Sprintf("COMMENT ON TABLE %s IS '%s'",
|
||||
w.qualTable(schema.SQLName(), table.SQLName()), escapeQuote(table.Comment))
|
||||
stmt := fmt.Sprintf("COMMENT ON TABLE %s IS %s",
|
||||
w.qualTable(schema.Name, table.Name), quote(table.Comment))
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
|
||||
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))
|
||||
stmt := fmt.Sprintf("COMMENT ON COLUMN %s.%s IS %s",
|
||||
w.qualTable(schema.Name, table.Name), quoteIdent(column.Name), quote(column.Comment))
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
}
|
||||
@@ -461,9 +485,14 @@ func (w *Writer) generateSchemaStatements(schema *models.Schema) ([]string, erro
|
||||
// GenerateAddColumnStatements generates ALTER TABLE ADD COLUMN statements for existing tables
|
||||
// This is useful for schema evolution when new columns are added to existing tables
|
||||
func (w *Writer) GenerateAddColumnStatements(schema *models.Schema) ([]string, error) {
|
||||
var err error
|
||||
schema, err = w.prepareSchema(schema)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
statements := []string{}
|
||||
|
||||
statements = append(statements, fmt.Sprintf("-- Add missing columns for schema: %s", schema.Name))
|
||||
statements = append(statements, fmt.Sprintf("-- Add missing columns for schema: %s", commentText(schema.Name)))
|
||||
|
||||
for _, table := range schema.Tables {
|
||||
// Sort columns by sequence or name for consistent output
|
||||
@@ -501,9 +530,14 @@ func (w *Writer) GenerateAddColumnStatements(schema *models.Schema) ([]string, e
|
||||
}
|
||||
|
||||
func (w *Writer) GenerateAlterColumnTypeStatements(schema *models.Schema) ([]string, error) {
|
||||
var err error
|
||||
schema, err = w.prepareSchema(schema)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
statements := []string{}
|
||||
|
||||
statements = append(statements, fmt.Sprintf("-- Alter column types for schema: %s", schema.Name))
|
||||
statements = append(statements, fmt.Sprintf("-- Alter column types for schema: %s", commentText(schema.Name)))
|
||||
|
||||
for _, table := range schema.Tables {
|
||||
columns := getSortedColumns(table.Columns)
|
||||
@@ -533,9 +567,14 @@ func (w *Writer) GenerateAlterColumnTypeStatements(schema *models.Schema) ([]str
|
||||
// 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) {
|
||||
var err error
|
||||
schema, err = w.prepareSchema(schema)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
statements := []string{}
|
||||
|
||||
statements = append(statements, fmt.Sprintf("-- Alter column defaults for schema: %s", schema.Name))
|
||||
statements = append(statements, fmt.Sprintf("-- Alter column defaults for schema: %s", commentText(schema.Name)))
|
||||
|
||||
for _, table := range schema.Tables {
|
||||
columns := getSortedColumns(table.Columns)
|
||||
@@ -592,7 +631,7 @@ func formatColumnDefaultSQL(col *models.Column) (setDefault bool, defaultVal str
|
||||
return false, ""
|
||||
}
|
||||
if value, ok := col.Default.(string); ok {
|
||||
return true, writers.QuoteDefaultValue(stripBackticks(value), col.Type)
|
||||
return true, quoteDefaultValue(stripBackticks(value), col.Type)
|
||||
}
|
||||
return true, fmt.Sprintf("%v", col.Default)
|
||||
}
|
||||
@@ -601,9 +640,14 @@ func formatColumnDefaultSQL(col *models.Column) (setDefault bool, defaultVal str
|
||||
// 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) {
|
||||
var err error
|
||||
schema, err = w.prepareSchema(schema)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
statements := []string{}
|
||||
|
||||
statements = append(statements, fmt.Sprintf("-- Alter column nullability for schema: %s", schema.Name))
|
||||
statements = append(statements, fmt.Sprintf("-- Alter column nullability for schema: %s", commentText(schema.Name)))
|
||||
|
||||
for _, table := range schema.Tables {
|
||||
columns := getSortedColumns(table.Columns)
|
||||
@@ -626,10 +670,15 @@ func (w *Writer) GenerateAlterColumnNullabilityStatements(schema *models.Schema)
|
||||
|
||||
// GenerateAddColumnsForDatabase generates ALTER TABLE ADD COLUMN statements for the entire database
|
||||
func (w *Writer) GenerateAddColumnsForDatabase(db *models.Database) ([]string, error) {
|
||||
prepared, preparationErr := prepareModel(db, w.options.LowercaseIdentifiers)
|
||||
if preparationErr != nil {
|
||||
return nil, preparationErr
|
||||
}
|
||||
db = prepared
|
||||
statements := []string{}
|
||||
|
||||
statements = append(statements, "-- Add missing columns to existing tables")
|
||||
statements = append(statements, fmt.Sprintf("-- Database: %s", db.Name))
|
||||
statements = append(statements, fmt.Sprintf("-- Database: %s", commentText(db.Name)))
|
||||
statements = append(statements, "-- Generated by RelSpec")
|
||||
|
||||
for _, schema := range db.Schemas {
|
||||
@@ -666,7 +715,7 @@ func (w *Writer) generateCreateTableStatement(schema *models.Schema, table *mode
|
||||
}
|
||||
|
||||
stmt := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (\n%s\n)%s",
|
||||
w.qualTable(schema.SQLName(), table.SQLName()), strings.Join(columnDefs, ",\n"),
|
||||
w.qualTable(schema.Name, table.Name), strings.Join(columnDefs, ",\n"),
|
||||
pgTableDirectiveSuffix(table))
|
||||
statements = append(statements, stmt)
|
||||
|
||||
@@ -675,7 +724,7 @@ func (w *Writer) generateCreateTableStatement(schema *models.Schema, table *mode
|
||||
|
||||
// generateColumnDefinition generates column definition
|
||||
func (w *Writer) generateColumnDefinition(col *models.Column) string {
|
||||
parts := []string{col.SQLName()}
|
||||
parts := []string{quoteIdent(col.Name)}
|
||||
|
||||
parts = append(parts, effectiveColumnSQLType(col))
|
||||
|
||||
@@ -700,7 +749,7 @@ func (w *Writer) generateColumnDefinition(col *models.Column) string {
|
||||
if !col.Generated && !col.Identity && col.Default != nil {
|
||||
switch v := col.Default.(type) {
|
||||
case string:
|
||||
parts = append(parts, fmt.Sprintf("DEFAULT %s", writers.QuoteDefaultValue(stripBackticks(v), col.Type)))
|
||||
parts = append(parts, fmt.Sprintf("DEFAULT %s", quoteDefaultValue(stripBackticks(v), col.Type)))
|
||||
case bool:
|
||||
parts = append(parts, fmt.Sprintf("DEFAULT %v", v))
|
||||
default:
|
||||
@@ -764,13 +813,18 @@ func equivalentTypeListSQL(sqlType string) string {
|
||||
variants := pgsql.EquivalentSQLTypeVariants(sqlType)
|
||||
quoted := make([]string, 0, len(variants))
|
||||
for _, variant := range variants {
|
||||
quoted = append(quoted, fmt.Sprintf("'%s'", escapeQuote(variant)))
|
||||
quoted = append(quoted, quote(variant))
|
||||
}
|
||||
return strings.Join(quoted, ", ")
|
||||
}
|
||||
|
||||
// WriteSchema writes a single schema and all its tables
|
||||
func (w *Writer) WriteSchema(schema *models.Schema) error {
|
||||
var err error
|
||||
schema, err = w.prepareSchema(schema)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if w.writer == nil {
|
||||
w.writer = os.Stdout
|
||||
}
|
||||
@@ -885,6 +939,11 @@ func (w *Writer) WriteTable(table *models.Table) error {
|
||||
// WriteAddColumnStatements writes ALTER TABLE ADD COLUMN statements for a database
|
||||
// This is used for schema evolution/migration when new columns are added
|
||||
func (w *Writer) WriteAddColumnStatements(db *models.Database) error {
|
||||
prepared, preparationErr := prepareModel(db, w.options.LowercaseIdentifiers)
|
||||
if preparationErr != nil {
|
||||
return preparationErr
|
||||
}
|
||||
db = prepared
|
||||
var writer io.Writer
|
||||
var file *os.File
|
||||
var err error
|
||||
@@ -926,8 +985,8 @@ func (w *Writer) writeCreateSchema(schema *models.Schema) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(w.writer, "-- Schema: %s\n", schema.Name)
|
||||
fmt.Fprintf(w.writer, "CREATE SCHEMA IF NOT EXISTS %s;\n\n", schema.SQLName())
|
||||
fmt.Fprintf(w.writer, "-- Schema: %s\n", commentText(schema.Name))
|
||||
fmt.Fprintf(w.writer, "CREATE SCHEMA IF NOT EXISTS %s;\n\n", quoteIdent(schema.Name))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -946,7 +1005,7 @@ func (w *Writer) writeRequiredExtensions(schema *models.Schema) error {
|
||||
|
||||
// writeSequences generates CREATE SEQUENCE statements for identity columns
|
||||
func (w *Writer) writeSequences(schema *models.Schema) error {
|
||||
fmt.Fprintf(w.writer, "-- Sequences for schema: %s\n", schema.Name)
|
||||
fmt.Fprintf(w.writer, "-- Sequences for schema: %s\n", commentText(schema.Name))
|
||||
|
||||
for _, table := range schema.Tables {
|
||||
// Only create the sequence the primary key's nextval() default uses
|
||||
@@ -978,11 +1037,11 @@ func (w *Writer) writeSequences(schema *models.Schema) error {
|
||||
|
||||
// writeCreateTables generates CREATE TABLE statements
|
||||
func (w *Writer) writeCreateTables(schema *models.Schema) error {
|
||||
fmt.Fprintf(w.writer, "-- Tables for schema: %s\n", schema.Name)
|
||||
fmt.Fprintf(w.writer, "-- Tables for schema: %s\n", commentText(schema.Name))
|
||||
|
||||
for _, table := range schema.Tables {
|
||||
fmt.Fprintf(w.writer, "CREATE TABLE IF NOT EXISTS %s (\n",
|
||||
w.qualTable(schema.SQLName(), table.SQLName()))
|
||||
w.qualTable(schema.Name, table.Name))
|
||||
|
||||
// Write columns
|
||||
columns := getSortedColumns(table.Columns)
|
||||
@@ -1003,7 +1062,7 @@ func (w *Writer) writeCreateTables(schema *models.Schema) error {
|
||||
|
||||
// writeAddColumns generates ALTER TABLE ADD COLUMN statements for missing columns
|
||||
func (w *Writer) writeAddColumns(schema *models.Schema) error {
|
||||
fmt.Fprintf(w.writer, "-- Add missing columns for schema: %s\n", schema.Name)
|
||||
fmt.Fprintf(w.writer, "-- Add missing columns for schema: %s\n", commentText(schema.Name))
|
||||
|
||||
for _, table := range schema.Tables {
|
||||
// Sort columns by sequence or name for consistent output
|
||||
@@ -1032,7 +1091,7 @@ func (w *Writer) writeAddColumns(schema *models.Schema) error {
|
||||
}
|
||||
|
||||
func (w *Writer) writeAlterColumnTypes(schema *models.Schema) error {
|
||||
fmt.Fprintf(w.writer, "-- Alter column types for schema: %s\n", schema.Name)
|
||||
fmt.Fprintf(w.writer, "-- Alter column types for schema: %s\n", commentText(schema.Name))
|
||||
|
||||
statements, err := w.GenerateAlterColumnTypeStatements(schema)
|
||||
if err != nil {
|
||||
@@ -1047,7 +1106,7 @@ func (w *Writer) writeAlterColumnTypes(schema *models.Schema) error {
|
||||
}
|
||||
|
||||
func (w *Writer) writeAlterColumnDefaults(schema *models.Schema) error {
|
||||
fmt.Fprintf(w.writer, "-- Alter column defaults for schema: %s\n", schema.Name)
|
||||
fmt.Fprintf(w.writer, "-- Alter column defaults for schema: %s\n", commentText(schema.Name))
|
||||
|
||||
statements, err := w.GenerateAlterColumnDefaultStatements(schema)
|
||||
if err != nil {
|
||||
@@ -1062,7 +1121,7 @@ func (w *Writer) writeAlterColumnDefaults(schema *models.Schema) error {
|
||||
}
|
||||
|
||||
func (w *Writer) writeAlterColumnNullability(schema *models.Schema) error {
|
||||
fmt.Fprintf(w.writer, "-- Alter column nullability for schema: %s\n", schema.Name)
|
||||
fmt.Fprintf(w.writer, "-- Alter column nullability for schema: %s\n", commentText(schema.Name))
|
||||
|
||||
statements, err := w.GenerateAlterColumnNullabilityStatements(schema)
|
||||
if err != nil {
|
||||
@@ -1078,7 +1137,7 @@ func (w *Writer) writeAlterColumnNullability(schema *models.Schema) error {
|
||||
|
||||
// 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)
|
||||
fmt.Fprintf(w.writer, "-- Primary keys for schema: %s\n", commentText(schema.Name))
|
||||
|
||||
for _, table := range schema.Tables {
|
||||
// Find primary key constraint
|
||||
@@ -1091,21 +1150,21 @@ func (w *Writer) writePrimaryKeys(schema *models.Schema) error {
|
||||
}
|
||||
|
||||
var columnNames []string
|
||||
pkName := fmt.Sprintf("pk_%s_%s", schema.SQLName(), table.SQLName())
|
||||
pkName := fmt.Sprintf("pk_%s_%s", schema.Name, table.Name)
|
||||
|
||||
if pkConstraint != nil {
|
||||
// Build column list from explicit constraint
|
||||
columnNames = make([]string, 0, len(pkConstraint.Columns))
|
||||
for _, colName := range pkConstraint.Columns {
|
||||
if col, ok := table.Columns[colName]; ok {
|
||||
columnNames = append(columnNames, col.SQLName())
|
||||
columnNames = append(columnNames, col.Name)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No explicit PK constraint, check for columns with IsPrimaryKey = true
|
||||
for _, col := range table.Columns {
|
||||
if col.IsPrimaryKey {
|
||||
columnNames = append(columnNames, col.SQLName())
|
||||
columnNames = append(columnNames, col.Name)
|
||||
}
|
||||
}
|
||||
// Sort for consistent output
|
||||
@@ -1127,7 +1186,7 @@ func (w *Writer) writePrimaryKeys(schema *models.Schema) error {
|
||||
TableName: table.Name,
|
||||
ConstraintName: pkName,
|
||||
AutoGenNames: formatStringList(autoGenPKNames),
|
||||
Columns: strings.Join(columnNames, ", "),
|
||||
Columns: quotedIdentifiers(columnNames),
|
||||
ColumnNames: formatStringList(columnNames),
|
||||
}
|
||||
|
||||
@@ -1144,7 +1203,7 @@ func (w *Writer) writePrimaryKeys(schema *models.Schema) error {
|
||||
|
||||
// writeIndexes generates CREATE INDEX statements
|
||||
func (w *Writer) writeIndexes(schema *models.Schema) error {
|
||||
fmt.Fprintf(w.writer, "-- Indexes for schema: %s\n", schema.Name)
|
||||
fmt.Fprintf(w.writer, "-- Indexes for schema: %s\n", commentText(schema.Name))
|
||||
|
||||
for _, table := range schema.Tables {
|
||||
// Sort indexes by name for consistent output
|
||||
@@ -1170,7 +1229,7 @@ func (w *Writer) writeIndexes(schema *models.Schema) error {
|
||||
indexType = "ukey"
|
||||
}
|
||||
columnSuffix := strings.Join(index.Columns, "_")
|
||||
indexName = fmt.Sprintf("%s_%s_%s", indexType, table.SQLName(), strings.ToLower(columnSuffix))
|
||||
indexName = fmt.Sprintf("%s_%s_%s", indexType, table.Name, strings.ToLower(columnSuffix))
|
||||
}
|
||||
|
||||
indexType := index.Type
|
||||
@@ -1196,7 +1255,7 @@ func (w *Writer) writeIndexes(schema *models.Schema) error {
|
||||
|
||||
tablespaceClause := ""
|
||||
if ts := pgIndexDirectiveTablespace(index); ts != "" {
|
||||
tablespaceClause = fmt.Sprintf(" TABLESPACE %s", ts)
|
||||
tablespaceClause = fmt.Sprintf(" TABLESPACE %s", quoteIdent(ts))
|
||||
}
|
||||
|
||||
whereClause := ""
|
||||
@@ -1210,9 +1269,9 @@ func (w *Writer) writeIndexes(schema *models.Schema) error {
|
||||
}
|
||||
|
||||
fmt.Fprintf(w.writer, "CREATE %sINDEX %sIF NOT EXISTS %s\n",
|
||||
unique, concurrently, indexName)
|
||||
unique, concurrently, quoteIdent(indexName))
|
||||
fmt.Fprintf(w.writer, " ON %s USING %s (%s)%s%s%s;\n\n",
|
||||
w.qualTable(schema.SQLName(), table.SQLName()), indexType, strings.Join(columnExprs, ", "), withClause, tablespaceClause, whereClause)
|
||||
w.qualTable(schema.Name, table.Name), indexType, strings.Join(columnExprs, ", "), withClause, tablespaceClause, whereClause)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1221,7 +1280,7 @@ func (w *Writer) writeIndexes(schema *models.Schema) error {
|
||||
|
||||
// writeUniqueConstraints generates ALTER TABLE statements for unique constraints
|
||||
func (w *Writer) writeUniqueConstraints(schema *models.Schema) error {
|
||||
fmt.Fprintf(w.writer, "-- Unique constraints for schema: %s\n", schema.Name)
|
||||
fmt.Fprintf(w.writer, "-- Unique constraints for schema: %s\n", commentText(schema.Name))
|
||||
|
||||
for _, table := range schema.Tables {
|
||||
// Sort constraints by name for consistent output
|
||||
@@ -1240,7 +1299,7 @@ func (w *Writer) writeUniqueConstraints(schema *models.Schema) error {
|
||||
columnExprs := make([]string, 0, len(constraint.Columns))
|
||||
for _, colName := range constraint.Columns {
|
||||
if col, ok := table.Columns[colName]; ok {
|
||||
columnExprs = append(columnExprs, col.SQLName())
|
||||
columnExprs = append(columnExprs, quoteIdent(col.Name))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1268,7 +1327,7 @@ func (w *Writer) writeUniqueConstraints(schema *models.Schema) error {
|
||||
|
||||
// writeCheckConstraints generates ALTER TABLE statements for check constraints
|
||||
func (w *Writer) writeCheckConstraints(schema *models.Schema) error {
|
||||
fmt.Fprintf(w.writer, "-- Check constraints for schema: %s\n", schema.Name)
|
||||
fmt.Fprintf(w.writer, "-- Check constraints for schema: %s\n", commentText(schema.Name))
|
||||
|
||||
for _, table := range schema.Tables {
|
||||
// Sort constraints by name for consistent output
|
||||
@@ -1307,7 +1366,7 @@ func (w *Writer) writeCheckConstraints(schema *models.Schema) error {
|
||||
|
||||
// writeForeignKeys generates ALTER TABLE statements for foreign keys
|
||||
func (w *Writer) writeForeignKeys(schema *models.Schema) error {
|
||||
fmt.Fprintf(w.writer, "-- Foreign keys for schema: %s\n", schema.Name)
|
||||
fmt.Fprintf(w.writer, "-- Foreign keys for schema: %s\n", commentText(schema.Name))
|
||||
|
||||
for _, table := range schema.Tables {
|
||||
// Sort relationships by name for consistent output
|
||||
@@ -1327,7 +1386,7 @@ func (w *Writer) writeForeignKeys(schema *models.Schema) error {
|
||||
fkName = name
|
||||
}
|
||||
if fkName == "" {
|
||||
fkName = fmt.Sprintf("fk_%s_%s", table.SQLName(), rel.ToTable)
|
||||
fkName = fmt.Sprintf("fk_%s_%s", table.Name, rel.ToTable)
|
||||
}
|
||||
|
||||
// Find the foreign key constraint that matches this relationship.
|
||||
@@ -1354,13 +1413,13 @@ func (w *Writer) writeForeignKeys(schema *models.Schema) error {
|
||||
sourceColumns := make([]string, 0, len(fkConstraint.Columns))
|
||||
for _, colName := range fkConstraint.Columns {
|
||||
if col, ok := table.Columns[colName]; ok {
|
||||
sourceColumns = append(sourceColumns, col.SQLName())
|
||||
sourceColumns = append(sourceColumns, quoteIdent(col.Name))
|
||||
}
|
||||
}
|
||||
|
||||
targetColumns := make([]string, 0, len(fkConstraint.ReferencedColumns))
|
||||
for _, colName := range fkConstraint.ReferencedColumns {
|
||||
targetColumns = append(targetColumns, strings.ToLower(colName))
|
||||
targetColumns = append(targetColumns, quoteIdent(colName))
|
||||
}
|
||||
|
||||
if len(sourceColumns) == 0 || len(targetColumns) == 0 {
|
||||
@@ -1435,15 +1494,15 @@ func (w *Writer) writeForeignKeys(schema *models.Schema) error {
|
||||
sourceColumns := make([]string, 0, len(constraint.Columns))
|
||||
for _, colName := range constraint.Columns {
|
||||
if col, ok := table.Columns[colName]; ok {
|
||||
sourceColumns = append(sourceColumns, col.SQLName())
|
||||
sourceColumns = append(sourceColumns, quoteIdent(col.Name))
|
||||
} else {
|
||||
sourceColumns = append(sourceColumns, colName)
|
||||
sourceColumns = append(sourceColumns, quoteIdent(colName))
|
||||
}
|
||||
}
|
||||
|
||||
targetColumns := make([]string, 0, len(constraint.ReferencedColumns))
|
||||
for _, colName := range constraint.ReferencedColumns {
|
||||
targetColumns = append(targetColumns, strings.ToLower(colName))
|
||||
targetColumns = append(targetColumns, quoteIdent(colName))
|
||||
}
|
||||
|
||||
if len(sourceColumns) == 0 || len(targetColumns) == 0 {
|
||||
@@ -1492,7 +1551,7 @@ func (w *Writer) writeForeignKeys(schema *models.Schema) error {
|
||||
|
||||
// writeSetSequenceValues generates statements to set sequence current values
|
||||
func (w *Writer) writeSetSequenceValues(schema *models.Schema) error {
|
||||
fmt.Fprintf(w.writer, "-- Set sequence values for schema: %s\n", schema.Name)
|
||||
fmt.Fprintf(w.writer, "-- Set sequence values for schema: %s\n", commentText(schema.Name))
|
||||
|
||||
for _, table := range schema.Tables {
|
||||
seqName := primaryKeySequenceName(table)
|
||||
@@ -1521,22 +1580,22 @@ func (w *Writer) writeSetSequenceValues(schema *models.Schema) error {
|
||||
|
||||
// writeComments generates COMMENT statements for tables and columns
|
||||
func (w *Writer) writeComments(schema *models.Schema) error {
|
||||
fmt.Fprintf(w.writer, "-- Comments for schema: %s\n", schema.Name)
|
||||
fmt.Fprintf(w.writer, "-- Comments for schema: %s\n", commentText(schema.Name))
|
||||
|
||||
for _, table := range schema.Tables {
|
||||
// Table comment
|
||||
if table.Description != "" {
|
||||
fmt.Fprintf(w.writer, "COMMENT ON TABLE %s IS '%s';\n",
|
||||
w.qualTable(schema.SQLName(), table.SQLName()),
|
||||
escapeQuote(table.Description))
|
||||
fmt.Fprintf(w.writer, "COMMENT ON TABLE %s IS %s;\n",
|
||||
w.qualTable(schema.Name, table.Name),
|
||||
quote(table.Description))
|
||||
}
|
||||
|
||||
// Column comments
|
||||
for _, col := range getSortedColumns(table.Columns) {
|
||||
if col.Description != "" {
|
||||
fmt.Fprintf(w.writer, "COMMENT ON COLUMN %s.%s IS '%s';\n",
|
||||
w.qualTable(schema.SQLName(), table.SQLName()), col.SQLName(),
|
||||
escapeQuote(col.Description))
|
||||
fmt.Fprintf(w.writer, "COMMENT ON COLUMN %s.%s IS %s;\n",
|
||||
w.qualTable(schema.Name, table.Name), quoteIdent(col.Name),
|
||||
quote(col.Description))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1897,6 +1956,9 @@ func nativeGistBaseType(baseType string) bool {
|
||||
}
|
||||
|
||||
func resolveIndexColumn(table *models.Table, colName string) (*models.Column, bool) {
|
||||
if len(colName) >= 2 && colName[0] == '"' && colName[len(colName)-1] == '"' {
|
||||
colName = strings.ReplaceAll(colName[1:len(colName)-1], `""`, `"`)
|
||||
}
|
||||
if table == nil {
|
||||
return nil, false
|
||||
}
|
||||
@@ -1904,18 +1966,8 @@ func resolveIndexColumn(table *models.Table, colName string) (*models.Column, bo
|
||||
return col, true
|
||||
}
|
||||
|
||||
normalized := strings.ToLower(strings.Trim(colName, `"`))
|
||||
for key, col := range table.Columns {
|
||||
if col == nil {
|
||||
continue
|
||||
}
|
||||
if strings.ToLower(strings.Trim(key, `"`)) == normalized {
|
||||
return col, true
|
||||
}
|
||||
if strings.ToLower(strings.Trim(col.Name, `"`)) == normalized {
|
||||
return col, true
|
||||
}
|
||||
if strings.ToLower(strings.Trim(col.SQLName(), `"`)) == normalized {
|
||||
for _, col := range table.Columns {
|
||||
if col != nil && col.Name == colName {
|
||||
return col, true
|
||||
}
|
||||
}
|
||||
@@ -1972,7 +2024,7 @@ func sortIndexes(indexes map[string]*models.Index) []*models.Index {
|
||||
func formatStringList(items []string) string {
|
||||
quoted := make([]string, len(items))
|
||||
for i, item := range items {
|
||||
quoted[i] = fmt.Sprintf("'%s'", escapeQuote(item))
|
||||
quoted[i] = quote(item)
|
||||
}
|
||||
return strings.Join(quoted, ", ")
|
||||
}
|
||||
@@ -2049,19 +2101,20 @@ func extractSequenceName(defaultExpr string) string {
|
||||
if start == -1 {
|
||||
return ""
|
||||
}
|
||||
end := strings.Index(defaultExpr[start+1:], "'")
|
||||
if end == -1 {
|
||||
return ""
|
||||
var name strings.Builder
|
||||
for i := start + 1; i < len(defaultExpr); i++ {
|
||||
if defaultExpr[i] == '\'' {
|
||||
if i+1 < len(defaultExpr) && defaultExpr[i+1] == '\'' {
|
||||
name.WriteByte('\'')
|
||||
i++
|
||||
continue
|
||||
}
|
||||
return regclassLastName(name.String())
|
||||
}
|
||||
name.WriteByte(defaultExpr[i])
|
||||
}
|
||||
return ""
|
||||
|
||||
fullName := defaultExpr[start+1 : start+1+end]
|
||||
|
||||
// Remove schema prefix if present
|
||||
parts := strings.Split(fullName, ".")
|
||||
if len(parts) > 1 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
return fullName
|
||||
}
|
||||
|
||||
// executeDatabaseSQL applies db to a PostgreSQL database. By default it reads the live schema
|
||||
@@ -2126,7 +2179,7 @@ func (w *Writer) diffStatements(model, current *models.Database) ([]string, erro
|
||||
currentSchemas := make(map[string]*models.Schema)
|
||||
for _, cs := range current.Schemas {
|
||||
if cs != nil {
|
||||
currentSchemas[strings.ToLower(cs.Name)] = cs
|
||||
currentSchemas[cs.Name] = cs
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2142,25 +2195,25 @@ func (w *Writer) diffStatements(model, current *models.Database) ([]string, erro
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cs := currentSchemas[strings.ToLower(schema.Name)]
|
||||
cs := currentSchemas[schema.Name]
|
||||
if cs == nil && schema.Name != "public" {
|
||||
statements = append(statements,
|
||||
fmt.Sprintf("-- Schema: %s", schema.Name),
|
||||
fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s", schema.SQLName()))
|
||||
fmt.Sprintf("-- Schema: %s", commentText(schema.Name)),
|
||||
fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s", quoteIdent(schema.Name)))
|
||||
}
|
||||
|
||||
existing := make(map[string]*models.Table)
|
||||
knownSequences := make(map[string]bool)
|
||||
if cs != nil {
|
||||
for _, t := range cs.Tables {
|
||||
existing[strings.ToLower(t.Name)] = t
|
||||
existing[t.Name] = t
|
||||
}
|
||||
for _, seq := range cs.Sequences {
|
||||
knownSequences[strings.ToLower(seq.Name)] = true
|
||||
knownSequences[seq.Name] = true
|
||||
}
|
||||
}
|
||||
for _, table := range schema.Tables {
|
||||
currentTable, ok := existing[strings.ToLower(table.Name)]
|
||||
currentTable, ok := existing[table.Name]
|
||||
if !ok {
|
||||
// New table: no rows yet, so the sequence only needs creating.
|
||||
if stmt := w.primaryKeySequenceStatement(schema, table); stmt != "" {
|
||||
@@ -2173,7 +2226,7 @@ func (w *Writer) diffStatements(model, current *models.Database) ([]string, erro
|
||||
// database does not have yet: create it and move it past the data,
|
||||
// otherwise it restarts at 1 and collides with existing rows.
|
||||
seqName := primaryKeySequenceName(table)
|
||||
if seqName == "" || knownSequences[strings.ToLower(seqName)] {
|
||||
if seqName == "" || knownSequences[seqName] {
|
||||
continue
|
||||
}
|
||||
if cpk := currentTable.GetPrimaryKey(); cpk != nil && columnDefaultsEqual(table.GetPrimaryKey().Default, cpk.Default) {
|
||||
@@ -2209,7 +2262,7 @@ func (w *Writer) diffStatements(model, current *models.Database) ([]string, erro
|
||||
continue
|
||||
}
|
||||
if script.Schema != "" && script.Schema != lastSchema {
|
||||
statements = append(statements, fmt.Sprintf("-- Schema: %s", script.Schema))
|
||||
statements = append(statements, fmt.Sprintf("-- Schema: %s", commentText(script.Schema)))
|
||||
lastSchema = script.Schema
|
||||
}
|
||||
statements = append(statements, body)
|
||||
@@ -2311,23 +2364,8 @@ func extractTableNameFromCreate(stmt string) string {
|
||||
rest = strings.TrimSpace(rest[13:])
|
||||
}
|
||||
|
||||
// Get the table name (first token before '(' or whitespace)
|
||||
tokens := strings.FieldsFunc(rest, func(r rune) bool {
|
||||
return r == '(' || r == ' ' || r == '\n' || r == '\t'
|
||||
})
|
||||
|
||||
if len(tokens) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Handle schema.table format
|
||||
fullName := tokens[0]
|
||||
parts := strings.Split(fullName, ".")
|
||||
if len(parts) > 1 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
|
||||
return fullName
|
||||
_, name := parseQualifiedIdent(rest)
|
||||
return name
|
||||
}
|
||||
|
||||
// truncateStatement truncates long SQL statements for error messages
|
||||
@@ -2445,11 +2483,21 @@ func extractSQLStringValue(stmt, key string) string {
|
||||
// parseQualifiedIdent extracts (schema, name) from the start of s, handling optional "schema"."table" quoting.
|
||||
func parseQualifiedIdent(s string) (schema, name string) {
|
||||
token := firstBareIdent(s)
|
||||
parts := strings.SplitN(token, ".", 2)
|
||||
if len(parts) == 2 {
|
||||
return stripQuotes(parts[0]), stripQuotes(parts[1])
|
||||
quoted := false
|
||||
for i := 0; i < len(token); i++ {
|
||||
if token[i] == '"' {
|
||||
if quoted && i+1 < len(token) && token[i+1] == '"' {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
quoted = !quoted
|
||||
}
|
||||
if token[i] == '.' && !quoted {
|
||||
return stripQuotes(token[:i]), stripQuotes(token[i+1:])
|
||||
}
|
||||
}
|
||||
return "", stripQuotes(token)
|
||||
|
||||
}
|
||||
|
||||
// firstBareIdent returns the first whitespace/delimiter-terminated token, stripping outer quotes.
|
||||
@@ -2458,11 +2506,21 @@ func firstBareIdent(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
end := strings.IndexAny(s, " \t\n(,;")
|
||||
if end == -1 {
|
||||
return s
|
||||
quoted := false
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '"' {
|
||||
if quoted && i+1 < len(s) && s[i+1] == '"' {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
quoted = !quoted
|
||||
}
|
||||
if !quoted && strings.ContainsRune(" \t\r\n(,;", rune(s[i])) {
|
||||
return s[:i]
|
||||
}
|
||||
}
|
||||
return s[:end]
|
||||
return s
|
||||
|
||||
}
|
||||
|
||||
// firstIdentAfterKeyword returns the first identifier token after keyword (matched case-insensitively via upperStmt).
|
||||
@@ -2476,7 +2534,10 @@ func firstIdentAfterKeyword(stmt, upperStmt, keyword string) string {
|
||||
|
||||
// stripQuotes removes surrounding double-quotes from an identifier.
|
||||
func stripQuotes(s string) string {
|
||||
return strings.Trim(s, "\"")
|
||||
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
|
||||
return strings.ReplaceAll(s[1:len(s)-1], `""`, `"`)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// buildStmtContext assembles a display string from available identifiers.
|
||||
@@ -2598,3 +2659,5 @@ func detectStatementType(stmt string) string {
|
||||
func quoteIdentifier(s string) string {
|
||||
return quoteIdent(s)
|
||||
}
|
||||
|
||||
func commentText(s string) string { return strings.NewReplacer("\r", " ", "\n", " ").Replace(s) }
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestWriteDatabase(t *testing.T) {
|
||||
idCol := models.InitColumn("id", "users", "public")
|
||||
idCol.Type = "integer"
|
||||
idCol.Description = "Primary key"
|
||||
idCol.Default = "nextval('public.identity_users_id'::regclass)"
|
||||
idCol.Default = "nextval('\"public\".\"identity_users_id\"'::regclass)"
|
||||
table.Columns["id"] = idCol
|
||||
|
||||
nameCol := models.InitColumn("name", "users", "public")
|
||||
@@ -117,7 +117,7 @@ func TestWriteDatabase_ConcurrentIndex(t *testing.T) {
|
||||
|
||||
output := buf.String()
|
||||
|
||||
if !strings.Contains(output, "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email") {
|
||||
if !strings.Contains(output, "CREATE INDEX CONCURRENTLY IF NOT EXISTS \"idx_users_email\"") {
|
||||
t.Errorf("Output missing CONCURRENTLY index creation:\n%s", output)
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,7 @@ func TestWriteDatabase_GinIndexOnTextArrayDoesNotUseTrigramOperatorClass(t *test
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, `USING gin (tags array_ops)`) {
|
||||
if !strings.Contains(output, `USING gin ("tags" array_ops)`) {
|
||||
t.Fatalf("expected GIN index on array column with array_ops, got:\n%s", output)
|
||||
}
|
||||
if strings.Contains(output, "gin_trgm_ops") {
|
||||
@@ -172,7 +172,7 @@ func TestWriteDatabase_GinIndexOnQuotedTextColumnUsesTrigramOperatorClass(t *tes
|
||||
index := &models.Index{
|
||||
Name: "idx_agent_personas_name_gin",
|
||||
Type: "gin",
|
||||
Columns: []string{`"name"`},
|
||||
Columns: []string{"name"},
|
||||
}
|
||||
table.Indexes[index.Name] = index
|
||||
|
||||
@@ -191,7 +191,7 @@ func TestWriteDatabase_GinIndexOnQuotedTextColumnUsesTrigramOperatorClass(t *tes
|
||||
if !strings.Contains(output, `CREATE EXTENSION IF NOT EXISTS pg_trgm`) {
|
||||
t.Fatalf("expected trigram extension for text GIN index, got:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, `USING gin (name gin_trgm_ops)`) {
|
||||
if !strings.Contains(output, `USING gin ("name" gin_trgm_ops)`) {
|
||||
t.Fatalf("expected quoted text GIN index to include gin_trgm_ops, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
@@ -225,7 +225,7 @@ func TestWriteDatabase_GinIndexOnJSONBUsesJSONBOperatorClass(t *testing.T) {
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, `USING gin (details jsonb_ops)`) {
|
||||
if !strings.Contains(output, `USING gin ("details" jsonb_ops)`) {
|
||||
t.Fatalf("expected GIN jsonb index to include jsonb_ops, got:\n%s", output)
|
||||
}
|
||||
if strings.Contains(output, "gin_trgm_ops") {
|
||||
@@ -372,10 +372,10 @@ func TestWriteUniqueConstraints(t *testing.T) {
|
||||
if !strings.Contains(output, "-- Unique constraints for schema: public") {
|
||||
t.Errorf("Output missing unique constraints header")
|
||||
}
|
||||
if !strings.Contains(output, "ADD CONSTRAINT uq_email UNIQUE (email)") {
|
||||
if !strings.Contains(output, "ADD CONSTRAINT \"uq_email\" UNIQUE (\"email\")") {
|
||||
t.Errorf("Output missing uq_email unique constraint\nFull output:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "ADD CONSTRAINT uq_guid UNIQUE (guid)") {
|
||||
if !strings.Contains(output, "ADD CONSTRAINT \"uq_guid\" UNIQUE (\"guid\")") {
|
||||
t.Errorf("Output missing uq_guid unique constraint\nFull output:\n%s", output)
|
||||
}
|
||||
}
|
||||
@@ -453,13 +453,13 @@ func TestWriteCheckConstraints(t *testing.T) {
|
||||
if !strings.Contains(output, "-- Check constraints for schema: public") {
|
||||
t.Errorf("Output missing check constraints header")
|
||||
}
|
||||
if !strings.Contains(output, "ADD CONSTRAINT ck_price_positive CHECK (price >= 0)") {
|
||||
if !strings.Contains(output, "ADD CONSTRAINT \"ck_price_positive\" CHECK (price >= 0)") {
|
||||
t.Errorf("Output missing ck_price_positive check constraint\nFull output:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "ADD CONSTRAINT ck_status_valid CHECK (status IN ('active', 'inactive', 'discontinued'))") {
|
||||
if !strings.Contains(output, "ADD CONSTRAINT \"ck_status_valid\" CHECK (status IN ('active', 'inactive', 'discontinued'))") {
|
||||
t.Errorf("Output missing ck_status_valid check constraint\nFull output:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "ADD CONSTRAINT ck_quantity_nonnegative CHECK (quantity >= 0)") {
|
||||
if !strings.Contains(output, "ADD CONSTRAINT \"ck_quantity_nonnegative\" CHECK (quantity >= 0)") {
|
||||
t.Errorf("Output missing ck_quantity_nonnegative check constraint\nFull output:\n%s", output)
|
||||
}
|
||||
}
|
||||
@@ -573,9 +573,9 @@ func TestWriteAllConstraintTypes(t *testing.T) {
|
||||
// Verify all constraint types are present
|
||||
expectedConstraints := map[string]string{
|
||||
"Primary Key": "PRIMARY KEY",
|
||||
"Unique": "ADD CONSTRAINT uq_order_number UNIQUE (order_number)",
|
||||
"Check (total)": "ADD CONSTRAINT ck_total_positive CHECK (total > 0)",
|
||||
"Check (status)": "ADD CONSTRAINT ck_status_valid CHECK (status IN ('pending', 'completed', 'cancelled'))",
|
||||
"Unique": "ADD CONSTRAINT \"uq_order_number\" UNIQUE (\"order_number\")",
|
||||
"Check (total)": "ADD CONSTRAINT \"ck_total_positive\" CHECK (total > 0)",
|
||||
"Check (status)": "ADD CONSTRAINT \"ck_status_valid\" CHECK (status IN ('pending', 'completed', 'cancelled'))",
|
||||
"Foreign Key": "FOREIGN KEY",
|
||||
}
|
||||
|
||||
@@ -640,7 +640,7 @@ func TestEscapeQuote(t *testing.T) {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"simple text", "simple text"},
|
||||
{"\"simple\" text", "\"simple\" text"},
|
||||
{"text with 'quote'", "text with ''quote''"},
|
||||
{"multiple 'quotes' here", "multiple ''quotes'' here"},
|
||||
{"", ""},
|
||||
@@ -1016,9 +1016,9 @@ func TestGenerateAddColumnStatements(t *testing.T) {
|
||||
|
||||
// Verify expected elements
|
||||
expectedStrings := []string{
|
||||
"ALTER TABLE public.users ADD COLUMN IF NOT EXISTS id integer NOT NULL",
|
||||
"ALTER TABLE public.users ADD COLUMN IF NOT EXISTS email varchar(255) NOT NULL",
|
||||
"ALTER TABLE public.users ADD COLUMN IF NOT EXISTS status text DEFAULT 'active'",
|
||||
"ALTER TABLE \"public\".\"users\" ADD COLUMN IF NOT EXISTS \"id\" integer NOT NULL",
|
||||
"ALTER TABLE \"public\".\"users\" ADD COLUMN IF NOT EXISTS \"email\" varchar(255) NOT NULL",
|
||||
"ALTER TABLE \"public\".\"users\" ADD COLUMN IF NOT EXISTS \"status\" text DEFAULT 'active'",
|
||||
}
|
||||
|
||||
for _, expected := range expectedStrings {
|
||||
@@ -1071,10 +1071,10 @@ func TestWriteAddColumnStatements(t *testing.T) {
|
||||
t.Logf("Generated output:\n%s", output)
|
||||
|
||||
// Verify output contains expected elements
|
||||
if !strings.Contains(output, "ALTER TABLE public.products ADD COLUMN IF NOT EXISTS id integer") {
|
||||
if !strings.Contains(output, "ALTER TABLE \"public\".\"products\" ADD COLUMN IF NOT EXISTS \"id\" integer") {
|
||||
t.Errorf("Output missing ADD COLUMN for id\nFull output:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "ALTER TABLE public.products ADD COLUMN IF NOT EXISTS description text") {
|
||||
if !strings.Contains(output, "ALTER TABLE \"public\".\"products\" ADD COLUMN IF NOT EXISTS \"description\" text") {
|
||||
t.Errorf("Output missing ADD COLUMN for description\nFull output:\n%s", output)
|
||||
}
|
||||
if strings.Contains(output, "information_schema.columns") {
|
||||
@@ -1115,16 +1115,16 @@ func TestWriteSchema_EmitsGuardedAlterColumnTypeStatements(t *testing.T) {
|
||||
if !strings.Contains(output, "pg_catalog.format_type") {
|
||||
t.Fatalf("expected guarded live-type check, got:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "ALTER COLUMN name TYPE character varying(255)") {
|
||||
if !strings.Contains(output, "ALTER COLUMN \"name\" TYPE character varying(255)") {
|
||||
t.Fatalf("expected guarded alter for character varying(255), got:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "ARRAY['varchar(255)', 'character varying(255)']") {
|
||||
t.Fatalf("expected equivalent type spellings for varchar guard, got:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "ALTER COLUMN tags TYPE text[]") {
|
||||
if !strings.Contains(output, "ALTER COLUMN \"tags\" TYPE text[]") {
|
||||
t.Fatalf("expected guarded alter for array type, got:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, `ALTER COLUMN tags TYPE text[] USING tags::text[];`) {
|
||||
if !strings.Contains(output, `ALTER COLUMN "tags" TYPE text[] USING "tags"::text[];`) {
|
||||
t.Fatalf("expected guarded alter for array type to include USING cast, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
@@ -1158,7 +1158,7 @@ func TestWriteSchema_EmitsGuardedAlterColumnDefaultStatements(t *testing.T) {
|
||||
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'") {
|
||||
if !strings.Contains(output, "ALTER COLUMN \"status\" SET DEFAULT 'active'") {
|
||||
t.Fatalf("expected guarded SET DEFAULT for status column, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
@@ -1189,7 +1189,7 @@ func TestWriteSchema_AlterColumnDefaultStripsBackticksFromFunctionExpression(t *
|
||||
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()") {
|
||||
if !strings.Contains(output, "ALTER COLUMN \"updatedat\" SET DEFAULT now()") {
|
||||
t.Fatalf("expected guarded SET DEFAULT now() without backticks, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
@@ -1216,7 +1216,7 @@ func TestWriteSchema_GuardedAlterColumnTypePreservesConversionFailure(t *testing
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "ALTER COLUMN name TYPE integer USING name::integer;") {
|
||||
if !strings.Contains(output, "ALTER COLUMN \"name\" TYPE integer USING \"name\"::integer;") {
|
||||
t.Fatalf("expected explicit type conversion, got:\n%s", output)
|
||||
}
|
||||
for _, unsafe := range []string{"EXCEPTION WHEN OTHERS", "renamed_column", "RENAME COLUMN"} {
|
||||
@@ -1258,7 +1258,7 @@ func TestWriteSchema_EmitsGuardedAlterColumnNullabilityStatements(t *testing.T)
|
||||
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") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1284,13 +1284,13 @@ func TestWriteSchema_UsesStorageTypeForSerialAlterStatements(t *testing.T) {
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "ALTER COLUMN id TYPE bigint") {
|
||||
if !strings.Contains(output, "ALTER COLUMN \"id\" TYPE bigint") {
|
||||
t.Fatalf("expected serial alter to use bigint storage type, got:\n%s", output)
|
||||
}
|
||||
if strings.Contains(output, "ALTER COLUMN id TYPE bigserial;") {
|
||||
if strings.Contains(output, "ALTER COLUMN \"id\" TYPE bigserial;") {
|
||||
t.Fatalf("did not expect invalid bigserial alter statement, got:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, `ALTER COLUMN id TYPE bigint USING id::bigint;`) {
|
||||
if !strings.Contains(output, `ALTER COLUMN "id" TYPE bigint USING "id"::bigint;`) {
|
||||
t.Fatalf("expected serial alter to include USING cast, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
@@ -1361,7 +1361,7 @@ func TestWriteDatabase_VectorAndPostGISColumnsCreateExtensions(t *testing.T) {
|
||||
func TestWriteDatabase_HNSWIndexUsesDefaultVectorOperatorClass(t *testing.T) {
|
||||
output := writeDatabaseOutput(t, buildVectorSpatialSchema("hnsw", ""))
|
||||
|
||||
if !strings.Contains(output, "USING hnsw (embedding vector_cosine_ops)") {
|
||||
if !strings.Contains(output, "USING hnsw (\"embedding\" vector_cosine_ops)") {
|
||||
t.Fatalf("expected hnsw index with default vector operator class, got:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "CREATE EXTENSION IF NOT EXISTS vector;") {
|
||||
@@ -1372,7 +1372,7 @@ func TestWriteDatabase_HNSWIndexUsesDefaultVectorOperatorClass(t *testing.T) {
|
||||
func TestWriteDatabase_VectorIndexHonoursRequestedOperatorClassAndStorageParameters(t *testing.T) {
|
||||
output := writeDatabaseOutput(t, buildVectorSpatialSchema("ivfflat", "opclass=vector_l2_ops; with (lists=100)"))
|
||||
|
||||
if !strings.Contains(output, "USING ivfflat (embedding vector_l2_ops) WITH (lists = 100)") {
|
||||
if !strings.Contains(output, "USING ivfflat (\"embedding\" vector_l2_ops) WITH (lists = 100)") {
|
||||
t.Fatalf("expected ivfflat index with requested opclass and storage parameters, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
@@ -1380,7 +1380,7 @@ func TestWriteDatabase_VectorIndexHonoursRequestedOperatorClassAndStorageParamet
|
||||
func TestWriteDatabase_VectorIndexIgnoresIncompatibleOperatorClass(t *testing.T) {
|
||||
output := writeDatabaseOutput(t, buildVectorSpatialSchema("hnsw", "opclass=halfvec_l2_ops"))
|
||||
|
||||
if !strings.Contains(output, "USING hnsw (embedding vector_cosine_ops)") {
|
||||
if !strings.Contains(output, "USING hnsw (\"embedding\" vector_cosine_ops)") {
|
||||
t.Fatalf("expected halfvec operator class to be rejected for a vector column, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
@@ -1416,7 +1416,7 @@ func TestWriteDatabase_GistIndexOnGeometryUsesDefaultOperatorClass(t *testing.T)
|
||||
|
||||
output := writeDatabaseOutput(t, db)
|
||||
|
||||
if !strings.Contains(output, "USING gist (geom)") {
|
||||
if !strings.Contains(output, "USING gist (\"geom\")") {
|
||||
t.Fatalf("expected gist index to rely on the PostGIS default operator class, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
@@ -1442,7 +1442,7 @@ func TestWriteDatabase_GistIndexHonoursRequestedSpatialOperatorClass(t *testing.
|
||||
|
||||
output := writeDatabaseOutput(t, db)
|
||||
|
||||
if !strings.Contains(output, "USING gist (geom gist_geometry_ops_nd)") {
|
||||
if !strings.Contains(output, "USING gist (\"geom\" gist_geometry_ops_nd)") {
|
||||
t.Fatalf("expected requested spatial operator class, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
@@ -1460,7 +1460,7 @@ func TestGenerateDatabaseStatements_VectorIndexIncludesOperatorClassAndParameter
|
||||
for _, want := range []string{
|
||||
"CREATE EXTENSION IF NOT EXISTS vector",
|
||||
"CREATE EXTENSION IF NOT EXISTS postgis",
|
||||
"USING hnsw (embedding vector_ip_ops) WITH (m = 16)",
|
||||
"USING hnsw (\"embedding\" vector_ip_ops) WITH (m = 16)",
|
||||
} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Fatalf("expected statements to contain %q, got:\n%s", want, joined)
|
||||
@@ -1509,7 +1509,7 @@ func TestGenerateColumnDefinition_GeneratedColumnEmitsGeneratedClauseNotDefault(
|
||||
|
||||
got := w.generateColumnDefinition(col)
|
||||
|
||||
want := "geom text GENERATED ALWAYS AS (st_makepoint(lon, lat)) STORED NOT NULL"
|
||||
want := "\"geom\" text GENERATED ALWAYS AS (st_makepoint(lon, lat)) STORED NOT NULL"
|
||||
if got != want {
|
||||
t.Fatalf("generateColumnDefinition() = %q, want %q", got, want)
|
||||
}
|
||||
@@ -1528,7 +1528,7 @@ func TestGenerateColumnDefinition_IdentityColumnEmitsIdentityClauseNotDefault(t
|
||||
|
||||
got := w.generateColumnDefinition(col)
|
||||
|
||||
want := "id bigint GENERATED ALWAYS AS IDENTITY NOT NULL"
|
||||
want := "\"id\" bigint GENERATED ALWAYS AS IDENTITY NOT NULL"
|
||||
if got != want {
|
||||
t.Fatalf("generateColumnDefinition() = %q, want %q", got, want)
|
||||
}
|
||||
@@ -1575,7 +1575,7 @@ func TestWriteDatabase_MultipleForeignKeysToSameTable(t *testing.T) {
|
||||
}
|
||||
output := buf.String()
|
||||
for _, name := range []string{"rid_department", "rid_manager"} {
|
||||
want := "ADD CONSTRAINT fk_employee_" + name + "\n FOREIGN KEY (" + name + ")"
|
||||
want := "ADD CONSTRAINT " + quoteIdent("fk_employee_"+name) + "\n FOREIGN KEY (" + quoteIdent(name) + ")"
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("run %d: missing %q in output:\n%s", i, want, output)
|
||||
}
|
||||
|
||||
@@ -264,3 +264,11 @@ docker rm postgres-test
|
||||
- **SQL Directory Reader**: `pkg/readers/sqldir/` - Read scripts from filesystem
|
||||
- **Script Model**: `pkg/models/models.go` - Script structure definition
|
||||
- **pgx Documentation**: https://github.com/jackc/pgx - PostgreSQL driver docs
|
||||
|
||||
## Model embed access
|
||||
|
||||
SQL directory readers expand embed directives within their trusted input root.
|
||||
When executing unexpanded directives from a model, explicitly configure
|
||||
`WriterOptions.Metadata["asset_root"]` with the allowed directory. Script
|
||||
`source_path` metadata alone never authorizes file reads. Paths, symlinks, regular
|
||||
file types, and the 64 MiB asset-size limit are checked before reading.
|
||||
|
||||
@@ -120,7 +120,8 @@ func (w *Writer) runScripts(ctx context.Context, conn *pgx.Conn, scripts []*mode
|
||||
backend := writers.ExecutionBackend{ScriptManaged: true, Exec: func(ctx context.Context, i int, _ string) error {
|
||||
script := scripts[i]
|
||||
fmt.Printf("Executing script: %s (Priority=%d, Sequence=%d)\n", script.Name, script.Priority, script.Sequence)
|
||||
sql, err := processEmbedDirectives(script)
|
||||
assetRoot, _ := w.options.Metadata["asset_root"].(string)
|
||||
sql, err := processEmbedDirectives(script, assetRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("preparing script %s: %w", script.Name, err)
|
||||
}
|
||||
@@ -141,10 +142,16 @@ func scriptOperations(scripts []*models.Script) []writers.ExecutionOperation {
|
||||
return operations
|
||||
}
|
||||
|
||||
func processEmbedDirectives(script *models.Script) (string, error) {
|
||||
sqlPath, _ := script.Metadata[assetloader.ScriptSourcePathMetadataKey].(string)
|
||||
if sqlPath == "" {
|
||||
func processEmbedDirectives(script *models.Script, assetRoot string) (string, error) {
|
||||
if !assetloader.HasEmbedDirectives(script.SQL) {
|
||||
return script.SQL, nil
|
||||
}
|
||||
return assetloader.ProcessEmbedDirectives(sqlPath, script.SQL)
|
||||
if assetRoot == "" {
|
||||
return "", fmt.Errorf("asset_root is required to resolve embed directives from a model; source_path metadata does not authorize file access")
|
||||
}
|
||||
sqlPath, _ := script.Metadata[assetloader.ScriptSourcePathMetadataKey].(string)
|
||||
if sqlPath == "" {
|
||||
return "", fmt.Errorf("source_path is required for model embed directives")
|
||||
}
|
||||
return assetloader.ProcessEmbedDirectivesWithOptions(sqlPath, script.SQL, assetloader.EmbedOptions{AssetRoot: assetRoot})
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@ func TestProcessEmbedDirectives_UsesScriptSourcePath(t *testing.T) {
|
||||
script.SQL = "-- @embed: path=body.txt var=:body mode=text\nSELECT :body;"
|
||||
script.Metadata[assetloader.ScriptSourcePathMetadataKey] = sqlPath
|
||||
|
||||
got, err := processEmbedDirectives(script)
|
||||
got, err := processEmbedDirectives(script, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("processEmbedDirectives failed: %v", err)
|
||||
}
|
||||
@@ -241,15 +241,28 @@ func TestProcessEmbedDirectives_UsesScriptSourcePath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessEmbedDirectives_NoSourcePathLeavesSQLUnchanged(t *testing.T) {
|
||||
func TestProcessEmbedDirectives_NoSourcePathCannotAuthorizeRead(t *testing.T) {
|
||||
script := models.InitScript("seed")
|
||||
script.SQL = "-- @embed: path=missing.txt var=:body mode=text\nSELECT :body;"
|
||||
|
||||
got, err := processEmbedDirectives(script)
|
||||
if err != nil {
|
||||
t.Fatalf("processEmbedDirectives failed: %v", err)
|
||||
got, err := processEmbedDirectives(script, t.TempDir())
|
||||
if err == nil || got != "" {
|
||||
t.Fatalf("expected missing source_path failure, got %q %v", got, err)
|
||||
}
|
||||
if got != script.SQL {
|
||||
t.Fatalf("expected unchanged SQL without source path, got:\n%s", got)
|
||||
}
|
||||
|
||||
func TestProcessEmbedDirectivesModelSourcePathIsNotAuthorization(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "secret.txt"), []byte("secret"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
script := models.InitScript("untrusted")
|
||||
script.SQL = "-- @embed: path=secret.txt var=:body mode=text\nSELECT :body;"
|
||||
script.Metadata[assetloader.ScriptSourcePathMetadataKey] = filepath.Join(dir, "fake.sql")
|
||||
if _, err := processEmbedDirectives(script, ""); err == nil || !strings.Contains(err.Error(), "asset_root is required") {
|
||||
t.Fatalf("source metadata must not authorize file read: %v", err)
|
||||
}
|
||||
if _, err := processEmbedDirectives(script, t.TempDir()); err == nil {
|
||||
t.Fatal("source path outside trusted root must fail")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,15 +168,22 @@ relspec templ --from json --from-path schema.json --template test.tmpl
|
||||
|
||||
## Multi-file Output
|
||||
|
||||
For multi-file modes, the writer:
|
||||
For multi-file modes, `OutputPath` is always the output directory, including when
|
||||
it does not yet exist or has no trailing slash. An empty path uses the current
|
||||
directory. Database mode continues to treat `OutputPath` as one explicit file.
|
||||
|
||||
1. Iterates through items (schemas/scripts/tables)
|
||||
2. Creates `TemplateData` for each item
|
||||
3. Executes template with item data
|
||||
4. Generates filename using `filename_pattern` template
|
||||
5. Writes output to generated filename
|
||||
The writer renders and validates all filenames and contents before writing. Names
|
||||
must be relative, may contain safe subdirectories, and cannot contain traversal,
|
||||
absolute/drive/UNC paths, backslashes, Windows device names, alternate streams,
|
||||
control characters, or trailing dots/spaces. Duplicate filenames (including case
|
||||
variants) and file/directory collisions fail before any artifact is replaced.
|
||||
|
||||
Output directory is created automatically if it doesn't exist.
|
||||
Output directories are created automatically. Existing symlink roots, parents, and
|
||||
files are rejected. Writes and renames use `os.Root` to retain containment if paths
|
||||
change during generation. Each file is staged exclusively, closed successfully,
|
||||
and renamed into place; existing permissions are preserved on replacement.
|
||||
Multi-file publication is per file, so an I/O error partway through finalization can
|
||||
still leave a mixed generation (the broader audit A16 staging/manifest work remains).
|
||||
|
||||
## Filename Pattern Execution
|
||||
|
||||
|
||||
+119
-97
@@ -2,6 +2,9 @@ package template
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -9,6 +12,7 @@ import (
|
||||
"text/template"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/safefile"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
)
|
||||
|
||||
@@ -125,96 +129,132 @@ func (w *Writer) executeDatabaseMode(db *models.Database) error {
|
||||
return w.writeOutput(output, w.options.OutputPath)
|
||||
}
|
||||
|
||||
// executeSchemaMode executes the template once per schema
|
||||
// Multi-file generation renders and validates every artifact before touching output.
|
||||
func (w *Writer) executeSchemaMode(db *models.Database) error {
|
||||
var data []*TemplateData
|
||||
for _, schema := range db.Schemas {
|
||||
data := NewSchemaData(schema, w.options.Metadata)
|
||||
output, err := w.executeTemplate(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute template for schema %s: %w", schema.Name, err)
|
||||
}
|
||||
|
||||
filename, err := w.generateFilename(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate filename for schema %s: %w", schema.Name, err)
|
||||
}
|
||||
|
||||
if err := w.writeOutput(output, filename); err != nil {
|
||||
return fmt.Errorf("failed to write output for schema %s: %w", schema.Name, err)
|
||||
}
|
||||
data = append(data, NewSchemaData(schema, w.options.Metadata))
|
||||
}
|
||||
|
||||
return nil
|
||||
return w.executeFiles(data)
|
||||
}
|
||||
|
||||
// executeDomainMode executes the template once per domain
|
||||
func (w *Writer) executeDomainMode(db *models.Database) error {
|
||||
var data []*TemplateData
|
||||
for _, domain := range db.Domains {
|
||||
data := NewDomainData(domain, db, w.options.Metadata)
|
||||
output, err := w.executeTemplate(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute template for domain %s: %w", domain.Name, err)
|
||||
}
|
||||
|
||||
filename, err := w.generateFilename(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate filename for domain %s: %w", domain.Name, err)
|
||||
}
|
||||
|
||||
if err := w.writeOutput(output, filename); err != nil {
|
||||
return fmt.Errorf("failed to write output for domain %s: %w", domain.Name, err)
|
||||
}
|
||||
data = append(data, NewDomainData(domain, db, w.options.Metadata))
|
||||
}
|
||||
|
||||
return nil
|
||||
return w.executeFiles(data)
|
||||
}
|
||||
|
||||
// executeScriptMode executes the template once per script
|
||||
func (w *Writer) executeScriptMode(db *models.Database) error {
|
||||
var data []*TemplateData
|
||||
for _, schema := range db.Schemas {
|
||||
for _, script := range schema.Scripts {
|
||||
data := NewScriptData(script, schema, db, w.options.Metadata)
|
||||
output, err := w.executeTemplate(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute template for script %s: %w", script.Name, err)
|
||||
}
|
||||
data = append(data, NewScriptData(script, schema, db, w.options.Metadata))
|
||||
}
|
||||
}
|
||||
return w.executeFiles(data)
|
||||
}
|
||||
func (w *Writer) executeTableMode(db *models.Database) error {
|
||||
var data []*TemplateData
|
||||
for _, schema := range db.Schemas {
|
||||
for _, table := range schema.Tables {
|
||||
data = append(data, NewTableData(table, schema, db, w.options.Metadata))
|
||||
}
|
||||
}
|
||||
return w.executeFiles(data)
|
||||
}
|
||||
|
||||
filename, err := w.generateFilename(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate filename for script %s: %w", script.Name, err)
|
||||
}
|
||||
type artifact struct{ name, content string }
|
||||
|
||||
if err := w.writeOutput(output, filename); err != nil {
|
||||
return fmt.Errorf("failed to write output for script %s: %w", script.Name, err)
|
||||
func (w *Writer) outputRoot() string {
|
||||
if w.options.OutputPath == "" {
|
||||
return "."
|
||||
}
|
||||
return w.options.OutputPath
|
||||
}
|
||||
|
||||
func (w *Writer) executeFiles(data []*TemplateData) error {
|
||||
var files []artifact
|
||||
seen := make(map[string]bool)
|
||||
for _, item := range data {
|
||||
name, err := w.renderFilename(item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := strings.ToLower(filepath.ToSlash(name))
|
||||
if seen[key] {
|
||||
return fmt.Errorf("generated filename collision: %q", name)
|
||||
}
|
||||
seen[key] = true
|
||||
content, err := w.executeTemplate(item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files = append(files, artifact{name, content})
|
||||
}
|
||||
// Reject file/directory conflicts before creating any output.
|
||||
for name := range seen {
|
||||
for parent := filepath.ToSlash(filepath.Dir(name)); parent != "."; parent = filepath.ToSlash(filepath.Dir(parent)) {
|
||||
if seen[parent] {
|
||||
return fmt.Errorf("generated file/directory collision: %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
}
|
||||
rootPath := w.outputRoot()
|
||||
if err := safefile.CheckRoot(rootPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(rootPath, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
root, err := os.OpenRoot(rootPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer root.Close()
|
||||
// Preflight every destination, including existing directory and symlink conflicts.
|
||||
for _, file := range files {
|
||||
if err := safefile.Check(root, file.name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, file := range files {
|
||||
if err := w.writeArtifact(root, file); err != nil {
|
||||
return fmt.Errorf("write generated file %q: %w", file.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeTableMode executes the template once per table
|
||||
func (w *Writer) executeTableMode(db *models.Database) error {
|
||||
for _, schema := range db.Schemas {
|
||||
for _, table := range schema.Tables {
|
||||
data := NewTableData(table, schema, db, w.options.Metadata)
|
||||
output, err := w.executeTemplate(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute template for table %s.%s: %w", schema.Name, table.Name, err)
|
||||
}
|
||||
|
||||
filename, err := w.generateFilename(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate filename for table %s.%s: %w", schema.Name, table.Name, err)
|
||||
}
|
||||
|
||||
if err := w.writeOutput(output, filename); err != nil {
|
||||
return fmt.Errorf("failed to write output for table %s.%s: %w", schema.Name, table.Name, err)
|
||||
}
|
||||
}
|
||||
func (w *Writer) writeArtifact(root *os.Root, file artifact) error {
|
||||
if err := root.MkdirAll(filepath.Dir(file.name), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
// Exclusive unpredictable staging avoids truncating a destination (or a link target).
|
||||
var token [16]byte
|
||||
if _, err := rand.Read(token[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
temporary := ".relspec-" + hex.EncodeToString(token[:]) + ".tmp"
|
||||
mode := os.FileMode(0o644)
|
||||
if info, err := root.Lstat(file.name); err == nil && info.Mode().IsRegular() {
|
||||
mode = info.Mode().Perm()
|
||||
}
|
||||
staged, err := root.OpenFile(temporary, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer root.Remove(temporary)
|
||||
_, writeErr := staged.WriteString(file.content)
|
||||
if err := errors.Join(writeErr, staged.Close()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := safefile.Check(root, file.name); err != nil {
|
||||
return err
|
||||
}
|
||||
return root.Rename(temporary, file.name)
|
||||
}
|
||||
|
||||
// loadTemplate loads and parses the template file
|
||||
@@ -245,43 +285,25 @@ func (w *Writer) executeTemplate(data *TemplateData) (string, error) {
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// generateFilename generates a filename from the filename pattern
|
||||
func (w *Writer) generateFilename(data *TemplateData) (string, error) {
|
||||
// Parse filename pattern as a template
|
||||
// renderFilename returns a validated path relative to the explicit output root.
|
||||
func (w *Writer) renderFilename(data *TemplateData) (string, error) {
|
||||
tmpl, err := template.New("filename").Funcs(w.funcMap).Parse(w.filenamePattern)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid filename pattern: %w", err)
|
||||
}
|
||||
|
||||
// Execute filename template
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return "", fmt.Errorf("failed to generate filename: %w", err)
|
||||
}
|
||||
return safefile.Relative(buf.String())
|
||||
}
|
||||
|
||||
filename := buf.String()
|
||||
|
||||
// If output path is a directory, join with generated filename
|
||||
if w.options.OutputPath != "" {
|
||||
// Check if output path is a directory
|
||||
info, err := os.Stat(w.options.OutputPath)
|
||||
if err == nil && info.IsDir() {
|
||||
filename = filepath.Join(w.options.OutputPath, filename)
|
||||
} else {
|
||||
// If it doesn't exist, check if it looks like a directory (ends with /)
|
||||
if strings.HasSuffix(w.options.OutputPath, string(filepath.Separator)) {
|
||||
filename = filepath.Join(w.options.OutputPath, filename)
|
||||
} else {
|
||||
// Use output path as base directory
|
||||
dir := filepath.Dir(w.options.OutputPath)
|
||||
if dir != "." {
|
||||
filename = filepath.Join(dir, filename)
|
||||
}
|
||||
}
|
||||
}
|
||||
func (w *Writer) generateFilename(data *TemplateData) (string, error) {
|
||||
name, err := w.renderFilename(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return filename, nil
|
||||
return filepath.Join(w.outputRoot(), name), nil
|
||||
}
|
||||
|
||||
// writeOutput writes the output to a file or stdout
|
||||
|
||||
@@ -180,8 +180,8 @@ func TestWriterGenerateFilenameOutputPathForms(t *testing.T) {
|
||||
{"no output path", "", "users.txt"},
|
||||
{"existing dir", dir, filepath.Join(dir, "users.txt")},
|
||||
{"trailing separator", filepath.Join(dir, "new") + string(filepath.Separator), filepath.Join(dir, "new", "users.txt")},
|
||||
{"file path uses its dir", filepath.Join(dir, "x.out"), filepath.Join(dir, "users.txt")},
|
||||
{"bare file name", "x.out", "users.txt"},
|
||||
{"nonexistent path is root", filepath.Join(dir, "x.out"), filepath.Join(dir, "x.out", "users.txt")},
|
||||
{"bare path is root", "x.out", filepath.Join("x.out", "users.txt")},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -217,3 +217,123 @@ func TestWriterWriteSchemaAndTable(t *testing.T) {
|
||||
t.Errorf("WriteTable: %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiFileRejectsUnsafeFilenamesBeforeWriting(t *testing.T) {
|
||||
for _, name := range []string{"../escape", "/absolute", `..\escape`, `C:\escape`, `\\server\share\file`, "a/../escape", "a//escape", "CON", "nul.txt", "COM¹.txt", "file:stream", "trailing.", "trailing ", "line\nbreak"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
out := filepath.Join(t.TempDir(), "new-output")
|
||||
w, err := newTestWriter(t, "content", "schema", "{{.Name}}", out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db := models.InitDatabase("unsafe")
|
||||
db.Schemas = []*models.Schema{models.InitSchema("valid"), models.InitSchema(name)}
|
||||
if err := w.WriteDatabase(db); err == nil {
|
||||
t.Fatal("expected rejected filename")
|
||||
}
|
||||
if _, err := os.Stat(out); !os.IsNotExist(err) {
|
||||
t.Fatalf("validation failure must not create output: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiFileCollisionsDoNotOverwrite(t *testing.T) {
|
||||
for _, names := range [][]string{{"same.txt", "same.txt"}, {"SAME.txt", "same.txt"}, {"file", "file/child.txt"}} {
|
||||
out := t.TempDir()
|
||||
seed := filepath.Join(out, "same.txt")
|
||||
if err := os.WriteFile(seed, []byte("original"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w, err := newTestWriter(t, "replacement", "schema", "{{.Name}}", out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db := models.InitDatabase("collision")
|
||||
for _, name := range names {
|
||||
db.Schemas = append(db.Schemas, models.InitSchema(name))
|
||||
}
|
||||
if err := w.WriteDatabase(db); err == nil || !strings.Contains(err.Error(), "collision") {
|
||||
t.Fatalf("expected collision error: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(seed)
|
||||
if err != nil || string(got) != "original" {
|
||||
t.Fatalf("existing output changed: %q %v", got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiFileRejectsSymlinksBeforeAnyWrites(t *testing.T) {
|
||||
for _, kind := range []string{"parent", "leaf", "root"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
out, external := t.TempDir(), t.TempDir()
|
||||
secret := filepath.Join(external, "secret.txt")
|
||||
if err := os.WriteFile(secret, []byte("original"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link, target := filepath.Join(out, "link"), external
|
||||
pattern := "{{.Name}}/secret.txt"
|
||||
if kind == "leaf" {
|
||||
target = secret
|
||||
pattern = "{{.Name}}"
|
||||
}
|
||||
if kind == "root" {
|
||||
link = filepath.Join(out, "root")
|
||||
out = link
|
||||
pattern = "{{.Name}}"
|
||||
}
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
w, err := newTestWriter(t, "replacement", "schema", pattern, out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db := models.InitDatabase("links")
|
||||
db.Schemas = []*models.Schema{models.InitSchema("first"), models.InitSchema("link")}
|
||||
if err := w.WriteDatabase(db); err == nil {
|
||||
t.Fatal("expected symlink rejection")
|
||||
}
|
||||
got, err := os.ReadFile(secret)
|
||||
if err != nil || string(got) != "original" {
|
||||
t.Fatalf("symlink target changed: %q %v", got, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(out, "first")); !os.IsNotExist(err) {
|
||||
t.Fatalf("preflight must precede writes: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiFileNewDirectoryAndNestedFiles(t *testing.T) {
|
||||
out := filepath.Join(t.TempDir(), "new-output")
|
||||
w, err := newTestWriter(t, "{{.Table.Name}}", "table", "{{.ParentSchema.Name}}/{{.Name}}.txt", out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.WriteDatabase(modeDB()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(out, "a", "t1.txt"))
|
||||
if err != nil || string(got) != "t1" {
|
||||
t.Fatalf("nested output: %q %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiFileRootAncestorSymlinkRejected(t *testing.T) {
|
||||
parent, external := t.TempDir(), t.TempDir()
|
||||
link := filepath.Join(parent, "link")
|
||||
if err := os.Symlink(external, link); err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
w, err := newTestWriter(t, "content", "schema", "{{.Name}}.txt", filepath.Join(link, "new"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.WriteDatabase(modeDB()); err == nil {
|
||||
t.Fatal("root ancestor symlink must fail")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(external, "new")); !os.IsNotExist(err) {
|
||||
t.Fatalf("must not create external directories: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,9 @@ type WriterOptions struct {
|
||||
// Useful for databases like SQLite that do not support schemas.
|
||||
FlattenSchema bool
|
||||
|
||||
// LowercaseIdentifiers lowercases PostgreSQL names and structured references before generation.
|
||||
LowercaseIdentifiers bool
|
||||
|
||||
// NullableTypes selects the Go type package used for nullable columns in
|
||||
// code-generation writers (bun, gorm). Accepted values:
|
||||
// "sqltypes" — git.warky.dev/wdevs/relspecgo/pkg/sqltypes
|
||||
|
||||
Reference in New Issue
Block a user