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:
@@ -64,6 +64,8 @@ The reader recognizes the following Bun struct tags:
|
||||
- `autoincrement` - Auto-increment column
|
||||
- `default` - Default value
|
||||
- `unique` - Unique constraint
|
||||
- `generated` - Generated column marker (`Generated`; expression is not in the tag)
|
||||
- `identity` - Identity column marker (`Identity`, `ALWAYS`)
|
||||
- `rel` - Relationship definition
|
||||
|
||||
## Example Bun Model
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package bun
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
)
|
||||
|
||||
func TestReader_GeneratedAndIdentityMarkers(t *testing.T) {
|
||||
src := `package models
|
||||
|
||||
import "github.com/uptrace/bun"
|
||||
|
||||
type Person struct {
|
||||
bun.BaseModel ` + "`bun:\"table:people,alias:p\"`" + `
|
||||
|
||||
ID int64 ` + "`bun:\"id,pk,type:bigint,autoincrement,identity\"`" + `
|
||||
Seq int64 ` + "`bun:\"seq,type:bigint,scanonly,identity,notnull\"`" + `
|
||||
FullName string ` + "`bun:\"full_name,type:text,scanonly,generated,nullzero\"`" + `
|
||||
First string ` + "`bun:\"first,type:text,nullzero\"`" + `
|
||||
}
|
||||
`
|
||||
path := filepath.Join(t.TempDir(), "person.go")
|
||||
if err := os.WriteFile(path, []byte(src), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
db, err := NewReader(&readers.ReaderOptions{FilePath: path}).ReadDatabase()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDatabase() error = %v", err)
|
||||
}
|
||||
cols := db.Schemas[0].Tables[0].Columns
|
||||
|
||||
if !cols["id"].Identity || cols["id"].IdentityGeneration != "ALWAYS" {
|
||||
t.Errorf("id: identity = %v/%q, want true/ALWAYS", cols["id"].Identity, cols["id"].IdentityGeneration)
|
||||
}
|
||||
if !cols["seq"].Identity || cols["seq"].IdentityGeneration != "ALWAYS" {
|
||||
t.Errorf("seq: identity = %v/%q, want true/ALWAYS", cols["seq"].Identity, cols["seq"].IdentityGeneration)
|
||||
}
|
||||
if !cols["full_name"].Generated {
|
||||
t.Error("full_name should be generated")
|
||||
}
|
||||
if cols["first"].Generated || cols["first"].Identity {
|
||||
t.Error("first must be an ordinary column")
|
||||
}
|
||||
}
|
||||
@@ -659,6 +659,13 @@ func (r *Reader) parseColumn(fieldName string, fieldType ast.Expr, tag string, s
|
||||
hasExplicitNullableMarker = true
|
||||
case "autoincrement":
|
||||
column.AutoIncrement = true
|
||||
case "generated":
|
||||
// GENERATED ... STORED marker written by the Bun writer; the expression is not in the tag
|
||||
column.Generated = true
|
||||
case "identity":
|
||||
// GENERATED ALWAYS AS IDENTITY marker written by the Bun writer
|
||||
column.Identity = true
|
||||
column.IdentityGeneration = "ALWAYS"
|
||||
case "default":
|
||||
// Default value from Bun tag (e.g., default:gen_random_uuid())
|
||||
column.Default = value
|
||||
|
||||
@@ -85,6 +85,7 @@ export const postsRelations = relations(posts, ({ one }) => ({
|
||||
|
||||
## Notes
|
||||
|
||||
- `.generatedAlwaysAs(sql`expr`)` sets `Generated` + `GenerationExpression`; `.generatedAlwaysAsIdentity()` / `.generatedByDefaultAsIdentity()` set `Identity` + `IdentityGeneration`
|
||||
- Supports both PostgreSQL and MySQL Drizzle schemas
|
||||
- Extracts relationship information from `relations` definitions
|
||||
- Schema defaults to `public` for PostgreSQL
|
||||
|
||||
@@ -495,9 +495,24 @@ func (r *Reader) parseColumnModifiers(line string, column *models.Column, table
|
||||
}
|
||||
}
|
||||
|
||||
// Check for .generatedAlwaysAsIdentity()
|
||||
// Check for .generatedAlwaysAsIdentity() / .generatedByDefaultAsIdentity()
|
||||
if strings.Contains(line, ".generatedAlwaysAsIdentity()") {
|
||||
column.AutoIncrement = true
|
||||
column.Identity = true
|
||||
column.IdentityGeneration = "ALWAYS"
|
||||
}
|
||||
if strings.Contains(line, ".generatedByDefaultAsIdentity()") {
|
||||
column.AutoIncrement = true
|
||||
column.Identity = true
|
||||
column.IdentityGeneration = "BY DEFAULT"
|
||||
}
|
||||
|
||||
// Check for .generatedAlwaysAs(sql`expr`) (generated column)
|
||||
if idx := strings.Index(line, ".generatedAlwaysAs("); idx != -1 {
|
||||
if expr, ok := parseGeneratedAlwaysAs(line[idx+len(".generatedAlwaysAs("):]); ok {
|
||||
column.Generated = true
|
||||
column.GenerationExpression = expr
|
||||
}
|
||||
}
|
||||
|
||||
// Check for .references(() => otherTable.column)
|
||||
@@ -615,3 +630,34 @@ func (r *Reader) varNameToTableName(varName string) string {
|
||||
// For now, assume variable name matches table name
|
||||
return varName
|
||||
}
|
||||
|
||||
// parseGeneratedAlwaysAs extracts the SQL expression from the argument list of
|
||||
// generatedAlwaysAs(...), which follows the opening parenthesis in rest. The expression is
|
||||
// the contents of a sql`...` template (backticks inside it are escaped with a backslash)
|
||||
// or, failing that, a quoted string.
|
||||
func parseGeneratedAlwaysAs(rest string) (string, bool) {
|
||||
rest = strings.TrimSpace(rest)
|
||||
rest = strings.TrimPrefix(rest, "sql")
|
||||
if rest == "" {
|
||||
return "", false
|
||||
}
|
||||
quote := rest[0]
|
||||
if quote != '`' && quote != '\'' && quote != '"' {
|
||||
return "", false
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for i := 1; i < len(rest); i++ {
|
||||
ch := rest[i]
|
||||
if ch == '\\' && i+1 < len(rest) {
|
||||
i++
|
||||
sb.WriteByte(rest[i])
|
||||
continue
|
||||
}
|
||||
if ch == quote {
|
||||
return strings.TrimSpace(sb.String()), true
|
||||
}
|
||||
sb.WriteByte(ch)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -78,6 +78,8 @@ The reader recognizes the following GORM struct tags:
|
||||
- `not null` - NOT NULL constraint
|
||||
- `autoIncrement` - Auto-increment column
|
||||
- `default` - Default value
|
||||
- `generated` - Generated column marker (`Generated`; expression is not in the tag)
|
||||
- `identity` - Identity column marker (`Identity`, `ALWAYS`)
|
||||
- `size` - Column size/length
|
||||
- `index` - Create index
|
||||
- `uniqueIndex` - Create unique index
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package gorm
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
)
|
||||
|
||||
func TestReader_GeneratedAndIdentityMarkers(t *testing.T) {
|
||||
src := `package models
|
||||
|
||||
type Person struct {
|
||||
ID int64 ` + "`gorm:\"column:id;primaryKey;autoIncrement;type:bigint;identity\"`" + `
|
||||
Seq int64 ` + "`gorm:\"column:seq;type:bigint;<-:false;identity;not null\"`" + `
|
||||
FullName string ` + "`gorm:\"column:full_name;type:text;<-:false;generated\"`" + `
|
||||
First string ` + "`gorm:\"column:first;type:text\"`" + `
|
||||
}
|
||||
|
||||
func (Person) TableName() string {
|
||||
return "people"
|
||||
}
|
||||
`
|
||||
path := filepath.Join(t.TempDir(), "person.go")
|
||||
if err := os.WriteFile(path, []byte(src), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
db, err := NewReader(&readers.ReaderOptions{FilePath: path}).ReadDatabase()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDatabase() error = %v", err)
|
||||
}
|
||||
cols := db.Schemas[0].Tables[0].Columns
|
||||
|
||||
if !cols["id"].Identity || cols["id"].IdentityGeneration != "ALWAYS" {
|
||||
t.Errorf("id: identity = %v/%q, want true/ALWAYS", cols["id"].Identity, cols["id"].IdentityGeneration)
|
||||
}
|
||||
if !cols["seq"].Identity {
|
||||
t.Error("seq should be identity")
|
||||
}
|
||||
if !cols["full_name"].Generated {
|
||||
t.Error("full_name should be generated")
|
||||
}
|
||||
if cols["first"].Generated || cols["first"].Identity {
|
||||
t.Error("first must be an ordinary column")
|
||||
}
|
||||
}
|
||||
@@ -726,6 +726,15 @@ func (r *Reader) parseColumn(fieldName string, fieldType ast.Expr, tag string, s
|
||||
if _, ok := parts["autoincrement"]; ok {
|
||||
column.AutoIncrement = true
|
||||
}
|
||||
if _, ok := parts["generated"]; ok {
|
||||
// GENERATED ... STORED marker written by the GORM writer; the expression is not in the tag
|
||||
column.Generated = true
|
||||
}
|
||||
if _, ok := parts["identity"]; ok {
|
||||
// GENERATED ALWAYS AS IDENTITY marker written by the GORM writer
|
||||
column.Identity = true
|
||||
column.IdentityGeneration = "ALWAYS"
|
||||
}
|
||||
if def, ok := parts["default"]; ok {
|
||||
// Default value from GORM tag (e.g., default:gen_random_uuid())
|
||||
column.Default = def
|
||||
|
||||
@@ -25,6 +25,11 @@ sqlserver://user:pass@192.168.1.100:1433/production
|
||||
sqlserver://localhost/testdb?encrypt=disable
|
||||
```
|
||||
|
||||
## Computed and Identity Columns
|
||||
|
||||
- `sys.computed_columns` -> `Generated` + `GenerationExpression` (outer parentheses removed; persisted or not)
|
||||
- Identity columns -> `Identity` + `IdentityGeneration = ALWAYS`
|
||||
|
||||
## Supported Constraints
|
||||
|
||||
- Primary Keys
|
||||
|
||||
@@ -104,8 +104,12 @@ func (r *Reader) queryColumns(schemaName string) (map[string]map[string]*models.
|
||||
c.numeric_precision,
|
||||
c.numeric_scale,
|
||||
ISNULL(ep.value, '') as description,
|
||||
COLUMNPROPERTY(OBJECT_ID(QUOTENAME(c.table_schema) + '.' + QUOTENAME(c.table_name)), c.column_name, 'IsIdentity') as is_identity
|
||||
COLUMNPROPERTY(OBJECT_ID(QUOTENAME(c.table_schema) + '.' + QUOTENAME(c.table_name)), c.column_name, 'IsIdentity') as is_identity,
|
||||
cc.definition as computed_definition
|
||||
FROM information_schema.columns c
|
||||
LEFT JOIN sys.computed_columns cc
|
||||
ON cc.object_id = OBJECT_ID(QUOTENAME(c.table_schema) + '.' + QUOTENAME(c.table_name))
|
||||
AND cc.name = c.column_name
|
||||
LEFT JOIN sys.extended_properties ep
|
||||
ON ep.major_id = OBJECT_ID(QUOTENAME(c.table_schema) + '.' + QUOTENAME(c.table_name))
|
||||
AND ep.minor_id = COLUMNPROPERTY(OBJECT_ID(QUOTENAME(c.table_schema) + '.' + QUOTENAME(c.table_name)), c.column_name, 'ColumnId')
|
||||
@@ -127,8 +131,9 @@ func (r *Reader) queryColumns(schemaName string) (map[string]map[string]*models.
|
||||
var schema, tableName, columnName, isNullable, dataType, description string
|
||||
var ordinalPosition int
|
||||
var columnDefault, charMaxLength, numPrecision, numScale, isIdentity *int
|
||||
var computedDefinition *string
|
||||
|
||||
if err := rows.Scan(&schema, &tableName, &columnName, &ordinalPosition, &columnDefault, &isNullable, &dataType, &charMaxLength, &numPrecision, &numScale, &description, &isIdentity); err != nil {
|
||||
if err := rows.Scan(&schema, &tableName, &columnName, &ordinalPosition, &columnDefault, &isNullable, &dataType, &charMaxLength, &numPrecision, &numScale, &description, &isIdentity, &computedDefinition); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -144,6 +149,14 @@ func (r *Reader) queryColumns(schemaName string) (map[string]map[string]*models.
|
||||
// Check if this is an identity column (auto-increment)
|
||||
if isIdentity != nil && *isIdentity == 1 {
|
||||
column.AutoIncrement = true
|
||||
column.Identity = true
|
||||
column.IdentityGeneration = "ALWAYS"
|
||||
}
|
||||
|
||||
// Computed columns report their expression wrapped in an extra pair of parentheses
|
||||
if computedDefinition != nil && strings.TrimSpace(*computedDefinition) != "" {
|
||||
column.Generated = true
|
||||
column.GenerationExpression = trimOuterParens(*computedDefinition)
|
||||
}
|
||||
|
||||
if charMaxLength != nil && *charMaxLength > 0 {
|
||||
@@ -414,3 +427,25 @@ func (r *Reader) queryIndexes(schemaName string) (map[string][]*models.Index, er
|
||||
|
||||
return indexes, rows.Err()
|
||||
}
|
||||
|
||||
// trimOuterParens removes one pair of parentheses wrapping the whole expression, as
|
||||
// SQL Server stores computed column definitions.
|
||||
func trimOuterParens(expr string) string {
|
||||
expr = strings.TrimSpace(expr)
|
||||
if len(expr) < 2 || expr[0] != '(' || expr[len(expr)-1] != ')' {
|
||||
return expr
|
||||
}
|
||||
depth := 0
|
||||
for i := 0; i < len(expr); i++ {
|
||||
switch expr[i] {
|
||||
case '(':
|
||||
depth++
|
||||
case ')':
|
||||
depth--
|
||||
if depth == 0 && i != len(expr)-1 {
|
||||
return expr
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(expr[1 : len(expr)-1])
|
||||
}
|
||||
|
||||
@@ -85,3 +85,18 @@ func TestConvertMSSQLToCanonical(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimOuterParens(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"(([a])+([b]))": "([a])+([b])",
|
||||
"([a]+[b])": "[a]+[b]",
|
||||
"([a])+([b])": "([a])+([b])",
|
||||
"[a]+[b]": "[a]+[b]",
|
||||
" (upper([a])) ": "upper([a])",
|
||||
}
|
||||
for in, want := range tests {
|
||||
if got := trimOuterParens(in); got != want {
|
||||
t.Errorf("trimOuterParens(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ options := &readers.ReaderOptions{
|
||||
- Uses pure Go driver (modernc.org/sqlite) - no CGo required
|
||||
- Supports both file path and connection string
|
||||
- Auto-increment detection for INTEGER PRIMARY KEY columns
|
||||
- Generated columns (virtual and stored) are read via `PRAGMA table_xinfo`; the expression is parsed from the `CREATE TABLE` SQL
|
||||
- Foreign keys require `PRAGMA foreign_keys = ON` to be set
|
||||
|
||||
## Example Schema
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var generatedAsRegex = regexp.MustCompile(`(?is)\bAS\s*\(`)
|
||||
|
||||
// parseGeneratedExpression extracts the expression of a generated column from a CREATE
|
||||
// TABLE statement, e.g. `full TEXT GENERATED ALWAYS AS (a || b) STORED` yields `a || b`.
|
||||
// It returns "" when the column or its expression cannot be found.
|
||||
func parseGeneratedExpression(createSQL, columnName string) string {
|
||||
open := strings.Index(createSQL, "(")
|
||||
if open < 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, def := range splitTopLevel(createSQL[open+1:]) {
|
||||
if !strings.EqualFold(firstIdentifier(def), columnName) {
|
||||
continue
|
||||
}
|
||||
loc := generatedAsRegex.FindStringIndex(def)
|
||||
if loc == nil {
|
||||
return ""
|
||||
}
|
||||
return balancedParens(def[loc[1]-1:])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// splitTopLevel splits a table body on commas that are outside parentheses and quotes,
|
||||
// stopping at the parenthesis that closes the body.
|
||||
func splitTopLevel(body string) []string {
|
||||
var parts []string
|
||||
depth := 0
|
||||
var quote byte
|
||||
start := 0
|
||||
for i := 0; i < len(body); i++ {
|
||||
ch := body[i]
|
||||
if quote != 0 {
|
||||
if ch == quote {
|
||||
quote = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch ch {
|
||||
case '\'', '"', '`':
|
||||
quote = ch
|
||||
case '[':
|
||||
quote = ']'
|
||||
case '(':
|
||||
depth++
|
||||
case ')':
|
||||
if depth == 0 {
|
||||
return append(parts, body[start:i])
|
||||
}
|
||||
depth--
|
||||
case ',':
|
||||
if depth == 0 {
|
||||
parts = append(parts, body[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return append(parts, body[start:])
|
||||
}
|
||||
|
||||
// firstIdentifier returns the first (possibly quoted) identifier of a column definition.
|
||||
func firstIdentifier(def string) string {
|
||||
def = strings.TrimSpace(def)
|
||||
if def == "" {
|
||||
return ""
|
||||
}
|
||||
switch def[0] {
|
||||
case '"', '\'', '`':
|
||||
if end := strings.IndexByte(def[1:], def[0]); end >= 0 {
|
||||
return def[1 : 1+end]
|
||||
}
|
||||
case '[':
|
||||
if end := strings.IndexByte(def, ']'); end >= 0 {
|
||||
return def[1:end]
|
||||
}
|
||||
}
|
||||
if end := strings.IndexAny(def, " \t\r\n"); end >= 0 {
|
||||
return def[:end]
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// balancedParens returns the text inside the parenthesis group that starts at s[0].
|
||||
func balancedParens(s string) string {
|
||||
depth := 0
|
||||
var quote byte
|
||||
for i := 0; i < len(s); i++ {
|
||||
ch := s[i]
|
||||
if quote != 0 {
|
||||
if ch == quote {
|
||||
quote = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch ch {
|
||||
case '\'', '"':
|
||||
quote = ch
|
||||
case '(':
|
||||
depth++
|
||||
case ')':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return strings.TrimSpace(s[1:i])
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
)
|
||||
|
||||
func TestParseGeneratedExpression(t *testing.T) {
|
||||
createSQL := `CREATE TABLE people (
|
||||
id INTEGER PRIMARY KEY,
|
||||
"first" TEXT,
|
||||
last TEXT,
|
||||
full_name TEXT GENERATED ALWAYS AS (coalesce("first", '') || ' ' || last) STORED,
|
||||
initials TEXT AS (substr("first", 1, 1) || substr(last, 1, 1)),
|
||||
plain TEXT NOT NULL
|
||||
)`
|
||||
|
||||
tests := []struct {
|
||||
column string
|
||||
want string
|
||||
}{
|
||||
{"full_name", `coalesce("first", '') || ' ' || last`},
|
||||
{"initials", `substr("first", 1, 1) || substr(last, 1, 1)`},
|
||||
{"plain", ""},
|
||||
{"missing", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.column, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, parseGeneratedExpression(createSQL, tt.column))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_GeneratedColumns(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "gen.db")
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
require.NoError(t, err)
|
||||
_, err = db.Exec(`CREATE TABLE people (
|
||||
id INTEGER PRIMARY KEY,
|
||||
first TEXT,
|
||||
last TEXT,
|
||||
full_name TEXT GENERATED ALWAYS AS (first || ' ' || last) STORED,
|
||||
initials TEXT AS (substr(first, 1, 1) || substr(last, 1, 1))
|
||||
)`)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.Close())
|
||||
|
||||
got, err := NewReader(&readers.ReaderOptions{FilePath: dbPath}).ReadDatabase()
|
||||
require.NoError(t, err)
|
||||
|
||||
cols := got.Schemas[0].Tables[0].Columns
|
||||
require.Contains(t, cols, "full_name", "generated columns must be read")
|
||||
assert.True(t, cols["full_name"].Generated)
|
||||
assert.Equal(t, "first || ' ' || last", cols["full_name"].GenerationExpression)
|
||||
assert.True(t, cols["initials"].Generated)
|
||||
assert.Equal(t, "substr(first, 1, 1) || substr(last, 1, 1)", cols["initials"].GenerationExpression)
|
||||
assert.False(t, cols["first"].Generated)
|
||||
}
|
||||
@@ -75,7 +75,8 @@ func (r *Reader) queryViews() ([]*models.View, error) {
|
||||
|
||||
// queryColumns retrieves all columns for a given table or view
|
||||
func (r *Reader) queryColumns(tableName string) (map[string]*models.Column, error) {
|
||||
query := fmt.Sprintf("PRAGMA table_info(%s)", tableName)
|
||||
// table_xinfo, unlike table_info, also lists generated columns (hidden = 2 virtual, 3 stored)
|
||||
query := fmt.Sprintf("PRAGMA table_xinfo(%s)", tableName)
|
||||
|
||||
rows, err := r.db.QueryContext(r.ctx, query)
|
||||
if err != nil {
|
||||
@@ -84,24 +85,38 @@ func (r *Reader) queryColumns(tableName string) (map[string]*models.Column, erro
|
||||
defer rows.Close()
|
||||
|
||||
columns := make(map[string]*models.Column)
|
||||
var tableSQL string
|
||||
tableSQLLoaded := false
|
||||
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, dataType string
|
||||
var notNull, pk int
|
||||
var notNull, pk, hidden int
|
||||
var defaultValue *string
|
||||
|
||||
if err := rows.Scan(&cid, &name, &dataType, ¬Null, &defaultValue, &pk); err != nil {
|
||||
if err := rows.Scan(&cid, &name, &dataType, ¬Null, &defaultValue, &pk, &hidden); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Hidden virtual-table columns (hidden = 1) are not part of the schema
|
||||
if hidden == 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
column := models.InitColumn(name, tableName, "main")
|
||||
column.Type = r.mapDataType(strings.ToUpper(dataType))
|
||||
column.NotNull = (notNull == 1)
|
||||
column.IsPrimaryKey = (pk > 0)
|
||||
column.Sequence = uint(cid + 1)
|
||||
|
||||
if defaultValue != nil {
|
||||
if hidden == 2 || hidden == 3 {
|
||||
column.Generated = true
|
||||
if !tableSQLLoaded {
|
||||
tableSQL = r.tableSQL(tableName)
|
||||
tableSQLLoaded = true
|
||||
}
|
||||
column.GenerationExpression = parseGeneratedExpression(tableSQL, name)
|
||||
} else if defaultValue != nil {
|
||||
column.Default = *defaultValue
|
||||
}
|
||||
|
||||
@@ -116,6 +131,16 @@ func (r *Reader) queryColumns(tableName string) (map[string]*models.Column, erro
|
||||
return columns, rows.Err()
|
||||
}
|
||||
|
||||
// tableSQL returns the CREATE TABLE statement of a table, or "" when it cannot be read.
|
||||
func (r *Reader) tableSQL(tableName string) string {
|
||||
var sql string
|
||||
err := r.db.QueryRowContext(r.ctx, `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?`, tableName).Scan(&sql)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return sql
|
||||
}
|
||||
|
||||
// isAutoIncrement checks if a column is autoincrement
|
||||
func (r *Reader) isAutoIncrement(tableName, columnName string) bool {
|
||||
// Check sqlite_sequence table or parse CREATE TABLE statement
|
||||
|
||||
@@ -114,6 +114,8 @@ export class Post {
|
||||
- `@JoinColumn()` - Foreign key column
|
||||
- `@Index()` - Index definition
|
||||
- `@Unique()` - Unique constraint
|
||||
- `asExpression` / `generatedType` - Generated column (`Generated` + `GenerationExpression`)
|
||||
- `@PrimaryGeneratedColumn('identity')`, `@Generated('identity')`, `generatedIdentity` - Identity column
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
@@ -128,7 +128,6 @@ func (r *Reader) extractEntities(content string) []entityInfo {
|
||||
scanner := bufio.NewScanner(strings.NewReader(content))
|
||||
|
||||
entityRegex := regexp.MustCompile(`^export\s+class\s+(\w+)`)
|
||||
decoratorRegex := regexp.MustCompile(`^\s*@(\w+)(\([^)]*\))?`)
|
||||
fieldRegex := regexp.MustCompile(`^\s*(\w+):\s*([^;]+);`)
|
||||
|
||||
var currentEntity *entityInfo
|
||||
@@ -145,8 +144,7 @@ func (r *Reader) extractEntities(content string) []entityInfo {
|
||||
}
|
||||
|
||||
// Check for decorator
|
||||
if matches := decoratorRegex.FindStringSubmatch(trimmed); matches != nil {
|
||||
decorator := matches[0]
|
||||
if decorator, ok := matchDecorator(trimmed); ok {
|
||||
pendingDecorators = append(pendingDecorators, decorator)
|
||||
continue
|
||||
}
|
||||
@@ -488,7 +486,11 @@ func (r *Reader) parseColumnDecorator(decorator string, column *models.Column, t
|
||||
column.IsPrimaryKey = true
|
||||
column.NotNull = true
|
||||
|
||||
if strings.Contains(decorator, "'uuid'") {
|
||||
if strings.Contains(decorator, "'identity'") {
|
||||
column.AutoIncrement = true
|
||||
column.Identity = true
|
||||
column.IdentityGeneration = parseGeneratedIdentity(decorator)
|
||||
} else if strings.Contains(decorator, "'uuid'") {
|
||||
column.Type = "uuid"
|
||||
column.Default = "gen_random_uuid()"
|
||||
} else if strings.Contains(decorator, "'increment'") || strings.Contains(decorator, "()") {
|
||||
@@ -497,6 +499,17 @@ func (r *Reader) parseColumnDecorator(decorator string, column *models.Column, t
|
||||
return
|
||||
}
|
||||
|
||||
// @Generated('identity') on a non-key column; generatedIdentity is read from @Column
|
||||
if strings.HasPrefix(decorator, "@Generated") {
|
||||
if strings.Contains(decorator, "'identity'") {
|
||||
column.Identity = true
|
||||
if column.IdentityGeneration == "" {
|
||||
column.IdentityGeneration = "BY DEFAULT"
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// @Column
|
||||
if strings.HasPrefix(decorator, "@Column") {
|
||||
r.parseColumnOptions(decorator, column, table)
|
||||
@@ -586,6 +599,14 @@ func (r *Reader) parseColumnOptions(decorator string, column *models.Column, tab
|
||||
}
|
||||
}
|
||||
|
||||
if matches := asExpressionRegex.FindStringSubmatch(content); matches != nil {
|
||||
column.Generated = true
|
||||
column.GenerationExpression = unescapeSingleQuoted(matches[1])
|
||||
}
|
||||
if strings.Contains(content, "generatedIdentity") {
|
||||
column.IdentityGeneration = parseGeneratedIdentity(content)
|
||||
}
|
||||
|
||||
if strings.Contains(content, "nullable: true") || strings.Contains(content, "nullable:true") {
|
||||
column.NotNull = false
|
||||
}
|
||||
@@ -834,3 +855,71 @@ func (r *Reader) getPrimaryKeyColumn(table *models.Table) *models.Column {
|
||||
|
||||
return pk
|
||||
}
|
||||
|
||||
var (
|
||||
asExpressionRegex = regexp.MustCompile(`asExpression:\s*'((?:\\.|[^\\'])*)'`)
|
||||
generatedIdentityRegexp = regexp.MustCompile(`generatedIdentity:\s*['"](ALWAYS|BY DEFAULT)['"]`)
|
||||
)
|
||||
|
||||
// parseGeneratedIdentity returns the identity generation mode named in a decorator
|
||||
// ("ALWAYS" or "BY DEFAULT"), defaulting to "BY DEFAULT" as TypeORM does.
|
||||
func parseGeneratedIdentity(decorator string) string {
|
||||
if matches := generatedIdentityRegexp.FindStringSubmatch(decorator); matches != nil {
|
||||
return matches[1]
|
||||
}
|
||||
return "BY DEFAULT"
|
||||
}
|
||||
|
||||
// unescapeSingleQuoted reverses escaping applied inside a quoted TypeScript string.
|
||||
func unescapeSingleQuoted(s string) string {
|
||||
var sb strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\\' && i+1 < len(s) {
|
||||
i++
|
||||
}
|
||||
sb.WriteByte(s[i])
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
var decoratorNameRegex = regexp.MustCompile(`^@\w+`)
|
||||
|
||||
// matchDecorator returns the decorator at the start of line, including its argument list.
|
||||
// Parentheses inside quoted strings (e.g. a generated column expression) do not end it.
|
||||
func matchDecorator(line string) (string, bool) {
|
||||
name := decoratorNameRegex.FindString(line)
|
||||
if name == "" {
|
||||
return "", false
|
||||
}
|
||||
rest := line[len(name):]
|
||||
if !strings.HasPrefix(rest, "(") {
|
||||
return name, true
|
||||
}
|
||||
|
||||
depth := 0
|
||||
var quote byte
|
||||
for i := 0; i < len(rest); i++ {
|
||||
ch := rest[i]
|
||||
if quote != 0 {
|
||||
if ch == '\\' {
|
||||
i++
|
||||
} else if ch == quote {
|
||||
quote = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch ch {
|
||||
case '\'', '"', '`':
|
||||
quote = ch
|
||||
case '(':
|
||||
depth++
|
||||
case ')':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return name + rest[:i+1], true
|
||||
}
|
||||
}
|
||||
}
|
||||
// Unterminated argument list: keep the whole line, as a best effort
|
||||
return line, true
|
||||
}
|
||||
|
||||
@@ -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