Files
relspecgo/pkg/writers/pgsql/templates.go
T

599 lines
18 KiB
Go

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,
}
}