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:
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)`
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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}})
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user