More Roundtrip tests
Some checks are pending
CI / Test (1.23) (push) Waiting to run
CI / Test (1.24) (push) Waiting to run
CI / Test (1.25) (push) Waiting to run
CI / Lint (push) Waiting to run
CI / Build (push) Waiting to run

This commit is contained in:
2025-12-17 22:52:24 +02:00
parent 5e1448dcdb
commit a427aa5537
23 changed files with 22897 additions and 1319 deletions

View File

@@ -1,696 +0,0 @@
# PostgreSQL Migration Templates
## Overview
The PostgreSQL migration writer uses Go text templates to generate SQL, making the code much more maintainable and customizable than hardcoded string concatenation.
## Architecture
```
pkg/writers/pgsql/
├── templates/ # Template files
│ ├── create_table.tmpl # CREATE TABLE
│ ├── add_column.tmpl # ALTER TABLE ADD COLUMN
│ ├── alter_column_type.tmpl # ALTER TABLE ALTER COLUMN TYPE
│ ├── alter_column_default.tmpl # ALTER TABLE ALTER COLUMN DEFAULT
│ ├── create_primary_key.tmpl # ADD CONSTRAINT PRIMARY KEY
│ ├── create_index.tmpl # CREATE INDEX
│ ├── create_foreign_key.tmpl # ADD CONSTRAINT FOREIGN KEY
│ ├── drop_constraint.tmpl # DROP CONSTRAINT
│ ├── drop_index.tmpl # DROP INDEX
│ ├── comment_table.tmpl # COMMENT ON TABLE
│ ├── comment_column.tmpl # COMMENT ON COLUMN
│ ├── audit_tables.tmpl # CREATE audit tables
│ ├── audit_function.tmpl # CREATE audit function
│ └── audit_trigger.tmpl # CREATE audit trigger
├── templates.go # Template executor and data structures
└── migration_writer_templated.go # Templated migration writer
```
## Using Templates
### Basic Usage
```go
// Create template executor
executor, err := pgsql.NewTemplateExecutor()
if err != nil {
log.Fatal(err)
}
// Prepare data
data := pgsql.CreateTableData{
SchemaName: "public",
TableName: "users",
Columns: []pgsql.ColumnData{
{Name: "id", Type: "integer", NotNull: true},
{Name: "name", Type: "text"},
},
}
// Execute template
sql, err := executor.ExecuteCreateTable(data)
if err != nil {
log.Fatal(err)
}
fmt.Println(sql)
```
### Using Templated Migration Writer
```go
// Create templated migration writer
writer, err := pgsql.NewTemplatedMigrationWriter(&writers.WriterOptions{
OutputPath: "migration.sql",
})
if err != nil {
log.Fatal(err)
}
// Generate migration (uses templates internally)
err = writer.WriteMigration(modelDB, currentDB)
if err != nil {
log.Fatal(err)
}
```
## Template Data Structures
### CreateTableData
For `create_table.tmpl`:
```go
type CreateTableData struct {
SchemaName string
TableName string
Columns []ColumnData
}
type ColumnData struct {
Name string
Type string
Default string
NotNull bool
}
```
Example:
```go
data := CreateTableData{
SchemaName: "public",
TableName: "products",
Columns: []ColumnData{
{Name: "id", Type: "serial", NotNull: true},
{Name: "name", Type: "text", NotNull: true},
{Name: "price", Type: "numeric(10,2)", Default: "0.00"},
},
}
```
### AddColumnData
For `add_column.tmpl`:
```go
type AddColumnData struct {
SchemaName string
TableName string
ColumnName string
ColumnType string
Default string
NotNull bool
}
```
### CreateIndexData
For `create_index.tmpl`:
```go
type CreateIndexData struct {
SchemaName string
TableName string
IndexName string
IndexType string // btree, hash, gin, gist
Columns string // comma-separated
Unique bool
}
```
### CreateForeignKeyData
For `create_foreign_key.tmpl`:
```go
type CreateForeignKeyData struct {
SchemaName string
TableName string
ConstraintName string
SourceColumns string // comma-separated
TargetSchema string
TargetTable string
TargetColumns string // comma-separated
OnDelete string // CASCADE, SET NULL, etc.
OnUpdate string
}
```
### AuditFunctionData
For `audit_function.tmpl`:
```go
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
}
type AuditColumnData struct {
Name string
OldValue string // SQL expression for old value
NewValue string // SQL expression for new value
}
```
## Customizing Templates
### Modifying Existing Templates
Templates are embedded in the binary but can be modified at compile time:
1. **Edit template file** in `pkg/writers/pgsql/templates/`:
```go
// templates/create_table.tmpl
CREATE TABLE IF NOT EXISTS {{.SchemaName}}.{{.TableName}} (
{{- range $i, $col := .Columns}}
{{- if $i}},{{end}}
{{$col.Name}} {{$col.Type}}
{{- if $col.Default}} DEFAULT {{$col.Default}}{{end}}
{{- if $col.NotNull}} NOT NULL{{end}}
{{- end}}
);
-- Custom comment
COMMENT ON TABLE {{.SchemaName}}.{{.TableName}} IS 'Auto-generated by RelSpec';
```
2. **Rebuild** the application:
```bash
go build ./cmd/relspec
```
The new template is automatically embedded.
### Template Syntax Reference
#### Variables
```go
{{.FieldName}} // Access field
{{.SchemaName}} // String field
{{.NotNull}} // Boolean field
```
#### Conditionals
```go
{{if .NotNull}}
NOT NULL
{{end}}
{{if .Default}}
DEFAULT {{.Default}}
{{else}}
-- No default
{{end}}
```
#### Loops
```go
{{range $i, $col := .Columns}}
Column: {{$col.Name}} Type: {{$col.Type}}
{{end}}
```
#### Functions
```go
{{if eq .Type "CASCADE"}}
ON DELETE CASCADE
{{end}}
{{join .Columns ", "}} // Join string slice
```
### Creating New Templates
1. **Create template file** in `pkg/writers/pgsql/templates/`:
```go
// templates/custom_operation.tmpl
-- Custom operation for {{.TableName}}
ALTER TABLE {{.SchemaName}}.{{.TableName}}
{{.CustomOperation}};
```
2. **Define data structure** in `templates.go`:
```go
type CustomOperationData struct {
SchemaName string
TableName string
CustomOperation string
}
```
3. **Add executor method** in `templates.go`:
```go
func (te *TemplateExecutor) ExecuteCustomOperation(data CustomOperationData) (string, error) {
var buf bytes.Buffer
err := te.templates.ExecuteTemplate(&buf, "custom_operation.tmpl", data)
if err != nil {
return "", fmt.Errorf("failed to execute custom_operation template: %w", err)
}
return buf.String(), nil
}
```
4. **Use in migration writer**:
```go
sql, err := w.executor.ExecuteCustomOperation(CustomOperationData{
SchemaName: "public",
TableName: "users",
CustomOperation: "ADD COLUMN custom_field text",
})
```
## Template Examples
### Example 1: Custom Table Creation
Modify `create_table.tmpl` to add table options:
```sql
CREATE TABLE IF NOT EXISTS {{.SchemaName}}.{{.TableName}} (
{{- range $i, $col := .Columns}}
{{- if $i}},{{end}}
{{$col.Name}} {{$col.Type}}
{{- if $col.Default}} DEFAULT {{$col.Default}}{{end}}
{{- if $col.NotNull}} NOT NULL{{end}}
{{- end}}
) WITH (fillfactor = 90);
-- Add automatic comment
COMMENT ON TABLE {{.SchemaName}}.{{.TableName}}
IS 'Created: {{.CreatedDate}} | Version: {{.Version}}';
```
### Example 2: Custom Index with WHERE Clause
Add to `create_index.tmpl`:
```sql
CREATE {{if .Unique}}UNIQUE {{end}}INDEX IF NOT EXISTS {{.IndexName}}
ON {{.SchemaName}}.{{.TableName}}
USING {{.IndexType}} ({{.Columns}})
{{- if .Where}}
WHERE {{.Where}}
{{- end}}
{{- if .Include}}
INCLUDE ({{.Include}})
{{- end}};
```
Update data structure:
```go
type CreateIndexData struct {
SchemaName string
TableName string
IndexName string
IndexType string
Columns string
Unique bool
Where string // New field for partial indexes
Include string // New field for covering indexes
}
```
### Example 3: Enhanced Audit Function
Modify `audit_function.tmpl` to add custom logging:
```sql
CREATE OR REPLACE FUNCTION {{.SchemaName}}.{{.FunctionName}}()
RETURNS trigger AS
$body$
DECLARE
m_funcname text = '{{.FunctionName}}';
m_user text;
m_atevent integer;
m_application_name text;
BEGIN
-- Get current user and application
m_user := {{.UserFunction}}::text;
m_application_name := current_setting('application_name', true);
-- Custom logging
RAISE NOTICE 'Audit: % on %.% by % from %',
TG_OP, TG_TABLE_SCHEMA, TG_TABLE_NAME, m_user, m_application_name;
-- Rest of function...
...
```
## Best Practices
### 1. Keep Templates Simple
Templates should focus on SQL generation. Complex logic belongs in Go code:
**Good:**
```go
// In Go code
columns := buildColumnList(table)
// In template
{{range .Columns}}
{{.Name}} {{.Type}}
{{end}}
```
**Bad:**
```go
// Don't do complex transformations in templates
{{range .Columns}}
{{if eq .Type "integer"}}
{{.Name}} serial
{{else}}
{{.Name}} {{.Type}}
{{end}}
{{end}}
```
### 2. Use Descriptive Field Names
```go
// Good
type CreateTableData struct {
SchemaName string
TableName string
}
// Bad
type CreateTableData struct {
S string // What is S?
T string // What is T?
}
```
### 3. Document Template Data
Always document what data a template expects:
```go
// CreateTableData contains data for create table template.
// Used by templates/create_table.tmpl
type CreateTableData struct {
SchemaName string // Schema where table will be created
TableName string // Name of the table
Columns []ColumnData // List of columns to create
}
```
### 4. Handle SQL Injection
Always escape user input:
```go
// In Go code - escape before passing to template
data := CommentTableData{
SchemaName: schema,
TableName: table,
Comment: escapeQuote(userComment), // Escape quotes
}
```
### 5. Test Templates Thoroughly
```go
func TestTemplate_CreateTable(t *testing.T) {
executor, _ := NewTemplateExecutor()
data := CreateTableData{
SchemaName: "public",
TableName: "test",
Columns: []ColumnData{{Name: "id", Type: "integer"}},
}
sql, err := executor.ExecuteCreateTable(data)
if err != nil {
t.Fatal(err)
}
// Verify expected SQL patterns
if !strings.Contains(sql, "CREATE TABLE") {
t.Error("Missing CREATE TABLE")
}
}
```
## Benefits of Template-Based Approach
### Maintainability
**Before (string concatenation):**
```go
sql := fmt.Sprintf(`CREATE TABLE %s.%s (
%s %s%s%s
);`, schema, table, col, typ,
func() string {
if def != "" {
return " DEFAULT " + def
}
return ""
}(),
func() string {
if notNull {
return " NOT NULL"
}
return ""
}(),
)
```
**After (templates):**
```go
sql, _ := executor.ExecuteCreateTable(CreateTableData{
SchemaName: schema,
TableName: table,
Columns: columns,
})
```
### Customization
Users can modify templates without changing Go code:
- Edit template file
- Rebuild application
- New SQL generation logic active
### Testing
Templates can be tested independently:
```go
func TestAuditTemplate(t *testing.T) {
executor, _ := NewTemplateExecutor()
// Test with various data
for _, testCase := range testCases {
sql, err := executor.ExecuteAuditFunction(testCase.data)
// Verify output
}
}
```
### Readability
SQL templates are easier to read and review than Go string building code.
## Migration from Old Writer
To migrate from the old string-based writer to templates:
### Option 1: Use TemplatedMigrationWriter
```go
// Old
writer := pgsql.NewMigrationWriter(options)
// New
writer, err := pgsql.NewTemplatedMigrationWriter(options)
if err != nil {
log.Fatal(err)
}
// Same interface
writer.WriteMigration(model, current)
```
### Option 2: Keep Both
Both writers are available:
- `MigrationWriter` - Original string-based
- `TemplatedMigrationWriter` - New template-based
Choose based on your needs.
## Troubleshooting
### Template Not Found
```
Error: template: "my_template.tmpl" not defined
```
Solution: Ensure template file exists in `templates/` directory and rebuild.
### Template Execution Error
```
Error: template: create_table.tmpl:5:10: executing "create_table.tmpl"
at <.InvalidField>: can't evaluate field InvalidField
```
Solution: Check data structure has all fields used in template.
### Embedded Files Not Updating
If template changes aren't reflected:
1. Clean build cache: `go clean -cache`
2. Rebuild: `go build ./cmd/relspec`
3. Verify template file is in `templates/` directory
## Custom Template Functions
RelSpec provides a comprehensive library of template functions for SQL generation:
### String Manipulation
- `upper`, `lower` - Case conversion
- `snake_case`, `camelCase` - Naming convention conversion
- Usage: `{{upper .TableName}}``USERS`
### SQL Formatting
- `indent(spaces, text)` - Indent text
- `quote(string)` - Quote for SQL with escaping
- `escape(string)` - Escape special characters
- `safe_identifier(string)` - Make SQL-safe identifier
- Usage: `{{quote "O'Brien"}}``'O''Brien'`
### Type Conversion
- `goTypeToSQL(type)` - Convert Go type to PostgreSQL type
- `sqlTypeToGo(type)` - Convert PostgreSQL type to Go type
- `isNumeric(type)`, `isText(type)` - Type checking
- Usage: `{{goTypeToSQL "int64"}}``bigint`
### Collection Helpers
- `first(slice)`, `last(slice)` - Get elements
- `join_with(slice, sep)` - Join with custom separator
- Usage: `{{join_with .Columns ", "}}``id, name, email`
See [template_functions.go](template_functions.go) for full documentation.
## Template Inheritance and Composition
RelSpec supports Go template inheritance using `{{template}}` and `{{block}}`:
### Base Templates
- `base_ddl.tmpl` - Common DDL patterns
- `base_constraint.tmpl` - Constraint operations
- `fragments.tmpl` - Reusable fragments
### Using Fragments
```gotmpl
{{/* Use predefined fragments */}}
CREATE TABLE {{template "qualified_table" .}} (
{{range .Columns}}
{{template "column_definition" .}}
{{end}}
);
```
### Template Blocks
```gotmpl
{{/* Define with override capability */}}
{{define "table_options"}}
) {{block "storage_options" .}}WITH (fillfactor = 90){{end}};
{{end}}
```
See [TEMPLATE_INHERITANCE.md](TEMPLATE_INHERITANCE.md) for detailed guide.
## Visual Template Editor
A VS Code extension is available for visual template editing:
### Features
- **Live Preview** - See rendered SQL as you type
- **IntelliSense** - Auto-completion for functions
- **Validation** - Syntax checking and error highlighting
- **Scaffolding** - Quick template creation
- **Function Browser** - Browse available functions
### Installation
```bash
cd vscode-extension
npm install
npm run compile
code .
# Press F5 to launch
```
See [vscode-extension/README.md](../../vscode-extension/README.md) for full documentation.
## Future Enhancements
Completed:
- [x] Template inheritance/composition
- [x] Custom template functions library
- [x] Visual template editor (VS Code)
Potential future improvements:
- [ ] Parameterized templates (load from config)
- [ ] Template validation CLI tool
- [ ] Template library/marketplace
- [ ] Template versioning
- [ ] Hot-reload during development
## Contributing Templates
When contributing new templates:
1. Place in `pkg/writers/pgsql/templates/`
2. Use `.tmpl` extension
3. Document data structure in `templates.go`
4. Add executor method
5. Write tests
6. Update this documentation

