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
|
## 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
|
- DBML is designed for database documentation and diagramming
|
||||||
- Schema name defaults to `public`
|
- Schema name defaults to `public`
|
||||||
- Relationship cardinality is preserved
|
- 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:") {
|
} else if strings.HasPrefix(attr, "note:") {
|
||||||
// Parse column note/comment
|
// Parse column note/comment
|
||||||
note := strings.TrimSpace(strings.TrimPrefix(attr, "note:"))
|
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:") {
|
} else if strings.HasPrefix(attr, "ref:") {
|
||||||
// Parse inline reference
|
// Parse inline reference
|
||||||
// DBML semantics depend on context:
|
// DBML semantics depend on context:
|
||||||
|
|||||||
@@ -129,6 +129,9 @@ type User struct {
|
|||||||
- `default` - Default value
|
- `default` - Default value
|
||||||
- `rel` - Relationship definition
|
- `rel` - Relationship definition
|
||||||
- `type` - Explicit SQL type
|
- `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
|
## 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")
|
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
|
// Default value
|
||||||
if column.Default != nil {
|
if column.Default != nil {
|
||||||
// Sanitize default value to remove backticks, then quote based on column type
|
// Sanitize default value to remove backticks, then quote based on column type
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ Table table_name {
|
|||||||
- `unique` - Unique constraint
|
- `unique` - Unique constraint
|
||||||
- `default: value` - Default value
|
- `default: value` - Default value
|
||||||
- `note: 'text'` - Column note
|
- `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
|
### Relationships
|
||||||
```dbml
|
```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()
|
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
|
// tableToDBML converts a Table to DBML format string
|
||||||
func (w *Writer) tableToDBML(t *models.Table) string {
|
func (w *Writer) tableToDBML(t *models.Table) string {
|
||||||
var sb strings.Builder
|
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 {
|
if len(attrs) > 0 {
|
||||||
fmt.Fprintf(&sb, " [%s]", strings.Join(attrs, ", "))
|
fmt.Fprintf(&sb, " [%s]", strings.Join(attrs, ", "))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,6 +203,14 @@ The writer automatically generates relationship fields:
|
|||||||
- Relationship field names use 3-letter prefixes
|
- Relationship field names use 3-letter prefixes
|
||||||
- Includes proper `gorm` tags with `foreignKey` and `references`
|
- 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
|
## Notes
|
||||||
|
|
||||||
- Model names are prefixed with "Model" (e.g., `ModelUser`)
|
- 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")
|
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)
|
// Not null (skip if primary key, as it's implied)
|
||||||
if column.NotNull && !column.IsPrimaryKey {
|
if column.NotNull && !column.IsPrimaryKey {
|
||||||
parts = append(parts, "not null")
|
parts = append(parts, "not null")
|
||||||
|
|||||||
@@ -164,6 +164,12 @@ When `include_audit` is enabled, adds:
|
|||||||
## PostgreSQL-Specific Features
|
## PostgreSQL-Specific Features
|
||||||
|
|
||||||
- Serial types (SERIAL, BIGSERIAL)
|
- 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)
|
- Advanced types (UUID, JSONB, ARRAY)
|
||||||
- Schema-qualified names
|
- Schema-qualified names
|
||||||
- Constraint actions (CASCADE, RESTRICT, SET NULL)
|
- 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 {
|
if !exists {
|
||||||
// Column doesn't exist, add it
|
// Column doesn't exist, add it
|
||||||
_, defaultVal := formatColumnDefaultSQL(modelCol)
|
_, defaultVal := formatColumnDefaultSQL(modelCol)
|
||||||
|
generated, identity := columnGenerationClauses(modelCol)
|
||||||
|
|
||||||
sql, err := w.executor.ExecuteAddColumn(AddColumnData{
|
sql, err := w.executor.ExecuteAddColumn(AddColumnData{
|
||||||
SchemaName: schema.Name,
|
SchemaName: schema.Name,
|
||||||
@@ -450,6 +451,8 @@ func (w *MigrationWriter) generateAlterTableScripts(schema *models.Schema, model
|
|||||||
ColumnType: effectiveColumnSQLType(modelCol),
|
ColumnType: effectiveColumnSQLType(modelCol),
|
||||||
Default: defaultVal,
|
Default: defaultVal,
|
||||||
NotNull: modelCol.NotNull,
|
NotNull: modelCol.NotNull,
|
||||||
|
Generated: generated,
|
||||||
|
Identity: identity,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ type ColumnData struct {
|
|||||||
Type string
|
Type string
|
||||||
Default string
|
Default string
|
||||||
NotNull bool
|
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
|
// AddColumnData contains data for add column template
|
||||||
@@ -87,6 +91,10 @@ type AddColumnData struct {
|
|||||||
ColumnType string
|
ColumnType string
|
||||||
Default string
|
Default string
|
||||||
NotNull bool
|
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
|
// 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
|
// 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
|
// BuildCreateTableData builds CreateTableData from a models.Table
|
||||||
func BuildCreateTableData(schemaName string, table *models.Table) CreateTableData {
|
func BuildCreateTableData(schemaName string, table *models.Table) CreateTableData {
|
||||||
columns := make([]ColumnData, 0, len(table.Columns))
|
columns := make([]ColumnData, 0, len(table.Columns))
|
||||||
@@ -532,7 +553,8 @@ func BuildCreateTableData(schemaName string, table *models.Table) CreateTableDat
|
|||||||
Type: col.Type,
|
Type: col.Type,
|
||||||
NotNull: col.NotNull,
|
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 {
|
if value, ok := col.Default.(string); ok {
|
||||||
colData.Default = writers.QuoteDefaultValue(stripBackticks(value), col.Type)
|
colData.Default = writers.QuoteDefaultValue(stripBackticks(value), col.Type)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
ALTER TABLE {{qual_table .SchemaName .TableName}}
|
ALTER TABLE {{qual_table .SchemaName .TableName}}
|
||||||
ADD COLUMN IF NOT EXISTS {{quote_ident .ColumnName}} {{.ColumnType}}
|
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}};
|
{{- if .NotNull}} NOT NULL{{end}};
|
||||||
@@ -2,7 +2,9 @@ CREATE TABLE IF NOT EXISTS {{qual_table .SchemaName .TableName}} (
|
|||||||
{{- range $i, $col := .Columns}}
|
{{- range $i, $col := .Columns}}
|
||||||
{{- if $i}},{{end}}
|
{{- if $i}},{{end}}
|
||||||
{{quote_ident $col.Name}} {{$col.Type}}
|
{{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}}
|
{{- if $col.NotNull}} NOT NULL{{end}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
);
|
);
|
||||||
@@ -3,7 +3,9 @@
|
|||||||
{{/* Column definition fragment */}}
|
{{/* Column definition fragment */}}
|
||||||
{{- define "column_definition" -}}
|
{{- define "column_definition" -}}
|
||||||
{{.Name}} {{.Type}}
|
{{.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}}
|
{{- if .NotNull}} NOT NULL{{end}}
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user