From 9cc10715e3aa7b19bb70c9de337a9250a71c0d66 Mon Sep 17 00:00:00 2001 From: Hein Date: Fri, 2 Oct 2026 23:04:04 +0200 Subject: [PATCH] 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 --- pkg/readers/bun/README.md | 2 + pkg/readers/bun/generated_column_test.go | 48 ++++++++ pkg/readers/bun/reader.go | 7 ++ pkg/readers/drizzle/README.md | 1 + pkg/readers/drizzle/reader.go | 48 +++++++- pkg/readers/gorm/README.md | 2 + pkg/readers/gorm/generated_column_test.go | 48 ++++++++ pkg/readers/gorm/reader.go | 9 ++ pkg/readers/mssql/README.md | 5 + pkg/readers/mssql/queries.go | 39 +++++- pkg/readers/mssql/reader_test.go | 15 +++ pkg/readers/sqlite/README.md | 1 + pkg/readers/sqlite/generated.go | 116 ++++++++++++++++++ pkg/readers/sqlite/generated_test.go | 64 ++++++++++ pkg/readers/sqlite/queries.go | 33 ++++- pkg/readers/typeorm/README.md | 2 + pkg/readers/typeorm/reader.go | 97 ++++++++++++++- pkg/writers/drizzle/README.md | 1 + pkg/writers/drizzle/generated_columns_test.go | 71 +++++++++++ pkg/writers/drizzle/type_mapper.go | 8 +- pkg/writers/mssql/README.md | 3 +- pkg/writers/mssql/writer.go | 12 +- pkg/writers/mssql/writer_test.go | 16 +++ pkg/writers/sqlite/README.md | 1 + .../sqlite/templates/create_table.tmpl | 2 +- pkg/writers/sqlite/writer_test.go | 36 ++++++ pkg/writers/typeorm/README.md | 6 + pkg/writers/typeorm/generated_columns_test.go | 76 ++++++++++++ pkg/writers/typeorm/writer.go | 38 +++++- 29 files changed, 789 insertions(+), 18 deletions(-) create mode 100644 pkg/readers/bun/generated_column_test.go create mode 100644 pkg/readers/gorm/generated_column_test.go create mode 100644 pkg/readers/sqlite/generated.go create mode 100644 pkg/readers/sqlite/generated_test.go create mode 100644 pkg/writers/drizzle/generated_columns_test.go create mode 100644 pkg/writers/typeorm/generated_columns_test.go diff --git a/pkg/readers/bun/README.md b/pkg/readers/bun/README.md index 8386a67..50adf91 100644 --- a/pkg/readers/bun/README.md +++ b/pkg/readers/bun/README.md @@ -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 diff --git a/pkg/readers/bun/generated_column_test.go b/pkg/readers/bun/generated_column_test.go new file mode 100644 index 0000000..9a28df8 --- /dev/null +++ b/pkg/readers/bun/generated_column_test.go @@ -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") + } +} diff --git a/pkg/readers/bun/reader.go b/pkg/readers/bun/reader.go index c24bc00..cd483c0 100644 --- a/pkg/readers/bun/reader.go +++ b/pkg/readers/bun/reader.go @@ -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 diff --git a/pkg/readers/drizzle/README.md b/pkg/readers/drizzle/README.md index d0c65fb..eb2fc48 100644 --- a/pkg/readers/drizzle/README.md +++ b/pkg/readers/drizzle/README.md @@ -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 diff --git a/pkg/readers/drizzle/reader.go b/pkg/readers/drizzle/reader.go index 949bd39..504e422 100644 --- a/pkg/readers/drizzle/reader.go +++ b/pkg/readers/drizzle/reader.go @@ -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 +} diff --git a/pkg/readers/gorm/README.md b/pkg/readers/gorm/README.md index b022317..43a40d7 100644 --- a/pkg/readers/gorm/README.md +++ b/pkg/readers/gorm/README.md @@ -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 diff --git a/pkg/readers/gorm/generated_column_test.go b/pkg/readers/gorm/generated_column_test.go new file mode 100644 index 0000000..4001cb3 --- /dev/null +++ b/pkg/readers/gorm/generated_column_test.go @@ -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") + } +} diff --git a/pkg/readers/gorm/reader.go b/pkg/readers/gorm/reader.go index fa67116..4cf7411 100644 --- a/pkg/readers/gorm/reader.go +++ b/pkg/readers/gorm/reader.go @@ -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 diff --git a/pkg/readers/mssql/README.md b/pkg/readers/mssql/README.md index 247f219..3633f7b 100644 --- a/pkg/readers/mssql/README.md +++ b/pkg/readers/mssql/README.md @@ -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 diff --git a/pkg/readers/mssql/queries.go b/pkg/readers/mssql/queries.go index ca142ea..8465af7 100644 --- a/pkg/readers/mssql/queries.go +++ b/pkg/readers/mssql/queries.go @@ -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]) +} diff --git a/pkg/readers/mssql/reader_test.go b/pkg/readers/mssql/reader_test.go index ddd2ed1..70dc50a 100644 --- a/pkg/readers/mssql/reader_test.go +++ b/pkg/readers/mssql/reader_test.go @@ -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) + } + } +} diff --git a/pkg/readers/sqlite/README.md b/pkg/readers/sqlite/README.md index c025424..8148da3 100644 --- a/pkg/readers/sqlite/README.md +++ b/pkg/readers/sqlite/README.md @@ -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 diff --git a/pkg/readers/sqlite/generated.go b/pkg/readers/sqlite/generated.go new file mode 100644 index 0000000..8c54b9c --- /dev/null +++ b/pkg/readers/sqlite/generated.go @@ -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 "" +} diff --git a/pkg/readers/sqlite/generated_test.go b/pkg/readers/sqlite/generated_test.go new file mode 100644 index 0000000..00d778b --- /dev/null +++ b/pkg/readers/sqlite/generated_test.go @@ -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) +} diff --git a/pkg/readers/sqlite/queries.go b/pkg/readers/sqlite/queries.go index c1bd273..474e2f0 100644 --- a/pkg/readers/sqlite/queries.go +++ b/pkg/readers/sqlite/queries.go @@ -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 diff --git a/pkg/readers/typeorm/README.md b/pkg/readers/typeorm/README.md index c2a4be5..ef16437 100644 --- a/pkg/readers/typeorm/README.md +++ b/pkg/readers/typeorm/README.md @@ -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 diff --git a/pkg/readers/typeorm/reader.go b/pkg/readers/typeorm/reader.go index 52fc033..974ff43 100644 --- a/pkg/readers/typeorm/reader.go +++ b/pkg/readers/typeorm/reader.go @@ -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 +} diff --git a/pkg/writers/drizzle/README.md b/pkg/writers/drizzle/README.md index a5141e7..53867c2 100644 --- a/pkg/writers/drizzle/README.md +++ b/pkg/writers/drizzle/README.md @@ -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 diff --git a/pkg/writers/drizzle/generated_columns_test.go b/pkg/writers/drizzle/generated_columns_test.go new file mode 100644 index 0000000..a396cb7 --- /dev/null +++ b/pkg/writers/drizzle/generated_columns_test.go @@ -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) +} diff --git a/pkg/writers/drizzle/type_mapper.go b/pkg/writers/drizzle/type_mapper.go index 475d3df..dbe63e5 100644 --- a/pkg/writers/drizzle/type_mapper.go +++ b/pkg/writers/drizzle/type_mapper.go @@ -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 { diff --git a/pkg/writers/mssql/README.md b/pkg/writers/mssql/README.md index fe6cbbb..e054a8a 100644 --- a/pkg/writers/mssql/README.md +++ b/pkg/writers/mssql/README.md @@ -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)` diff --git a/pkg/writers/mssql/writer.go b/pkg/writers/mssql/writer.go index af39bf0..78d84a6 100644 --- a/pkg/writers/mssql/writer.go +++ b/pkg/writers/mssql/writer.go @@ -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)") } diff --git a/pkg/writers/mssql/writer_test.go b/pkg/writers/mssql/writer_test.go index 3232a54..39178b3 100644 --- a/pkg/writers/mssql/writer_test.go +++ b/pkg/writers/mssql/writer_test.go @@ -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)) +} diff --git a/pkg/writers/sqlite/README.md b/pkg/writers/sqlite/README.md index 4effeb3..e37b850 100644 --- a/pkg/writers/sqlite/README.md +++ b/pkg/writers/sqlite/README.md @@ -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 diff --git a/pkg/writers/sqlite/templates/create_table.tmpl b/pkg/writers/sqlite/templates/create_table.tmpl index 7949c8b..72d9569 100644 --- a/pkg/writers/sqlite/templates/create_table.tmpl +++ b/pkg/writers/sqlite/templates/create_table.tmpl @@ -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}}) diff --git a/pkg/writers/sqlite/writer_test.go b/pkg/writers/sqlite/writer_test.go index 23e3d7c..022360d 100644 --- a/pkg/writers/sqlite/writer_test.go +++ b/pkg/writers/sqlite/writer_test.go @@ -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()) + } +} diff --git a/pkg/writers/typeorm/README.md b/pkg/writers/typeorm/README.md index e61e47b..1259228 100644 --- a/pkg/writers/typeorm/README.md +++ b/pkg/writers/typeorm/README.md @@ -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 | diff --git a/pkg/writers/typeorm/generated_columns_test.go b/pkg/writers/typeorm/generated_columns_test.go new file mode 100644 index 0000000..01f5523 --- /dev/null +++ b/pkg/writers/typeorm/generated_columns_test.go @@ -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) +} diff --git a/pkg/writers/typeorm/writer.go b/pkg/writers/typeorm/writer.go index 91a497e..3c4b980 100644 --- a/pkg/writers/typeorm/writer.go +++ b/pkg/writers/typeorm/writer.go @@ -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