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:
2026-10-02 22:58:00 +02:00
parent 275424c605
commit bbab5ce936
21 changed files with 516 additions and 5 deletions
+1
View File
@@ -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
+38
View File
@@ -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
}
+3 -1
View File
@@ -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:
+3
View File
@@ -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
+46
View File
@@ -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)
}
})
}
}
+49
View File
@@ -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)
}
})
}
}
+16
View File
@@ -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
+2
View File
@@ -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
@@ -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)
}
+22
View File
@@ -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, ", "))
}
+8
View File
@@ -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`)
+45
View File
@@ -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)
}
})
}
}
+49
View File
@@ -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)
}
})
}
}
+15
View File
@@ -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")
+6
View File
@@ -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)
+101
View File
@@ -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)
}
}
+3
View File
@@ -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
+23 -1
View File
@@ -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 {
+3 -1
View File
@@ -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 -1
View File
@@ -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 -}}