package pgsql import ( "bytes" "embed" "fmt" "strings" "text/template" "git.warky.dev/wdevs/relspecgo/pkg/models" ) //go:embed templates/*.tmpl var templateFS embed.FS // TemplateExecutor manages and executes SQL templates type TemplateExecutor struct { templates *template.Template } // NewTemplateExecutor creates a new template executor. // flattenSchema controls whether schema.table identifiers use dot or underscore separation. func NewTemplateExecutor(flattenSchema bool) (*TemplateExecutor, error) { // Create template with custom functions funcMap := make(template.FuncMap) for k, v := range TemplateFunctions() { funcMap[k] = v } // qual_table returns a quoted, schema-qualified identifier. // With flatten=false: "schema"."table". // With flatten=true: "schema_table". funcMap["qual_table"] = func(schema, name string) string { if schema == "" { return quoteIdent(name) } if flattenSchema { return quoteIdent(schema + "_" + name) } return quoteIdent(schema) + "." + quoteIdent(name) } funcMap["dollar_delimiter"] = func() string { return "$$" } tmpl := template.New("").Funcs(funcMap) entries, err := templateFS.ReadDir("templates") if err != nil { 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{ templates: tmpl, }, 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 type CreateTableData struct { SchemaName string TableName string Columns []ColumnData } // ColumnData represents column information type ColumnData struct { Name string Type string Default string NotNull bool // Generated is the expression of a GENERATED ALWAYS AS (...) STORED column. Generated string // Identity is "ALWAYS" or "BY DEFAULT" for a GENERATED ... AS IDENTITY column. Identity string } // AddColumnData contains data for add column template type AddColumnData struct { SchemaName string TableName string ColumnName string ColumnType string Default string NotNull bool // Generated is the expression of a GENERATED ALWAYS AS (...) STORED column. Generated string // Identity is "ALWAYS" or "BY DEFAULT" for a GENERATED ... AS IDENTITY column. Identity string } // AlterColumnTypeWithCheckData contains data for the guarded alter column // type template, which only alters existing columns whose live type // differs from the desired one. Conversion errors propagate unchanged. type AlterColumnTypeWithCheckData struct { SchemaName string TableName string ColumnName string NewType string EquivalentTypes string UsingExpr string PreflightPredicate string } // AlterColumnDefaultWithCheckData contains data for the guarded alter // column default template, which only alters existing columns whose live // default differs from the desired one. type AlterColumnDefaultWithCheckData struct { SchemaName string TableName string ColumnName string SetDefault bool DefaultValue string } // AlterColumnNullabilityWithCheckData contains data for the guarded alter // column nullability template, which only alters existing columns whose // live NOT NULL state differs from the desired one. type AlterColumnNullabilityWithCheckData struct { SchemaName string TableName string ColumnName string NotNull bool } // CreatePrimaryKeyData contains data for create primary key template type CreatePrimaryKeyData struct { SchemaName string TableName string ConstraintName string Columns string } // CreateIndexData contains data for create index template type CreateIndexData struct { SchemaName string TableName string IndexName string IndexType string Columns string Unique bool Concurrent bool // StorageParameters holds access-method parameters rendered as WITH (...), // e.g. "lists = 100" for ivfflat or "m = 16, ef_construction = 64" for hnsw. StorageParameters string } // CreateForeignKeyData contains data for create foreign key template type CreateForeignKeyData struct { SchemaName string TableName string ConstraintName string SourceColumns string TargetSchema string TargetTable string TargetColumns string OnDelete string OnUpdate string Deferral string // DEFERRABLE / DEFERRABLE INITIALLY DEFERRED / NOT DEFERRABLE, or empty } // DropConstraintData contains data for drop constraint template type DropConstraintData struct { SchemaName string TableName string ConstraintName string } // DropIndexData contains data for drop index template type DropIndexData struct { SchemaName string IndexName string } // CommentTableData contains data for table comment template type CommentTableData struct { SchemaName string TableName string Comment string } // CommentColumnData contains data for column comment template type CommentColumnData struct { SchemaName string TableName string ColumnName string Comment string } // AuditTablesData contains data for audit tables template type AuditTablesData struct { AuditSchema string } // AuditColumnData represents a column in audit template type AuditColumnData struct { Name string OldValue string NewValue string } // AuditFunctionData contains data for audit function template type AuditFunctionData struct { SchemaName string FunctionName string TableName string TablePrefix string PrimaryKey string AuditSchema string UserFunction string AuditInsert bool AuditUpdate bool AuditDelete bool UpdateCondition string UpdateColumns []AuditColumnData DeleteColumns []AuditColumnData } // AuditTriggerData contains data for audit trigger template type AuditTriggerData struct { SchemaName string TableName string TriggerName string FunctionName string Events string } // CreateUniqueConstraintData contains data for create unique constraint template type CreateUniqueConstraintData struct { SchemaName string TableName string ConstraintName string Columns string UsingIndex string // when set, promotes this existing unique index instead of building a new one } // CreateCheckConstraintData contains data for create check constraint template type CreateCheckConstraintData struct { SchemaName string TableName string ConstraintName string Expression string } // CreateForeignKeyWithCheckData contains data for create foreign key with existence check template type CreateForeignKeyWithCheckData struct { SchemaName string TableName string ConstraintName string SourceColumns string TargetSchema string TargetTable string TargetColumns string OnDelete string OnUpdate string Deferral string // DEFERRABLE / DEFERRABLE INITIALLY DEFERRED / NOT DEFERRABLE, or empty } // SetSequenceValueData contains data for set sequence value template type SetSequenceValueData struct { SchemaName string TableName string SequenceName string ColumnName string } // CreateSequenceData contains data for create sequence template type CreateSequenceData struct { SchemaName string SequenceName string Increment int MinValue int64 MaxValue int64 StartValue int64 CacheSize int } // AddColumnWithCheckData contains data for add column with existence check template type AddColumnWithCheckData struct { SchemaName string TableName string ColumnName string ColumnDefinition string } // CreatePrimaryKeyWithAutoGenCheckData contains data for primary key with auto-generated key check template type CreatePrimaryKeyWithAutoGenCheckData struct { SchemaName string TableName string ConstraintName string AutoGenNames string // Comma-separated list of names like "'name1', 'name2'" Columns string ColumnNames string // Comma-separated list of quoted column names like "'id', 'tenant_id'" } // Execute methods for each template // ExecuteCreateTable executes the create table template func (te *TemplateExecutor) ExecuteCreateTable(data CreateTableData) (string, error) { return te.execute("create_table.tmpl", data) } // ExecuteAddColumn executes the add column template func (te *TemplateExecutor) ExecuteAddColumn(data AddColumnData) (string, error) { 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) { 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) { 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) { 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) { return te.execute("create_primary_key.tmpl", data) } // ExecuteCreateIndex executes the create index template func (te *TemplateExecutor) ExecuteCreateIndex(data CreateIndexData) (string, error) { return te.execute("create_index.tmpl", data) } // ExecuteCreateForeignKey executes the create foreign key template func (te *TemplateExecutor) ExecuteCreateForeignKey(data CreateForeignKeyData) (string, error) { return te.execute("create_foreign_key.tmpl", data) } // ExecuteDropConstraint executes the drop constraint template func (te *TemplateExecutor) ExecuteDropConstraint(data DropConstraintData) (string, error) { return te.execute("drop_constraint.tmpl", data) } // ExecuteDropIndex executes the drop index template func (te *TemplateExecutor) ExecuteDropIndex(data DropIndexData) (string, error) { return te.execute("drop_index.tmpl", data) } // ExecuteCommentTable executes the table comment template func (te *TemplateExecutor) ExecuteCommentTable(data CommentTableData) (string, error) { return te.execute("comment_table.tmpl", data) } // ExecuteCommentColumn executes the column comment template func (te *TemplateExecutor) ExecuteCommentColumn(data CommentColumnData) (string, error) { return te.execute("comment_column.tmpl", data) } // ExecuteAuditTables executes the audit tables template func (te *TemplateExecutor) ExecuteAuditTables(data AuditTablesData) (string, error) { return te.execute("audit_tables.tmpl", data) } // ExecuteAuditFunction executes the audit function template func (te *TemplateExecutor) ExecuteAuditFunction(data AuditFunctionData) (string, error) { return te.execute("audit_function.tmpl", data) } // ExecuteAuditTrigger executes the audit trigger template func (te *TemplateExecutor) ExecuteAuditTrigger(data AuditTriggerData) (string, error) { return te.execute("audit_trigger.tmpl", data) } // ExecuteCreateUniqueConstraint executes the create unique constraint template func (te *TemplateExecutor) ExecuteCreateUniqueConstraint(data CreateUniqueConstraintData) (string, error) { return te.execute("create_unique_constraint.tmpl", data) } // ExecuteCreateCheckConstraint executes the create check constraint template func (te *TemplateExecutor) ExecuteCreateCheckConstraint(data CreateCheckConstraintData) (string, error) { 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) { 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) { return te.execute("set_sequence_value.tmpl", data) } // ExecuteCreateSequence executes the create sequence template func (te *TemplateExecutor) ExecuteCreateSequence(data CreateSequenceData) (string, error) { return te.execute("create_sequence.tmpl", data) } // ExecuteAddColumnWithCheck executes the add column with check template func (te *TemplateExecutor) ExecuteAddColumnWithCheck(data AddColumnWithCheckData) (string, error) { 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) { return te.execute("create_primary_key_with_autogen_check.tmpl", data) } // Helper functions to build template data from models // columnGenerationClauses returns the generation expression of a GENERATED ... STORED // column and the generation mode of an identity column ("" for each that does not apply). // Such columns cannot carry a DEFAULT. func columnGenerationClauses(col *models.Column) (generated, identity string) { if col.Generated && col.GenerationExpression != "" { return col.GenerationExpression, "" } if col.Identity && col.IdentityGeneration != "" { return "", strings.ToUpper(col.IdentityGeneration) } return "", "" } // BuildCreateTableData builds CreateTableData from a models.Table func BuildCreateTableData(schemaName string, table *models.Table) CreateTableData { columns := make([]ColumnData, 0, len(table.Columns)) // Get sorted columns sortedCols := getSortedColumns(table.Columns) for _, col := range sortedCols { colData := ColumnData{ Name: col.Name, Type: col.Type, NotNull: col.NotNull, } colData.Generated, colData.Identity = columnGenerationClauses(col) if col.Default != nil && colData.Generated == "" && colData.Identity == "" { if value, ok := col.Default.(string); ok { colData.Default = quoteDefaultValue(stripBackticks(value), col.Type) } else { colData.Default = fmt.Sprintf("%v", col.Default) } } columns = append(columns, colData) } return CreateTableData{ SchemaName: schemaName, TableName: table.Name, Columns: columns, } } // BuildAuditFunctionData builds AuditFunctionData from table and config func BuildAuditFunctionData( schemaName string, table *models.Table, pk *models.Column, config *TableAuditConfig, auditSchema string, userFunction string, ) AuditFunctionData { funcName := fmt.Sprintf("tf_audit_%s", table.Name) // Build list of audited columns auditedColumns := make([]*models.Column, 0) for _, col := range sortColumns(table.Columns) { if col.Name == pk.Name { continue } excluded := false for _, excl := range config.ExcludedColumns { if strings.EqualFold(col.Name, excl) { excluded = true break } } if excluded { continue } auditedColumns = append(auditedColumns, col) } // Build update condition updateComparisons := make([]string, 0) for _, col := range auditedColumns { updateComparisons = append(updateComparisons, fmt.Sprintf("old.%s IS DISTINCT FROM new.%s", quoteIdent(col.Name), quoteIdent(col.Name))) } updateCondition := strings.Join(updateComparisons, " OR ") // Build update columns data updateColumns := make([]AuditColumnData, 0) for _, col := range auditedColumns { isEncrypted := false for _, enc := range config.EncryptedColumns { if strings.EqualFold(col.Name, enc) { isEncrypted = true break } } oldValue := fmt.Sprintf("old.%s::text", quoteIdent(col.Name)) newValue := fmt.Sprintf("new.%s::text", quoteIdent(col.Name)) if isEncrypted { oldValue = "'****************'" newValue = "'****************'" } updateColumns = append(updateColumns, AuditColumnData{ Name: col.Name, OldValue: oldValue, NewValue: newValue, }) } // Build delete columns data (same as update but only old values) deleteColumns := make([]AuditColumnData, 0) for _, col := range auditedColumns { isEncrypted := false for _, enc := range config.EncryptedColumns { if strings.EqualFold(col.Name, enc) { isEncrypted = true break } } oldValue := fmt.Sprintf("old.%s::text", quoteIdent(col.Name)) if isEncrypted { oldValue = "'****************'" } deleteColumns = append(deleteColumns, AuditColumnData{ Name: col.Name, OldValue: oldValue, }) } tablePrefix := "NULL" if config.TablePrefix != "" { tablePrefix = quote(config.TablePrefix) } return AuditFunctionData{ SchemaName: schemaName, FunctionName: funcName, TableName: table.Name, TablePrefix: tablePrefix, PrimaryKey: pk.Name, AuditSchema: auditSchema, UserFunction: userFunction, AuditInsert: config.AuditInsert, AuditUpdate: config.AuditUpdate, AuditDelete: config.AuditDelete, UpdateCondition: updateCondition, UpdateColumns: updateColumns, DeleteColumns: deleteColumns, } }