diff --git a/pkg/readers/dbml/README.md b/pkg/readers/dbml/README.md index ef0daa7..d4fc313 100644 --- a/pkg/readers/dbml/README.md +++ b/pkg/readers/dbml/README.md @@ -157,6 +157,7 @@ grammar and the supported-directive matrix. ## Notes +- Column notes `GENERATED ALWAYS AS (expr) STORED` and `GENERATED ALWAYS|BY DEFAULT AS IDENTITY` set `Generated`/`GenerationExpression` and `Identity`/`IdentityGeneration`; they are not kept as comments - DBML is designed for database documentation and diagramming - Schema name defaults to `public` - Relationship cardinality is preserved diff --git a/pkg/readers/dbml/generated_columns.go b/pkg/readers/dbml/generated_columns.go new file mode 100644 index 0000000..ff47e7a --- /dev/null +++ b/pkg/readers/dbml/generated_columns.go @@ -0,0 +1,38 @@ +package dbml + +import ( + "regexp" + "strings" + + "git.warky.dev/wdevs/relspecgo/pkg/models" +) + +var ( + generatedNoteRegex = regexp.MustCompile(`(?is)^GENERATED\s+ALWAYS\s+AS\s*\((.*)\)\s*STORED$`) + identityNoteRegex = regexp.MustCompile(`(?i)^GENERATED\s+(ALWAYS|BY\s+DEFAULT)\s+AS\s+IDENTITY$`) +) + +// applyColumnMarkerNote interprets a column note written by the DBML writer for +// GENERATED ... STORED and GENERATED ... AS IDENTITY columns, setting the matching +// model fields. It returns true when the note was a marker, in which case it is not +// a user comment. +func applyColumnMarkerNote(column *models.Column, rawNote string) bool { + note := strings.TrimSpace(rawNote) + if len(note) >= 2 && (note[0] == '\'' || note[0] == '"') && note[len(note)-1] == note[0] { + note = note[1 : len(note)-1] + } + note = strings.ReplaceAll(strings.ReplaceAll(note, `\'`, `'`), `\\`, `\`) + note = strings.TrimSpace(note) + + if m := generatedNoteRegex.FindStringSubmatch(note); m != nil { + column.Generated = true + column.GenerationExpression = strings.TrimSpace(m[1]) + return true + } + if m := identityNoteRegex.FindStringSubmatch(note); m != nil { + column.Identity = true + column.IdentityGeneration = strings.Join(strings.Fields(strings.ToUpper(m[1])), " ") + return true + } + return false +} diff --git a/pkg/readers/dbml/reader.go b/pkg/readers/dbml/reader.go index c2a1888..317c117 100644 --- a/pkg/readers/dbml/reader.go +++ b/pkg/readers/dbml/reader.go @@ -804,7 +804,9 @@ func (r *Reader) parseColumn(line, tableName, schemaName string) (*models.Column } else if strings.HasPrefix(attr, "note:") { // Parse column note/comment note := strings.TrimSpace(strings.TrimPrefix(attr, "note:")) - column.Comment = strings.Trim(note, "'\"") + if !applyColumnMarkerNote(column, note) { + column.Comment = strings.Trim(note, "'\"") + } } else if strings.HasPrefix(attr, "ref:") { // Parse inline reference // DBML semantics depend on context: diff --git a/pkg/writers/bun/README.md b/pkg/writers/bun/README.md index fd3f17f..f0f4748 100644 --- a/pkg/writers/bun/README.md +++ b/pkg/writers/bun/README.md @@ -129,6 +129,9 @@ type User struct { - `default` - Default value - `rel` - Relationship definition - `type` - Explicit SQL type +- `scanonly` - Excluded from INSERT/UPDATE: `GENERATED ... STORED` columns, and `GENERATED ALWAYS AS IDENTITY` columns that are not the primary key +- `generated` - Marks a `GENERATED ... STORED` column +- `identity` - Marks a `GENERATED ALWAYS AS IDENTITY` column (a primary key gets only this marker, since Bun drops `scanonly` fields from the PK list) ## Type Mapping diff --git a/pkg/writers/bun/generated_column_test.go b/pkg/writers/bun/generated_column_test.go new file mode 100644 index 0000000..c5c904d --- /dev/null +++ b/pkg/writers/bun/generated_column_test.go @@ -0,0 +1,46 @@ +package bun + +import ( + "strings" + "testing" + + "git.warky.dev/wdevs/relspecgo/pkg/models" +) + +func TestBuildBunTag_GeneratedColumn(t *testing.T) { + mapper := NewTypeMapper("", "") + + generated := models.InitColumn("full_name", "users", "public") + generated.Type = "text" + generated.Generated = true + generated.GenerationExpression = "first || ' ' || last" + + plain := models.InitColumn("first", "users", "public") + plain.Type = "text" + + tests := []struct { + name string + column *models.Column + want bool + }{ + {"generated column is scanonly and marked", generated, true}, + {"ordinary column is untouched", plain, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tag := mapper.BuildBunTag(tt.column, nil) + parts := strings.Split(tag, ",") + has := func(s string) bool { + for _, p := range parts { + if p == s { + return true + } + } + return false + } + if has("scanonly") != tt.want || has("generated") != tt.want { + t.Errorf("tag %q: scanonly/generated present = %v/%v, want %v", tag, has("scanonly"), has("generated"), tt.want) + } + }) + } +} diff --git a/pkg/writers/bun/identity_column_test.go b/pkg/writers/bun/identity_column_test.go new file mode 100644 index 0000000..67bbb81 --- /dev/null +++ b/pkg/writers/bun/identity_column_test.go @@ -0,0 +1,49 @@ +package bun + +import ( + "strings" + "testing" + + "git.warky.dev/wdevs/relspecgo/pkg/models" +) + +func TestBuildBunTag_IdentityColumn(t *testing.T) { + mapper := NewTypeMapper("", "") + + build := func(pk bool, identity bool, generation string) *models.Column { + col := models.InitColumn("id", "users", "public") + col.Type = "bigint" + col.IsPrimaryKey = pk + col.Identity = identity + col.IdentityGeneration = generation + return col + } + + tests := []struct { + name string + column *models.Column + wantFlag bool + wantMarker bool + }{ + {"always identity non-pk is write-blocked", build(false, true, "ALWAYS"), true, true}, + {"always identity pk is only marked", build(true, true, "ALWAYS"), false, true}, + {"by default identity is writable", build(false, true, "BY DEFAULT"), false, false}, + {"non-identity is untouched", build(false, false, ""), false, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tag := mapper.BuildBunTag(tt.column, nil) + has := func(s string) bool { + for _, p := range strings.Split(tag, ",") { + if p == s { + return true + } + } + return false + } + if has("scanonly") != tt.wantFlag || has("identity") != tt.wantMarker { + t.Errorf("tag %q: scanonly/identity = %v/%v, want %v/%v", tag, has("scanonly"), has("identity"), tt.wantFlag, tt.wantMarker) + } + }) + } +} diff --git a/pkg/writers/bun/type_mapper.go b/pkg/writers/bun/type_mapper.go index acfd5eb..f55e938 100644 --- a/pkg/writers/bun/type_mapper.go +++ b/pkg/writers/bun/type_mapper.go @@ -366,6 +366,22 @@ func (tm *TypeMapper) BuildBunTag(column *models.Column, table *models.Table) st parts = append(parts, "autoincrement") } + // GENERATED ALWAYS ... STORED columns are computed by the database and cannot be + // written: scanonly keeps them out of INSERT/UPDATE, generated marks why. + if column.Generated { + parts = append(parts, "scanonly", "generated") + } + + // GENERATED ALWAYS AS IDENTITY rejects explicit values. Bun drops scanonly fields + // from the table's PK list, so a primary key only gets the marker; its zero value + // is already sent as DEFAULT via autoincrement. + if column.Identity && strings.EqualFold(column.IdentityGeneration, "ALWAYS") { + if !column.IsPrimaryKey { + parts = append(parts, "scanonly") + } + parts = append(parts, "identity") + } + // Default value if column.Default != nil { // Sanitize default value to remove backticks, then quote based on column type diff --git a/pkg/writers/dbml/README.md b/pkg/writers/dbml/README.md index 84e4712..d497842 100644 --- a/pkg/writers/dbml/README.md +++ b/pkg/writers/dbml/README.md @@ -111,6 +111,8 @@ Table table_name { - `unique` - Unique constraint - `default: value` - Default value - `note: 'text'` - Column note +- `note: 'GENERATED ALWAYS AS (expr) STORED'` - Generated column (`Generated`, `GenerationExpression`) +- `note: 'GENERATED ALWAYS|BY DEFAULT AS IDENTITY'` - Identity column (`Identity`, `IdentityGeneration`) ### Relationships ```dbml diff --git a/pkg/writers/dbml/generated_columns_test.go b/pkg/writers/dbml/generated_columns_test.go new file mode 100644 index 0000000..c16aae6 --- /dev/null +++ b/pkg/writers/dbml/generated_columns_test.go @@ -0,0 +1,77 @@ +package dbml + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "git.warky.dev/wdevs/relspecgo/pkg/models" + "git.warky.dev/wdevs/relspecgo/pkg/readers" + dbmlreader "git.warky.dev/wdevs/relspecgo/pkg/readers/dbml" + "git.warky.dev/wdevs/relspecgo/pkg/writers" +) + +func TestGeneratedAndIdentityColumnsRoundTrip(t *testing.T) { + table := models.InitTable("people", "public") + + id := models.InitColumn("id", "people", "public") + id.Type = "bigint" + id.IsPrimaryKey = true + id.AutoIncrement = true + id.Identity = true + id.IdentityGeneration = "ALWAYS" + table.Columns["id"] = id + + seq := models.InitColumn("seq", "people", "public") + seq.Type = "integer" + seq.Identity = true + seq.IdentityGeneration = "BY DEFAULT" + table.Columns["seq"] = seq + + full := models.InitColumn("full_name", "people", "public") + full.Type = "text" + full.Generated = true + full.GenerationExpression = "first || ' ' || last" + table.Columns["full_name"] = full + + plain := models.InitColumn("first", "people", "public") + plain.Type = "text" + plain.Comment = "given name" + table.Columns["first"] = plain + + schema := models.InitSchema("public") + schema.Tables = append(schema.Tables, table) + db := models.InitDatabase("testdb") + db.Schemas = append(db.Schemas, schema) + + out := filepath.Join(t.TempDir(), "people.dbml") + require.NoError(t, NewWriter(&writers.WriterOptions{OutputPath: out}).WriteDatabase(db)) + + content, err := os.ReadFile(out) + require.NoError(t, err) + assert.Contains(t, string(content), "GENERATED ALWAYS AS (first || \\' \\' || last) STORED") + assert.Contains(t, string(content), "GENERATED ALWAYS AS IDENTITY") + assert.Contains(t, string(content), "GENERATED BY DEFAULT AS IDENTITY") + + got, err := dbmlreader.NewReader(&readers.ReaderOptions{FilePath: out}).ReadDatabase() + require.NoError(t, err) + cols := got.Schemas[0].Tables[0].Columns + + assert.True(t, cols["id"].Identity) + assert.Equal(t, "ALWAYS", cols["id"].IdentityGeneration) + assert.Empty(t, cols["id"].Comment, "marker note must not become a comment") + + assert.True(t, cols["seq"].Identity) + assert.Equal(t, "BY DEFAULT", cols["seq"].IdentityGeneration) + + assert.True(t, cols["full_name"].Generated) + assert.Equal(t, "first || ' ' || last", cols["full_name"].GenerationExpression) + assert.Empty(t, cols["full_name"].Comment) + + assert.False(t, cols["first"].Generated) + assert.False(t, cols["first"].Identity) + assert.Equal(t, "given name", cols["first"].Comment) +} diff --git a/pkg/writers/dbml/writer.go b/pkg/writers/dbml/writer.go index b9e7035..4a7c581 100644 --- a/pkg/writers/dbml/writer.go +++ b/pkg/writers/dbml/writer.go @@ -114,6 +114,24 @@ func (w *Writer) schemaToDBML(schema *models.Schema) string { return sb.String() } +// columnMarkerNote returns the note marking a column the database computes itself +// (GENERATED ... STORED, or GENERATED ... AS IDENTITY), or "" for an ordinary column. +// The DBML reader turns the note back into the model's Generated/Identity fields. +func columnMarkerNote(column *models.Column) string { + switch { + case column.Generated: + return fmt.Sprintf("GENERATED ALWAYS AS (%s) STORED", column.GenerationExpression) + case column.Identity && column.IdentityGeneration != "": + return fmt.Sprintf("GENERATED %s AS IDENTITY", strings.ToUpper(column.IdentityGeneration)) + } + return "" +} + +// escapeNote escapes a value for use inside a single-quoted DBML note. +func escapeNote(s string) string { + return strings.ReplaceAll(strings.ReplaceAll(s, `\`, `\\`), `'`, `\'`) +} + // tableToDBML converts a Table to DBML format string func (w *Writer) tableToDBML(t *models.Table) string { var sb strings.Builder @@ -146,6 +164,10 @@ func (w *Writer) tableToDBML(t *models.Table) string { } } + if marker := columnMarkerNote(column); marker != "" { + attrs = append(attrs, fmt.Sprintf("note: '%s'", escapeNote(marker))) + } + if len(attrs) > 0 { fmt.Fprintf(&sb, " [%s]", strings.Join(attrs, ", ")) } diff --git a/pkg/writers/gorm/README.md b/pkg/writers/gorm/README.md index dd6b41c..1b2e78c 100644 --- a/pkg/writers/gorm/README.md +++ b/pkg/writers/gorm/README.md @@ -203,6 +203,14 @@ The writer automatically generates relationship fields: - Relationship field names use 3-letter prefixes - Includes proper `gorm` tags with `foreignKey` and `references` +## Read-only Columns + +| Column | Tags | +|---|---| +| `GENERATED ... STORED` | `<-:false;generated` | +| `GENERATED ALWAYS AS IDENTITY`, not PK | `<-:false;identity` | +| `GENERATED ALWAYS AS IDENTITY`, PK | `identity` only | + ## Notes - Model names are prefixed with "Model" (e.g., `ModelUser`) diff --git a/pkg/writers/gorm/generated_column_test.go b/pkg/writers/gorm/generated_column_test.go new file mode 100644 index 0000000..19f8a35 --- /dev/null +++ b/pkg/writers/gorm/generated_column_test.go @@ -0,0 +1,45 @@ +package gorm + +import ( + "strings" + "testing" + + "git.warky.dev/wdevs/relspecgo/pkg/models" +) + +func TestBuildGormTag_GeneratedColumn(t *testing.T) { + mapper := NewTypeMapper("") + + generated := models.InitColumn("full_name", "users", "public") + generated.Type = "text" + generated.Generated = true + + plain := models.InitColumn("first", "users", "public") + plain.Type = "text" + + tests := []struct { + name string + column *models.Column + want bool + }{ + {"generated column is write-disabled and marked", generated, true}, + {"ordinary column is untouched", plain, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tag := mapper.BuildGormTag(tt.column, nil) + parts := strings.Split(tag, ";") + has := func(s string) bool { + for _, p := range parts { + if p == s { + return true + } + } + return false + } + if has("<-:false") != tt.want || has("generated") != tt.want { + t.Errorf("tag %q: <-:false/generated present = %v/%v, want %v", tag, has("<-:false"), has("generated"), tt.want) + } + }) + } +} diff --git a/pkg/writers/gorm/identity_column_test.go b/pkg/writers/gorm/identity_column_test.go new file mode 100644 index 0000000..f8f4ee8 --- /dev/null +++ b/pkg/writers/gorm/identity_column_test.go @@ -0,0 +1,49 @@ +package gorm + +import ( + "strings" + "testing" + + "git.warky.dev/wdevs/relspecgo/pkg/models" +) + +func TestBuildGormTag_IdentityColumn(t *testing.T) { + mapper := NewTypeMapper("") + + build := func(pk bool, identity bool, generation string) *models.Column { + col := models.InitColumn("id", "users", "public") + col.Type = "bigint" + col.IsPrimaryKey = pk + col.Identity = identity + col.IdentityGeneration = generation + return col + } + + tests := []struct { + name string + column *models.Column + wantFlag bool + wantMarker bool + }{ + {"always identity non-pk is write-blocked", build(false, true, "ALWAYS"), true, true}, + {"always identity pk is only marked", build(true, true, "ALWAYS"), false, true}, + {"by default identity is writable", build(false, true, "BY DEFAULT"), false, false}, + {"non-identity is untouched", build(false, false, ""), false, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tag := mapper.BuildGormTag(tt.column, nil) + has := func(s string) bool { + for _, p := range strings.Split(tag, ";") { + if p == s { + return true + } + } + return false + } + if has("<-:false") != tt.wantFlag || has("identity") != tt.wantMarker { + t.Errorf("tag %q: <-:false/identity = %v/%v, want %v/%v", tag, has("<-:false"), has("identity"), tt.wantFlag, tt.wantMarker) + } + }) + } +} diff --git a/pkg/writers/gorm/type_mapper.go b/pkg/writers/gorm/type_mapper.go index 7f7e375..0d9a7dc 100644 --- a/pkg/writers/gorm/type_mapper.go +++ b/pkg/writers/gorm/type_mapper.go @@ -401,6 +401,21 @@ func (tm *TypeMapper) BuildGormTag(column *models.Column, table *models.Table) s parts = append(parts, "autoIncrement") } + // GENERATED ALWAYS ... STORED columns are computed by the database and cannot be + // written: disable write permission so GORM leaves them out of INSERT/UPDATE, and mark why. + if column.Generated { + parts = append(parts, "<-:false", "generated") + } + + // GENERATED ALWAYS AS IDENTITY rejects explicit values. A primary key only gets the + // marker so it keeps its normal create/return behavior. + if column.Identity && strings.EqualFold(column.IdentityGeneration, "ALWAYS") { + if !column.IsPrimaryKey { + parts = append(parts, "<-:false") + } + parts = append(parts, "identity") + } + // Not null (skip if primary key, as it's implied) if column.NotNull && !column.IsPrimaryKey { parts = append(parts, "not null") diff --git a/pkg/writers/pgsql/README.md b/pkg/writers/pgsql/README.md index 1482ad8..564e49f 100644 --- a/pkg/writers/pgsql/README.md +++ b/pkg/writers/pgsql/README.md @@ -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) diff --git a/pkg/writers/pgsql/generated_column_test.go b/pkg/writers/pgsql/generated_column_test.go new file mode 100644 index 0000000..ac90f0e --- /dev/null +++ b/pkg/writers/pgsql/generated_column_test.go @@ -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) + } +} diff --git a/pkg/writers/pgsql/migration_writer.go b/pkg/writers/pgsql/migration_writer.go index e1f903e..1d58824 100644 --- a/pkg/writers/pgsql/migration_writer.go +++ b/pkg/writers/pgsql/migration_writer.go @@ -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 diff --git a/pkg/writers/pgsql/templates.go b/pkg/writers/pgsql/templates.go index 7fa738e..26b26f5 100644 --- a/pkg/writers/pgsql/templates.go +++ b/pkg/writers/pgsql/templates.go @@ -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 { diff --git a/pkg/writers/pgsql/templates/add_column.tmpl b/pkg/writers/pgsql/templates/add_column.tmpl index 6e3e1e3..c5b0905 100644 --- a/pkg/writers/pgsql/templates/add_column.tmpl +++ b/pkg/writers/pgsql/templates/add_column.tmpl @@ -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}}; \ No newline at end of file diff --git a/pkg/writers/pgsql/templates/create_table.tmpl b/pkg/writers/pgsql/templates/create_table.tmpl index 56053c3..1dc62e3 100644 --- a/pkg/writers/pgsql/templates/create_table.tmpl +++ b/pkg/writers/pgsql/templates/create_table.tmpl @@ -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}} ); \ No newline at end of file diff --git a/pkg/writers/pgsql/templates/fragments.tmpl b/pkg/writers/pgsql/templates/fragments.tmpl index 664422a..f755711 100644 --- a/pkg/writers/pgsql/templates/fragments.tmpl +++ b/pkg/writers/pgsql/templates/fragments.tmpl @@ -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 -}}