fix: contain generated files and assets and harden SQL rendering

This commit is contained in:
2026-10-12 20:59:46 +02:00
parent 2fa48335d3
commit dd97413b8e
68 changed files with 2328 additions and 798 deletions
+22
View File
@@ -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
+5 -4
View File
@@ -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)
}
+11 -11
View File
@@ -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))
}
}
+2 -2
View File
@@ -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)
}
}
+2 -2
View File
@@ -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)
+116
View File
@@ -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
}
+45 -35
View File
@@ -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
}
}
+27 -27
View File
@@ -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 {
+251
View File
@@ -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] = &currentValue
currentID := *id
currentTable.Columns[key] = &currentID
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)
}
}
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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 {
+12 -34
View File
@@ -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
+3 -3
View File
@@ -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
View File
@@ -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}};
+16 -16
View File
@@ -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))}};
+24 -24
View File
@@ -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 -}}
+8 -8
View File
@@ -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 -2
View File
@@ -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}};
+2 -2
View File
@@ -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
View File
@@ -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) }
+39 -39
View File
@@ -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)
}