View File

@@ -62,6 +62,234 @@ func (w *Writer) WriteDatabase(db *models.Database) error {
return nil
}
// 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) {
statements := []string{}
// Add header comment
statements = append(statements, fmt.Sprintf("-- PostgreSQL Database Schema"))
statements = append(statements, fmt.Sprintf("-- Database: %s", db.Name))
statements = append(statements, fmt.Sprintf("-- Generated by RelSpec"))
// Process each schema in the database
for _, schema := range db.Schemas {
schemaStatements, err := w.GenerateSchemaStatements(schema)
if err != nil {
return nil, fmt.Errorf("failed to generate statements for schema %s: %w", schema.Name, err)
}
statements = append(statements, schemaStatements...)
}
return statements, nil
}
// GenerateSchemaStatements generates SQL statements as a list for a single schema
func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, error) {
statements := []string{}
// Phase 1: Create schema
if schema.Name != "public" {
statements = append(statements, fmt.Sprintf("-- Schema: %s", schema.Name))
statements = append(statements, fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s", schema.SQLName()))
}
// Phase 2: Create sequences
for _, table := range schema.Tables {
pk := table.GetPrimaryKey()
if pk == nil || !isIntegerType(pk.Type) || pk.Default == "" {
continue
}
defaultStr, ok := pk.Default.(string)
if !ok || !strings.Contains(strings.ToLower(defaultStr), "nextval") {
continue
}
seqName := extractSequenceName(defaultStr)
if seqName == "" {
continue
}
stmt := fmt.Sprintf("CREATE SEQUENCE IF NOT EXISTS %s.%s\n INCREMENT 1\n MINVALUE 1\n MAXVALUE 9223372036854775807\n START 1\n CACHE 1",
schema.SQLName(), seqName)
statements = append(statements, stmt)
}
// Phase 3: Create tables
for _, table := range schema.Tables {
stmts, err := w.generateCreateTableStatement(schema, table)
if err != nil {
return nil, fmt.Errorf("failed to generate table %s: %w", table.Name, err)
}
statements = append(statements, stmts...)
}
// Phase 4: Primary keys
for _, table := range schema.Tables {
for _, constraint := range table.Constraints {
if constraint.Type != models.PrimaryKeyConstraint {
continue
}
stmt := fmt.Sprintf("ALTER TABLE %s.%s ADD CONSTRAINT %s PRIMARY KEY (%s)",
schema.SQLName(), table.SQLName(), constraint.Name, strings.Join(constraint.Columns, ", "))
statements = append(statements, stmt)
}
}
// Phase 5: Indexes
for _, table := range schema.Tables {
for _, index := range table.Indexes {
// Skip primary key indexes
if strings.HasSuffix(index.Name, "_pkey") {
continue
}
uniqueStr := ""
if index.Unique {
uniqueStr = "UNIQUE "
}
indexType := index.Type
if indexType == "" {
indexType = "btree"
}
whereClause := ""
if index.Where != "" {
whereClause = fmt.Sprintf(" WHERE %s", index.Where)
}
stmt := fmt.Sprintf("CREATE %sINDEX IF NOT EXISTS %s ON %s.%s USING %s (%s)%s",
uniqueStr, index.Name, schema.SQLName(), table.SQLName(), indexType, strings.Join(index.Columns, ", "), whereClause)
statements = append(statements, stmt)
}
}
// Phase 6: Foreign keys
for _, table := range schema.Tables {
for _, constraint := range table.Constraints {
if constraint.Type != models.ForeignKeyConstraint {
continue
}
refSchema := constraint.ReferencedSchema
if refSchema == "" {
refSchema = schema.Name
}
onDelete := constraint.OnDelete
if onDelete == "" {
onDelete = "NO ACTION"
}
onUpdate := constraint.OnUpdate
if onUpdate == "" {
onUpdate = "NO ACTION"
}
stmt := fmt.Sprintf("ALTER TABLE %s.%s ADD CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s.%s(%s) ON DELETE %s ON UPDATE %s",
schema.SQLName(), table.SQLName(), constraint.Name,
strings.Join(constraint.Columns, ", "),
strings.ToLower(refSchema), strings.ToLower(constraint.ReferencedTable),
strings.Join(constraint.ReferencedColumns, ", "),
onDelete, onUpdate)
statements = append(statements, stmt)
}
}
// Phase 7: Comments
for _, table := range schema.Tables {
if table.Comment != "" {
stmt := fmt.Sprintf("COMMENT ON TABLE %s.%s IS '%s'",
schema.SQLName(), table.SQLName(), escapeQuote(table.Comment))
statements = append(statements, stmt)
}
for _, column := range table.Columns {
if column.Comment != "" {
stmt := fmt.Sprintf("COMMENT ON COLUMN %s.%s.%s IS '%s'",
schema.SQLName(), table.SQLName(), column.SQLName(), escapeQuote(column.Comment))
statements = append(statements, stmt)
}
}
}
return statements, nil
}
// generateCreateTableStatement generates CREATE TABLE statement
func (w *Writer) generateCreateTableStatement(schema *models.Schema, table *models.Table) ([]string, error) {
statements := []string{}
// Sort columns by sequence or name
columns := make([]*models.Column, 0, len(table.Columns))
for _, col := range table.Columns {
columns = append(columns, col)
}
sort.Slice(columns, func(i, j int) bool {
if columns[i].Sequence != columns[j].Sequence {
return columns[i].Sequence < columns[j].Sequence
}
return columns[i].Name < columns[j].Name
})
columnDefs := []string{}
for _, col := range columns {
def := w.generateColumnDefinition(col)
columnDefs = append(columnDefs, " "+def)
}
stmt := fmt.Sprintf("CREATE TABLE %s.%s (\n%s\n)",
schema.SQLName(), table.SQLName(), strings.Join(columnDefs, ",\n"))
statements = append(statements, stmt)
return statements, nil
}
// generateColumnDefinition generates column definition
func (w *Writer) generateColumnDefinition(col *models.Column) string {
parts := []string{col.SQLName()}
// Type with length/precision
typeStr := col.Type
if col.Length > 0 && col.Precision == 0 {
typeStr = fmt.Sprintf("%s(%d)", col.Type, col.Length)
} else if col.Precision > 0 {
if col.Scale > 0 {
typeStr = fmt.Sprintf("%s(%d,%d)", col.Type, col.Precision, col.Scale)
} else {
typeStr = fmt.Sprintf("%s(%d)", col.Type, col.Precision)
}
}
parts = append(parts, typeStr)
// NOT NULL
if col.NotNull {
parts = append(parts, "NOT NULL")
}
// DEFAULT
if col.Default != nil {
switch v := col.Default.(type) {
case string:
if strings.HasPrefix(v, "nextval") || strings.HasPrefix(v, "CURRENT_") || strings.Contains(v, "()") {
parts = append(parts, fmt.Sprintf("DEFAULT %s", v))
} else if v == "true" || v == "false" {
parts = append(parts, fmt.Sprintf("DEFAULT %s", v))
} else {
parts = append(parts, fmt.Sprintf("DEFAULT '%s'", escapeQuote(v)))
}
case bool:
parts = append(parts, fmt.Sprintf("DEFAULT %v", v))
default:
parts = append(parts, fmt.Sprintf("DEFAULT %v", v))
}
}
return strings.Join(parts, " ")
}
// WriteSchema writes a single schema and all its tables
func (w *Writer) WriteSchema(schema *models.Schema) error {
if w.writer == nil {
@@ -494,3 +722,26 @@ func isIntegerType(colType string) bool {
func escapeQuote(s string) string {
return strings.ReplaceAll(s, "'", "''")
}
// extractSequenceName extracts sequence name from nextval() expression
// Example: "nextval('public.users_id_seq'::regclass)" returns "users_id_seq"
func extractSequenceName(defaultExpr string) string {
// Look for nextval('schema.sequence_name'::regclass) pattern
start := strings.Index(defaultExpr, "'")
if start == -1 {
return ""
}
end := strings.Index(defaultExpr[start+1:], "'")
if end == -1 {
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
}