feat: generated and identity columns for mssql, sqlite, drizzle, typeorm, bun and gorm readers

- mssql: write computed columns (AS (expr) PERSISTED) and identity; read computed definition and identity
- sqlite: write GENERATED ALWAYS AS (expr) STORED; read generated columns via table_xinfo and parse the expression
- drizzle: generatedAlwaysAs / generated(Always|ByDefault)AsIdentity, read and write
- typeorm: asExpression/generatedType and identity decorators, read and write; decorator scan is now quote-aware
- bun, gorm readers: read generated and identity markers
- readmes updated
This commit is contained in:
2026-10-02 23:04:04 +02:00
parent bbab5ce936
commit 9cc10715e3
29 changed files with 789 additions and 18 deletions
+1
View File
@@ -113,6 +113,7 @@ export const postsRelations = relations(posts, ({ one }) => ({
## Notes
- Generated columns: `.generatedAlwaysAs(sql`expr`)`; identity columns: `.generatedAlwaysAsIdentity()` / `.generatedByDefaultAsIdentity()`
- Table names and column names are preserved as-is
- Relationships are generated as separate relation definitions
- Constraint actions (CASCADE, etc.) are included in references
@@ -0,0 +1,71 @@
package drizzle
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"
drizzlereader "git.warky.dev/wdevs/relspecgo/pkg/readers/drizzle"
"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.AutoIncrement = true
seq.Identity = true
seq.IdentityGeneration = "BY DEFAULT"
table.Columns["seq"] = seq
first := models.InitColumn("first", "people", "public")
first.Type = "text"
table.Columns["first"] = first
full := models.InitColumn("full_name", "people", "public")
full.Type = "text"
full.Generated = true
full.GenerationExpression = "first || ' ' || upper(first)"
table.Columns["full_name"] = full
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(), "schema.ts")
require.NoError(t, NewWriter(&writers.WriterOptions{OutputPath: out}).WriteDatabase(db))
content, err := os.ReadFile(out)
require.NoError(t, err)
assert.Contains(t, string(content), "generatedAlwaysAs(sql`first || ' ' || upper(first)`)")
assert.Contains(t, string(content), "generatedAlwaysAsIdentity()")
assert.Contains(t, string(content), "generatedByDefaultAsIdentity()")
got, err := drizzlereader.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.True(t, cols["seq"].Identity)
assert.Equal(t, "BY DEFAULT", cols["seq"].IdentityGeneration)
assert.True(t, cols["full_name"].Generated)
assert.Equal(t, "first || ' ' || upper(first)", cols["full_name"].GenerationExpression)
assert.False(t, cols["first"].Generated)
assert.False(t, cols["first"].Identity)
}
+7 -1
View File
@@ -140,7 +140,13 @@ func (tm *TypeMapper) buildColumnModifiers(col *models.Column, table *models.Tab
}
// Default value
if col.AutoIncrement {
if col.Generated && col.GenerationExpression != "" {
// GENERATED ALWAYS AS (expr) STORED
expr := strings.ReplaceAll(col.GenerationExpression, "`", "\\`")
modifiers = append(modifiers, fmt.Sprintf("generatedAlwaysAs(sql`%s`)", expr))
} else if col.Identity && strings.EqualFold(col.IdentityGeneration, "BY DEFAULT") {
modifiers = append(modifiers, "generatedByDefaultAsIdentity()")
} else if col.AutoIncrement || col.Identity {
// For auto-increment, use generatedAlwaysAsIdentity()
modifiers = append(modifiers, "generatedAlwaysAsIdentity()")
} else if col.Default != nil {
+2 -1
View File
@@ -127,4 +127,5 @@ go test ./pkg/writers/mssql/...
- Views are not currently supported in the writer
- Sequences are not supported (MSSQL uses IDENTITY instead)
- Partitioning and advanced features are not supported
- Generated DDL assumes no triggers or computed columns
- Generated DDL assumes no triggers
- Generated columns are written as computed columns `[col] AS (expr) PERSISTED` (no type, no DEFAULT); identity columns as `IDENTITY(1,1)`
+11 -1
View File
@@ -187,6 +187,16 @@ func (w *Writer) writeCreateTable(schema *models.Schema, table *models.Table) er
func (w *Writer) generateColumnDefinition(col *models.Column) string {
parts := []string{fmt.Sprintf("[%s]", col.Name)}
// Computed column: "name AS (expr) PERSISTED" has no data type and cannot carry
// IDENTITY or a DEFAULT. PERSISTED mirrors the stored semantics of Column.Generated.
if col.Generated && col.GenerationExpression != "" {
parts = append(parts, fmt.Sprintf("AS (%s) PERSISTED", col.GenerationExpression))
if col.NotNull {
parts = append(parts, "NOT NULL")
}
return strings.Join(parts, " ")
}
// Type with length/precision
baseType := mssql.ConvertCanonicalToMSSQL(col.Type)
typeStr := baseType
@@ -215,7 +225,7 @@ func (w *Writer) generateColumnDefinition(col *models.Column) string {
parts = append(parts, typeStr)
// IDENTITY for auto-increment
if col.AutoIncrement {
if col.AutoIncrement || col.Identity {
parts = append(parts, "IDENTITY(1,1)")
}
+16
View File
@@ -204,3 +204,19 @@ func TestWriteComments(t *testing.T) {
assert.Contains(t, output, "User accounts table")
assert.Contains(t, output, "Primary key")
}
func TestGenerateColumnDefinition_ComputedAndIdentity(t *testing.T) {
writer := NewWriter(&writers.WriterOptions{})
computed := &models.Column{
Name: "full_name",
Type: "string",
Generated: true,
GenerationExpression: "[first] + ' ' + [last]",
Default: "ignored",
}
assert.Equal(t, "[full_name] AS ([first] + ' ' + [last]) PERSISTED", writer.generateColumnDefinition(computed))
identity := &models.Column{Name: "id", Type: "int", NotNull: true, Identity: true, IdentityGeneration: "ALWAYS"}
assert.Equal(t, "[id] INT IDENTITY(1,1) NOT NULL", writer.generateColumnDefinition(identity))
}
+1
View File
@@ -229,6 +229,7 @@ func main() {
## Notes
- Generated columns are written as `col TYPE GENERATED ALWAYS AS (expr) STORED` (no DEFAULT)
- Schema flattening is **always enabled** for SQLite output (cannot be disabled); the default schema (`public`/`main`) produces bare table names, other schemas are prefixed
- Constraint and index names are prefixed with the flattened table name to avoid collisions
- Generated SQL is compatible with SQLite 3.x
@@ -1,7 +1,7 @@
CREATE TABLE {{quote_ident (qualified_table_name .Schema .Name)}} (
{{- $hasAutoIncrement := false}}
{{- range $i, $col := .Columns}}{{if $i}},{{end}}
{{quote_ident $col.Name}} {{map_type $col.Type}}{{column_collate $col}}{{if is_autoincrement $col}}{{$hasAutoIncrement = true}} PRIMARY KEY AUTOINCREMENT{{else}}{{if $col.NotNull}} NOT NULL{{end}}{{if ne (format_default $col) ""}} DEFAULT {{format_default $col}}{{end}}{{end}}
{{quote_ident $col.Name}} {{map_type $col.Type}}{{column_collate $col}}{{if and $col.Generated $col.GenerationExpression}} GENERATED ALWAYS AS ({{$col.GenerationExpression}}) STORED{{if $col.NotNull}} NOT NULL{{end}}{{else if is_autoincrement $col}}{{$hasAutoIncrement = true}} PRIMARY KEY AUTOINCREMENT{{else}}{{if $col.NotNull}} NOT NULL{{end}}{{if ne (format_default $col) ""}} DEFAULT {{format_default $col}}{{end}}{{end}}
{{- end}}
{{- if and .PrimaryKey (not $hasAutoIncrement)}}{{if gt (len .Columns) 0}},{{end}}
PRIMARY KEY ({{range $i, $colName := .PrimaryKey.Columns}}{{if $i}}, {{end}}{{quote_ident $colName}}{{end}})
+36
View File
@@ -421,3 +421,39 @@ func TestQuoteIdentifier(t *testing.T) {
}
}
}
func TestWriteDatabase_GeneratedColumn(t *testing.T) {
table := models.InitTable("people", "public")
first := models.InitColumn("first", "people", "public")
first.Type = "text"
first.Sequence = 1
table.Columns["first"] = first
full := models.InitColumn("full_name", "people", "public")
full.Type = "text"
full.Sequence = 2
full.NotNull = true
full.Generated = true
full.GenerationExpression = "first || ' ' || first"
full.Default = "ignored"
table.Columns["full_name"] = full
schema := models.InitSchema("public")
schema.Tables = append(schema.Tables, table)
db := models.InitDatabase("testdb")
db.Schemas = append(db.Schemas, schema)
var buf bytes.Buffer
writer := NewWriter(&writers.WriterOptions{})
writer.writer = &buf
if err := writer.WriteDatabase(db); err != nil {
t.Fatalf("WriteDatabase failed: %v", err)
}
want := `"full_name" TEXT GENERATED ALWAYS AS (first || ' ' || first) STORED NOT NULL`
if !strings.Contains(buf.String(), want) {
t.Errorf("missing %q in:\n%s", want, buf.String())
}
if strings.Contains(buf.String(), "DEFAULT") && strings.Contains(buf.String(), "ignored") {
t.Errorf("generated column must not carry a DEFAULT:\n%s", buf.String())
}
}
+6
View File
@@ -146,6 +146,12 @@ export class Post {
})
```
Generated and identity columns:
- `asExpression: '...'`, `generatedType: 'STORED'` - generated column (no default)
- `@PrimaryGeneratedColumn('identity', { generatedIdentity: 'ALWAYS' })` - identity primary key
- `@Generated('identity')` + `generatedIdentity: '...'` - identity column that is not the primary key
## Type Mapping
| SQL Type | TypeScript Type | TypeORM Type |
@@ -0,0 +1,76 @@
package typeorm
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"
typeormreader "git.warky.dev/wdevs/relspecgo/pkg/readers/typeorm"
"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.NotNull = true
id.AutoIncrement = true
id.Identity = true
id.IdentityGeneration = "ALWAYS"
table.Columns["id"] = id
seq := models.InitColumn("seq", "people", "public")
seq.Type = "integer"
seq.NotNull = true
seq.Identity = true
seq.IdentityGeneration = "BY DEFAULT"
table.Columns["seq"] = seq
first := models.InitColumn("first", "people", "public")
first.Type = "text"
first.NotNull = true
table.Columns["first"] = first
full := models.InitColumn("full_name", "people", "public")
full.Type = "text"
full.NotNull = true
full.Generated = true
full.GenerationExpression = "first || ' ' || upper(first)"
table.Columns["full_name"] = full
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(), "entities.ts")
require.NoError(t, NewWriter(&writers.WriterOptions{OutputPath: out}).WriteDatabase(db))
content, err := os.ReadFile(out)
require.NoError(t, err)
assert.Contains(t, string(content), `asExpression: 'first || \' \' || upper(first)'`)
assert.Contains(t, string(content), "generatedType: 'STORED'")
assert.Contains(t, string(content), "@PrimaryGeneratedColumn('identity', { generatedIdentity: 'ALWAYS' })")
assert.Contains(t, string(content), "@Generated('identity')")
assert.Contains(t, string(content), "Generated } from 'typeorm'")
got, err := typeormreader.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.True(t, cols["seq"].Identity)
assert.Equal(t, "BY DEFAULT", cols["seq"].IdentityGeneration)
assert.True(t, cols["full_name"].Generated)
assert.Equal(t, "first || ' ' || upper(first)", cols["full_name"].GenerationExpression)
assert.False(t, cols["first"].Generated)
assert.False(t, cols["first"].Identity)
}
+35 -3
View File
@@ -97,6 +97,7 @@ func (w *Writer) generateImports(db *models.Database) string {
needsCreateDate := false
needsUpdateDate := false
needsViewEntity := false
needsGenerated := false
for _, schema := range db.Schemas {
// Check for views
@@ -107,6 +108,9 @@ func (w *Writer) generateImports(db *models.Database) string {
for _, table := range schema.Tables {
// Check for timestamp columns
for _, col := range table.Columns {
if isNonKeyIdentity(col) {
needsGenerated = true
}
if col.Default == "now()" {
needsCreateDate = true
}
@@ -161,6 +165,9 @@ func (w *Writer) generateImports(db *models.Database) string {
if needsViewEntity {
imports = append(imports, "ViewEntity")
}
if needsGenerated {
imports = append(imports, "Generated")
}
return fmt.Sprintf("import { %s } from 'typeorm';\n", strings.Join(imports, ", "))
}
@@ -299,14 +306,16 @@ func (w *Writer) columnToField(col *models.Column, table *models.Table) string {
// Generate decorator
if col.IsPrimaryKey {
if col.AutoIncrement {
if col.Identity && col.IdentityGeneration != "" {
fmt.Fprintf(&sb, " @PrimaryGeneratedColumn('identity', { generatedIdentity: '%s' })\n", strings.ToUpper(col.IdentityGeneration))
} else if col.AutoIncrement {
sb.WriteString(" @PrimaryGeneratedColumn('increment')\n")
} else if col.Type == "uuid" || strings.Contains(fmt.Sprint(col.Default), "uuid") {
sb.WriteString(" @PrimaryGeneratedColumn('uuid')\n")
} else {
sb.WriteString(" @PrimaryGeneratedColumn()\n")
}
} else if col.Default == "now()" {
} else if col.Default == "now()" && !col.Generated {
sb.WriteString(" @CreateDateColumn()\n")
} else if strings.Contains(col.Comment, "auto-update") {
sb.WriteString(" @UpdateDateColumn()\n")
@@ -318,6 +327,9 @@ func (w *Writer) columnToField(col *models.Column, table *models.Table) string {
} else {
sb.WriteString(" @Column()\n")
}
if isNonKeyIdentity(col) {
sb.WriteString(" @Generated('identity')\n")
}
}
// Generate field declaration
@@ -351,8 +363,18 @@ func (w *Writer) buildColumnOptions(col *models.Column, table *models.Table) str
options = append(options, "unique: true")
}
// Generated column (GENERATED ALWAYS AS (expr) STORED)
if col.Generated && col.GenerationExpression != "" {
options = append(options, fmt.Sprintf("asExpression: '%s'", escapeSingleQuoted(col.GenerationExpression)), "generatedType: 'STORED'")
}
// Identity column that is not the primary key
if isNonKeyIdentity(col) {
options = append(options, fmt.Sprintf("generatedIdentity: '%s'", strings.ToUpper(col.IdentityGeneration)))
}
// Default
if col.Default != nil && col.Default != "now()" {
if !col.Generated && !col.Identity && col.Default != nil && col.Default != "now()" {
defaultStr := fmt.Sprint(col.Default)
if defaultStr != "" {
options = append(options, fmt.Sprintf("default: '%s'", defaultStr))
@@ -362,6 +384,16 @@ func (w *Writer) buildColumnOptions(col *models.Column, table *models.Table) str
return strings.Join(options, ", ")
}
// isNonKeyIdentity reports whether col is a GENERATED ... AS IDENTITY column outside the primary key.
func isNonKeyIdentity(col *models.Column) bool {
return col.Identity && col.IdentityGeneration != "" && !col.IsPrimaryKey
}
// escapeSingleQuoted escapes a value for use inside a single-quoted TypeScript string.
func escapeSingleQuoted(s string) string {
return strings.ReplaceAll(strings.ReplaceAll(s, `\`, `\\`), `'`, `\'`)
}
// needsExplicitType checks if a SQL type needs explicit type declaration
func (w *Writer) needsExplicitType(sqlType string) bool {
// Types that don't map cleanly to TypeScript types need explicit declaration