From ab3818cd0ca8c0c2527d5feebd6e18a998e189be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:44:29 +0000 Subject: [PATCH] feat: add sql export command with multi-DBMS support Agent-Logs-Url: https://github.com/headercat/erdn-lang/sessions/b3efad42-e0a8-45f1-b22d-38a5e4ce981e Co-authored-by: nemorize <51209191+nemorize@users.noreply.github.com> --- README.md | 32 ++ cmd/erdn/main.go | 50 ++- internal/sqlexport/sqlexport.go | 474 +++++++++++++++++++++++++++ internal/sqlexport/sqlexport_test.go | 280 ++++++++++++++++ website/guide.md | 32 ++ 5 files changed, 865 insertions(+), 3 deletions(-) create mode 100644 internal/sqlexport/sqlexport.go create mode 100644 internal/sqlexport/sqlexport_test.go diff --git a/README.md b/README.md index 1e986ca..45e2690 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ go build -o erdn ./cmd/erdn ``` erdn render [--out ] erdn validate +erdn sql [--dbms ] [--out ] ``` ### `render` @@ -75,6 +76,37 @@ erdn validate schema.erdn # OK ``` +### `sql` + +Generates SQL DDL (`CREATE TABLE`, indexes, and foreign key constraints) from the schema. Use `--dbms` to target a specific database engine (default: `mysql`). + +| DBMS | Flag value | +|------|-----------| +| MySQL | `mysql` | +| PostgreSQL | `postgresql` | +| Microsoft SQL Server | `mssql` | +| Oracle Database | `oracle` | +| SQLite | `sqlite` | + +```sh +# Output written to schema.sql by default (MySQL) +erdn sql schema.erdn + +# Target PostgreSQL +erdn sql schema.erdn --dbms postgresql + +# Specify a custom output path +erdn sql schema.erdn --dbms mssql --out migrations/001_init.sql +``` + +The generated SQL includes: + +- `CREATE TABLE` statements with DBMS-appropriate types and constraints. +- `PRIMARY KEY` constraints. +- `AUTO_INCREMENT` / `IDENTITY(1,1)` / `GENERATED ALWAYS AS IDENTITY` for auto-increment columns. +- `CREATE INDEX` statements for `indexed` columns. +- `ALTER TABLE … ADD CONSTRAINT … FOREIGN KEY` statements derived from `link` declarations (inline `FOREIGN KEY` for SQLite). + --- ## Syntax diff --git a/cmd/erdn/main.go b/cmd/erdn/main.go index e7b4cac..cb71003 100644 --- a/cmd/erdn/main.go +++ b/cmd/erdn/main.go @@ -12,6 +12,7 @@ import ( "github.com/headercat/erdn-lang/internal/parser" "github.com/headercat/erdn-lang/internal/render" "github.com/headercat/erdn-lang/internal/semantic" + "github.com/headercat/erdn-lang/internal/sqlexport" ) func main() { @@ -25,6 +26,8 @@ func main() { runRender(os.Args[2:]) case "validate": runValidate(os.Args[2:]) + case "sql": + runSQL(os.Args[2:]) default: fmt.Fprintf(os.Stderr, "unknown subcommand: %s\n", os.Args[1]) printUsage() @@ -33,11 +36,13 @@ func main() { } func printUsage() { - fmt.Fprintln(os.Stderr, `erdn - erdn-lang schema toolchain + fmt.Fprintf(os.Stderr, `erdn - erdn-lang schema toolchain Usage: - erdn render [--out ] - erdn validate `) + erdn render [--out ] + erdn validate + erdn sql [--dbms <%s>] [--out ] +`, strings.Join(sqlexport.SupportedDBMS, "|")) } // loadAndValidate parses and semantically validates a schema file. @@ -122,3 +127,42 @@ func runValidate(args []string) { } fmt.Println("OK") } + +func runSQL(args []string) { + fs := flag.NewFlagSet("sql", flag.ExitOnError) + dbmsFlag := fs.String("dbms", string(sqlexport.DBMSMySQL), + "target DBMS ("+strings.Join(sqlexport.SupportedDBMS, ", ")+")") + outFlag := fs.String("out", "", "output file path (default: .sql)") + if err := fs.Parse(args); err != nil { + os.Exit(1) + } + if fs.NArg() < 1 { + fmt.Fprintln(os.Stderr, "sql: missing schema file") + os.Exit(1) + } + if !sqlexport.ValidDBMS(*dbmsFlag) { + fmt.Fprintf(os.Stderr, "sql: unknown DBMS %q; supported: %s\n", + *dbmsFlag, strings.Join(sqlexport.SupportedDBMS, ", ")) + os.Exit(1) + } + + schemaFile := fs.Arg(0) + prog, err := loadAndValidate(schemaFile) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + outPath := *outFlag + if outPath == "" { + base := strings.TrimSuffix(schemaFile, filepath.Ext(schemaFile)) + outPath = base + ".sql" + } + + sql := sqlexport.Generate(prog, sqlexport.DBMS(*dbmsFlag)) + if err := os.WriteFile(outPath, []byte(sql), 0644); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Printf("exported %s\n", outPath) +} diff --git a/internal/sqlexport/sqlexport.go b/internal/sqlexport/sqlexport.go new file mode 100644 index 0000000..1cb877f --- /dev/null +++ b/internal/sqlexport/sqlexport.go @@ -0,0 +1,474 @@ +package sqlexport + +import ( + "fmt" + "strings" + + "github.com/headercat/erdn-lang/internal/ast" +) + +// DBMS identifies the target database management system. +type DBMS string + +const ( + DBMSMySQL DBMS = "mysql" + DBMSPostgreSQL DBMS = "postgresql" + DBMSMSSQL DBMS = "mssql" + DBMSOracle DBMS = "oracle" + DBMSSQLite DBMS = "sqlite" +) + +// SupportedDBMS is the ordered list of supported DBMS identifiers. +var SupportedDBMS = []string{ + string(DBMSMySQL), + string(DBMSPostgreSQL), + string(DBMSMSSQL), + string(DBMSOracle), + string(DBMSSQLite), +} + +// ValidDBMS reports whether name is a recognised DBMS identifier. +func ValidDBMS(name string) bool { + for _, s := range SupportedDBMS { + if s == name { + return true + } + } + return false +} + +// Generate converts an erdn-lang AST into SQL DDL for the given DBMS. +func Generate(prog *ast.Program, dbms DBMS) string { + g := &generator{dbms: dbms} + return g.generate(prog) +} + +type generator struct { + dbms DBMS +} + +func (g *generator) generate(prog *ast.Program) string { + var sb strings.Builder + + fmt.Fprintf(&sb, "-- Generated by erdn-lang\n") + fmt.Fprintf(&sb, "-- DBMS: %s\n", g.dbms) + + // SQLite requires a PRAGMA to enforce foreign key constraints. + if g.dbms == DBMSSQLite && len(prog.Links) > 0 { + sb.WriteString("\nPRAGMA foreign_keys = ON;\n") + } + + // CREATE TABLE statements. + for i, t := range prog.Tables { + sb.WriteString("\n") + if i > 0 { + sb.WriteString("\n") + } + g.writeCreateTable(&sb, t, prog.Links) + } + + // CREATE INDEX statements. + hasIndexes := false + for _, t := range prog.Tables { + for _, col := range t.Columns { + if g.hasModifier(col, ast.ModIndexed) { + hasIndexes = true + break + } + } + if hasIndexes { + break + } + } + if hasIndexes { + sb.WriteString("\n") + for _, t := range prog.Tables { + g.writeIndexes(&sb, t) + } + } + + // ALTER TABLE … ADD CONSTRAINT … FOREIGN KEY … for non-SQLite databases. + // SQLite FK constraints are declared inline inside CREATE TABLE. + if g.dbms != DBMSSQLite && len(prog.Links) > 0 { + sb.WriteString("\n") + for _, l := range prog.Links { + g.writeForeignKey(&sb, l) + } + } + + // Trim trailing newlines and add exactly one. + return strings.TrimRight(sb.String(), "\n") + "\n" +} + +// writeCreateTable writes a single CREATE TABLE statement. +func (g *generator) writeCreateTable(sb *strings.Builder, t *ast.Table, links []*ast.Link) { + q := g.quote + + // Find the primary key column (if any). + var pkCol *ast.Column + for _, col := range t.Columns { + if g.hasModifier(col, ast.ModPrimaryKey) { + pkCol = col + break + } + } + + fmt.Fprintf(sb, "CREATE TABLE %s (\n", q(t.Name)) + + var lines []string + for _, col := range t.Columns { + lines = append(lines, g.columnDef(col)) + } + + // Table-level PRIMARY KEY constraint. + // SQLite uses inline "INTEGER PRIMARY KEY [AUTOINCREMENT]", not a separate constraint. + if pkCol != nil && g.dbms != DBMSSQLite { + lines = append(lines, fmt.Sprintf(" PRIMARY KEY (%s)", q(pkCol.Name))) + } + + // SQLite: emit FOREIGN KEY table constraints inline. + if g.dbms == DBMSSQLite { + for _, l := range links { + if l.ToTable == t.Name { + lines = append(lines, fmt.Sprintf(" FOREIGN KEY (%s) REFERENCES %s (%s)", + q(l.ToColumn), q(l.FromTable), q(l.FromColumn))) + } + } + } + + fmt.Fprintf(sb, "%s\n);\n", strings.Join(lines, ",\n")) +} + +// writeIndexes writes CREATE INDEX statements for indexed columns in table t. +func (g *generator) writeIndexes(sb *strings.Builder, t *ast.Table) { + q := g.quote + for _, col := range t.Columns { + if g.hasModifier(col, ast.ModIndexed) { + idxName := fmt.Sprintf("idx_%s_%s", t.Name, col.Name) + fmt.Fprintf(sb, "CREATE INDEX %s ON %s (%s);\n", + q(idxName), q(t.Name), q(col.Name)) + } + } +} + +// writeForeignKey writes an ALTER TABLE … ADD CONSTRAINT … FOREIGN KEY statement. +func (g *generator) writeForeignKey(sb *strings.Builder, l *ast.Link) { + q := g.quote + constraintName := fmt.Sprintf("fk_%s_%s", l.ToTable, l.ToColumn) + fmt.Fprintf(sb, "ALTER TABLE %s ADD CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s (%s);\n", + q(l.ToTable), q(constraintName), q(l.ToColumn), q(l.FromTable), q(l.FromColumn)) +} + +// columnDef returns the SQL column definition line (with two-space indent). +func (g *generator) columnDef(col *ast.Column) string { + q := g.quote + + isPK := g.hasModifier(col, ast.ModPrimaryKey) + isAutoInc := g.hasModifier(col, ast.ModAutoIncrement) + isNotNull := g.hasModifier(col, ast.ModNotNull) + + typePart := g.mapType(col.Type, col.TypeParams) + + // SQLite special case: INTEGER PRIMARY KEY [AUTOINCREMENT] is the canonical + // rowid alias; it must be expressed inline, not as a table constraint. + if g.dbms == DBMSSQLite && isPK { + if isAutoInc { + return fmt.Sprintf(" %s INTEGER PRIMARY KEY AUTOINCREMENT", q(col.Name)) + } + return fmt.Sprintf(" %s INTEGER PRIMARY KEY", q(col.Name)) + } + + var colSB strings.Builder + fmt.Fprintf(&colSB, " %s %s", q(col.Name), typePart) + + // GENERATED ALWAYS AS IDENTITY / IDENTITY(1,1) immediately follows the type. + if isAutoInc { + switch g.dbms { + case DBMSPostgreSQL, DBMSOracle: + colSB.WriteString(" GENERATED ALWAYS AS IDENTITY") + case DBMSMSSQL: + colSB.WriteString(" IDENTITY(1,1)") + } + } + + // NOT NULL is required for primary key columns (even without an explicit modifier). + if isNotNull || isPK { + colSB.WriteString(" NOT NULL") + } + + // DEFAULT value. + for _, mod := range col.Modifiers { + if mod.Kind == ast.ModDefault { + fmt.Fprintf(&colSB, " DEFAULT %s", g.formatDefault(mod.Value)) + break + } + } + + // MySQL AUTO_INCREMENT appears at the end of the column definition. + if isAutoInc && g.dbms == DBMSMySQL { + colSB.WriteString(" AUTO_INCREMENT") + } + + return colSB.String() +} + +// quote wraps an identifier with the DBMS-appropriate quoting characters. +func (g *generator) quote(name string) string { + switch g.dbms { + case DBMSMySQL: + return "`" + name + "`" + case DBMSMSSQL: + return "[" + name + "]" + default: // PostgreSQL, Oracle, SQLite + return `"` + name + `"` + } +} + +// formatDefault converts an erdn default value token to its SQL representation. +// +// erdn stores the raw content of default(…), e.g. "draft" (with double quotes) +// or NOW(). This function: +// - Maps NOW() to the DBMS-specific current-timestamp function. +// - Converts double-quoted string literals to single-quoted SQL strings. +// - Passes numeric and other literals through unchanged. +func (g *generator) formatDefault(val string) string { + if strings.EqualFold(val, "NOW()") { + switch g.dbms { + case DBMSMSSQL: + return "GETDATE()" + case DBMSOracle: + return "SYSDATE" + case DBMSSQLite: + return "CURRENT_TIMESTAMP" + default: + return "NOW()" + } + } + + // Convert erdn double-quoted string literals to SQL single-quoted strings. + if len(val) >= 2 && val[0] == '"' && val[len(val)-1] == '"' { + return "'" + val[1:len(val)-1] + "'" + } + + return val +} + +// mapType returns the SQL type name for the given erdn type and optional parameters. +func (g *generator) mapType(typeName string, params []string) string { + lower := strings.ToLower(typeName) + paramStr := "" + if len(params) > 0 { + paramStr = "(" + strings.Join(params, ", ") + ")" + } + + switch g.dbms { + case DBMSMySQL: + return mapTypeMySQL(lower, paramStr) + case DBMSPostgreSQL: + return mapTypePostgreSQL(lower, paramStr) + case DBMSMSSQL: + return mapTypeMSSQL(lower, params, paramStr) + case DBMSOracle: + return mapTypeOracle(lower, paramStr) + case DBMSSQLite: + return mapTypeSQLite(lower, paramStr) + } + return strings.ToUpper(typeName) + paramStr +} + +func mapTypeMySQL(lower, paramStr string) string { + switch lower { + case "bigint": + return "BIGINT" + case "int", "integer": + return "INT" + case "smallint": + return "SMALLINT" + case "tinyint": + return "TINYINT" + case "varchar": + return "VARCHAR" + paramStr + case "char": + return "CHAR" + paramStr + case "text", "mediumtext", "longtext": + return strings.ToUpper(lower) + case "bool", "boolean": + return "TINYINT(1)" + case "timestamp": + return "TIMESTAMP" + case "datetime": + return "DATETIME" + case "date": + return "DATE" + case "time": + return "TIME" + case "float": + return "FLOAT" + case "double": + return "DOUBLE" + case "decimal", "numeric": + return strings.ToUpper(lower) + paramStr + case "blob": + return "BLOB" + case "json": + return "JSON" + case "uuid": + return "CHAR(36)" + } + return strings.ToUpper(lower) + paramStr +} + +func mapTypePostgreSQL(lower, paramStr string) string { + switch lower { + case "bigint": + return "BIGINT" + case "int", "integer": + return "INT" + case "smallint": + return "SMALLINT" + case "tinyint": + return "SMALLINT" + case "varchar": + return "VARCHAR" + paramStr + case "char": + return "CHAR" + paramStr + case "text", "mediumtext", "longtext": + return "TEXT" + case "bool", "boolean": + return "BOOLEAN" + case "timestamp": + return "TIMESTAMP" + case "datetime": + return "TIMESTAMP" + case "date": + return "DATE" + case "time": + return "TIME" + case "float": + return "REAL" + case "double": + return "DOUBLE PRECISION" + case "decimal", "numeric": + return strings.ToUpper(lower) + paramStr + case "blob": + return "BYTEA" + case "json": + return "JSON" + case "uuid": + return "UUID" + } + return strings.ToUpper(lower) + paramStr +} + +func mapTypeMSSQL(lower string, params []string, paramStr string) string { + switch lower { + case "bigint": + return "BIGINT" + case "int", "integer": + return "INT" + case "smallint": + return "SMALLINT" + case "tinyint": + return "TINYINT" + case "varchar": + if len(params) > 0 { + return "NVARCHAR" + paramStr + } + return "NVARCHAR(255)" + case "char": + if len(params) > 0 { + return "NCHAR" + paramStr + } + return "NCHAR(1)" + case "text", "mediumtext", "longtext": + return "NVARCHAR(MAX)" + case "bool", "boolean": + return "BIT" + case "timestamp", "datetime": + return "DATETIME" + case "date": + return "DATE" + case "time": + return "TIME" + case "float": + return "REAL" + case "double": + return "FLOAT" + case "decimal", "numeric": + return strings.ToUpper(lower) + paramStr + case "blob": + return "VARBINARY(MAX)" + case "json": + return "NVARCHAR(MAX)" + case "uuid": + return "UNIQUEIDENTIFIER" + } + return strings.ToUpper(lower) + paramStr +} + +func mapTypeOracle(lower, paramStr string) string { + switch lower { + case "bigint": + return "NUMBER(19)" + case "int", "integer": + return "NUMBER(10)" + case "smallint": + return "NUMBER(5)" + case "tinyint": + return "NUMBER(3)" + case "varchar": + return "VARCHAR2" + paramStr + case "char": + return "CHAR" + paramStr + case "text", "mediumtext", "longtext": + return "CLOB" + case "bool", "boolean": + return "NUMBER(1)" + case "timestamp", "datetime": + return "TIMESTAMP" + case "date": + return "DATE" + case "time": + return "INTERVAL DAY TO SECOND" + case "float": + return "FLOAT" + case "double": + return "FLOAT(126)" + case "decimal", "numeric": + return "NUMBER" + paramStr + case "blob": + return "BLOB" + case "json": + return "CLOB" + case "uuid": + return "CHAR(36)" + } + return strings.ToUpper(lower) + paramStr +} + +func mapTypeSQLite(lower, paramStr string) string { + switch lower { + case "bigint", "int", "integer", "smallint", "tinyint", "bool", "boolean": + return "INTEGER" + case "varchar", "char", "text", "mediumtext", "longtext", + "timestamp", "datetime", "date", "time", "json", "uuid": + return "TEXT" + case "float", "double", "real": + return "REAL" + case "decimal", "numeric": + return "NUMERIC" + paramStr + case "blob": + return "BLOB" + } + return strings.ToUpper(lower) +} + +// hasModifier reports whether col has a modifier of the given kind. +func (g *generator) hasModifier(col *ast.Column, kind ast.ModifierKind) bool { + for _, mod := range col.Modifiers { + if mod.Kind == kind { + return true + } + } + return false +} diff --git a/internal/sqlexport/sqlexport_test.go b/internal/sqlexport/sqlexport_test.go new file mode 100644 index 0000000..88300dd --- /dev/null +++ b/internal/sqlexport/sqlexport_test.go @@ -0,0 +1,280 @@ +package sqlexport + +import ( + "strings" + "testing" + + "github.com/headercat/erdn-lang/internal/parser" + "github.com/headercat/erdn-lang/internal/semantic" +) + +func generateSQL(t *testing.T, src string, dbms DBMS) string { + t.Helper() + prog, err := parser.ParseString(src) + if err != nil { + t.Fatalf("parse error: %v", err) + } + errs := semantic.Validate(prog) + if len(errs) > 0 { + t.Fatalf("validation errors: %v", errs) + } + return Generate(prog, dbms) +} + +const blogSchema = ` +table authors ( + id bigint primary-key auto-increment + username varchar(64) not-null indexed + email varchar(255) not-null indexed + bio text nullable +) + +table posts ( + id bigint primary-key auto-increment + author_id bigint not-null indexed + title varchar(512) not-null + status varchar(32) not-null default("draft") + created_at timestamp not-null default(NOW()) +) + +link one authors.id to many posts.author_id +` + +func TestValidDBMS(t *testing.T) { + for _, name := range SupportedDBMS { + if !ValidDBMS(name) { + t.Errorf("ValidDBMS(%q) = false, want true", name) + } + } + if ValidDBMS("unknown") { + t.Error("ValidDBMS(\"unknown\") = true, want false") + } +} + +func TestGenerateHeader(t *testing.T) { + for _, dbms := range []DBMS{DBMSMySQL, DBMSPostgreSQL, DBMSMSSQL, DBMSOracle, DBMSSQLite} { + out := generateSQL(t, `table t (id bigint primary-key)`, dbms) + if !strings.Contains(out, "-- Generated by erdn-lang") { + t.Errorf("DBMS %s: missing header comment", dbms) + } + if !strings.Contains(out, string(dbms)) { + t.Errorf("DBMS %s: missing DBMS name in header", dbms) + } + } +} + +func TestGenerateMySQL(t *testing.T) { + out := generateSQL(t, blogSchema, DBMSMySQL) + + // Identifier quoting + if !strings.Contains(out, "`authors`") { + t.Error("MySQL: expected backtick-quoted table name `authors`") + } + // AUTO_INCREMENT + if !strings.Contains(out, "AUTO_INCREMENT") { + t.Error("MySQL: expected AUTO_INCREMENT") + } + // Type mappings + if !strings.Contains(out, "VARCHAR(64)") { + t.Error("MySQL: expected VARCHAR(64)") + } + if !strings.Contains(out, "TIMESTAMP") { + t.Error("MySQL: expected TIMESTAMP") + } + // Default string conversion + if !strings.Contains(out, "DEFAULT 'draft'") { + t.Error("MySQL: expected DEFAULT 'draft'") + } + // NOW() preserved + if !strings.Contains(out, "DEFAULT NOW()") { + t.Error("MySQL: expected DEFAULT NOW()") + } + // Primary key constraint + if !strings.Contains(out, "PRIMARY KEY (`id`)") { + t.Error("MySQL: expected PRIMARY KEY (`id`)") + } + // Foreign key + if !strings.Contains(out, "ALTER TABLE `posts` ADD CONSTRAINT `fk_posts_author_id` FOREIGN KEY (`author_id`) REFERENCES `authors` (`id`)") { + t.Error("MySQL: expected ALTER TABLE foreign key statement") + } + // Index + if !strings.Contains(out, "CREATE INDEX `idx_authors_username` ON `authors` (`username`)") { + t.Error("MySQL: expected CREATE INDEX for indexed column") + } +} + +func TestGeneratePostgreSQL(t *testing.T) { + out := generateSQL(t, blogSchema, DBMSPostgreSQL) + + // Identifier quoting + if !strings.Contains(out, `"authors"`) { + t.Error("PostgreSQL: expected double-quoted table name") + } + // GENERATED ALWAYS AS IDENTITY + if !strings.Contains(out, "GENERATED ALWAYS AS IDENTITY") { + t.Error("PostgreSQL: expected GENERATED ALWAYS AS IDENTITY") + } + // Type mappings + if !strings.Contains(out, "VARCHAR(64)") { + t.Error("PostgreSQL: expected VARCHAR(64)") + } + // Default string + if !strings.Contains(out, "DEFAULT 'draft'") { + t.Error("PostgreSQL: expected DEFAULT 'draft'") + } + // NOW() preserved + if !strings.Contains(out, "DEFAULT NOW()") { + t.Error("PostgreSQL: expected DEFAULT NOW()") + } + // Foreign key + if !strings.Contains(out, `ALTER TABLE "posts" ADD CONSTRAINT "fk_posts_author_id" FOREIGN KEY ("author_id") REFERENCES "authors" ("id")`) { + t.Error("PostgreSQL: expected ALTER TABLE foreign key statement") + } +} + +func TestGenerateMSSQL(t *testing.T) { + out := generateSQL(t, blogSchema, DBMSMSSQL) + + // Identifier quoting + if !strings.Contains(out, "[authors]") { + t.Error("MSSQL: expected bracket-quoted table name") + } + // IDENTITY(1,1) + if !strings.Contains(out, "IDENTITY(1,1)") { + t.Error("MSSQL: expected IDENTITY(1,1)") + } + // varchar → NVARCHAR + if !strings.Contains(out, "NVARCHAR(64)") { + t.Error("MSSQL: expected NVARCHAR(64)") + } + // timestamp → DATETIME + if !strings.Contains(out, "DATETIME") { + t.Error("MSSQL: expected DATETIME") + } + // NOW() → GETDATE() + if !strings.Contains(out, "DEFAULT GETDATE()") { + t.Error("MSSQL: expected DEFAULT GETDATE()") + } +} + +func TestGenerateOracle(t *testing.T) { + out := generateSQL(t, blogSchema, DBMSOracle) + + // Identifier quoting + if !strings.Contains(out, `"authors"`) { + t.Error("Oracle: expected double-quoted table name") + } + // GENERATED ALWAYS AS IDENTITY + if !strings.Contains(out, "GENERATED ALWAYS AS IDENTITY") { + t.Error("Oracle: expected GENERATED ALWAYS AS IDENTITY") + } + // bigint → NUMBER(19) + if !strings.Contains(out, "NUMBER(19)") { + t.Error("Oracle: expected NUMBER(19) for bigint") + } + // varchar → VARCHAR2 + if !strings.Contains(out, "VARCHAR2(64)") { + t.Error("Oracle: expected VARCHAR2(64)") + } + // NOW() → SYSDATE + if !strings.Contains(out, "DEFAULT SYSDATE") { + t.Error("Oracle: expected DEFAULT SYSDATE") + } +} + +func TestGenerateSQLite(t *testing.T) { + out := generateSQL(t, blogSchema, DBMSSQLite) + + // Identifier quoting + if !strings.Contains(out, `"authors"`) { + t.Error("SQLite: expected double-quoted table name") + } + // INTEGER PRIMARY KEY AUTOINCREMENT inline + if !strings.Contains(out, `"id" INTEGER PRIMARY KEY AUTOINCREMENT`) { + t.Error("SQLite: expected inline INTEGER PRIMARY KEY AUTOINCREMENT") + } + // No separate ALTER TABLE FK (SQLite FK is inline) + if strings.Contains(out, "ALTER TABLE") { + t.Error("SQLite: unexpected ALTER TABLE (SQLite uses inline FK)") + } + // Inline FOREIGN KEY constraint + if !strings.Contains(out, `FOREIGN KEY ("author_id") REFERENCES "authors" ("id")`) { + t.Error("SQLite: expected inline FOREIGN KEY constraint") + } + // PRAGMA foreign_keys + if !strings.Contains(out, "PRAGMA foreign_keys = ON") { + t.Error("SQLite: expected PRAGMA foreign_keys = ON") + } + // NOW() → CURRENT_TIMESTAMP + if !strings.Contains(out, "DEFAULT CURRENT_TIMESTAMP") { + t.Error("SQLite: expected DEFAULT CURRENT_TIMESTAMP") + } +} + +func TestGenerateNoLinks(t *testing.T) { + src := `table users ( + id bigint primary-key + name varchar(255) not-null +)` + out := generateSQL(t, src, DBMSMySQL) + if strings.Contains(out, "ALTER TABLE") { + t.Error("expected no ALTER TABLE when there are no links") + } + if strings.Contains(out, "PRAGMA") { + t.Error("expected no PRAGMA when there are no links") + } +} + +func TestGenerateTypeMapping(t *testing.T) { + src := `table types ( + a bool + b boolean + c text + d double + e blob + f json + g uuid +)` + + cases := []struct { + dbms DBMS + want []string + }{ + {DBMSMySQL, []string{"TINYINT(1)", "TEXT", "DOUBLE", "BLOB", "JSON", "CHAR(36)"}}, + {DBMSPostgreSQL, []string{"BOOLEAN", "TEXT", "DOUBLE PRECISION", "BYTEA", "JSON", "UUID"}}, + {DBMSMSSQL, []string{"BIT", "NVARCHAR(MAX)", "FLOAT", "VARBINARY(MAX)", "NVARCHAR(MAX)", "UNIQUEIDENTIFIER"}}, + {DBMSOracle, []string{"NUMBER(1)", "CLOB", "FLOAT(126)", "BLOB", "CLOB", "CHAR(36)"}}, + {DBMSSQLite, []string{"INTEGER", "TEXT", "REAL", "BLOB", "TEXT", "TEXT"}}, + } + + for _, tc := range cases { + out := generateSQL(t, src, tc.dbms) + for _, want := range tc.want { + if !strings.Contains(out, want) { + t.Errorf("DBMS %s: expected %q in output:\n%s", tc.dbms, want, out) + } + } + } +} + +func TestGenerateSelfReferentialLink(t *testing.T) { + src := ` +table categories ( + id bigint primary-key auto-increment + parent_id bigint nullable + name varchar(128) not-null +) +link one categories.id to many categories.parent_id +` + // MySQL: self-referential FK via ALTER TABLE + out := generateSQL(t, src, DBMSMySQL) + if !strings.Contains(out, "ALTER TABLE `categories` ADD CONSTRAINT `fk_categories_parent_id` FOREIGN KEY (`parent_id`) REFERENCES `categories` (`id`)") { + t.Error("MySQL: expected self-referential ALTER TABLE FK") + } + + // SQLite: self-referential FK inline + out = generateSQL(t, src, DBMSSQLite) + if !strings.Contains(out, `FOREIGN KEY ("parent_id") REFERENCES "categories" ("id")`) { + t.Error("SQLite: expected inline self-referential FOREIGN KEY") + } +} diff --git a/website/guide.md b/website/guide.md index 083351f..c9806da 100644 --- a/website/guide.md +++ b/website/guide.md @@ -33,6 +33,7 @@ You can move the resulting `erdn` binary anywhere on your `PATH`. ``` erdn render [--out ] erdn validate +erdn sql [--dbms ] [--out ] ``` ### `render` @@ -65,6 +66,37 @@ erdn validate schema.erdn Use `validate` in CI pipelines to catch schema errors early. +### `sql` + +Generates SQL DDL from the schema — `CREATE TABLE` statements, indexes, and foreign key constraints. Use `--dbms` to target a specific database engine (default: `mysql`). + +| DBMS | Flag value | +|------|-----------| +| MySQL | `mysql` | +| PostgreSQL | `postgresql` | +| Microsoft SQL Server | `mssql` | +| Oracle Database | `oracle` | +| SQLite | `sqlite` | + +```sh +# Output defaults to .sql (MySQL) +erdn sql schema.erdn + +# Target PostgreSQL +erdn sql schema.erdn --dbms postgresql + +# Specify a custom output path +erdn sql schema.erdn --dbms mssql --out migrations/001_init.sql +``` + +The generated SQL includes: + +- `CREATE TABLE` statements with DBMS-appropriate column types and constraints. +- `PRIMARY KEY` constraints. +- Auto-increment syntax suited to the target DBMS (`AUTO_INCREMENT`, `IDENTITY(1,1)`, `GENERATED ALWAYS AS IDENTITY`, or `AUTOINCREMENT`). +- `CREATE INDEX` statements for columns marked `indexed`. +- Foreign key constraints derived from `link` declarations — as `ALTER TABLE … ADD CONSTRAINT … FOREIGN KEY` for most databases, or as inline `FOREIGN KEY` table constraints for SQLite. + ## Writing Your First Schema Create a file called `blog.erdn`: