feat: mark generated and identity columns across writers and DBML
- bun: scanonly+generated for GENERATED columns; scanonly+identity for ALWAYS identity (PK gets identity only) - gorm: <-:false+generated / identity, same primary key rule - dbml: write GENERATED/IDENTITY column notes and read them back into the model - pgsql: emit generation/identity clauses in migration create-table and add-column, no DEFAULT - readmes updated
This commit is contained in:
@@ -164,6 +164,12 @@ When `include_audit` is enabled, adds:
|
||||
## PostgreSQL-Specific Features
|
||||
|
||||
- Serial types (SERIAL, BIGSERIAL)
|
||||
- Generated columns: `GENERATED ALWAYS AS (expr) STORED` (`Column.Generated` + `GenerationExpression`)
|
||||
- Identity columns: `GENERATED ALWAYS|BY DEFAULT AS IDENTITY` (`Column.Identity` + `IdentityGeneration`)
|
||||
- Generated/identity columns never carry a `DEFAULT`; emitted in `CREATE TABLE` and `ADD COLUMN` (full DDL and migration)
|
||||
- Existing generated/identity columns are not diffed (expression changes are not applied)
|
||||
- PK sequence = the `nextval()` default's sequence; `setval` moves it past `MAX(pk)`, forward only
|
||||
- `serial`/`bigserial` columns without a model default keep their `nextval()` default in diffs
|
||||
- Advanced types (UUID, JSONB, ARRAY)
|
||||
- Schema-qualified names
|
||||
- Constraint actions (CASCADE, RESTRICT, SET NULL)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package pgsql
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
)
|
||||
|
||||
func generationTestTable(withColumns bool) *models.Table {
|
||||
table := models.InitTable("people", "public")
|
||||
|
||||
first := models.InitColumn("first", "people", "public")
|
||||
first.Type = "text"
|
||||
first.Sequence = 1
|
||||
table.Columns["first"] = first
|
||||
|
||||
if !withColumns {
|
||||
return table
|
||||
}
|
||||
|
||||
full := models.InitColumn("full_name", "people", "public")
|
||||
full.Type = "text"
|
||||
full.Sequence = 2
|
||||
full.Generated = true
|
||||
full.GenerationExpression = "first || ' ' || last"
|
||||
full.Default = "ignored"
|
||||
table.Columns["full_name"] = full
|
||||
|
||||
ident := models.InitColumn("seq", "people", "public")
|
||||
ident.Type = "bigint"
|
||||
ident.Sequence = 3
|
||||
ident.NotNull = true
|
||||
ident.Identity = true
|
||||
ident.IdentityGeneration = "ALWAYS"
|
||||
table.Columns["seq"] = ident
|
||||
|
||||
return table
|
||||
}
|
||||
|
||||
func generationTestDB(table *models.Table) *models.Database {
|
||||
db := models.InitDatabase("testdb")
|
||||
schema := models.InitSchema("public")
|
||||
schema.Tables = append(schema.Tables, table)
|
||||
db.Schemas = append(db.Schemas, schema)
|
||||
return db
|
||||
}
|
||||
|
||||
func TestGeneratedColumns_MigrationCreateAndAddColumn(t *testing.T) {
|
||||
want := []string{
|
||||
"GENERATED ALWAYS AS (first || ' ' || last) STORED",
|
||||
"GENERATED ALWAYS AS IDENTITY",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
current *models.Database
|
||||
}{
|
||||
{"new table", models.InitDatabase("testdb")},
|
||||
{"new columns on existing table", generationTestDB(generationTestTable(false))},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w := NewWriter(&writers.WriterOptions{})
|
||||
stmts, err := w.diffStatements(generationTestDB(generationTestTable(true)), tt.current)
|
||||
if err != nil {
|
||||
t.Fatalf("diffStatements failed: %v", err)
|
||||
}
|
||||
out := diffJoin(stmts)
|
||||
for _, s := range want {
|
||||
if !strings.Contains(out, s) {
|
||||
t.Errorf("missing %q:\n%s", s, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "DEFAULT ignored") || strings.Contains(out, "DEFAULT 'ignored'") {
|
||||
t.Errorf("generated column must not carry a DEFAULT:\n%s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedColumns_FullDDL(t *testing.T) {
|
||||
w := NewWriter(&writers.WriterOptions{})
|
||||
stmts, err := w.GenerateDatabaseStatements(generationTestDB(generationTestTable(true)))
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateDatabaseStatements failed: %v", err)
|
||||
}
|
||||
out := diffJoin(stmts)
|
||||
for _, s := range []string{
|
||||
"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)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "DEFAULT 'ignored'") {
|
||||
t.Errorf("generated column must not carry a DEFAULT:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -442,6 +442,7 @@ func (w *MigrationWriter) generateAlterTableScripts(schema *models.Schema, model
|
||||
if !exists {
|
||||
// Column doesn't exist, add it
|
||||
_, defaultVal := formatColumnDefaultSQL(modelCol)
|
||||
generated, identity := columnGenerationClauses(modelCol)
|
||||
|
||||
sql, err := w.executor.ExecuteAddColumn(AddColumnData{
|
||||
SchemaName: schema.Name,
|
||||
@@ -450,6 +451,8 @@ func (w *MigrationWriter) generateAlterTableScripts(schema *models.Schema, model
|
||||
ColumnType: effectiveColumnSQLType(modelCol),
|
||||
Default: defaultVal,
|
||||
NotNull: modelCol.NotNull,
|
||||
Generated: generated,
|
||||
Identity: identity,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -77,6 +77,10 @@ type ColumnData struct {
|
||||
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
|
||||
@@ -87,6 +91,10 @@ type AddColumnData struct {
|
||||
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
|
||||
@@ -520,6 +528,19 @@ func (te *TemplateExecutor) ExecuteCreatePrimaryKeyWithAutoGenCheck(data CreateP
|
||||
|
||||
// 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))
|
||||
@@ -532,7 +553,8 @@ func BuildCreateTableData(schemaName string, table *models.Table) CreateTableDat
|
||||
Type: col.Type,
|
||||
NotNull: col.NotNull,
|
||||
}
|
||||
if col.Default != nil {
|
||||
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)
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
ALTER TABLE {{qual_table .SchemaName .TableName}}
|
||||
ADD COLUMN IF NOT EXISTS {{quote_ident .ColumnName}} {{.ColumnType}}
|
||||
{{- if .Default}} DEFAULT {{.Default}}{{end}}
|
||||
{{- 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}}
|
||||
{{- if .NotNull}} NOT NULL{{end}};
|
||||
@@ -2,7 +2,9 @@ CREATE TABLE IF NOT EXISTS {{qual_table .SchemaName .TableName}} (
|
||||
{{- range $i, $col := .Columns}}
|
||||
{{- if $i}},{{end}}
|
||||
{{quote_ident $col.Name}} {{$col.Type}}
|
||||
{{- if $col.Default}} DEFAULT {{$col.Default}}{{end}}
|
||||
{{- if $col.Generated}} GENERATED ALWAYS AS ({{$col.Generated}}) STORED{{end}}
|
||||
{{- if $col.Identity}} GENERATED {{$col.Identity}} AS IDENTITY{{end}}
|
||||
{{- if and $col.Default (not $col.Generated) (not $col.Identity)}} DEFAULT {{$col.Default}}{{end}}
|
||||
{{- if $col.NotNull}} NOT NULL{{end}}
|
||||
{{- end}}
|
||||
);
|
||||
@@ -3,7 +3,9 @@
|
||||
{{/* Column definition fragment */}}
|
||||
{{- define "column_definition" -}}
|
||||
{{.Name}} {{.Type}}
|
||||
{{- if .Default}} DEFAULT {{.Default}}{{end}}
|
||||
{{- 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}}
|
||||
{{- if .NotNull}} NOT NULL{{end}}
|
||||
{{- end -}}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user