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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, ", "))
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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