diff --git a/AGENTS.md b/AGENTS.md index 932d3ce1..4b8709fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ vespertide/ │ ├── vespertide-planner/ # Schema diffing, baseline reconstruction, validation │ ├── vespertide-query/ # SQL generation (Postgres/MySQL/SQLite) │ ├── vespertide-cli/ # CLI commands: init, diff, sql, revision, export -│ ├── vespertide-exporter/ # ORM codegen: SeaORM, SQLAlchemy, SQLModel, JPA, Prisma +│ ├── vespertide-exporter/ # ORM codegen: SeaORM, SQLAlchemy, SQLModel, JPA, GORM, Django, Prisma │ ├── vespertide-loader/ # Filesystem loading of models/migrations │ ├── vespertide-config/ # vespertide.json configuration │ ├── vespertide-lsp/ # Language server: 13 LSP capabilities + HS-7~11 caching @@ -47,7 +47,7 @@ vespertide/ | Schema diffing | `vespertide-planner/src/diff/` | topological sort for FK deps | | SQL generation | `vespertide-query/src/sql/` | One file per action type | | CLI commands | `vespertide-cli/src/commands/` | `cmd_*` functions | -| ORM export | `vespertide-exporter/src/{seaorm,sqlalchemy,sqlmodel,jpa,prisma}/` | Backend-specific generators | +| ORM export | `vespertide-exporter/src/{seaorm,sqlalchemy,sqlmodel,jpa,gorm,django,prisma}/` | Backend-specific generators | | Compile-time macro | `vespertide-macro/src/lib.rs` | `vespertide_migration!` proc macro | | **LSP RingCache (HS-7~11)** | `vespertide-lsp/src/cache.rs` | Generic ring-buffer LRU shared across symbols/diagnostics/drift/semantic-token caches | | **LSP drift cache** | `vespertide-lsp/src/drift/cache.rs` | HS-10 drift cache implementation | @@ -368,12 +368,12 @@ fn create_table_snapshot(#[case] backend: DatabaseBackend) { } ``` -This is the same pattern used by `vespertide-query` (3 backends, 357 snapshots) and `vespertide-exporter` (5 ORMs via `Orm` enum, 335 cross-ORM snapshots). When adding a new backend / ORM / format, the change is **one `#[case::name(Value)]` line**. +This is the same pattern used by `vespertide-query` (3 backends, 357 snapshots) and `vespertide-exporter` (7 ORMs via `Orm` enum, 459 cross-ORM snapshots). When adding a new backend / ORM / format, the change is **one `#[case::name(Value)]` line**. ### Exporter snapshots MUST cover ALL ORMs (no per-ORM snapshots) -Every `vespertide-exporter` snapshot test MUST be written through the shared `orm_cases!` rstest macro in `crates/vespertide-exporter/src/tests/mod.rs`, which renders each fixture for **all five ORMs** (`Orm::SeaOrm`, `Orm::SqlAlchemy`, `Orm::SqlModel`, `Orm::Jpa`, `Orm::Prisma`). A new export scenario = ONE fixture + ONE `orm_cases!(...)` line, producing exactly five snapshots (one per ORM) in the single shared `crates/vespertide-exporter/src/tests/snapshots/` directory. +Every `vespertide-exporter` snapshot test MUST be written through the shared `orm_cases!` rstest macro in `crates/vespertide-exporter/src/tests/mod.rs`, which renders each fixture for **all seven ORMs** (`Orm::SeaOrm`, `Orm::SqlAlchemy`, `Orm::SqlModel`, `Orm::Jpa`, `Orm::Gorm`, `Orm::Django`, `Orm::Prisma`). A new export scenario = ONE fixture + ONE `orm_cases!(...)` line, producing exactly seven snapshots (one per ORM) in the single shared `crates/vespertide-exporter/src/tests/snapshots/` directory. GORM and Django were folded into the shared macro after initially shipping with their own separate per-backend snapshot suites (`src/gorm/tests/snapshots/`, `src/django/snapshots/`) — those module-local suites still exist for backend-specific unit coverage (type mapping, naming helpers, config wiring) that doesn't fit the shared fixture shape. -FORBIDDEN: per-ORM `#[test]` snapshot functions inside `src/seaorm/`, `src/sqlalchemy/`, `src/sqlmodel/`, `src/jpa/`, `src/prisma/`, or any `snapshots/` directory other than `src/tests/snapshots/`. A scenario snapshotted for only one ORM is a defect — ORM output must always be cross-compared across all five. When adding a new ORM the change is a single `#[case::(Orm::)]` line in the macro, never a new per-ORM test. +FORBIDDEN: per-ORM `#[test]` snapshot functions inside `src/seaorm/`, `src/sqlalchemy/`, `src/sqlmodel/`, `src/jpa/`, `src/prisma/`, or any `snapshots/` directory other than `src/tests/snapshots/`. A scenario snapshotted for only one ORM is a defect — ORM output must always be cross-compared across all seven. When adding a new ORM the change is a single `#[case::(Orm::)]` line in the macro, never a new per-ORM test. Exception: an entry point that exists in only one backend (e.g. Prisma's single-file `render_schema`, which deduplicates enums globally) is not a cross-ORM scenario, so its snapshot tests live as inline tests of that module — with the snapshot files still written to the shared `src/tests/snapshots/` via `with_settings!(snapshot_path => ...)`. diff --git a/CLAUDE.md b/CLAUDE.md index 43c994c2..eef4bd20 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -@AGENTS.md +@AGENTS.md \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index ad7d1559..d41d0fab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,12 +11,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "aliasable" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" - [[package]] name = "alloca" version = "0.4.0" @@ -99,9 +93,9 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "ar_archive_writer" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" dependencies = [ "object", ] @@ -238,13 +232,13 @@ checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" [[package]] name = "bstr" -version = "1.12.1" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" dependencies = [ "memchr", "regex-automata", - "serde", + "serde_core", ] [[package]] @@ -255,9 +249,9 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cast" @@ -267,9 +261,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.63" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", "jobserver", @@ -399,9 +393,9 @@ dependencies = [ [[package]] name = "console" -version = "0.16.3" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" dependencies = [ "encode_unicode", "libc", @@ -452,9 +446,9 @@ dependencies = [ [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -471,9 +465,9 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crunchy" @@ -702,12 +696,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "foldhash" version = "0.2.0" @@ -837,15 +825,13 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "wasip2", - "wasip3", ] [[package]] @@ -871,22 +857,13 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] - [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ - "foldhash 0.2.0", + "foldhash", ] [[package]] @@ -895,14 +872,14 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash 0.2.0", + "foldhash", ] [[package]] name = "hashlink" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5081f264ed7adee96ea4b4778b6bb9da0a7228b084587aa3bd3ff05da7c5a3b" +checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" dependencies = [ "hashbrown 0.17.1", ] @@ -1040,12 +1017,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -1081,15 +1052,13 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", - "serde", - "serde_core", ] [[package]] name = "insta" -version = "1.47.2" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" dependencies = [ "console", "once_cell", @@ -1139,23 +1108,22 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -1171,12 +1139,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" version = "0.2.186" @@ -1243,9 +1205,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "ls-types" @@ -1294,9 +1256,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "minimal-lexical" @@ -1391,30 +1353,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "ouroboros" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" -dependencies = [ - "aliasable", - "ouroboros_macro", - "static_assertions", -] - -[[package]] -name = "ouroboros_macro" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" -dependencies = [ - "heck 0.4.1", - "proc-macro2", - "proc-macro2-diagnostics", - "quote", - "syn", -] - [[package]] name = "page_size" version = "0.6.0" @@ -1614,19 +1552,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "proc-macro2-diagnostics" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "version_check", - "yansi", -] - [[package]] name = "proptest" version = "1.11.0" @@ -1716,9 +1641,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -1737,9 +1662,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", "rand_core", @@ -1844,9 +1769,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" dependencies = [ "aho-corasick", "memchr", @@ -1856,9 +1781,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" dependencies = [ "aho-corasick", "memchr", @@ -1867,9 +1792,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "relative-path" @@ -1950,9 +1875,9 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -1991,9 +1916,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "rusty-fork" @@ -2055,9 +1980,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "sea-orm" -version = "2.0.0-rc.40" +version = "2.0.0-rc.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "628c3b6acb53ca9942f7f151431ed49db92dafa14d15976a1b9db9d4bd06431c" +checksum = "a1db0850846a673d7819a8120bae1df3c2cc0c020bd7cbe50eb7b2f74b9efabb" dependencies = [ "async-stream", "async-trait", @@ -2065,7 +1990,6 @@ dependencies = [ "futures-util", "itertools 0.14.0", "log", - "ouroboros", "sea-orm-macros", "sea-query", "serde", @@ -2073,14 +1997,15 @@ dependencies = [ "thiserror 2.0.18", "tracing", "url", + "uuid", "web-time", ] [[package]] name = "sea-orm-macros" -version = "2.0.0-rc.40" +version = "2.0.0-rc.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68a91def07bceb98aab308f7dd16c27496b76a6b7b92b94a61b309b5043d93d5" +checksum = "8e166e50db6f9992fc65c488ef089e50072a31253588e3aa863aa6a9f34f2851" dependencies = [ "heck 0.5.0", "itertools 0.14.0", @@ -2286,9 +2211,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" @@ -2341,12 +2266,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "streaming-iterator" version = "0.1.9" @@ -2367,9 +2286,9 @@ checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -2406,7 +2325,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -2469,9 +2388,9 @@ dependencies = [ [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] @@ -2698,9 +2617,9 @@ dependencies = [ [[package]] name = "tree-sitter" -version = "0.26.9" +version = "0.26.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dab76d0b724ba557954125188cf0633a1ca43199ced82d95c7b9c32cc3de1f3" +checksum = "3c343ed63e3f5c64d1acdecb5d2c13d4e169cb5fde0052106ebaa6c6f27f9e55" dependencies = [ "cc", "regex", @@ -2738,9 +2657,9 @@ dependencies = [ [[package]] name = "trybuild" -version = "1.0.116" +version = "1.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c635f0191bd3a2941013e5062667100969f8c4e9cd787c14f977265d73616e" +checksum = "0710d4dfbeae4f9c390baa784c49858a7468fa433f3fe5d0ec5ebef651cf59f9" dependencies = [ "dissimilar", "glob", @@ -2807,12 +2726,13 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.2" +version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -2828,12 +2748,6 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - [[package]] name = "vespertide" version = "0.2.1" @@ -2882,6 +2796,7 @@ name = "vespertide-config" version = "0.2.1" dependencies = [ "clap", + "rstest", "schemars", "serde", "serde_json", @@ -2958,7 +2873,7 @@ dependencies = [ "lsp-types", "proptest", "rstest", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "serde", "serde_json", "serde_yaml", @@ -3082,27 +2997,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -3113,9 +3019,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3123,9 +3029,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", @@ -3136,52 +3042,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.13.0", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "web-sys" -version = "0.3.99" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -3390,112 +3262,18 @@ dependencies = [ "memchr", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck 0.5.0", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.13.0", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "writeable" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - [[package]] name = "yoke" version = "0.8.3" @@ -3521,18 +3299,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.50" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.50" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", @@ -3562,9 +3340,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" diff --git a/README.md b/README.md index 91efe46e..b3e1b75a 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Declarative database schema management. Define your schemas in JSON, and Vespert - **Enum Types**: Native string enums and integer enums (no migration needed for new values) - **Zero-Runtime Migrations**: Compile-time macro generates database-specific SQL - **JSON Schema Validation**: Ships with JSON Schemas for IDE autocompletion and validation -- **ORM Export**: Export schemas to SeaORM, SQLAlchemy, SQLModel, JPA, Prisma +- **ORM Export**: Export schemas to SeaORM, SQLAlchemy, SQLModel, JPA, GORM, Django, Prisma - **Language Server**: First-class editor support via the bundled `vespertide-lsp` — see [LSP Features](#lsp-features) below ## What's new in 0.2.0 @@ -232,6 +232,10 @@ The only exception is adding `fill_with` values when prompted (for NOT NULL colu vespertide export --orm seaorm # Rust - SeaORM entities vespertide export --orm sqlalchemy # Python - SQLAlchemy models vespertide export --orm sqlmodel # Python - SQLModel (FastAPI) +vespertide export --orm jpa # Java - JPA entities +vespertide export --orm gorm # Go - GORM models +vespertide export --orm django # Python - Django models +vespertide export --orm prisma # Prisma schema (backend-neutral) ``` ## Runtime Migrations (Macro) diff --git a/apps/landing/devup.json b/apps/landing/devup.json index b100ac5f..b6db3ff7 100644 --- a/apps/landing/devup.json +++ b/apps/landing/devup.json @@ -492,7 +492,21 @@ }, null, null - ] + ], + "code": { + "fontFamily": "D2Coding", + "fontWeight": 400, + "fontSize": "13px", + "lineHeight": 1.5, + "letterSpacing": "0em" + }, + "eyebrow": { + "fontFamily": "D2Coding", + "fontWeight": 400, + "fontSize": "11px", + "lineHeight": 1.4, + "letterSpacing": "0.14em" + } } } } \ No newline at end of file diff --git a/apps/landing/src/app/_components/code-tabs.tsx b/apps/landing/src/app/_components/code-tabs.tsx new file mode 100644 index 00000000..506b1d7d --- /dev/null +++ b/apps/landing/src/app/_components/code-tabs.tsx @@ -0,0 +1,238 @@ +import { Box, Text } from '@devup-ui/react' + +import { CodeWindow, HighlightedCode } from './code-window' + +export interface CodeExample { + key: string + label: string + file: string + html: string +} + +export type CodeExampleQuad = [CodeExample, CodeExample, CodeExample, CodeExample] + +// devup-ui extracts `selectors` strings at build time, so each of the 4 +// fixed tabs is written out literally rather than generated by a .map() +// over a runtime key — a template-literal selector key can't be statically +// extracted into CSS. +// +// Each target element (label / title / panel) declares its own "when am I +// shown" condition anchored on the checkbox's id (`#code-tab-N:checked ~ * +// &`), rather than the checkbox declaring rules for elements elsewhere in +// the tree. The checkbox's own `:checked` state isn't observable from the +// checkbox's position in isolation, so ownership of the visibility rule +// belongs on the element it affects. +export function CodeTabs({ examples: [a, b, c, d] }: { examples: CodeExampleQuad }) { + return ( + + + + + + + + {a.label} + + + {b.label} + + + {c.label} + + + {d.label} + + + } + title={ + <> + + {a.file} + + + {b.file} + + + {c.file} + + + {d.file} + + + } + > + + + + + + + + + + + + + + + ) +} diff --git a/apps/landing/src/app/_components/code-window.tsx b/apps/landing/src/app/_components/code-window.tsx new file mode 100644 index 00000000..2200a9ff --- /dev/null +++ b/apps/landing/src/app/_components/code-window.tsx @@ -0,0 +1,108 @@ +import { Box, Flex, globalCss, Text } from '@devup-ui/react' +import type { ComponentProps, ReactNode } from 'react' + +globalCss({ + '.shiki, .shiki span': { + fontFamily: 'D2Coding', + fontSize: '13px', + lineHeight: '1.65', + }, + '.shiki': { + background: 'transparent !important', + padding: '0', + margin: '0', + overflowX: 'auto', + }, + '[data-theme="dark"] .shiki, [data-theme="dark"] .shiki span': { + color: 'var(--shiki-dark) !important', + backgroundColor: 'transparent !important', + }, +}) + +export function CodeWindow({ + title, + tabs, + children, + ...props +}: { + title: ReactNode + tabs?: ReactNode + children: ReactNode +} & Omit>, 'title'>) { + return ( + + + + {Array.from({ length: 3 }, (_, i) => ( + + ))} + + + {title} + + {tabs && ( + + {tabs} + + )} + + + {children} + + + ) +} + +export function HighlightedCode({ html }: { html: string }) { + return
+} + +export function StaticCodeBlock({ + title, + html, +}: { + title: string + html: string +}) { + return ( + + + + ) +} + +export function HeroCodeWrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/apps/landing/src/app/_components/copy-install.tsx b/apps/landing/src/app/_components/copy-install.tsx new file mode 100644 index 00000000..b602c7f6 --- /dev/null +++ b/apps/landing/src/app/_components/copy-install.tsx @@ -0,0 +1,60 @@ +'use client' + +import { Box, Flex, Text } from '@devup-ui/react' +import { useState } from 'react' + +export function CopyInstall({ + command = 'cargo install vespertide-cli', +}: { + command?: string +}) { + const [copied, setCopied] = useState(false) + + const copy = () => { + navigator.clipboard?.writeText(command) + setCopied(true) + setTimeout(() => setCopied(false), 1400) + } + + return ( + + + $ + + + {command} + + { + e.stopPropagation() + copy() + }} + px="9px" + py="5px" + transition="color .15s, border-color .15s" + typography="eyebrow" + > + {copied ? 'copied' : 'copy'} + + + ) +} diff --git a/apps/landing/src/app/_components/example.tsx b/apps/landing/src/app/_components/example.tsx deleted file mode 100644 index 6e69244b..00000000 --- a/apps/landing/src/app/_components/example.tsx +++ /dev/null @@ -1,102 +0,0 @@ -'use client' - -import { Flex, Image } from '@devup-ui/react' -import { ComponentProps, createContext, useContext, useState } from 'react' - -const ExampleContext = createContext<{ - selected: string - setSelected: (selected: string) => void - selectedExample?: { - id: string - title: string - description: string - imageUrl: string - } -} | null>(null) - -export function useExample() { - const context = useContext(ExampleContext) - if (!context) { - throw new Error('useExample must be used within a ExampleProvider') - } - return context -} - -export function ExampleProvider({ - defaultSelected = '', - examples, - children, -}: { - defaultSelected?: string - examples: { - id: string - title: string - description: string - imageUrl: string - }[] - children: React.ReactNode -}) { - const [selected, setSelected] = useState(defaultSelected) - const selectedExample = examples.find((example) => example.id === selected) - return ( - - {children} - - ) -} - -export function ExampleContainer({ - value, - ...props -}: ComponentProps> & { value?: string }) { - const { selected, setSelected } = useExample() - const isSelected = selected === value - return ( - setSelected(value) : undefined} - overflow="hidden" - px="$spacingSpacing24" - py="$spacingSpacing20" - styleOrder={1} - transition="all .1s" - {...props} - /> - ) -} - -export function ExampleImage({ - ...props -}: Omit>, 'src'>) { - const { selectedExample } = useExample() - return ( - {selectedExample?.title - ) -} - -export function Example() {} diff --git a/apps/landing/src/app/_components/join-icon-button.tsx b/apps/landing/src/app/_components/join-icon-button.tsx deleted file mode 100644 index 24128d88..00000000 --- a/apps/landing/src/app/_components/join-icon-button.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Flex } from '@devup-ui/react' -import { ComponentProps } from 'react' - -export function JoinIconButton(props: ComponentProps>) { - return ( - - ) -} diff --git a/apps/landing/src/app/_lib/highlight.ts b/apps/landing/src/app/_lib/highlight.ts new file mode 100644 index 00000000..82e8cf23 --- /dev/null +++ b/apps/landing/src/app/_lib/highlight.ts @@ -0,0 +1,19 @@ +import { codeToHtml } from 'shiki' + +export type CodeLang = 'json' | 'shell' | 'rust' + +const LANG_MAP: Record = { + json: 'json', + shell: 'bash', + rust: 'rust', +} + +export async function highlight(code: string, lang: CodeLang): Promise { + return codeToHtml(code, { + lang: LANG_MAP[lang], + themes: { + light: 'github-light', + dark: 'github-dark', + }, + }) +} diff --git a/apps/landing/src/app/page.tsx b/apps/landing/src/app/page.tsx index b61eaf4c..2ec1823c 100644 --- a/apps/landing/src/app/page.tsx +++ b/apps/landing/src/app/page.tsx @@ -1,18 +1,14 @@ -import { JoinIconButton } from '@app/_components/join-icon-button' -import { Box, Center, css, Flex, Text, VStack } from '@devup-ui/react' -import { Image } from '@devup-ui/react' +import { Box, Flex, Text, VStack } from '@devup-ui/react' import type { Metadata } from 'next' import Link from 'next/link' +import type { ComponentProps } from 'react' import { Button } from '@/components/button' -import { GnbIcon } from '@/components/header/gnb-icon' -import { HeaderSentinel } from '@/components/header/header-sentinel' -import { - ExampleContainer, - ExampleImage, - ExampleProvider, -} from './_components/example' +import { CodeTabs, type CodeExampleQuad } from './_components/code-tabs' +import { CodeWindow, HighlightedCode } from './_components/code-window' +import { CopyInstall } from './_components/copy-install' +import { highlight } from './_lib/highlight' export const metadata: Metadata = { alternates: { @@ -20,290 +16,969 @@ export const metadata: Metadata = { }, } -const EXAMPLES = [ +const VERSION = '0.1.61' +const GITHUB_URL = 'https://github.com/dev-five-git/vespertide' +const DOCS_URL = '/documentation' +const DISCORD_URL = 'https://discord.com/invite/8zjcGc7cWh' +const KAKAO_URL = 'https://open.kakao.com/o/giONwVAh' +const CRATES_URL = 'https://crates.io/crates/vespertide-cli' + +const HERO_MODEL_JSON = `{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/main/schemas/model.schema.json", + "name": "user", + "columns": [ + { "name": "id", "type": "integer", "primary_key": true }, + { "name": "email", "type": "text", "unique": true, "index": true }, + { "name": "name", "type": { "kind": "varchar", "length": 100 } }, + { + "name": "status", + "type": { "kind": "enum", "name": "user_status", + "values": ["active", "inactive", "banned"] }, + "default": "'active'" + }, + { "name": "created_at", "type": "timestamptz", "default": "NOW()" } + ] +}` + +const EXAMPLE_MODEL = `{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/main/schemas/model.schema.json", + "name": "post", + "columns": [ + { "name": "id", "type": "integer", "primary_key": true }, + { "name": "title", "type": { "kind": "varchar", "length": 200 } }, + { "name": "body", "type": "text" }, + { + "name": "author_id", + "type": "integer", + "foreign_key": { + "ref_table": "user", + "ref_columns": ["id"], + "on_delete": "cascade" + }, + "index": true + }, + { + "name": "status", + "type": { "kind": "enum", "name": "post_status", + "values": ["draft", "published", "archived"] }, + "default": "'draft'" + } + ] +}` + +const EXAMPLE_CLI = `# Initialize a new project +$ vespertide init + +# Scaffold a model +$ vespertide new post + +# Edit models/post.json, then preview the diff +$ vespertide diff ++ create_table post (id, title, body, author_id, status) ++ create_enum post_status [draft, published, archived] ++ create_index ix_post_author_id ON post (author_id) + +# Inspect dialect-specific SQL +$ vespertide sql --backend postgres + +# Persist as a migration file +$ vespertide revision -m "create post table"` + +const EXAMPLE_RUNTIME = `use sea_orm::Database; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let db = Database::connect("postgres://user:pass@localhost/mydb").await?; + + // Generated at compile time, run on startup. + vespertide::vespertide_migration!(db).await?; + + Ok(()) +}` + +const EXAMPLE_EXPORT = `# Generate Rust SeaORM entities +$ vespertide export --orm seaorm + +# Or Python — SQLAlchemy +$ vespertide export --orm sqlalchemy + +# Or FastAPI-flavoured SQLModel +$ vespertide export --orm sqlmodel + +# Or Go — GORM +$ vespertide export --orm gorm` + +type FeatureIconName = + | 'hamburger' + | 'arrow-up-right' + | 'chevron' + | 'logo-image' + | 'devfive' + | 'theme-dark' + | 'search' + | 'external-link' + | 'github' + +const FEATURES: { icon: FeatureIconName; title: string; desc: string }[] = [ + { + icon: 'hamburger', + title: 'Declarative schema', + desc: 'Describe your desired database state in JSON files. The current model is the source of truth.', + }, + { + icon: 'arrow-up-right', + title: 'Automatic diffing', + desc: 'Vespertide replays applied migrations and compares them to your models to compute changes.', + }, + { + icon: 'chevron', + title: 'Typed migration plans', + desc: 'Generates safe, portable MigrationAction enums — not raw SQL. Review before you commit.', + }, + { + icon: 'logo-image', + title: 'Multi-database', + desc: 'PostgreSQL, MySQL, and SQLite — same schema, identical semantics, backend-aware quoting.', + }, + { + icon: 'devfive', + title: 'Native enums', + desc: 'First-class string and integer enums. Add new integer values without ever touching the DB.', + }, + { + icon: 'theme-dark', + title: 'Zero-runtime macro', + desc: 'vespertide_migration!() generates database-specific SQL at compile time. Nothing to ship at runtime.', + }, + { + icon: 'search', + title: 'JSON Schema validation', + desc: 'Ships with JSON Schemas — autocomplete, hover docs, and instant errors in your editor.', + }, + { + icon: 'external-link', + title: 'ORM export', + desc: 'One command emits SeaORM, SQLAlchemy, SQLModel, or GORM — entities stay in lockstep with schema.', + }, + { + icon: 'github', + title: 'Built in Rust', + desc: "Single binary CLI, no Node, no Python, no JVM. cargo install vespertide-cli and you're done.", + }, +] + +const STEPS: { title: string; desc: string; mono: string }[] = [ + { + title: 'Define', + desc: 'Author JSON models in your editor with full IDE validation via JSON Schema.', + mono: 'models/user.json', + }, + { + title: 'Replay', + desc: 'Vespertide reconstructs the baseline schema by replaying applied migrations.', + mono: 'migrations/*.sql', + }, + { + title: 'Diff', + desc: 'Current models are compared to the baseline to find what changed.', + mono: 'vespertide diff', + }, + { + title: 'Plan', + desc: 'Differences are converted into typed MigrationAction enums.', + mono: 'MigrationAction', + }, { - id: '1', - title: 'How to Use', - description: - 'Lorem ipsum dolor sit amet. Etiam sit amet feugiat turpis. Proin nec ante a sem vestibulum sodales non ut ex.', - imageUrl: '/images/hero-figure.webp', + title: 'Emit', + desc: 'Actions translate to dialect-specific SQL — Postgres, MySQL, or SQLite.', + mono: 'vespertide sql', + }, +] + +const DBS = [ + { + key: 'PG', + name: 'PostgreSQL', + quote: '"identifier"', + note: 'Full feature support — native enums, JSONB, INET, CIDR, TSVECTOR.', }, { - id: '2', - title: 'How to Use', - description: - 'Lorem ipsum dolor sit amet. Etiam sit amet feugiat turpis. Proin nec ante a sem vestibulum sodales non ut ex.', - imageUrl: '/images/join-us-bg.webp', + key: 'MY', + name: 'MySQL', + quote: '`identifier`', + note: 'Full feature support with MySQL-aware identifier quoting and types.', }, { - id: '3', - title: 'How to Use', - description: - 'Lorem ipsum dolor sit amet. Etiam sit amet feugiat turpis. Proin nec ante a sem vestibulum sodales non ut ex.', - imageUrl: '/images/code.webp', + key: 'SL', + name: 'SQLite', + quote: '"identifier"', + note: 'Full feature support — perfect for tests, CLIs, and embedded apps.', }, ] -export default function HomePage() { +const ORMS = [ + { lang: 'Rust', name: 'SeaORM' }, + { lang: 'Python', name: 'SQLAlchemy' }, + { lang: 'Python', name: 'SQLModel · FastAPI' }, + { lang: 'Go', name: 'GORM' }, +] + +function MaskIcon({ + icon, + size = '24px', + color = '$vespertidePrimary', + ...props +}: { + icon: FeatureIconName | 'discord' | 'kakao' + size?: string + color?: string +} & ComponentProps>) { return ( - <> - -
+ ) +} + +function SectionHead({ + eyebrow, + title, + emphasis, + lede, +}: { + eyebrow: string + title: string + emphasis?: string + lede?: string +}) { + return ( + + + — {eyebrow} + + + {title} + {emphasis && ( + + {emphasis} + + )} + + {lede && ( + + {lede} + + )} + + ) +} + +function HeroSection({ codeHtml }: { codeHtml: string }) { + return ( + + + + + + + + + v{VERSION} · Apache-2.0 · Rust + + + + + Define schemas. +
+ + Forget migrations. + +
+ + + + Vespertide is a declarative database schema manager for Rust. Write + your tables in JSON, and let it diff, plan, and emit type-safe + migrations to Postgres, MySQL, and SQLite — automatically. + + + + + + + + + + + + View on GitHub + + + + + + + + + {[ + { num: `v${VERSION}`, lbl: 'Version' }, + { num: '3', lbl: 'Databases' }, + { num: '4', lbl: 'ORM exports' }, + { num: '0ms', lbl: 'Runtime cost' }, + ].map((s) => ( + + + {s.num} + + + {s.lbl} + + + ))} + +
+ + + + + + +
+
+
+ ) +} + +function FeaturesSection() { + return ( + + + + + + - + {FEATURES.map((f) => ( - - Lorem ipsum dolor sit amet,
- consectetur adipiscing elit. + + + {f.title} - - Etiam sit amet feugiat turpis. Proin nec ante a sem vestibulum - sodales non ut ex.
- Morbi diam turpis, fringilla vitae enim et, egestas consequat - nibh.
- Etiam auctor cursus urna sit amet elementum. + + {f.desc}
- - -
-
+ ))} +
+ + + ) +} -
+ + + + + - - - - Title + {STEPS.map((s, i) => ( + + + {String(i + 1).padStart(2, '0')} - - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam - venenatis, elit in hendrerit porta, augue ante scelerisque diam,{' '} -
- ac egestas lacus est nec urna. Cras commodo risus hendrerit, - suscipit nibh at, porttitor dui. + + {s.title} -
- - {[0, 1, 2, 3].map((i) => ( - + {s.desc} +
+ + - + +
+ ))} +
+
+ + ) +} + +function ExamplesSection({ examples }: { examples: CodeExampleQuad }) { + return ( + + + + + + — Examples + + + One source of truth, +
+ four ways to use it. +
+ + Your JSON models drive the diff, the SQL, the ORM entities, and the + runtime macro. Pick the workflow that fits your team — Vespertide + stays consistent. + + + {[ + { + k: 'Models.', + v: 'Inline foreign keys, enums, and constraints — no separate schema language.', + }, + { + k: 'CLI.', + v: 'diff, sql, revision, status, log — every step is a plain command.', + }, + { + k: 'Runtime.', + v: 'Compile-time macro, zero overhead, no migrations folder shipped to prod.', + }, + { + k: 'Export.', + v: 'SeaORM, SQLAlchemy, SQLModel, GORM — typed entities, generated.', + }, + ].map((it) => ( + + - - Feature title - - - Lorem ipsum dolor sit amet. Etiam sit amet feugiat turpis. - Proin nec ante a sem vestibulum sodales non ut ex.{' '} + → + + + + {it.k} - + {it.v} + ))}
-
- - -
- - - - Title - - - Lorem ipsum dolor sit amet, consectetur adipiscing elit. - Nullam venenatis ac egestas lacus est nec urna.{' '} - - - + + + + + + + ) +} + +function CompatibilitySection() { + return ( + + + + + + + + {DBS.map((db) => ( + + - - - - + {db.key} + - - {EXAMPLES.map(({ id, title, description }) => ( - - - - {title} - - - {description} - - - - ))} - - + + {db.name} + + + + {db.quote} + + + {db.note} + -
-
- - + + + + — ORM export + + + Generate typed entities for the runtime you use. + + + + vespertide export --orm <target> + {' '} + emits up-to-date entities from your current models. + + + {ORMS.map((orm) => ( + + + {orm.lang} + + {orm.name} + + ))} + + + + + ) +} + +function ChannelRow({ + href, + icon, + name, + meta, +}: { + href: string + icon: 'github' | 'discord' | 'kakao' + name: string + meta: string +}) { + return ( + + + + + + + {name} + + - + + + ) +} + +function CommunitySection() { + return ( + + + + + - - - - Join our community - - - Join our Discord and help build the future of frontend with - CSS-in-JS!{' '} + + + — Get started + + + Install once.{' '} + + Iterate forever. - - - - - - - - - - - + + + Vespertide is open source under Apache-2.0 and built in public. + Join the community, file an issue, or pair with us in Discord. + + + + + - - - + + + + Star on GitHub + + - join us background image - - + + + + + +
+ + + + + Apache-2.0 · v{VERSION} ·{' '} + + crates.io + + + + built with Rust · maintained in Seoul + + - + + ) +} + +export default async function HomePage() { + const [heroHtml, modelHtml, cliHtml, runtimeHtml, exportHtml] = + await Promise.all([ + highlight(HERO_MODEL_JSON, 'json'), + highlight(EXAMPLE_MODEL, 'json'), + highlight(EXAMPLE_CLI, 'shell'), + highlight(EXAMPLE_RUNTIME, 'rust'), + highlight(EXAMPLE_EXPORT, 'shell'), + ]) + + const examples: CodeExampleQuad = [ + { key: 'model', label: 'Model', file: 'models/post.json', html: modelHtml }, + { key: 'cli', label: 'CLI', file: '~/projects/blog', html: cliHtml }, + { key: 'runtime', label: 'Runtime', file: 'src/main.rs', html: runtimeHtml }, + { + key: 'export', + label: 'ORM export', + file: '$ vespertide export', + html: exportHtml, + }, + ] + + return ( + + + + + + + + ) } diff --git a/crates/vespertide-cli/src/commands/erd/mod.rs b/crates/vespertide-cli/src/commands/erd/mod.rs index f44a409a..18e9dfc9 100644 --- a/crates/vespertide-cli/src/commands/erd/mod.rs +++ b/crates/vespertide-cli/src/commands/erd/mod.rs @@ -132,14 +132,12 @@ pub(super) fn filter_tables_with_warnings( } fn normalize_tables(tables: Vec) -> Result> { - tables - .into_iter() - .map(|table| { - table - .normalize() - .with_context(|| format!("normalize table '{}'", table.name)) - }) - .collect() + let mut normalized = Vec::with_capacity(tables.len()); + for table in tables { + let context = format!("normalize table '{}'", table.name); + normalized.push(table.normalize().context(context)?); + } + Ok(normalized) } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] diff --git a/crates/vespertide-cli/src/commands/erd/tests/mod.rs b/crates/vespertide-cli/src/commands/erd/tests/mod.rs index f44250b7..452e5924 100644 --- a/crates/vespertide-cli/src/commands/erd/tests/mod.rs +++ b/crates/vespertide-cli/src/commands/erd/tests/mod.rs @@ -1099,6 +1099,47 @@ fn foreign_key_column_groups_pushes_object_inline_fk_without_table_constraint() assert_eq!(groups, vec![expected_group]); } +/// Inline FK whose parent table is not in the schema is silently ignored. +/// Covers the `table_lookup.get(parent_table)?` `None` branch inside +/// `inline_foreign_key_relation` (the early-return path when the referenced +/// table is absent from the provided schema). +#[test] +fn inline_fk_to_absent_table_is_ignored() { + let article = table( + "article", + vec![ + primary_key("id", integer()), + foreign_key("author_id", "user.id"), + ], + ); + // "user" table is deliberately absent — FK reference cannot resolve. + assert!(collect_foreign_key_relations(&[article]).is_empty()); +} + +/// Mirrors `inline_fk_to_absent_table_is_ignored`, but for a table-level +/// `TableConstraint::ForeignKey` (the `let ... else { continue }` early-exit +/// path in `collect_foreign_key_relations` when the referenced table is +/// absent from the provided schema). +#[test] +fn table_level_fk_to_absent_table_is_ignored() { + let article = TableDef { + name: "article".into(), + description: None, + columns: vec![primary_key("id", integer()), column("author_id", integer())], + constraints: vec![TableConstraint::ForeignKey { + name: Some("fk_article__author_id".into()), + columns: vec!["author_id".into()], + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + orphan_strategy: Default::default(), + }], + }; + // "user" table is deliberately absent — FK reference cannot resolve. + assert!(collect_foreign_key_relations(&[article]).is_empty()); +} + #[test] fn foreign_key_column_groups_pushes_new_inline_group_after_table_constraint() { let tbl = TableDef { diff --git a/crates/vespertide-cli/src/commands/erd/tests/svg_coverage.rs b/crates/vespertide-cli/src/commands/erd/tests/svg_coverage.rs index 47cf2f61..fa200362 100644 --- a/crates/vespertide-cli/src/commands/erd/tests/svg_coverage.rs +++ b/crates/vespertide-cli/src/commands/erd/tests/svg_coverage.rs @@ -627,3 +627,23 @@ fn normalize_tables_preserves_tables_and_normalizes() { post.constraints ); } + +// `normalize_tables` propagates each table's `.normalize()` error via `?`, +// wrapped with a "normalize table ''" context. An inline FK reference +// missing the required "table.column" dot format fails normalize's FK +// parsing, exercising that error path. +#[test] +fn normalize_tables_propagates_normalize_error_with_context() { + let raw = vec![table( + "dup", + vec![ + primary_key("id", integer()), + foreign_key("owner_id", "noformat"), + ], + )]; + let err = normalize_tables(raw).expect_err("malformed inline FK reference must fail normalize"); + assert!( + format!("{err:#}").contains("normalize table 'dup'"), + "error must be wrapped with table context: {err:#}" + ); +} diff --git a/crates/vespertide-cli/src/commands/export/mod.rs b/crates/vespertide-cli/src/commands/export/mod.rs index cd85b9bb..5c42cd83 100644 --- a/crates/vespertide-cli/src/commands/export/mod.rs +++ b/crates/vespertide-cli/src/commands/export/mod.rs @@ -8,7 +8,8 @@ use tokio::fs; use vespertide_config::VespertideConfig; use vespertide_core::TableDef; use vespertide_exporter::{ - Orm, prisma, render_entity_with_schema, seaorm::SeaOrmExporterWithConfig, + Orm, django::DjangoExporterWithConfig, gorm::GormExporterWithConfig, prisma, + render_entity_with_schema, seaorm::SeaOrmExporterWithConfig, }; use vespertide_naming::{IdentifierStart, sanitize_identifier, seaorm_module_name}; @@ -64,14 +65,19 @@ pub async fn cmd_export(orm: Orm, export_dir: Option) -> Result<()> { // Derive crate:: prefix from export directory (e.g., "src/models" -> "crate::models") let crate_prefix = export_dir_to_crate_prefix(&target_root); - // Create SeaORM exporter with config if needed + // Create per-ORM exporters that honor their `vespertide.json` config section let seaorm_exporter = SeaOrmExporterWithConfig::new(config.seaorm(), config.prefix()); + let django_exporter = DjangoExporterWithConfig::new(config.django()); + let gorm_package_name = config.gorm_package_name(&target_root); + let gorm_exporter = GormExporterWithConfig::new(&gorm_package_name); let render_context = ExportRenderContext { target_root: &target_root, all_tables: &all_tables, module_paths: &module_paths, crate_prefix: &crate_prefix, seaorm_exporter: &seaorm_exporter, + django_exporter: &django_exporter, + gorm_exporter: &gorm_exporter, orm_kind: orm, }; @@ -133,6 +139,8 @@ struct ExportRenderContext<'a> { module_paths: &'a HashMap>, crate_prefix: &'a str, seaorm_exporter: &'a SeaOrmExporterWithConfig<'a>, + django_exporter: &'a DjangoExporterWithConfig<'a>, + gorm_exporter: &'a GormExporterWithConfig<'a>, orm_kind: Orm, } @@ -150,6 +158,14 @@ fn render_export_entity( context.crate_prefix, ) .map_err(|e| anyhow::anyhow!(e)), + Orm::Django => context + .django_exporter + .render_entity_with_schema(table, context.all_tables) + .map_err(|e| anyhow::anyhow!(e)), + Orm::Gorm => context + .gorm_exporter + .render_entity_with_schema(table, context.all_tables) + .map_err(|e| anyhow::anyhow!(e)), _ => render_entity_with_schema(context.orm_kind, table, context.all_tables) .map_err(|e| anyhow::anyhow!(e)), }?; diff --git a/crates/vespertide-cli/src/commands/export/tests/mod.rs b/crates/vespertide-cli/src/commands/export/tests/mod.rs index cdabb86c..4145ab14 100644 --- a/crates/vespertide-cli/src/commands/export/tests/mod.rs +++ b/crates/vespertide-cli/src/commands/export/tests/mod.rs @@ -602,3 +602,80 @@ fn build_output_path_jpa_strips_vespertide_suffix() { fn test_to_pascal_case(#[case] input: &str, #[case] expected: &str) { assert_eq!(to_pascal_case(input), expected); } + +#[test] +fn build_output_path_gorm_go_extension() { + let root = Path::new("src/models"); + let out = build_output_path(root, Path::new("user.json"), Orm::Gorm); + assert_eq!(out, Path::new("src/models/user.go")); +} + +#[tokio::test] +async fn clean_export_dir_removes_go_files_for_gorm() { + let tmp = tempdir().unwrap(); + let root = tmp.path().join("export_dir"); + std_fs::create_dir_all(&root).unwrap(); + + std_fs::write(root.join("model.go"), "// go file").unwrap(); + std_fs::write(root.join("keep.rs"), "// keep this").unwrap(); + + clean_export_dir(&root, Orm::Gorm).await.unwrap(); + + assert!(!root.join("model.go").exists()); + assert!(root.join("keep.rs").exists()); +} + +#[tokio::test] +async fn clean_export_dir_removes_py_files_for_django() { + let tmp = tempdir().unwrap(); + let root = tmp.path().join("export_dir"); + std_fs::create_dir_all(&root).unwrap(); + + // Create some .py files that should be cleaned + std_fs::write(root.join("old_model.py"), "# old python file").unwrap(); + // Create a .rs file that should NOT be cleaned + std_fs::write(root.join("keep.rs"), "// keep this").unwrap(); + + clean_export_dir(&root, Orm::Django).await.unwrap(); + + // .py files should be gone + assert!(!root.join("old_model.py").exists()); + // .rs file should remain + assert!(root.join("keep.rs").exists()); +} + +#[tokio::test] +#[serial] +async fn export_writes_gorm_files_with_go_extension() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + + let model = sample_table("widgets"); + write_model(Path::new("models/widgets.json"), &model); + + cmd_export(Orm::Gorm, None).await.unwrap(); + + let out = PathBuf::from("src/models/widgets.go"); + assert!(out.exists()); + let content = std_fs::read_to_string(out).unwrap(); + assert!(content.contains("widgets")); +} + +#[tokio::test] +#[serial] +async fn export_writes_django_files_with_py_extension() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + + let model = sample_table("gadgets"); + write_model(Path::new("models/gadgets.json"), &model); + + cmd_export(Orm::Django, None).await.unwrap(); + + let out = PathBuf::from("src/models/gadgets.py"); + assert!(out.exists()); + let content = std_fs::read_to_string(out).unwrap(); + assert!(content.contains("gadgets")); +} diff --git a/crates/vespertide-cli/tests/integration_test.rs b/crates/vespertide-cli/tests/integration_test.rs index 562d3d42..17eeb527 100644 --- a/crates/vespertide-cli/tests/integration_test.rs +++ b/crates/vespertide-cli/tests/integration_test.rs @@ -108,3 +108,76 @@ fn test_main_with_export_command() { cmd.args(["export", "--orm", "seaorm"]); let _ = cmd.assert(); } + +fn write_minimal_export_project(root: &std::path::Path) { + std::fs::write( + root.join("vespertide.json"), + r#"{ + "modelsDir": "models", + "migrationsDir": "migrations", + "tableNamingCase": "snake", + "columnNamingCase": "snake", + "modelFormat": "json", + "migrationFormat": "json", + "modelExportDir": "generated" +}"#, + ) + .expect("write config"); + + let models_dir = root.join("models"); + std::fs::create_dir(&models_dir).expect("create models dir"); + std::fs::write( + models_dir.join("users.json"), + r#"{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/model.schema.json", + "name": "users", + "columns": [ + { "name": "id", "type": "integer", "nullable": false, "primary_key": { "auto_increment": true } }, + { "name": "name", "type": { "kind": "varchar", "length": 100 }, "nullable": false } + ] +}"#, + ) + .expect("write model"); +} + +#[test] +fn test_export_django_writes_files_end_to_end() { + let temp_dir = tempfile::TempDir::new().expect("create temp dir"); + write_minimal_export_project(temp_dir.path()); + + vespertide() + .current_dir(temp_dir.path()) + .args(["export", "--orm", "django", "--export-dir", "generated"]) + .assert() + .success(); + + let output_file = temp_dir.path().join("generated").join("users.py"); + assert!( + output_file.exists(), + "expected {} to exist", + output_file.display() + ); + let content = std::fs::read_to_string(&output_file).expect("read generated file"); + assert!(content.contains("class Users(models.Model):")); +} + +#[test] +fn test_export_gorm_writes_files_end_to_end() { + let temp_dir = tempfile::TempDir::new().expect("create temp dir"); + write_minimal_export_project(temp_dir.path()); + + vespertide() + .current_dir(temp_dir.path()) + .args(["export", "--orm", "gorm", "--export-dir", "generated"]) + .assert() + .success(); + + let output_file = temp_dir.path().join("generated").join("users.go"); + assert!( + output_file.exists(), + "expected {} to exist", + output_file.display() + ); + let content = std::fs::read_to_string(&output_file).expect("read generated file"); + assert!(content.contains("type Users struct {")); +} diff --git a/crates/vespertide-config/Cargo.toml b/crates/vespertide-config/Cargo.toml index 4cd9b28a..54bda399 100644 --- a/crates/vespertide-config/Cargo.toml +++ b/crates/vespertide-config/Cargo.toml @@ -23,6 +23,7 @@ schema = ["dep:schemars"] [dev-dependencies] serde_json = "1" +rstest = "0.26" [lints] workspace = true diff --git a/crates/vespertide-config/src/config.rs b/crates/vespertide-config/src/config.rs index 126c28c3..a9138bfc 100644 --- a/crates/vespertide-config/src/config.rs +++ b/crates/vespertide-config/src/config.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; @@ -78,6 +79,105 @@ impl SeaOrmConfig { } } +/// Django-specific export configuration. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct DjangoConfig { + /// Explicit `app_label` written into every generated model's `Meta` + /// class. Needed when generated models don't live inside a standard + /// Django app package layout, where Django would otherwise infer the + /// label from the containing package. `None` (default) omits + /// `app_label` and leaves Django's normal inference in place. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub app_label: Option, +} + +impl DjangoConfig { + /// Explicit `app_label` to emit in every model's `Meta` class, if set. + pub fn app_label(&self) -> Option<&str> { + self.app_label.as_deref() + } +} + +/// GORM-specific export configuration. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct GormConfig { + /// Go package name emitted at the top of every generated file + /// (`package `). `None` (default) infers the name from the + /// export directory's final path segment (sanitized to a valid Go + /// identifier), falling back to `"models"` when that segment isn't + /// usable. See [`VespertideConfig::gorm_package_name`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub package_name: Option, +} + +impl GormConfig { + /// Explicit Go package name from config, if set. Prefer + /// [`VespertideConfig::gorm_package_name`] to resolve the effective + /// name (this accessor doesn't apply the folder-based inference). + pub fn package_name(&self) -> Option<&str> { + self.package_name.as_deref() + } +} + +/// Fallback Go package name used when neither an explicit `gorm.package_name` +/// nor a usable export directory name is available. +pub const DEFAULT_GORM_PACKAGE_NAME: &str = "models"; + +/// Go reserved words, which can't be used as a package name. +const GO_RESERVED_WORDS: &[&str] = &[ + "break", + "default", + "func", + "interface", + "select", + "case", + "defer", + "go", + "map", + "struct", + "chan", + "else", + "goto", + "package", + "switch", + "const", + "fallthrough", + "if", + "range", + "type", + "continue", + "for", + "import", + "return", + "var", +]; + +/// Sanitize a candidate string into a valid, idiomatic Go package identifier: +/// lowercase ASCII letters/digits only, must not start with a digit, must +/// not collide with a Go reserved word. Returns `None` when nothing usable +/// remains (e.g. an all-Unicode or empty candidate). +fn sanitize_go_package_name(candidate: &str) -> Option { + let cleaned: String = candidate + .chars() + .filter(char::is_ascii_alphanumeric) + .map(|c| c.to_ascii_lowercase()) + .collect(); + + if cleaned.is_empty() || cleaned.starts_with(|c: char| c.is_ascii_digit()) { + return None; + } + if GO_RESERVED_WORDS.contains(&cleaned.as_str()) { + return None; + } + Some(cleaned) +} + /// Top-level vespertide configuration. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] @@ -100,6 +200,12 @@ pub struct VespertideConfig { /// SeaORM-specific export configuration. #[serde(default)] pub seaorm: SeaOrmConfig, + /// Django-specific export configuration. + #[serde(default)] + pub django: DjangoConfig, + /// GORM-specific export configuration. + #[serde(default)] + pub gorm: GormConfig, /// Prefix to add to all table names (including migration version table). /// Default: "" (no prefix) #[serde(default)] @@ -138,6 +244,8 @@ impl Default for VespertideConfig { migration_filename_pattern: default_migration_filename_pattern(), model_export_dir: default_model_export_dir(), seaorm: SeaOrmConfig::default(), + django: DjangoConfig::default(), + gorm: GormConfig::default(), prefix: String::new(), lock_timeout_ms: None, statement_timeout_ms: None, @@ -191,6 +299,38 @@ impl VespertideConfig { &self.seaorm } + /// Django-specific export configuration. + pub fn django(&self) -> &DjangoConfig { + &self.django + } + + /// GORM-specific export configuration. + pub fn gorm(&self) -> &GormConfig { + &self.gorm + } + + /// Effective Go package name for GORM export: an explicit + /// `gorm.package_name` always wins; otherwise it's inferred from + /// `export_dir`'s final path segment (sanitized to a valid Go + /// identifier), falling back to [`DEFAULT_GORM_PACKAGE_NAME`] when that + /// segment isn't usable (e.g. empty, digit-led, or non-ASCII). + /// + /// `export_dir` is the *actual* directory the `.go` files will be + /// written to — normally `model_export_dir`, but callers must pass + /// whatever directory wins after resolving CLI overrides (e.g. + /// `vespertide export --export-dir `), since Go requires the + /// `package` declaration to match the directory the files live in. + pub fn gorm_package_name(&self, export_dir: &Path) -> Cow<'_, str> { + if let Some(name) = &self.gorm.package_name { + return Cow::Borrowed(name); + } + let inferred = export_dir + .file_name() + .and_then(|s| s.to_str()) + .and_then(sanitize_go_package_name); + Cow::Owned(inferred.unwrap_or_else(|| DEFAULT_GORM_PACKAGE_NAME.to_string())) + } + /// Prefix to add to all table names. pub fn prefix(&self) -> &str { &self.prefix diff --git a/crates/vespertide-config/src/lib.rs b/crates/vespertide-config/src/lib.rs index e8f6640d..b1539b58 100644 --- a/crates/vespertide-config/src/lib.rs +++ b/crates/vespertide-config/src/lib.rs @@ -7,7 +7,10 @@ pub mod config; pub mod file_format; pub mod name_case; -pub use config::{SeaOrmConfig, VespertideConfig, default_migration_filename_pattern}; +pub use config::{ + DEFAULT_GORM_PACKAGE_NAME, DjangoConfig, GormConfig, SeaOrmConfig, VespertideConfig, + default_migration_filename_pattern, +}; pub use file_format::FileFormat; pub use name_case::NameCase; @@ -15,6 +18,8 @@ pub use name_case::NameCase; mod tests { use std::path::{Path, PathBuf}; + use rstest::rstest; + use super::*; #[test] @@ -100,4 +105,137 @@ mod tests { let cfg: VespertideConfig = serde_json::from_str(json).unwrap(); assert_eq!(cfg.seaorm().extra_enum_derives(), &["MyDerive"]); } + + #[test] + fn django_config_default_has_no_app_label() { + let cfg = DjangoConfig::default(); + assert_eq!(cfg.app_label(), None); + } + + #[test] + fn django_config_accessor() { + let cfg = DjangoConfig { + app_label: Some("myapp".to_string()), + }; + assert_eq!(cfg.app_label(), Some("myapp")); + } + + #[test] + fn django_config_deserialize_with_defaults() { + let json = r"{}"; + let cfg: DjangoConfig = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.app_label(), None); + } + + #[test] + fn django_config_deserialize_with_app_label() { + let json = r#"{"appLabel": "myapp"}"#; + let cfg: DjangoConfig = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.app_label(), Some("myapp")); + } + + #[test] + fn django_config_app_label_absent_from_json_when_none() { + let cfg = DjangoConfig::default(); + let json = serde_json::to_string(&cfg).unwrap(); + assert!( + !json.contains("appLabel"), + "None app_label must not serialize: {json}" + ); + } + + #[test] + fn gorm_config_default_package_name_is_none() { + let cfg = GormConfig::default(); + assert_eq!(cfg.package_name(), None); + } + + #[test] + fn gorm_config_accessor() { + let cfg = GormConfig { + package_name: Some("entities".to_string()), + }; + assert_eq!(cfg.package_name(), Some("entities")); + } + + #[test] + fn gorm_config_deserialize_with_defaults() { + let json = r"{}"; + let cfg: GormConfig = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.package_name(), None); + } + + #[test] + fn gorm_config_deserialize_with_custom_package_name() { + let json = r#"{"packageName": "entities"}"#; + let cfg: GormConfig = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.package_name(), Some("entities")); + } + + #[test] + fn vespertide_config_django_and_gorm_accessors() { + let cfg = VespertideConfig::default(); + assert_eq!(cfg.django().app_label(), None); + assert_eq!(cfg.gorm().package_name(), None); + // model_export_dir defaults to "src/models", so the inferred name matches + // the pre-existing fixed default. + assert_eq!(cfg.gorm_package_name(cfg.model_export_dir()), "models"); + } + + #[test] + fn vespertide_config_deserialize_with_django_and_gorm() { + let json = r#"{ + "modelsDir": "models", + "migrationsDir": "migrations", + "tableNamingCase": "snake", + "columnNamingCase": "snake", + "django": { + "appLabel": "myapp" + }, + "gorm": { + "packageName": "entities" + } + }"#; + let cfg: VespertideConfig = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.django().app_label(), Some("myapp")); + assert_eq!(cfg.gorm().package_name(), Some("entities")); + assert_eq!(cfg.gorm_package_name(cfg.model_export_dir()), "entities"); + } + + #[rstest] + #[case::default_dir_matches_folder("src/models", "models")] + #[case::infers_from_folder_name("src/entities", "entities")] + #[case::strips_invalid_chars("src/db-models", "dbmodels")] + #[case::falls_back_when_digit_led("src/2024-models", "models")] + #[case::falls_back_on_non_ascii("src/모델", "models")] + #[case::falls_back_on_reserved_word("src/type", "models")] + fn gorm_package_name_inferred_from_export_dir( + #[case] export_dir: &str, + #[case] expected: &str, + ) { + let cfg = VespertideConfig::default(); + assert_eq!(cfg.gorm_package_name(Path::new(export_dir)), expected); + } + + #[test] + fn gorm_package_name_explicit_override_wins_over_inference() { + let cfg = VespertideConfig { + gorm: GormConfig { + package_name: Some("custom".to_string()), + }, + ..Default::default() + }; + assert_eq!(cfg.gorm_package_name(Path::new("src/entities")), "custom"); + } + + #[test] + fn gorm_package_name_tracks_cli_export_dir_override_not_config_default() { + // The `--export-dir` CLI flag can point somewhere other than + // `model_export_dir`; the inferred package name must follow the + // actual write target, since Go requires `package` to match the + // directory the files live in. + let cfg = VespertideConfig::default(); + assert_eq!(cfg.model_export_dir(), Path::new("src/models")); + assert_eq!(cfg.gorm_package_name(Path::new("generated")), "generated"); + } } diff --git a/crates/vespertide-core/src/arbitrary/mod.rs b/crates/vespertide-core/src/arbitrary/mod.rs index 23fc6d44..73e9aa15 100644 --- a/crates/vespertide-core/src/arbitrary/mod.rs +++ b/crates/vespertide-core/src/arbitrary/mod.rs @@ -466,13 +466,173 @@ fn names_are_unique<'a>(names: impl Iterator) -> bool { fn arb_default_string() -> impl Strategy { prop_oneof![ - arb_safe_ident(), + // Bare identifier: filter PostgreSQL reserved words that cause parse errors + // when used as an unquoted DEFAULT expression (e.g. `DEFAULT in` is invalid). + arb_safe_ident().prop_filter("avoid pg reserved keyword as bare default", |s| { + !is_pg_reserved_keyword(s) + }), arb_safe_ident().prop_map(|ident| format!("'{ident}'")), Just("NOW()".to_string()), Just("CURRENT_TIMESTAMP".to_string()), ] } +/// PostgreSQL 17 reserved keywords (§C.1 Type A) that match `[a-z][a-z0-9_]*`. +/// A bare word from this list in a DEFAULT clause causes a parse error. +fn is_pg_reserved_keyword(word: &str) -> bool { + matches!( + word, + "all" + | "analyse" + | "analyze" + | "and" + | "any" + | "array" + | "as" + | "asc" + | "asymmetric" + | "authorization" + | "between" + | "bigint" + | "binary" + | "bit" + | "boolean" + | "both" + | "case" + | "cast" + | "char" + | "character" + | "check" + | "coalesce" + | "collate" + | "collation" + | "column" + | "concurrently" + | "constraint" + | "create" + | "cross" + | "current_catalog" + | "current_date" + | "current_role" + | "current_schema" + | "current_time" + | "current_timestamp" + | "current_user" + | "dec" + | "decimal" + | "default" + | "deferrable" + | "desc" + | "distinct" + | "do" + | "else" + | "end" + | "except" + | "exists" + | "extract" + | "false" + | "fetch" + | "float" + | "for" + | "foreign" + | "freeze" + | "from" + | "full" + | "grant" + | "group" + | "grouping" + | "having" + | "ilike" + | "in" + | "initially" + | "inner" + | "inout" + | "int" + | "integer" + | "intersect" + | "interval" + | "into" + | "is" + | "isnull" + | "join" + | "json" + | "lateral" + | "leading" + | "left" + | "like" + | "limit" + | "localtime" + | "localtimestamp" + | "national" + | "natural" + | "nchar" + | "none" + | "normalize" + | "not" + | "notnull" + | "null" + | "nullif" + | "numeric" + | "offset" + | "on" + | "only" + | "open" + | "or" + | "order" + | "out" + | "outer" + | "overlaps" + | "overlay" + | "placing" + | "position" + | "precision" + | "primary" + | "real" + | "references" + | "returning" + | "right" + | "row" + | "select" + | "session_user" + | "similar" + | "smallint" + | "some" + | "substring" + | "symmetric" + | "system_user" + | "table" + | "tablesample" + | "then" + | "time" + | "timestamp" + | "to" + | "trailing" + | "treat" + | "trim" + | "true" + | "union" + | "unique" + | "user" + | "using" + | "values" + | "varchar" + | "variadic" + | "verbose" + | "when" + | "where" + | "window" + | "with" + | "xmlexists" + | "xmlforest" + | "xmlnamespaces" + | "xmlparse" + | "xmlpi" + | "xmlroot" + | "xmlserialize" + | "xmltable" + ) +} + fn arb_comment() -> impl Strategy { collection::vec(prop::char::range('a', 'z'), 0..=80) .prop_map(|chars| chars.into_iter().collect()) diff --git a/crates/vespertide-core/src/lib.rs b/crates/vespertide-core/src/lib.rs index d0818a42..e9d73c28 100644 --- a/crates/vespertide-core/src/lib.rs +++ b/crates/vespertide-core/src/lib.rs @@ -15,6 +15,7 @@ pub use migration::{MigrationError, MigrationOptions}; pub use schema::{ CheckViolationStrategy, ColumnDef, ColumnName, ColumnType, ComplexColumnType, ConstraintKind, DefaultValue, EnumValues, ForeignKeyOrphanStrategy, IndexDef, IndexName, KeepPolicy, NumValue, - PrimaryKeyAdditionStrategy, ReferenceAction, SimpleColumnType, StrOrBoolOrArray, StringOrBool, - TableConstraint, TableDef, TableName, TableValidationError, UniqueConstraintStrategy, + PrimaryKeyAdditionStrategy, ReferenceAction, ReferenceActionKind, SimpleColumnKind, + SimpleColumnType, StrOrBoolOrArray, StringOrBool, TableConstraint, TableDef, TableName, + TableValidationError, UniqueConstraintStrategy, }; diff --git a/crates/vespertide-core/src/schema/column.rs b/crates/vespertide-core/src/schema/column.rs index 2e891967..7301c773 100644 --- a/crates/vespertide-core/src/schema/column.rs +++ b/crates/vespertide-core/src/schema/column.rs @@ -392,6 +392,68 @@ impl SimpleColumnType { } } +/// Closed, exhaustive mirror of [`SimpleColumnType`] for downstream crates that need to +/// `match` on it without a wildcard arm. +/// +/// [`SimpleColumnType`] is `#[non_exhaustive]`, so every match on it made *outside* +/// `vespertide-core` must carry a `_` arm even when every current variant is already +/// handled — that arm is genuinely unreachable and shows up as a permanent 0-hit line +/// under coverage instrumentation. This type is deliberately **not** `#[non_exhaustive]`: +/// the conversion below is written inside the crate that owns `SimpleColumnType`, where +/// the non-exhaustiveness restriction doesn't apply, so it can be matched exhaustively +/// with no wildcard, here and in every downstream crate. If `SimpleColumnType` ever gains +/// a variant, `From for SimpleColumnKind` below fails to compile until +/// a matching variant is added here — a compile-time forcing function that replaces the +/// old pattern of a runtime `unreachable!()` guard that only a test could catch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SimpleColumnKind { + SmallInt, + Integer, + BigInt, + Real, + DoublePrecision, + Text, + Boolean, + Date, + Time, + Timestamp, + Timestamptz, + Interval, + Bytea, + Uuid, + Json, + Inet, + Cidr, + Macaddr, + Xml, +} + +impl From for SimpleColumnKind { + fn from(ty: SimpleColumnType) -> Self { + match ty { + SimpleColumnType::SmallInt => Self::SmallInt, + SimpleColumnType::Integer => Self::Integer, + SimpleColumnType::BigInt => Self::BigInt, + SimpleColumnType::Real => Self::Real, + SimpleColumnType::DoublePrecision => Self::DoublePrecision, + SimpleColumnType::Text => Self::Text, + SimpleColumnType::Boolean => Self::Boolean, + SimpleColumnType::Date => Self::Date, + SimpleColumnType::Time => Self::Time, + SimpleColumnType::Timestamp => Self::Timestamp, + SimpleColumnType::Timestamptz => Self::Timestamptz, + SimpleColumnType::Interval => Self::Interval, + SimpleColumnType::Bytea => Self::Bytea, + SimpleColumnType::Uuid => Self::Uuid, + SimpleColumnType::Json => Self::Json, + SimpleColumnType::Inet => Self::Inet, + SimpleColumnType::Cidr => Self::Cidr, + SimpleColumnType::Macaddr => Self::Macaddr, + SimpleColumnType::Xml => Self::Xml, + } + } +} + /// A single variant of an integer-backed enum, pairing a Rust-friendly name with its stored value. /// /// Used inside [`EnumValues::Integer`] to define enums that are stored as `INTEGER` in the diff --git a/crates/vespertide-core/src/schema/mod.rs b/crates/vespertide-core/src/schema/mod.rs index f2db4668..e7f32d97 100644 --- a/crates/vespertide-core/src/schema/mod.rs +++ b/crates/vespertide-core/src/schema/mod.rs @@ -14,7 +14,8 @@ pub mod unique_strategy; pub use check_violation_strategy::CheckViolationStrategy; pub use column::{ - ColumnDef, ColumnType, ComplexColumnType, EnumValues, NumValue, SimpleColumnType, + ColumnDef, ColumnType, ComplexColumnType, EnumValues, NumValue, SimpleColumnKind, + SimpleColumnType, }; pub use constraint::{ConstraintKind, TableConstraint}; pub use fk_orphan_strategy::ForeignKeyOrphanStrategy; @@ -22,7 +23,7 @@ pub use index::IndexDef; pub use names::{ColumnName, IndexName, TableName}; pub use pk_addition_strategy::PrimaryKeyAdditionStrategy; pub use primary_key::PrimaryKeyDef; -pub use reference::ReferenceAction; +pub use reference::{ReferenceAction, ReferenceActionKind}; pub use str_or_bool::{DefaultValue, StrOrBoolOrArray, StringOrBool}; pub use table::{TableDef, TableValidationError}; pub use unique_strategy::{KeepPolicy, UniqueConstraintStrategy}; @@ -795,5 +796,32 @@ mod tests { }); assert_eq!(ty.enum_variant_names(), Some(vec![])); } + + #[rstest] + #[case(SimpleColumnType::SmallInt, SimpleColumnKind::SmallInt)] + #[case(SimpleColumnType::Integer, SimpleColumnKind::Integer)] + #[case(SimpleColumnType::BigInt, SimpleColumnKind::BigInt)] + #[case(SimpleColumnType::Real, SimpleColumnKind::Real)] + #[case(SimpleColumnType::DoublePrecision, SimpleColumnKind::DoublePrecision)] + #[case(SimpleColumnType::Text, SimpleColumnKind::Text)] + #[case(SimpleColumnType::Boolean, SimpleColumnKind::Boolean)] + #[case(SimpleColumnType::Date, SimpleColumnKind::Date)] + #[case(SimpleColumnType::Time, SimpleColumnKind::Time)] + #[case(SimpleColumnType::Timestamp, SimpleColumnKind::Timestamp)] + #[case(SimpleColumnType::Timestamptz, SimpleColumnKind::Timestamptz)] + #[case(SimpleColumnType::Interval, SimpleColumnKind::Interval)] + #[case(SimpleColumnType::Bytea, SimpleColumnKind::Bytea)] + #[case(SimpleColumnType::Uuid, SimpleColumnKind::Uuid)] + #[case(SimpleColumnType::Json, SimpleColumnKind::Json)] + #[case(SimpleColumnType::Inet, SimpleColumnKind::Inet)] + #[case(SimpleColumnType::Cidr, SimpleColumnKind::Cidr)] + #[case(SimpleColumnType::Macaddr, SimpleColumnKind::Macaddr)] + #[case(SimpleColumnType::Xml, SimpleColumnKind::Xml)] + fn test_simple_column_kind_from_matches_variant( + #[case] ty: SimpleColumnType, + #[case] expected: SimpleColumnKind, + ) { + assert_eq!(SimpleColumnKind::from(ty), expected); + } } } diff --git a/crates/vespertide-core/src/schema/reference.rs b/crates/vespertide-core/src/schema/reference.rs index d4d246ef..3ddaa551 100644 --- a/crates/vespertide-core/src/schema/reference.rs +++ b/crates/vespertide-core/src/schema/reference.rs @@ -44,6 +44,41 @@ impl ReferenceAction { } } +/// Closed, exhaustive mirror of [`ReferenceAction`] for downstream crates that need to +/// `match` on it without a wildcard arm. +/// +/// [`ReferenceAction`] is `#[non_exhaustive]`, so every match on it made *outside* +/// `vespertide-core` must carry a `_` arm even when every current variant is already +/// handled — that arm is genuinely unreachable and shows up as a permanent 0-hit line +/// under coverage instrumentation. This type is deliberately **not** `#[non_exhaustive]`: +/// the conversion below is written inside the crate that owns `ReferenceAction`, where +/// the non-exhaustiveness restriction doesn't apply, so it can be matched exhaustively +/// with no wildcard, here and in every downstream crate. If `ReferenceAction` ever gains +/// a variant, `From<&ReferenceAction> for ReferenceActionKind` below fails to compile +/// until a matching variant is added here — a compile-time forcing function that +/// replaces the old pattern of a runtime `unreachable!()` guard that only a test could +/// catch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReferenceActionKind { + Cascade, + Restrict, + SetNull, + SetDefault, + NoAction, +} + +impl From<&ReferenceAction> for ReferenceActionKind { + fn from(action: &ReferenceAction) -> Self { + match action { + ReferenceAction::Cascade => Self::Cascade, + ReferenceAction::Restrict => Self::Restrict, + ReferenceAction::SetNull => Self::SetNull, + ReferenceAction::SetDefault => Self::SetDefault, + ReferenceAction::NoAction => Self::NoAction, + } + } +} + #[cfg(test)] mod tests { //! Coverage-closure tests for `ReferenceAction::to_sql_keyword`. @@ -67,4 +102,17 @@ mod tests { // lines 40, 41, 42. assert_eq!(action.to_sql_keyword(), expected); } + + #[rstest] + #[case::cascade(ReferenceAction::Cascade, ReferenceActionKind::Cascade)] + #[case::restrict(ReferenceAction::Restrict, ReferenceActionKind::Restrict)] + #[case::set_null(ReferenceAction::SetNull, ReferenceActionKind::SetNull)] + #[case::set_default(ReferenceAction::SetDefault, ReferenceActionKind::SetDefault)] + #[case::no_action(ReferenceAction::NoAction, ReferenceActionKind::NoAction)] + fn reference_action_kind_from_matches_variant( + #[case] action: ReferenceAction, + #[case] expected: ReferenceActionKind, + ) { + assert_eq!(ReferenceActionKind::from(&action), expected); + } } diff --git a/crates/vespertide-exporter/AGENTS.md b/crates/vespertide-exporter/AGENTS.md index 1a9e82ba..b8d58dfb 100644 --- a/crates/vespertide-exporter/AGENTS.md +++ b/crates/vespertide-exporter/AGENTS.md @@ -1,6 +1,7 @@ # vespertide-exporter -ORM code generation from `TableDef` schemas → SeaORM (Rust), SQLAlchemy (Python), SQLModel (Python). +ORM code generation from `TableDef` schemas → SeaORM (Rust), SQLAlchemy (Python), SQLModel (Python), +JPA (Java), GORM (Go), Django (Python). ## STRUCTURE @@ -8,10 +9,15 @@ ORM code generation from `TableDef` schemas → SeaORM (Rust), SQLAlchemy (Pytho src/ ├── lib.rs # Re-exports all backends ├── orm.rs # OrmExporter trait, Orm enum, dispatch functions -├── seaorm/mod.rs # 4122 lines, scheduled for split - Entity/Model/Relation generation -├── sqlalchemy/mod.rs # 1383 lines, scheduled for split - declarative_base models -└── sqlmodel/mod.rs # 1274 lines, scheduled for split - SQLModel + Pydantic models -snapshots/ # insta snapshot files for testing +├── seaorm/mod.rs # scheduled for split - Entity/Model/Relation generation +├── sqlalchemy/mod.rs # scheduled for split - declarative_base models +├── sqlmodel/mod.rs # scheduled for split - SQLModel + Pydantic models +├── jpa/mod.rs # JPA entity generation +├── gorm/mod.rs # GORM struct + gorm-tag generation; tests/ holds its own snapshot suite +├── django/mod.rs # Django models.Model generation; snapshots/ holds its own snapshot suite +├── utils/python.rs # Shared Python-target helpers (e.g. collect_composite_fks — used by +│ # sqlalchemy AND django, since both target Python) +└── tests/ # Shared cross-ORM `orm_cases!` fixtures + snapshots/ (all six ORMs) ``` ## WHERE TO LOOK @@ -45,6 +51,41 @@ snapshots/ # insta snapshot files for testing - Lighter import tracking (no `sa_types` - uses native Python types) - `sa_column_kwargs` for SQLAlchemy-specific options +### GORM (Go) +- **Forward FK**: single-column FK → belongs-to struct field with a `gorm:"foreignKey:..."` tag; + composite (multi-column) FK → single relation field via comma-separated + `foreignKey:Col1,Col2;references:RefCol1,RefCol2` (a real GORM feature, unlike Django below) +- **Reverse (has-many)**: `find_reverse_relations()` scans the full schema for FKs pointing back at + the table, including **self-referencing FKs** (a table referencing itself, e.g. + `categories.parent_id -> categories.id`) — the self-ref case is named `Children` rather than a + pluralized table name to avoid colliding with the struct's own name +- **No M2M/junction detection**: a junction table (composite-PK, 2+ FKs) is rendered as a plain + has-many to the junction struct itself, not a dedicated M2M relation — same limitation Django had + before this was added there; not yet closed for GORM +- **Config**: `GormExporterWithConfig` takes the *resolved* package name (a `&str`), not a `GormConfig` — callers get it from `VespertideConfig::gorm_package_name(export_dir)`, which uses an explicit `gorm.package_name` if set, otherwise infers one from the actual export directory's final path segment (sanitized to a valid Go identifier), falling back to `"models"`. The CLI passes the real write target (`--export-dir` override or `model_export_dir`), not the config's static default, since Go requires `package` to match the directory the files live in. +- **Tests**: own snapshot suite under `gorm/tests/` (not the shared `orm_cases!` fixtures until the + Django/GORM cross-ORM wiring pass), split into `tests/mod.rs` (type mapping, snapshots) and + `tests/relations.rs` (composite-FK + self-ref regression tests) + +### Django (Python) +- Renders `models.Model` classes with a `class Meta` (`db_table`, `indexes`, `constraints`) +- **M2M junction detection**: `find_many_to_many_fields()` recognizes composite-PK, 2+ FK junction + tables and emits `ManyToManyField(..., through=..., related_name="+")` on both sides; purely + self-referential junctions are skipped rather than guessed at +- **Composite (multi-column) FK**: Django has no native multi-column FK field, so + `collect_composite_fks` (from `utils/python.rs`, shared with SQLAlchemy) is used to emit a + `# composite foreign key: (...) -> ref_table(...)` comment instead of silently dropping the + relationship +- **`build_default()`**: only emits a bare (unquoted) SQL default when it parses as a numeric + literal — an unrecognized bare constant (e.g. a named SQL constant) is omitted rather than + emitted as an undefined Python name +- **PK kwarg**: `primary_key=True` is always emitted for the (non-composite) PK column, regardless + of field type — `models.AutoField`/`SmallAutoField`/`BigAutoField` do **not** imply + `primary_key=True` in real Django; omitting it fails Django's own `fields.E100` system check +- **Config**: `DjangoExporterWithConfig` for `app_label` (omitted from `Meta` when unset) +- **Tests**: own snapshot suite under `django/snapshots/` (via inline `#[cfg(test)] mod tests` in + `django/mod.rs`) + ## TESTING ```bash @@ -58,7 +99,11 @@ cargo insta accept - Snapshot testing with `insta` crate (YAML format) - `rstest` for parameterized tests across all ORM backends -- 66 snapshot files across exporter snapshot directories +- Shared cross-ORM suite (`src/tests/snapshots/`, driven by the `orm_cases!` macro in + `src/tests/mod.rs`): 362 snapshots across all six ORMs. Plus module-local suites for backend + behavior that doesn't fit the shared fixture shape: `gorm/tests/snapshots/`, + `django/snapshots/`, and per-backend snapshots under `seaorm/`, `sqlalchemy/`, `sqlmodel/`, + `jpa/`. ## NOTES diff --git a/crates/vespertide-exporter/src/django/enums.rs b/crates/vespertide-exporter/src/django/enums.rs new file mode 100644 index 00000000..85dae123 --- /dev/null +++ b/crates/vespertide-exporter/src/django/enums.rs @@ -0,0 +1,25 @@ +use vespertide_core::schema::column::EnumValues; + +use super::render::to_upper_snake_case; + +pub(super) fn render_enum(lines: &mut Vec, class_name: &str, values: &EnumValues) { + match values { + EnumValues::String(vals) => { + lines.push(format!("class {class_name}(models.TextChoices):")); + for val in vals { + let const_name = to_upper_snake_case(val); + lines.push(format!(" {const_name} = \"{val}\", \"{val}\"")); + } + } + EnumValues::Integer(vals) => { + lines.push(format!("class {class_name}(models.IntegerChoices):")); + for val in vals { + let const_name = to_upper_snake_case(&val.name); + lines.push(format!( + " {const_name} = {}, \"{}\"", + val.value, val.name + )); + } + } + } +} diff --git a/crates/vespertide-exporter/src/django/mod.rs b/crates/vespertide-exporter/src/django/mod.rs new file mode 100644 index 00000000..5174f2d6 --- /dev/null +++ b/crates/vespertide-exporter/src/django/mod.rs @@ -0,0 +1,892 @@ +mod enums; +mod render; +mod types; + +use crate::orm::OrmExporter; +use vespertide_config::DjangoConfig; +use vespertide_core::TableDef; + +pub use render::{ + export, export_with_config, render_entity, render_entity_with_schema, + render_entity_with_schema_and_config, +}; + +pub struct DjangoExporter; + +impl OrmExporter for DjangoExporter { + fn render_entity(&self, table: &TableDef) -> Result { + render_entity(table) + } + + fn render_entity_with_schema( + &self, + table: &TableDef, + schema: &[TableDef], + ) -> Result { + render_entity_with_schema(table, schema) + } +} + +/// Django exporter that honors `vespertide.json`'s `django` config section +/// (currently an optional `app_label` written into every model's `Meta` +/// class). Mirrors `seaorm::SeaOrmExporterWithConfig`. +pub struct DjangoExporterWithConfig<'a> { + pub config: &'a DjangoConfig, +} + +impl<'a> DjangoExporterWithConfig<'a> { + pub fn new(config: &'a DjangoConfig) -> Self { + Self { config } + } + + pub fn render_entity_with_schema( + &self, + table: &TableDef, + schema: &[TableDef], + ) -> Result { + render_entity_with_schema_and_config(table, schema, self.config.app_label()) + } +} + +#[cfg(test)] +pub(crate) fn to_pascal_case_for_tests(s: &str) -> String { + render::to_pascal_case(s) +} + +#[cfg(test)] +mod tests { + use super::*; + use insta::assert_snapshot; + use rstest::rstest; + use vespertide_core::schema::column::{EnumValues, SimpleColumnType}; + use vespertide_core::schema::constraint::TableConstraint; + use vespertide_core::{ + ColumnType, ComplexColumnType, DefaultValue, NumValue, ReferenceAction, TableDef, + }; + + fn col(name: &str, ty: ColumnType) -> vespertide_core::ColumnDef { + vespertide_core::ColumnDef::new(name, ty, false) + } + + fn nullable_col(name: &str, ty: ColumnType) -> vespertide_core::ColumnDef { + vespertide_core::ColumnDef::new(name, ty, true) + } + + fn auto_pk(columns: &[&str]) -> TableConstraint { + TableConstraint::PrimaryKey { + auto_increment: true, + columns: columns.iter().copied().map(Into::into).collect(), + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + } + } + + fn pk(columns: &[&str]) -> TableConstraint { + TableConstraint::PrimaryKey { + auto_increment: false, + columns: columns.iter().copied().map(Into::into).collect(), + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + } + } + + fn fk(col: &str, ref_table: &str, on_delete: Option) -> TableConstraint { + TableConstraint::ForeignKey { + name: None, + columns: vec![col.into()], + ref_table: ref_table.into(), + ref_columns: vec!["id".into()], + on_delete, + on_update: None, + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + } + } + + // ----------------------------------------------------------------------- + // Basic table with autoincrement PK + nullable field + // ----------------------------------------------------------------------- + + #[test] + fn test_basic_table() { + let table = TableDef { + name: "users".into(), + description: Some("User accounts".into()), + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "email", + ColumnType::Complex(ComplexColumnType::Varchar { length: 255 }), + ), + nullable_col("name", ColumnType::Simple(SimpleColumnType::Text)), + ], + constraints: vec![ + auto_pk(&["id"]), + TableConstraint::Unique { + name: None, + columns: vec!["email".into()], + strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates { + keep: vespertide_core::KeepPolicy::First, + }, + }, + ], + }; + assert_snapshot!(render_entity(&table).unwrap()); + } + + // ----------------------------------------------------------------------- + // FK field: `_id` suffix stripping + // ----------------------------------------------------------------------- + + #[test] + fn test_table_with_fk() { + let table = TableDef { + name: "posts".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("author_id", ColumnType::Simple(SimpleColumnType::Integer)), + col("title", ColumnType::Simple(SimpleColumnType::Text)), + ], + constraints: vec![ + auto_pk(&["id"]), + fk("author_id", "users", Some(ReferenceAction::Cascade)), + ], + }; + assert_snapshot!(render_entity(&table).unwrap()); + } + + // ----------------------------------------------------------------------- + // TextChoices enum + // ----------------------------------------------------------------------- + + #[test] + fn test_table_with_string_enum() { + let table = TableDef { + name: "orders".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), { + let mut c = col( + "status", + ColumnType::Complex(ComplexColumnType::Enum { + name: "order_status".into(), + values: EnumValues::String(vec![ + "pending".into(), + "shipped".into(), + "delivered".into(), + ]), + }), + ); + c.default = Some(DefaultValue::String("'pending'".into())); + c + }], + constraints: vec![auto_pk(&["id"])], + }; + assert_snapshot!(render_entity(&table).unwrap()); + } + + // ----------------------------------------------------------------------- + // IntegerChoices enum + // ----------------------------------------------------------------------- + + #[test] + fn test_table_with_integer_enum() { + let table = TableDef { + name: "tasks".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "priority", + ColumnType::Complex(ComplexColumnType::Enum { + name: "priority_level".into(), + values: EnumValues::Integer(vec![ + NumValue { + name: "low".into(), + value: 0, + }, + NumValue { + name: "medium".into(), + value: 10, + }, + NumValue { + name: "high".into(), + value: 20, + }, + ]), + }), + ), + ], + constraints: vec![pk(&["id"])], + }; + assert_snapshot!(render_entity(&table).unwrap()); + } + + // ----------------------------------------------------------------------- + // Composite PK + // ----------------------------------------------------------------------- + + #[test] + fn test_composite_pk() { + let table = TableDef { + name: "order_items".into(), + description: None, + columns: vec![ + col("order_id", ColumnType::Simple(SimpleColumnType::Integer)), + col("product_id", ColumnType::Simple(SimpleColumnType::Integer)), + col("quantity", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![pk(&["order_id", "product_id"])], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains("pk = models.CompositePrimaryKey(\"order_id\", \"product_id\")"), + "expected Django 5.2+ CompositePrimaryKey declaration, got:\n{result}" + ); + assert!( + !result.contains("primary_key=True"), + "individual composite-PK columns must not also carry primary_key=True, got:\n{result}" + ); + assert_snapshot!(result); + } + + #[test] + fn test_composite_pk_of_fk_columns_uses_attname_not_field_name() { + // Composite PK made of FK columns: CompositePrimaryKey must reference + // the Django attname ("{field}_id"), not the stripped field name + // ("article"/"user") used for the ForeignKey attribute itself. + let table = TableDef { + name: "article_user".into(), + description: None, + columns: vec![ + col("article_id", ColumnType::Simple(SimpleColumnType::Integer)), + col("user_id", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![ + pk(&["article_id", "user_id"]), + fk("article_id", "articles", Some(ReferenceAction::Cascade)), + fk("user_id", "users", Some(ReferenceAction::Cascade)), + ], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains("pk = models.CompositePrimaryKey(\"article_id\", \"user_id\")"), + "expected attname-based CompositePrimaryKey args, got:\n{result}" + ); + } + + // ----------------------------------------------------------------------- + // Indexes and composite unique in Meta + // ----------------------------------------------------------------------- + + #[test] + fn test_indexes_and_composite_unique() { + let table = TableDef { + name: "articles".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "slug", + ColumnType::Complex(ComplexColumnType::Varchar { length: 200 }), + ), + col("author_id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "created_at", + ColumnType::Simple(SimpleColumnType::Timestamptz), + ), + ], + constraints: vec![ + auto_pk(&["id"]), + TableConstraint::Index { + name: Some("ix_articles__created_at".into()), + columns: vec!["created_at".into()], + }, + TableConstraint::Unique { + name: Some("uq_articles__slug_author".into()), + columns: vec!["slug".into(), "author_id".into()], + strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates { + keep: vespertide_core::KeepPolicy::First, + }, + }, + ], + }; + assert_snapshot!(render_entity(&table).unwrap()); + } + + // ----------------------------------------------------------------------- + // server default (NOW()) → timezone.now + // ----------------------------------------------------------------------- + + #[test] + fn test_server_default_timezone() { + let table = TableDef { + name: "events".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + { + let mut c = col( + "created_at", + ColumnType::Simple(SimpleColumnType::Timestamptz), + ); + c.default = Some(DefaultValue::String("NOW()".into())); + c + }, + { + let mut c = col("count", ColumnType::Simple(SimpleColumnType::Integer)); + c.default = Some(DefaultValue::Integer(0)); + c + }, + ], + constraints: vec![auto_pk(&["id"])], + }; + let result = render_entity(&table).unwrap(); + assert!(result.contains("from django.utils import timezone")); + assert!(result.contains("default=timezone.now")); + assert!(result.contains("default=0")); + assert_snapshot!(result); + } + + // ----------------------------------------------------------------------- + // Type coverage — all simple types + // ----------------------------------------------------------------------- + + #[rstest] + #[case::small_int(SimpleColumnType::SmallInt, "models.SmallIntegerField")] + #[case::bigint(SimpleColumnType::BigInt, "models.BigIntegerField")] + #[case::real(SimpleColumnType::Real, "models.FloatField")] + #[case::text(SimpleColumnType::Text, "models.TextField")] + #[case::boolean(SimpleColumnType::Boolean, "models.BooleanField")] + #[case::date(SimpleColumnType::Date, "models.DateField")] + #[case::time(SimpleColumnType::Time, "models.TimeField")] + #[case::timestamp(SimpleColumnType::Timestamp, "models.DateTimeField")] + #[case::uuid(SimpleColumnType::Uuid, "models.UUIDField")] + #[case::json(SimpleColumnType::Json, "models.JSONField")] + #[case::bytea(SimpleColumnType::Bytea, "models.BinaryField")] + #[case::inet(SimpleColumnType::Inet, "models.GenericIPAddressField")] + #[case::interval(SimpleColumnType::Interval, "models.DurationField")] + #[case::macaddr(SimpleColumnType::Macaddr, "models.CharField")] + fn test_simple_type_mapping(#[case] ty: SimpleColumnType, #[case] expected: &str) { + let table = TableDef { + name: "t".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("val", ColumnType::Simple(ty)), + ], + constraints: vec![pk(&["id"])], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains(expected), + "expected {expected} in:\n{result}" + ); + } + + #[rstest] + #[case::small_auto(SimpleColumnType::SmallInt, "models.SmallAutoField")] + #[case::big_auto(SimpleColumnType::BigInt, "models.BigAutoField")] + fn test_auto_pk_field_types(#[case] ty: SimpleColumnType, #[case] expected: &str) { + let table = TableDef { + name: "t".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(ty))], + constraints: vec![auto_pk(&["id"])], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains(expected), + "expected {expected} in:\n{result}" + ); + } + + #[test] + fn test_numeric_field() { + let table = TableDef { + name: "prices".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "amount", + ColumnType::Complex(ComplexColumnType::Numeric { + precision: 10, + scale: 2, + }), + ), + ], + constraints: vec![auto_pk(&["id"])], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains("models.DecimalField"), + "expected DecimalField" + ); + assert!(result.contains("max_digits=10"), "expected max_digits=10"); + assert!( + result.contains("decimal_places=2"), + "expected decimal_places=2" + ); + } + + #[test] + fn test_custom_type_field() { + let table = TableDef { + name: "docs".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "data", + ColumnType::Complex(ComplexColumnType::Custom { + custom_type: "JSONB".into(), + }), + ), + ], + constraints: vec![auto_pk(&["id"])], + }; + let result = render_entity(&table).unwrap(); + // Custom type → models.TextField (Django has no native JSONB) + assert!( + result.contains("data = models.TextField()"), + "expected Custom→TextField in:\n{result}" + ); + } + + #[test] + fn test_uuid_default() { + let mut id_col = col("id", ColumnType::Simple(SimpleColumnType::Uuid)); + id_col.default = Some(DefaultValue::String("gen_random_uuid()".into())); + let table = TableDef { + name: "sessions".into(), + description: None, + columns: vec![id_col], + constraints: vec![pk(&["id"])], + }; + let result = render_entity(&table).unwrap(); + assert!(result.contains("import uuid"), "expected uuid import"); + assert!( + result.contains("default=uuid.uuid4"), + "expected uuid4 callable" + ); + } + + #[test] + fn test_export_multi_table() { + let users = TableDef { + name: "users".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], + constraints: vec![auto_pk(&["id"])], + }; + let posts = TableDef { + name: "posts".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("author_id", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![ + auto_pk(&["id"]), + fk("author_id", "users", Some(ReferenceAction::Cascade)), + ], + }; + let result = export(&[users, posts]).unwrap(); + assert!(result.contains("class Users(models.Model):")); + assert!(result.contains("class Posts(models.Model):")); + } + + #[test] + fn test_nullable_fk_with_db_column() { + // FK column without `_id` suffix → emits db_column kwarg + // Nullable FK → emits null=True, blank=True + let table = TableDef { + name: "comments".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + nullable_col("parent", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![ + auto_pk(&["id"]), + fk("parent", "comments", Some(ReferenceAction::SetNull)), + ], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains(r#"db_column="parent""#), + "expected db_column kwarg" + ); + assert!( + result.contains("null=True"), + "expected null=True for nullable FK" + ); + assert!( + result.contains("blank=True"), + "expected blank=True for nullable FK" + ); + } + + // ----------------------------------------------------------------------- + // build_default: Boolean false → "False" + // ----------------------------------------------------------------------- + + #[test] + fn test_bool_false_default() { + let mut flag = col("enabled", ColumnType::Simple(SimpleColumnType::Boolean)); + flag.default = Some(DefaultValue::Bool(false)); + let table = TableDef { + name: "settings".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + flag, + ], + constraints: vec![auto_pk(&["id"])], + }; + let result = render_entity(&table).unwrap(); + assert!(result.contains("default=False"), "expected default=False"); + } + + // ----------------------------------------------------------------------- + // build_default: Boolean true → "True" + // ----------------------------------------------------------------------- + + #[test] + fn test_bool_true_default() { + let mut flag = col("enabled", ColumnType::Simple(SimpleColumnType::Boolean)); + flag.default = Some(DefaultValue::Bool(true)); + let table = TableDef { + name: "settings".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + flag, + ], + constraints: vec![auto_pk(&["id"])], + }; + let result = render_entity(&table).unwrap(); + assert!(result.contains("default=True"), "expected default=True"); + } + + // ----------------------------------------------------------------------- + // build_default: functional default on non-Timestamp/UUID type → None (omitted) + // ----------------------------------------------------------------------- + + #[test] + fn test_functional_default_non_special() { + let mut seq_id = col("seq_id", ColumnType::Simple(SimpleColumnType::Integer)); + seq_id.default = Some(DefaultValue::String("nextval('my_seq')".into())); + let table = TableDef { + name: "items".into(), + description: None, + columns: vec![seq_id], + constraints: vec![pk(&["seq_id"])], + }; + let result = render_entity(&table).unwrap(); + assert!( + !result.contains("default="), + "functional default should be omitted" + ); + } + + // ----------------------------------------------------------------------- + // reference_action_str: Restrict, SetDefault, NoAction + // ----------------------------------------------------------------------- + + #[rstest] + #[case(ReferenceAction::Restrict, "models.RESTRICT")] + #[case(ReferenceAction::SetDefault, "models.SET_DEFAULT")] + #[case(ReferenceAction::NoAction, "models.DO_NOTHING")] + fn test_fk_on_delete_actions(#[case] action: ReferenceAction, #[case] expected: &str) { + let table = TableDef { + name: "comments".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("post_id", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![auto_pk(&["id"]), fk("post_id", "posts", Some(action))], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains(expected), + "expected {expected} in:\n{result}" + ); + } + + // ----------------------------------------------------------------------- + // Column comment → emits "# ..." line before the field + // ----------------------------------------------------------------------- + + #[test] + fn test_column_comment() { + let mut c = col("name", ColumnType::Simple(SimpleColumnType::Text)); + c.comment = Some("The user's full name".into()); + let table = TableDef { + name: "users".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), c], + constraints: vec![auto_pk(&["id"])], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains(" # The user's full name"), + "expected column comment in output" + ); + } + + // ----------------------------------------------------------------------- + // Unnamed index and unnamed composite unique in Meta + // ----------------------------------------------------------------------- + + #[test] + fn test_index_and_unique_no_name() { + let table = TableDef { + name: "entries".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "slug", + ColumnType::Complex(ComplexColumnType::Varchar { length: 100 }), + ), + col( + "tag", + ColumnType::Complex(ComplexColumnType::Varchar { length: 50 }), + ), + ], + constraints: vec![ + auto_pk(&["id"]), + TableConstraint::Index { + name: None, + columns: vec!["slug".into()], + }, + TableConstraint::Unique { + name: None, + columns: vec!["slug".into(), "tag".into()], + strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates { + keep: vespertide_core::KeepPolicy::First, + }, + }, + ], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains("models.Index(fields=[\"slug\"]),"), + "expected unnamed Index" + ); + assert!( + result.contains("models.UniqueConstraint(fields=[\"slug\", \"tag\"]),"), + "expected unnamed UniqueConstraint" + ); + } + + // ----------------------------------------------------------------------- + // Many-to-many junction table recognition (render_entity_with_schema) + // ----------------------------------------------------------------------- + + fn users_table() -> TableDef { + TableDef { + name: "users".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], + constraints: vec![auto_pk(&["id"])], + } + } + + fn tags_table() -> TableDef { + TableDef { + name: "tags".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], + constraints: vec![auto_pk(&["id"])], + } + } + + fn junction_table( + name: &str, + left_col: &str, + left_ref: &str, + right_col: &str, + right_ref: &str, + ) -> TableDef { + TableDef { + name: name.into(), + description: None, + columns: vec![ + col(left_col, ColumnType::Simple(SimpleColumnType::Integer)), + col(right_col, ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![ + pk(&[left_col, right_col]), + fk(left_col, left_ref, None), + fk(right_col, right_ref, None), + ], + } + } + + #[test] + fn test_many_to_many_junction_table() { + let users = users_table(); + let tags = tags_table(); + let user_tags = junction_table("user_tags", "user_id", "users", "tag_id", "tags"); + let schema = vec![users.clone(), tags.clone(), user_tags.clone()]; + + let result = render_entity_with_schema(&users, &schema).unwrap(); + assert!( + result.contains( + "tags = models.ManyToManyField(\"Tags\", through=\"UserTags\", related_name=\"+\")" + ), + "expected ManyToManyField on users side, got:\n{result}" + ); + + let result = render_entity_with_schema(&tags, &schema).unwrap(); + assert!( + result.contains( + "users = models.ManyToManyField(\"Users\", through=\"UserTags\", related_name=\"+\")" + ), + "expected ManyToManyField on tags side, got:\n{result}" + ); + } + + #[test] + fn test_many_to_many_disambiguates_multiple_junctions_to_same_target() { + let users = users_table(); + let tags = tags_table(); + let user_tags = junction_table("user_tags", "user_id", "users", "tag_id", "tags"); + let user_favorite_tags = + junction_table("user_favorite_tags", "user_id", "users", "tag_id", "tags"); + let schema = vec![users.clone(), tags, user_tags, user_favorite_tags]; + + let result = render_entity_with_schema(&users, &schema).unwrap(); + assert!( + result.contains( + "tags_via_user_tags = models.ManyToManyField(\"Tags\", through=\"UserTags\"" + ), + "expected disambiguated field for user_tags junction, got:\n{result}" + ); + assert!( + result.contains( + "tags_via_user_favorite_tags = models.ManyToManyField(\"Tags\", through=\"UserFavoriteTags\"" + ), + "expected disambiguated field for user_favorite_tags junction, got:\n{result}" + ); + } + + #[test] + fn test_purely_self_referential_junction_is_skipped() { + // "friends" links users to users on both sides — not a two-sided M2M + // we can safely name, so no ManyToManyField should be emitted. + let users = users_table(); + let friends = junction_table("friends", "user_id", "users", "friend_id", "users"); + let schema = vec![users.clone(), friends]; + + let result = render_entity_with_schema(&users, &schema).unwrap(); + assert!( + !result.contains("ManyToManyField"), + "self-referential junction must not produce a guessed M2M field, got:\n{result}" + ); + } + + #[test] + fn test_junction_table_unrelated_to_current_table_is_ignored() { + // "order_tags" is a genuine junction (composite PK, 2 FKs both in the + // PK), but neither side references `users` at all — it links + // "orders" and "tags" together, so it must not produce any + // ManyToManyField on `users`. + let users = users_table(); + let tags = tags_table(); + let orders = TableDef { + name: "orders".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], + constraints: vec![auto_pk(&["id"])], + }; + let order_tags = junction_table("order_tags", "order_id", "orders", "tag_id", "tags"); + let schema = vec![users.clone(), tags, orders, order_tags]; + + let result = render_entity_with_schema(&users, &schema).unwrap(); + assert!( + !result.contains("ManyToManyField"), + "junction table unrelated to `users` must not produce a M2M field, got:\n{result}" + ); + } + + #[test] + fn test_export_multi_table_includes_many_to_many() { + let users = users_table(); + let tags = tags_table(); + let user_tags = junction_table("user_tags", "user_id", "users", "tag_id", "tags"); + let result = export(&[users, tags, user_tags]).unwrap(); + assert!( + result.contains("models.ManyToManyField(\"Tags\", through=\"UserTags\""), + "expected ManyToManyField in multi-table export, got:\n{result}" + ); + } + + // ----------------------------------------------------------------------- + // Composite FK: Django has no native multi-column FK field, so it must + // be surfaced as a comment instead of silently dropped. + // ----------------------------------------------------------------------- + + #[test] + fn test_composite_fk_emits_comment() { + let table = TableDef { + name: "order_items".into(), + description: None, + columns: vec![ + col("order_id", ColumnType::Simple(SimpleColumnType::Integer)), + col("region_id", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![ + pk(&["order_id", "region_id"]), + TableConstraint::ForeignKey { + name: None, + columns: vec!["order_id".into(), "region_id".into()], + ref_table: "order_regions".into(), + ref_columns: vec!["order_id".into(), "region_id".into()], + on_delete: None, + on_update: None, + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + ], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains( + "# composite foreign key: (order_id, region_id) -> order_regions(order_id, region_id)" + ), + "expected composite FK comment, got:\n{result}" + ); + } + + // ----------------------------------------------------------------------- + // DjangoExporterWithConfig: app_label reaches the Meta class + // ----------------------------------------------------------------------- + + #[test] + fn test_app_label_omitted_by_default() { + let table = users_table(); + let schema = vec![table.clone()]; + let config = DjangoConfig::default(); + let exporter = DjangoExporterWithConfig::new(&config); + let result = exporter.render_entity_with_schema(&table, &schema).unwrap(); + assert!( + !result.contains("app_label"), + "expected no app_label with default config, got:\n{result}" + ); + } + + #[test] + fn test_app_label_from_config_reaches_meta_class() { + let table = users_table(); + let schema = vec![table.clone()]; + let mut config = DjangoConfig::default(); + config.app_label = Some("myapp".to_string()); + let exporter = DjangoExporterWithConfig::new(&config); + let result = exporter.render_entity_with_schema(&table, &schema).unwrap(); + assert!( + result.contains(" app_label = \"myapp\""), + "expected app_label in Meta class, got:\n{result}" + ); + } +} diff --git a/crates/vespertide-exporter/src/django/render.rs b/crates/vespertide-exporter/src/django/render.rs new file mode 100644 index 00000000..c855ac33 --- /dev/null +++ b/crates/vespertide-exporter/src/django/render.rs @@ -0,0 +1,671 @@ +use std::collections::{HashMap, HashSet}; + +use super::enums::render_enum; +use super::types::{UsedImports, build_field_kwargs, django_field_type, reference_action_str}; +use crate::utils::python::collect_composite_fks; +use vespertide_core::schema::column::{ColumnType, ComplexColumnType}; +use vespertide_core::schema::constraint::TableConstraint; +use vespertide_core::{ReferenceAction, TableDef}; +use vespertide_naming::{IdentifierStart, sanitize_identifier}; + +pub fn render_entity(table: &TableDef) -> Result { + let mut used = UsedImports::default(); + let body = render_entity_part(table, &mut used, &[], None); + Ok(assemble_with_imports(&used, &[body])) +} + +/// Render a single table with full schema context so many-to-many junction +/// tables can be recognized and exposed as `ManyToManyField(..., through=...)`. +pub fn render_entity_with_schema(table: &TableDef, schema: &[TableDef]) -> Result { + render_entity_with_schema_and_config(table, schema, None) +} + +/// Same as [`render_entity_with_schema`], but with an optional `app_label` +/// (from `vespertide.json`'s `django` config) written into every model's +/// `Meta` class. +pub fn render_entity_with_schema_and_config( + table: &TableDef, + schema: &[TableDef], + app_label: Option<&str>, +) -> Result { + let mut used = UsedImports::default(); + let m2m_fields = find_many_to_many_fields(table, schema); + let body = render_entity_part(table, &mut used, &m2m_fields, app_label); + Ok(assemble_with_imports(&used, &[body])) +} + +pub fn export(schema: &[TableDef]) -> Result { + export_with_config(schema, None) +} + +/// Same as [`export`], but with an optional `app_label` written into every +/// model's `Meta` class. +pub fn export_with_config(schema: &[TableDef], app_label: Option<&str>) -> Result { + let mut used = UsedImports::default(); + let parts: Vec = schema + .iter() + .map(|t| { + let m2m_fields = find_many_to_many_fields(t, schema); + render_entity_part(t, &mut used, &m2m_fields, app_label) + }) + .collect(); + Ok(assemble_with_imports(&used, &parts)) +} + +/// Recognize many-to-many junction tables (composite PK, 2+ FKs, all FK +/// columns part of the PK) that reference `table`, and render the +/// corresponding `ManyToManyField` lines for the *other* side of each +/// junction. Purely self-referential junctions (every FK pointing back at +/// `table`) are skipped rather than guessed at. +fn find_many_to_many_fields(table: &TableDef, schema: &[TableDef]) -> Vec { + let mut matches: Vec<(String, String)> = Vec::new(); // (target_table, junction_table) + + for other in schema { + if other.name == table.name { + continue; + } + + let other_pk: HashSet = other + .constraints + .iter() + .filter_map(|c| { + if let TableConstraint::PrimaryKey { columns, .. } = c { + Some( + columns + .iter() + .map(|c| c.as_str().to_owned()) + .collect::>(), + ) + } else { + None + } + }) + .flatten() + .collect(); + if other_pk.len() < 2 { + continue; + } + + let fks: Vec<(Vec, String)> = other + .constraints + .iter() + .filter_map(|c| { + if let TableConstraint::ForeignKey { + columns, ref_table, .. + } = c + { + Some(( + columns.iter().map(|c| c.as_str().to_owned()).collect(), + ref_table.as_str().to_owned(), + )) + } else { + None + } + }) + .collect(); + if fks.len() < 2 { + continue; + } + + let all_fk_cols_in_pk = fks + .iter() + .all(|(cols, _)| cols.iter().all(|c| other_pk.contains(c.as_str()))); + if !all_fk_cols_in_pk { + continue; + } + + if !fks + .iter() + .any(|(_, ref_table)| ref_table.as_str() == table.name.as_str()) + { + continue; + } + if fks + .iter() + .all(|(_, ref_table)| ref_table.as_str() == table.name.as_str()) + { + continue; + } + + for (_, ref_table) in &fks { + if ref_table.as_str() == table.name.as_str() { + continue; + } + if schema.iter().any(|t| t.name.as_str() == ref_table.as_str()) { + matches.push((ref_table.clone(), other.name.as_str().to_owned())); + } + } + } + + let mut target_counts: HashMap = HashMap::new(); + for (target, _) in &matches { + *target_counts.entry(target.clone()).or_default() += 1; + } + + let mut used_names: HashSet = HashSet::new(); + matches + .iter() + .map(|(target, junction)| { + let base = pluralize(target); + let field_name = if target_counts.get(target).copied().unwrap_or(0) > 1 { + unique_name(&format!("{base}_via_{junction}"), &mut used_names) + } else { + unique_name(&base, &mut used_names) + }; + let target_class = sanitize_identifier(&to_pascal_case(target), IdentifierStart::Underscore); + let junction_class = + sanitize_identifier(&to_pascal_case(junction), IdentifierStart::Underscore); + format!( + " {field_name} = models.ManyToManyField(\"{target_class}\", through=\"{junction_class}\", related_name=\"+\")" + ) + }) + .collect() +} + +fn pluralize(name: &str) -> String { + if name.ends_with('s') { + name.to_string() + } else { + format!("{name}s") + } +} + +fn unique_name(base: &str, used: &mut HashSet) -> String { + if used.insert(base.to_string()) { + return base.to_string(); + } + let mut n = 2; + loop { + let candidate = format!("{base}_{n}"); + if used.insert(candidate.clone()) { + return candidate; + } + n += 1; + } +} + +fn render_entity_part( + table: &TableDef, + used: &mut UsedImports, + extra_fields: &[String], + app_label: Option<&str>, +) -> String { + let mut lines: Vec = Vec::new(); + + // --- Constraint lookups --- + let pk_columns: HashSet = table + .constraints + .iter() + .filter_map(|c| { + if let TableConstraint::PrimaryKey { columns, .. } = c { + Some( + columns + .iter() + .map(|c| c.as_str().to_owned()) + .collect::>(), + ) + } else { + None + } + }) + .flatten() + .collect(); + + let auto_increment = table.constraints.iter().any(|c| { + matches!( + c, + TableConstraint::PrimaryKey { + auto_increment: true, + .. + } + ) + }); + + let is_composite_pk = pk_columns.len() > 1; + + // Column order (not just membership) matters for CompositePrimaryKey's + // positional args, so capture it separately from the `pk_columns` set. + let pk_columns_ordered: Vec = table + .constraints + .iter() + .find_map(|c| { + if let TableConstraint::PrimaryKey { columns, .. } = c { + Some(columns.iter().map(|c| c.as_str().to_owned()).collect()) + } else { + None + } + }) + .unwrap_or_default(); + + let single_unique_cols: HashSet = table + .constraints + .iter() + .filter_map(|c| { + if let TableConstraint::Unique { columns, .. } = c { + if columns.len() == 1 { + Some(columns[0].as_str().to_owned()) + } else { + None + } + } else { + None + } + }) + .collect(); + + // single-column FK info: col_name → (ref_table, on_delete, on_update) + let fk_map: HashMap, Option<&ReferenceAction>)> = table + .constraints + .iter() + .filter_map(|c| { + if let TableConstraint::ForeignKey { + columns, + ref_table, + ref_columns, + on_delete, + on_update, + .. + } = c + && columns.len() == 1 + && ref_columns.len() == 1 + { + return Some(( + columns[0].as_str().to_owned(), + (ref_table.as_str(), on_delete.as_ref(), on_update.as_ref()), + )); + } + None + }) + .collect(); + + // Enum class names for this table's columns + let enum_class_map: HashMap<&str, String> = table + .columns + .iter() + .filter_map(|col| { + if let ColumnType::Complex(ComplexColumnType::Enum { name, .. }) = &col.r#type { + Some(( + col.name.as_str(), + sanitize_identifier(&to_pascal_case(name), IdentifierStart::Underscore), + )) + } else { + None + } + }) + .collect(); + + // --- Enum class definitions --- + let mut seen_enums: HashSet = HashSet::new(); + for col in &table.columns { + if let ColumnType::Complex(ComplexColumnType::Enum { name, values }) = &col.r#type { + let class_name = + sanitize_identifier(&to_pascal_case(name), IdentifierStart::Underscore); + if seen_enums.insert(class_name.clone()) { + render_enum(&mut lines, &class_name, values); + lines.push(String::new()); + } + } + } + + // --- Class declaration --- + let class_name = sanitize_identifier(&to_pascal_case(&table.name), IdentifierStart::Underscore); + if let Some(ref desc) = table.description { + lines.push(format!("class {class_name}(models.Model):")); + lines.push(format!(" \"\"\"{}\"\"\"", desc.replace('\n', " "))); + lines.push(String::new()); + } else { + lines.push(format!("class {class_name}(models.Model):")); + } + + // Composite PK: Django (5.2+) represents this natively via + // `pk = models.CompositePrimaryKey(...)`, referencing each column by its + // attname (a ForeignKey's attname is always `{field_name}_id`, regardless + // of any `db_column` override). Without this, Django would fall back to + // adding its own implicit auto `id` PK, which doesn't correspond to any + // real uniqueness constraint on the actual table. + if is_composite_pk { + let attnames: Vec = pk_columns_ordered + .iter() + .map(|col| { + if fk_map.contains_key(col.as_str()) { + let (field_name, _) = fk_field_name(col); + format!("{field_name}_id") + } else { + col.clone() + } + }) + .collect(); + let args = attnames + .iter() + .map(|a| format!("\"{a}\"")) + .collect::>() + .join(", "); + lines.push(format!(" pk = models.CompositePrimaryKey({args})")); + } + + // --- Fields --- + // Sanitizing distinct column names (e.g. `a_id` -> `a`, `a` -> `a`) can + // collapse two originally-distinct columns onto the same Python + // attribute name; disambiguate with a numeric suffix rather than + // silently emitting a duplicate class attribute. + let mut used_field_names: HashSet = HashSet::new(); + for col in &table.columns { + let is_pk = pk_columns.contains(col.name.as_str()); + let is_unique = single_unique_cols.contains(col.name.as_str()); + + if let Some(ref comment) = col.comment { + lines.push(format!(" # {}", comment.replace('\n', " "))); + } + + if let Some(&(ref_table, on_delete, on_update)) = fk_map.get(col.name.as_str()) { + render_fk_field( + &mut lines, + &col.name, + ref_table, + on_delete, + on_update, + col.nullable, + &mut used_field_names, + ); + } else { + let effective_pk = is_pk && !is_composite_pk; + let field_type = django_field_type( + &col.r#type, + effective_pk, + auto_increment && !is_composite_pk, + ); + let field_name = unique_name( + &sanitize_identifier(col.name.as_str(), IdentifierStart::Underscore), + &mut used_field_names, + ); + let db_column = if field_name == col.name.as_str() { + None + } else { + Some(col.name.as_str()) + }; + let kwargs = build_field_kwargs( + &col.r#type, + effective_pk, + is_unique, + col.nullable, + col.default.as_ref(), + enum_class_map.get(col.name.as_str()).map(String::as_str), + db_column, + used, + ); + let kwargs_str = kwargs.join(", "); + if kwargs_str.is_empty() { + lines.push(format!(" {field_name} = {field_type}()")); + } else { + lines.push(format!(" {field_name} = {field_type}({kwargs_str})")); + } + } + } + + for line in extra_fields { + lines.push(line.clone()); + } + + // Composite (multi-column) FKs have no native Django ORM field — surface + // them as a comment rather than silently dropping the relationship info. + // The individual columns still render above as plain scalar fields, and + // referential integrity is enforced by the generated database schema. + for fk in collect_composite_fks(table) { + let local = fk.local_cols.join(", "); + let refs = fk.ref_cols.join(", "); + lines.push(format!( + " # composite foreign key: ({local}) -> {}({refs})", + fk.ref_table + )); + } + + // --- Meta class --- + let indexes: Vec<_> = table + .constraints + .iter() + .filter_map(|c| { + if let TableConstraint::Index { name, columns } = c { + Some((name.as_deref(), columns.as_slice())) + } else { + None + } + }) + .collect(); + + let composite_uniques: Vec<_> = table + .constraints + .iter() + .filter_map(|c| { + if let TableConstraint::Unique { name, columns, .. } = c { + if columns.len() > 1 { + Some((name.as_deref(), columns.as_slice())) + } else { + None + } + } else { + None + } + }) + .collect(); + + lines.push(String::new()); + lines.push(" class Meta:".into()); + lines.push(format!(" db_table = \"{}\"", table.name)); + if let Some(label) = app_label { + lines.push(format!(" app_label = \"{label}\"")); + } + + if !indexes.is_empty() { + lines.push(" indexes = [".into()); + for (name, cols) in &indexes { + let fields = cols + .iter() + .map(|c| format!("\"{c}\"")) + .collect::>() + .join(", "); + if let Some(n) = name { + lines.push(format!( + " models.Index(fields=[{fields}], name=\"{n}\")," + )); + } else { + lines.push(format!(" models.Index(fields=[{fields}]),")); + } + } + lines.push(" ]".into()); + } + + if !composite_uniques.is_empty() { + lines.push(" constraints = [".into()); + for (name, cols) in &composite_uniques { + let fields = cols + .iter() + .map(|c| format!("\"{c}\"")) + .collect::>() + .join(", "); + if let Some(n) = name { + lines.push(format!( + " models.UniqueConstraint(fields=[{fields}], name=\"{n}\")," + )); + } else { + lines.push(format!( + " models.UniqueConstraint(fields=[{fields}])," + )); + } + } + lines.push(" ]".into()); + } + + lines.push(String::new()); + lines.join("\n") +} + +fn render_fk_field( + lines: &mut Vec, + col_name: &str, + ref_table: &str, + on_delete: Option<&ReferenceAction>, + on_update: Option<&ReferenceAction>, + nullable: bool, + used_field_names: &mut HashSet, +) { + let (field_name, db_column) = fk_field_name(col_name); + // The `_id` strip can collapse two distinct columns onto the same + // attribute name (e.g. `a_id` -> `a` colliding with a real column `a`). + let deduped_field_name = unique_name(&field_name, used_field_names); + let db_column = + db_column.or_else(|| (deduped_field_name != field_name).then(|| col_name.to_string())); + let field_name = deduped_field_name; + let ref_class = sanitize_identifier(&to_pascal_case(ref_table), IdentifierStart::Underscore); + let on_delete_str = on_delete.map_or("models.RESTRICT", reference_action_str); + + let _ = on_update; // Django ForeignKey has no on_update param; silently ignored + + let mut kwargs = vec![ + format!("\"{ref_class}\""), + format!("on_delete={on_delete_str}"), + ]; + if let Some(db_col) = db_column { + kwargs.push(format!("db_column=\"{db_col}\"")); + } + kwargs.push("related_name=\"+\"".into()); + if nullable { + kwargs.push("null=True".into()); + kwargs.push("blank=True".into()); + } + + let kwargs_str = kwargs.join(", "); + lines.push(format!( + " {field_name} = models.ForeignKey({kwargs_str})" + )); +} + +/// Returns (field_name, Option). +/// If col_name ends with `_id`, strip it — Django automatically appends `_id`. +/// Otherwise, emit db_column explicitly so Django uses the raw column name. +/// Either way, `field_name` is sanitized into a valid Python identifier; if +/// that sanitization (or the `_id` strip) changes anything, `db_column` is +/// set to the original column name so the DB mapping isn't lost. +fn fk_field_name(col_name: &str) -> (String, Option) { + if let Some(base) = col_name.strip_suffix("_id") { + let sanitized = sanitize_identifier(base, IdentifierStart::Underscore); + if sanitized == base { + (sanitized, None) + } else { + (sanitized, Some(col_name.to_string())) + } + } else { + ( + sanitize_identifier(col_name, IdentifierStart::Underscore), + Some(col_name.to_string()), + ) + } +} + +fn assemble_with_imports(used: &UsedImports, parts: &[String]) -> String { + let mut lines: Vec = Vec::new(); + + lines.push("from __future__ import annotations".into()); + lines.push(String::new()); + + if used.needs_timezone { + lines.push("from django.utils import timezone".into()); + } + if used.needs_uuid_default { + lines.push("import uuid".into()); + } + + lines.push("from django.db import models".into()); + lines.push(String::new()); + lines.push(String::new()); + + lines.push(parts.join("\n")); + lines.join("\n") +} + +pub(super) fn to_pascal_case(s: &str) -> String { + s.split('_') + .map(|word| { + let mut chars = word.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().chain(chars).collect(), + } + }) + .collect() +} + +pub(super) fn to_upper_snake_case(s: &str) -> String { + let mut result = String::new(); + let chars: Vec = s.chars().collect(); + for (i, &c) in chars.iter().enumerate() { + if c == '-' || c == ' ' { + if !result.ends_with('_') { + result.push('_'); + } + } else if c == '_' { + result.push('_'); + } else if c.is_uppercase() && i > 0 && !result.ends_with('_') { + // Only split on camelCase transitions (lowercase/digit → uppercase). + // Adjacent uppercase letters (e.g. "ERROR") are not split. + let prev = chars[i - 1]; + if prev.is_lowercase() || prev.is_ascii_digit() { + result.push('_'); + } + result.push(c); + } else { + result.push(c.to_ascii_uppercase()); + } + } + // Python identifiers cannot start with a digit + if result.starts_with(|c: char| c.is_ascii_digit()) { + result.insert(0, '_'); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[rstest::rstest] + #[case("pending", "PENDING")] + #[case("in_progress", "IN_PROGRESS")] + #[case("inProgress", "IN_PROGRESS")] + #[case("ERROR_LEVEL", "ERROR_LEVEL")] + #[case("info-level", "INFO_LEVEL")] + #[case("1critical", "_1CRITICAL")] + fn test_to_upper_snake_case(#[case] input: &str, #[case] expected: &str) { + assert_eq!(to_upper_snake_case(input), expected); + } + + #[rstest::rstest] + #[case("author_id", "author", None)] + #[case("user_id", "user", None)] + #[case("parent", "parent", Some("parent"))] + #[case("ref", "ref", Some("ref"))] + fn test_fk_field_name( + #[case] col: &str, + #[case] expected_field: &str, + #[case] expected_db_col: Option<&str>, + ) { + let (field, db_col) = fk_field_name(col); + assert_eq!(field, expected_field); + assert_eq!(db_col.as_deref(), expected_db_col); + } + + #[test] + fn test_to_pascal_case_double_underscore() { + // Double underscore produces an empty word, triggering the None arm in to_pascal_case + assert_eq!(to_pascal_case("order__item"), "OrderItem"); + assert_eq!(to_pascal_case("_leading"), "Leading"); + assert_eq!(to_pascal_case("trailing_"), "Trailing"); + } + + #[test] + fn test_unique_name_double_collision_appends_incrementing_suffix() { + let mut used = HashSet::new(); + used.insert("tag".to_string()); + used.insert("tag_2".to_string()); + assert_eq!(unique_name("tag", &mut used), "tag_3"); + } +} diff --git a/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__basic_table.snap b/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__basic_table.snap new file mode 100644 index 00000000..47063e83 --- /dev/null +++ b/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__basic_table.snap @@ -0,0 +1,19 @@ +--- +source: crates/vespertide-exporter/src/django/mod.rs +assertion_line: 106 +expression: render_entity(&table).unwrap() +--- +from __future__ import annotations + +from django.db import models + + +class Users(models.Model): + """User accounts""" + + id = models.AutoField(primary_key=True) + email = models.CharField(max_length=255, unique=True) + name = models.TextField(null=True, blank=True) + + class Meta: + db_table = "users" diff --git a/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__composite_pk.snap b/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__composite_pk.snap new file mode 100644 index 00000000..b5332681 --- /dev/null +++ b/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__composite_pk.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/django/mod.rs +assertion_line: 238 +expression: render_entity(&table).unwrap() +--- +from __future__ import annotations + +from django.db import models + + +class OrderItems(models.Model): + pk = models.CompositePrimaryKey("order_id", "product_id") + order_id = models.IntegerField() + product_id = models.IntegerField() + quantity = models.IntegerField() + + class Meta: + db_table = "order_items" diff --git a/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__indexes_and_composite_unique.snap b/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__indexes_and_composite_unique.snap new file mode 100644 index 00000000..b98c8390 --- /dev/null +++ b/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__indexes_and_composite_unique.snap @@ -0,0 +1,24 @@ +--- +source: crates/vespertide-exporter/src/django/mod.rs +assertion_line: 252 +expression: render_entity(&table).unwrap() +--- +from __future__ import annotations + +from django.db import models + + +class Articles(models.Model): + id = models.AutoField(primary_key=True) + slug = models.CharField(max_length=200) + author_id = models.IntegerField() + created_at = models.DateTimeField() + + class Meta: + db_table = "articles" + indexes = [ + models.Index(fields=["created_at"], name="ix_articles__created_at"), + ] + constraints = [ + models.UniqueConstraint(fields=["slug", "author_id"], name="uq_articles__slug_author"), + ] diff --git a/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__server_default_timezone.snap b/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__server_default_timezone.snap new file mode 100644 index 00000000..2cc3d147 --- /dev/null +++ b/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__server_default_timezone.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/django/mod.rs +assertion_line: 286 +expression: result +--- +from __future__ import annotations + +from django.utils import timezone +from django.db import models + + +class Events(models.Model): + id = models.AutoField(primary_key=True) + created_at = models.DateTimeField(default=timezone.now) + count = models.IntegerField(default=0) + + class Meta: + db_table = "events" diff --git a/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__table_with_fk.snap b/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__table_with_fk.snap new file mode 100644 index 00000000..9b1c8e90 --- /dev/null +++ b/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__table_with_fk.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/django/mod.rs +assertion_line: 128 +expression: render_entity(&table).unwrap() +--- +from __future__ import annotations + +from django.db import models + + +class Posts(models.Model): + id = models.AutoField(primary_key=True) + author = models.ForeignKey("Users", on_delete=models.CASCADE, related_name="+") + title = models.TextField() + + class Meta: + db_table = "posts" diff --git a/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__table_with_integer_enum.snap b/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__table_with_integer_enum.snap new file mode 100644 index 00000000..df5d2323 --- /dev/null +++ b/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__table_with_integer_enum.snap @@ -0,0 +1,21 @@ +--- +source: crates/vespertide-exporter/src/django/mod.rs +assertion_line: 175 +expression: render_entity(&table).unwrap() +--- +from __future__ import annotations + +from django.db import models + + +class PriorityLevel(models.IntegerChoices): + LOW = 0, "low" + MEDIUM = 10, "medium" + HIGH = 20, "high" + +class Tasks(models.Model): + id = models.IntegerField(primary_key=True) + priority = models.IntegerField(choices=PriorityLevel.choices) + + class Meta: + db_table = "tasks" diff --git a/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__table_with_string_enum.snap b/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__table_with_string_enum.snap new file mode 100644 index 00000000..2b208135 --- /dev/null +++ b/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__table_with_string_enum.snap @@ -0,0 +1,21 @@ +--- +source: crates/vespertide-exporter/src/django/mod.rs +assertion_line: 157 +expression: render_entity(&table).unwrap() +--- +from __future__ import annotations + +from django.db import models + + +class OrderStatus(models.TextChoices): + PENDING = "pending", "pending" + SHIPPED = "shipped", "shipped" + DELIVERED = "delivered", "delivered" + +class Orders(models.Model): + id = models.AutoField(primary_key=True) + status = models.CharField(max_length=9, choices=OrderStatus.choices, default="pending") + + class Meta: + db_table = "orders" diff --git a/crates/vespertide-exporter/src/django/types.rs b/crates/vespertide-exporter/src/django/types.rs new file mode 100644 index 00000000..913e97ab --- /dev/null +++ b/crates/vespertide-exporter/src/django/types.rs @@ -0,0 +1,200 @@ +use vespertide_core::DefaultValue; +use vespertide_core::schema::column::{ + ColumnType, ComplexColumnType, EnumValues, SimpleColumnKind, SimpleColumnType, +}; + +#[derive(Default)] +pub(super) struct UsedImports { + pub(super) needs_timezone: bool, + pub(super) needs_uuid_default: bool, +} + +pub(super) fn django_field_type( + col_type: &ColumnType, + is_pk: bool, + auto_increment: bool, +) -> &'static str { + match col_type { + ColumnType::Simple(ty) => match SimpleColumnKind::from(*ty) { + SimpleColumnKind::SmallInt => { + if is_pk && auto_increment { + "models.SmallAutoField" + } else { + "models.SmallIntegerField" + } + } + SimpleColumnKind::Integer => { + if is_pk && auto_increment { + "models.AutoField" + } else { + "models.IntegerField" + } + } + SimpleColumnKind::BigInt => { + if is_pk && auto_increment { + "models.BigAutoField" + } else { + "models.BigIntegerField" + } + } + SimpleColumnKind::Real | SimpleColumnKind::DoublePrecision => "models.FloatField", + SimpleColumnKind::Text | SimpleColumnKind::Xml => "models.TextField", + SimpleColumnKind::Boolean => "models.BooleanField", + SimpleColumnKind::Date => "models.DateField", + SimpleColumnKind::Time => "models.TimeField", + SimpleColumnKind::Timestamp | SimpleColumnKind::Timestamptz => "models.DateTimeField", + SimpleColumnKind::Interval => "models.DurationField", + SimpleColumnKind::Bytea => "models.BinaryField", + SimpleColumnKind::Uuid => "models.UUIDField", + SimpleColumnKind::Json => "models.JSONField", + SimpleColumnKind::Inet | SimpleColumnKind::Cidr => "models.GenericIPAddressField", + SimpleColumnKind::Macaddr => "models.CharField", + }, + ColumnType::Complex(ty) => match ty { + ComplexColumnType::Varchar { .. } | ComplexColumnType::Char { .. } => { + "models.CharField" + } + ComplexColumnType::Numeric { .. } => "models.DecimalField", + ComplexColumnType::Custom { .. } => "models.TextField", + ComplexColumnType::Enum { values, .. } => match values { + EnumValues::String(_) => "models.CharField", + EnumValues::Integer(_) => "models.IntegerField", + }, + // `#[non_exhaustive]` future-variant guard; unreachable today. + #[cfg(not(tarpaulin_include))] + _ => { + unreachable!("ComplexColumnType is #[non_exhaustive]; all variants matched") + } + }, + } +} + +#[expect( + clippy::too_many_arguments, + reason = "all params are independent field-kwarg inputs; a context struct would add noise without reducing coupling" +)] +pub(super) fn build_field_kwargs( + col_type: &ColumnType, + is_pk: bool, + is_unique: bool, + nullable: bool, + default: Option<&DefaultValue>, + enum_class_name: Option<&str>, + db_column: Option<&str>, + used: &mut UsedImports, +) -> Vec { + let mut kwargs: Vec = Vec::new(); + + if let Some(db_col) = db_column { + kwargs.push(format!("db_column=\"{db_col}\"")); + } + + // Size / precision kwargs + match col_type { + ColumnType::Complex( + ComplexColumnType::Varchar { length } | ComplexColumnType::Char { length }, + ) => { + kwargs.push(format!("max_length={length}")); + } + ColumnType::Simple(SimpleColumnType::Macaddr) => { + kwargs.push("max_length=17".into()); + } + ColumnType::Complex(ComplexColumnType::Numeric { precision, scale }) => { + kwargs.push(format!("max_digits={precision}")); + kwargs.push(format!("decimal_places={scale}")); + } + ColumnType::Complex(ComplexColumnType::Enum { values, .. }) => { + if let Some(class) = enum_class_name { + if let EnumValues::String(vals) = values { + let mut max_len = 1; + for v in vals { + if v.len() > max_len { + max_len = v.len(); + } + } + kwargs.push(format!("max_length={max_len}")); + } + kwargs.push(format!("choices={class}.choices")); + } + } + _ => {} + } + + for (cond, kwarg) in [ + (is_pk, "primary_key=True"), + (is_unique && !is_pk, "unique=True"), + ] { + if cond { + kwargs.push(kwarg.into()); + } + } + if nullable && !is_pk { + kwargs.push("null=True".into()); + kwargs.push("blank=True".into()); + } + if let Some(dv) = default + && let Some(expr) = build_default(col_type, &dv.to_sql(), used) + { + kwargs.push(format!("default={expr}")); + } + + kwargs +} + +pub(super) fn build_default( + col_type: &ColumnType, + sql: &str, + used: &mut UsedImports, +) -> Option { + if sql.contains('(') { + let up = sql.to_uppercase(); + let is_timestamp_col = matches!( + col_type, + ColumnType::Simple(SimpleColumnType::Timestamp | SimpleColumnType::Timestamptz) + ); + if is_timestamp_col && (up.contains("NOW") || up.contains("CURRENT_TIMESTAMP")) { + used.needs_timezone = true; + return Some("timezone.now".into()); + } + if matches!(col_type, ColumnType::Simple(SimpleColumnType::Uuid)) { + used.needs_uuid_default = true; + return Some("uuid.uuid4".into()); + } + return None; + } + + let up = sql.to_uppercase(); + if up == "TRUE" { + return Some("True".into()); + } + if up == "FALSE" { + return Some("False".into()); + } + + if sql.starts_with('\'') && sql.ends_with('\'') && sql.len() >= 2 { + let inner = &sql[1..sql.len() - 1]; + return Some(format!("\"{}\"", inner.replace('"', "\\\""))); + } + + // A bare numeric literal (e.g. "0", "-1.5") is valid Python as-is. Any + // other bare, unquoted token is an unresolvable DB-level constant/ + // expression (e.g. a named SQL constant) — emitting it verbatim would + // produce an undefined-name reference in the generated Python, so omit + // the default entirely rather than guess. + if sql.parse::().is_ok() { + return Some(sql.into()); + } + + None +} + +pub(super) fn reference_action_str(action: &vespertide_core::ReferenceAction) -> &'static str { + use vespertide_core::ReferenceActionKind; + match ReferenceActionKind::from(action) { + ReferenceActionKind::Cascade => "models.CASCADE", + ReferenceActionKind::Restrict => "models.RESTRICT", + ReferenceActionKind::SetNull => "models.SET_NULL", + ReferenceActionKind::SetDefault => "models.SET_DEFAULT", + ReferenceActionKind::NoAction => "models.DO_NOTHING", + } +} diff --git a/crates/vespertide-exporter/src/gorm/mod.rs b/crates/vespertide-exporter/src/gorm/mod.rs new file mode 100644 index 00000000..03763fe0 --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/mod.rs @@ -0,0 +1,994 @@ +use std::collections::{HashMap, HashSet}; + +use crate::orm::OrmExporter; +use vespertide_config::DEFAULT_GORM_PACKAGE_NAME; +use vespertide_core::schema::column::{ + ColumnType, ComplexColumnType, EnumValues, SimpleColumnKind, SimpleColumnType, +}; +use vespertide_core::schema::constraint::TableConstraint; +use vespertide_core::schema::names::ColumnName; +use vespertide_core::{ColumnDef, DefaultValue, ReferenceAction, ReferenceActionKind, TableDef}; +use vespertide_naming::{IdentifierStart, sanitize_identifier}; + +/// Track which Go imports are actually used to generate minimal import statements. +#[expect( + clippy::struct_excessive_bools, + reason = "four independent import-presence flags; enum would add verbosity without clarity" +)] +#[derive(Default)] +struct UsedImports { + needs_time: bool, + needs_uuid: bool, + needs_datatypes: bool, + needs_decimal: bool, +} + +impl UsedImports { + fn add_column_type(&mut self, col_type: &ColumnType) { + match col_type { + ColumnType::Simple(ty) => match ty { + SimpleColumnType::Date + | SimpleColumnType::Time + | SimpleColumnType::Timestamp + | SimpleColumnType::Timestamptz => { + self.needs_time = true; + } + SimpleColumnType::Uuid => { + self.needs_uuid = true; + } + SimpleColumnType::Json => { + self.needs_datatypes = true; + } + _ => {} + }, + ColumnType::Complex(ty) => { + if let ComplexColumnType::Numeric { .. } = ty { + self.needs_decimal = true; + } + if let ComplexColumnType::Custom { custom_type } = ty + && custom_type.to_uppercase() == "JSONB" + { + self.needs_datatypes = true; + } + } + } + } +} + +pub struct GormExporter; + +impl OrmExporter for GormExporter { + fn render_entity(&self, table: &TableDef) -> Result { + render_entity(table) + } + + fn render_entity_with_schema( + &self, + table: &TableDef, + schema: &[TableDef], + ) -> Result { + render_entity_with_schema(table, schema) + } +} + +/// GORM exporter that honors `vespertide.json`'s `gorm` config section +/// (currently the effective Go package name — see +/// `VespertideConfig::gorm_package_name`, which resolves an explicit +/// `gorm.package_name` or infers one from the actual export directory — +/// emitted at the top of every file). Mirrors `seaorm::SeaOrmExporterWithConfig`. +pub struct GormExporterWithConfig<'a> { + package_name: &'a str, +} + +impl<'a> GormExporterWithConfig<'a> { + /// `package_name` is the already-resolved effective package name (see + /// `VespertideConfig::gorm_package_name`), not the raw `GormConfig` + /// field — resolving requires the actual export directory, which the + /// `GormConfig` alone doesn't know. + pub fn new(package_name: &'a str) -> Self { + Self { package_name } + } + + pub fn render_entity(&self, table: &TableDef) -> Result { + Ok(render_entity_inner_with_package( + table, + &[], + self.package_name, + )) + } + + pub fn render_entity_with_schema( + &self, + table: &TableDef, + schema: &[TableDef], + ) -> Result { + Ok(render_entity_inner_with_package( + table, + schema, + self.package_name, + )) + } +} + +/// Render a GORM entity for the given table definition. +pub fn render_entity(table: &TableDef) -> Result { + Ok(render_entity_inner(table, &[])) +} + +/// Render a GORM entity with full schema context for reverse-relation (HasMany) generation. +pub fn render_entity_with_schema(table: &TableDef, schema: &[TableDef]) -> Result { + Ok(render_entity_inner(table, schema)) +} + +#[cfg(test)] +pub(crate) fn to_pascal_case_for_tests(s: &str) -> String { + to_pascal_case(s) +} + +fn render_entity_inner(table: &TableDef, schema: &[TableDef]) -> String { + render_entity_inner_with_package(table, schema, DEFAULT_GORM_PACKAGE_NAME) +} + +fn render_entity_inner_with_package( + table: &TableDef, + schema: &[TableDef], + package_name: &str, +) -> String { + let mut lines: Vec = Vec::new(); + + let struct_name = + sanitize_identifier(&to_pascal_case(&table.name), IdentifierStart::Underscore); + + // Find enum names that appear in multiple schema tables (need qualified Go type names) + let conflicting_enums: HashSet = { + let mut counts: HashMap = HashMap::new(); + for col in &table.columns { + if let ColumnType::Complex(ComplexColumnType::Enum { name, .. }) = &col.r#type { + counts + .entry(sanitize_identifier( + &to_pascal_case(name), + IdentifierStart::Underscore, + )) + .or_insert(1); + } + } + for other in schema { + if other.name == table.name { + continue; + } + let mut seen = HashSet::new(); + for col in &other.columns { + if let ColumnType::Complex(ComplexColumnType::Enum { name, .. }) = &col.r#type { + let pascal = + sanitize_identifier(&to_pascal_case(name), IdentifierStart::Underscore); + if seen.insert(pascal.clone()) { + *counts.entry(pascal).or_default() += 1; + } + } + } + } + counts + .into_iter() + .filter(|(_, c)| *c > 1) + .map(|(n, _)| n) + .collect() + }; + + // Collect enums defined in this table's columns, with qualified names where needed + let enums: Vec<(&str, &EnumValues, String)> = table + .columns + .iter() + .filter_map(|col| { + if let ColumnType::Complex(ComplexColumnType::Enum { name, values }) = &col.r#type { + let pascal = + sanitize_identifier(&to_pascal_case(name), IdentifierStart::Underscore); + let qualified = if conflicting_enums.contains(&pascal) { + format!("{struct_name}{pascal}") + } else { + pascal + }; + Some((name.as_str(), values, qualified)) + } else { + None + } + }) + .collect(); + let enum_name_map: HashMap<&str, String> = enums + .iter() + .map(|(name, _, qualified)| (*name, qualified.clone())) + .collect(); + + let fk_by_column = collect_fk_info(&table.constraints); + + let pk_columns: HashSet = table + .constraints + .iter() + .filter_map(|c| { + if let TableConstraint::PrimaryKey { columns, .. } = c { + Some(columns.clone()) + } else { + None + } + }) + .flatten() + .map(|c| c.as_str().to_owned()) + .collect(); + + let auto_increment = table.constraints.iter().any(|c| { + matches!( + c, + TableConstraint::PrimaryKey { + auto_increment: true, + .. + } + ) + }); + + let is_composite_pk = pk_columns.len() > 1; + + let single_unique_columns: HashSet = table + .constraints + .iter() + .filter_map(|c| { + if let TableConstraint::Unique { columns, .. } = c { + if columns.len() == 1 { + Some(columns[0].as_str().to_owned()) + } else { + None + } + } else { + None + } + }) + .collect(); + + let index_map = collect_index_info(&table.constraints); + let composite_unique_map = collect_composite_unique_info(&table.constraints); + + let mut used_imports = UsedImports::default(); + for col in &table.columns { + used_imports.add_column_type(&col.r#type); + } + + let reverse_relations = find_reverse_relations(&table.name, schema); + + // --- Package declaration --- + lines.push(format!("package {package_name}")); + lines.push(String::new()); + + // --- Imports --- + let has_stdlib = used_imports.needs_time; + let has_external = + used_imports.needs_uuid || used_imports.needs_datatypes || used_imports.needs_decimal; + + if has_stdlib || has_external { + lines.push("import (".into()); + if has_stdlib { + lines.push(" \"time\"".into()); + } + if has_stdlib && has_external { + lines.push(String::new()); + } + if used_imports.needs_datatypes { + lines.push(" \"gorm.io/datatypes\"".into()); + } + if used_imports.needs_uuid { + lines.push(" \"github.com/google/uuid\"".into()); + } + if used_imports.needs_decimal { + lines.push(" \"github.com/shopspring/decimal\"".into()); + } + lines.push(")".into()); + lines.push(String::new()); + } + + // --- Enum type declarations --- + for (_, values, qualified_name) in &enums { + render_enum(&mut lines, qualified_name, values); + lines.push(String::new()); + } + + // --- Struct definition --- + if let Some(ref desc) = table.description { + lines.push(format!("// {}", desc.replace('\n', " "))); + } + + lines.push(format!("type {struct_name} struct {{")); + + // Every real column's field name is reserved up front so belongs-to + // relation fields (single-column and composite) can detect a collision + // regardless of which column — FK or plain — happens to come first in + // the table definition. + let used_field_names: HashSet = table + .columns + .iter() + .map(|c| to_go_field_name(&c.name)) + .collect(); + let mut used_relation_names = used_field_names.clone(); + + for col in &table.columns { + let is_pk = pk_columns.contains(col.name.as_str()); + let is_unique = single_unique_columns.contains(col.name.as_str()); + let indexes = index_map + .get(col.name.as_str()) + .map_or(&[][..], Vec::as_slice); + let composite_unique_name = composite_unique_map.get(col.name.as_str()); + + if let Some(ref comment) = col.comment { + lines.push(format!(" // {}", comment.replace('\n', " "))); + } + + render_column_field( + &mut lines, + col, + is_pk, + auto_increment && !is_composite_pk, + is_unique, + indexes, + composite_unique_name, + &enum_name_map, + ); + + if let Some(fk) = fk_by_column.get(col.name.as_str()) { + render_fk_relation_field(&mut lines, col, fk, &mut used_relation_names); + } + } + + // Composite (multi-column) FK relation fields. GORM supports composite + // associations via comma-separated `foreignKey`/`references` tags, unlike + // Django which has no native equivalent. + for fk in collect_composite_fk_info(&table.constraints) { + render_composite_fk_relation_field(&mut lines, &fk, &mut used_relation_names); + } + + // Reverse relation fields (HasMany) derived from schema context + for rel in &reverse_relations { + let mut constraint_parts: Vec = Vec::new(); + if let Some(ref action) = rel.on_delete { + constraint_parts.push(format!("OnDelete:{}", reference_action_str(action))); + } + if let Some(ref action) = rel.on_update { + constraint_parts.push(format!("OnUpdate:{}", reference_action_str(action))); + } + let fk_field = to_go_field_name(&rel.fk_column); + let gorm_tag = if constraint_parts.is_empty() { + format!("foreignKey:{fk_field}") + } else { + format!( + "foreignKey:{fk_field};constraint:{}", + constraint_parts.join(",") + ) + }; + lines.push(format!( + " {field_name} []{ref_struct} `gorm:\"{gorm_tag}\" json:\"-\"`", + field_name = rel.field_name, + ref_struct = + sanitize_identifier(&to_pascal_case(&rel.ref_table), IdentifierStart::Underscore), + )); + } + + lines.push("}".into()); + lines.push(String::new()); + + // --- TableName() method --- + if needs_table_name_method(&table.name, &struct_name) { + lines.push(format!( + "func ({struct_name}) TableName() string {{ return \"{name}\" }}", + name = table.name, + )); + lines.push(String::new()); + } + + lines.join("\n") +} + +// --------------------------------------------------------------------------- +// FK info collection +// --------------------------------------------------------------------------- + +struct FkInfo { + ref_table: String, + on_delete: Option, + on_update: Option, +} + +struct CompositeFkInfo { + local_cols: Vec, + ref_table: String, + ref_cols: Vec, + on_delete: Option, + on_update: Option, +} + +fn collect_composite_fk_info(constraints: &[TableConstraint]) -> Vec { + constraints + .iter() + .filter_map(|c| { + if let TableConstraint::ForeignKey { + columns, + ref_table, + ref_columns, + on_delete, + on_update, + .. + } = c + && columns.len() > 1 + && columns.len() == ref_columns.len() + { + return Some(CompositeFkInfo { + local_cols: columns.iter().map(|c| c.as_str().to_owned()).collect(), + ref_table: ref_table.as_str().to_owned(), + ref_cols: ref_columns.iter().map(|c| c.as_str().to_owned()).collect(), + on_delete: on_delete.clone(), + on_update: on_update.clone(), + }); + } + None + }) + .collect() +} + +fn collect_fk_info(constraints: &[TableConstraint]) -> HashMap { + constraints + .iter() + .filter_map(|c| { + if let TableConstraint::ForeignKey { + columns, + ref_table, + ref_columns, + on_delete, + on_update, + .. + } = c + { + if columns.len() == 1 && ref_columns.len() == 1 { + Some(( + columns[0].as_str().to_owned(), + FkInfo { + ref_table: ref_table.as_str().to_owned(), + on_delete: on_delete.clone(), + on_update: on_update.clone(), + }, + )) + } else { + None + } + } else { + None + } + }) + .collect() +} + +// --------------------------------------------------------------------------- +// Index info collection +// --------------------------------------------------------------------------- + +struct IndexInfo { + name: Option, +} + +fn collect_index_info(constraints: &[TableConstraint]) -> HashMap> { + let mut map: HashMap> = HashMap::new(); + for c in constraints { + if let TableConstraint::Index { name, columns } = c { + for col in columns { + map.entry(col.as_str().to_owned()) + .or_default() + .push(IndexInfo { + name: name.as_ref().map(|n| n.as_str().to_owned()), + }); + } + } + } + map +} + +fn collect_composite_unique_info(constraints: &[TableConstraint]) -> HashMap { + let mut map = HashMap::new(); + for c in constraints { + if let TableConstraint::Unique { name, columns, .. } = c + && columns.len() > 1 + { + let uq_name = name.as_ref().map_or_else( + || { + let parts: Vec<&str> = columns.iter().map(ColumnName::as_str).collect(); + format!("uq_{}", parts.join("_")) + }, + |n| n.as_str().to_owned(), + ); + for col in columns { + map.insert(col.as_str().to_owned(), uq_name.clone()); + } + } + } + map +} + +// --------------------------------------------------------------------------- +// Reverse relation discovery +// --------------------------------------------------------------------------- + +struct ReverseRelation { + field_name: String, + ref_table: String, + fk_column: String, + on_delete: Option, + on_update: Option, +} + +fn find_reverse_relations(table_name: &str, schema: &[TableDef]) -> Vec { + type RawRelation = ( + String, + String, + String, + Option, + Option, + ); + let mut raw: Vec = Vec::new(); + for other in schema { + // Note: self-referencing tables (other.name == table_name) are NOT + // skipped here — a table's own FK column pointing back at itself + // (e.g. categories.parent_id -> categories.id) must still produce a + // reverse has-many ("Children") relation on the same struct. + for c in &other.constraints { + if let TableConstraint::ForeignKey { + columns, + ref_table, + on_delete, + on_update, + .. + } = c + && ref_table.as_str() == table_name + && columns.len() == 1 + { + let fk_col = columns[0].as_str().to_owned(); + let is_self_ref = other.name.as_str() == table_name; + let base_name = if is_self_ref { + "Children".to_string() + } else { + let pascal = sanitize_identifier( + &to_pascal_case(other.name.as_str()), + IdentifierStart::Underscore, + ); + if pascal.ends_with('s') { + pascal + } else { + format!("{pascal}s") + } + }; + raw.push(( + other.name.as_str().to_owned(), + fk_col, + base_name, + on_delete.clone(), + on_update.clone(), + )); + } + } + } + + let mut name_count: HashMap = HashMap::new(); + for (_, _, base_name, _, _) in &raw { + *name_count.entry(base_name.clone()).or_default() += 1; + } + + raw.into_iter() + .map(|(ref_table, fk_col, base_name, on_delete, on_update)| { + let field_name = if *name_count.get(&base_name).unwrap_or(&0) > 1 { + format!("{}By{}", base_name, to_go_field_name(&fk_col)) + } else { + base_name + }; + ReverseRelation { + field_name, + ref_table, + fk_column: fk_col, + on_delete, + on_update, + } + }) + .collect() +} + +// --------------------------------------------------------------------------- +// Enum rendering +// --------------------------------------------------------------------------- + +fn render_enum(lines: &mut Vec, name: &str, values: &EnumValues) { + // `name` is already the sanitized, PascalCased (and possibly struct-qualified) + // identifier built by the caller — re-running `to_pascal_case` here would + // split on the `_` a leading-digit escape (e.g. `_1users`) introduces and + // silently drop it. + let type_name = name; + + let mut rendered = match values { + EnumValues::String(_) => { + vec![ + format!("type {type_name} string"), + String::new(), + "const (".into(), + ] + } + EnumValues::Integer(_) => { + vec![ + format!("type {type_name} int"), + String::new(), + "const (".into(), + ] + } + }; + + match values { + EnumValues::String(vals) => { + for val in vals { + let const_name = format!("{type_name}{}", to_pascal_case(val)); + rendered.push(format!(" {const_name} {type_name} = \"{val}\"")); + } + } + EnumValues::Integer(vals) => { + for val in vals { + let const_name = format!("{type_name}{}", to_pascal_case(&val.name)); + rendered.push(format!(" {const_name} {type_name} = {}", val.value)); + } + } + } + + rendered.push(")".into()); + lines.extend(rendered); +} + +// --------------------------------------------------------------------------- +// Field rendering +// --------------------------------------------------------------------------- + +#[expect( + clippy::too_many_arguments, + reason = "all params are independent field-rendering inputs; a context struct would add noise without reducing coupling" +)] +fn render_column_field( + lines: &mut Vec, + col: &ColumnDef, + is_pk: bool, + auto_increment: bool, + is_unique: bool, + indexes: &[IndexInfo], + composite_unique_name: Option<&String>, + enum_name_map: &HashMap<&str, String>, +) { + let go_type = go_type_for_column_mapped(&col.r#type, col.nullable, enum_name_map); + let field_name = to_go_field_name(&col.name); + let gorm_tag = build_gorm_tag( + col, + is_pk, + auto_increment, + is_unique, + indexes, + composite_unique_name, + ); + + lines.push(format!( + " {field_name} {go_type} `gorm:\"{gorm_tag}\" json:\"{json_name}\"`", + json_name = col.name, + )); +} + +fn render_fk_relation_field( + lines: &mut Vec, + col: &ColumnDef, + fk: &FkInfo, + used_relation_names: &mut HashSet, +) { + let ref_struct = + sanitize_identifier(&to_pascal_case(&fk.ref_table), IdentifierStart::Underscore); + let fk_field_name = to_go_field_name(&col.name); + let mut relation_field_name = infer_relation_field_name(&col.name); + if relation_field_name == fk_field_name { + relation_field_name = format!("{relation_field_name}{ref_struct}"); + } + // The name above only rules out colliding with this FK's own scalar + // field; it can still collide with an unrelated real column (or another + // relation) elsewhere in the table, so fall back to a numbered suffix. + if used_relation_names.contains(&relation_field_name) { + let mut n = 2; + loop { + let candidate = format!("{relation_field_name}{n}"); + if !used_relation_names.contains(&candidate) { + relation_field_name = candidate; + break; + } + n += 1; + } + } + used_relation_names.insert(relation_field_name.clone()); + + let mut constraint_parts: Vec = Vec::new(); + if let Some(ref action) = fk.on_delete { + constraint_parts.push(format!("OnDelete:{}", reference_action_str(action))); + } + if let Some(ref action) = fk.on_update { + constraint_parts.push(format!("OnUpdate:{}", reference_action_str(action))); + } + + let gorm_tag = if constraint_parts.is_empty() { + format!("foreignKey:{fk_field_name}") + } else { + format!( + "foreignKey:{fk_field_name};constraint:{}", + constraint_parts.join(",") + ) + }; + + let type_expr = if col.nullable { + format!("*{ref_struct}") + } else { + ref_struct + }; + + lines.push(format!( + " {relation_field_name} {type_expr} `gorm:\"{gorm_tag}\" json:\"-\"`" + )); +} + +/// Render a belongs-to relation field for a composite (multi-column) FK, +/// using GORM's comma-separated `foreignKey`/`references` tag syntax. +fn render_composite_fk_relation_field( + lines: &mut Vec, + fk: &CompositeFkInfo, + used_relation_names: &mut HashSet, +) { + let ref_struct = + sanitize_identifier(&to_pascal_case(&fk.ref_table), IdentifierStart::Underscore); + + let mut relation_field_name = ref_struct.clone(); + if used_relation_names.contains(&relation_field_name) { + let mut n = 2; + loop { + let candidate = format!("{relation_field_name}{n}"); + if !used_relation_names.contains(&candidate) { + relation_field_name = candidate; + break; + } + n += 1; + } + } + used_relation_names.insert(relation_field_name.clone()); + + let fk_fields: Vec = fk.local_cols.iter().map(|c| to_go_field_name(c)).collect(); + let ref_fields: Vec = fk.ref_cols.iter().map(|c| to_go_field_name(c)).collect(); + + let mut constraint_parts: Vec = Vec::new(); + if let Some(ref action) = fk.on_delete { + constraint_parts.push(format!("OnDelete:{}", reference_action_str(action))); + } + if let Some(ref action) = fk.on_update { + constraint_parts.push(format!("OnUpdate:{}", reference_action_str(action))); + } + + let gorm_tag = if constraint_parts.is_empty() { + format!( + "foreignKey:{};references:{}", + fk_fields.join(","), + ref_fields.join(",") + ) + } else { + format!( + "foreignKey:{};references:{};constraint:{}", + fk_fields.join(","), + ref_fields.join(","), + constraint_parts.join(",") + ) + }; + + lines.push(format!( + " {relation_field_name} {ref_struct} `gorm:\"{gorm_tag}\" json:\"-\"`" + )); +} + +// --------------------------------------------------------------------------- +// GORM tag building +// --------------------------------------------------------------------------- + +fn build_gorm_tag( + col: &ColumnDef, + is_pk: bool, + auto_increment: bool, + is_unique: bool, + indexes: &[IndexInfo], + composite_unique_name: Option<&String>, +) -> String { + let mut parts: Vec = vec![format!("column:{}", col.name)]; + + if is_pk { + parts.push("primaryKey".into()); + } + if is_pk && auto_increment { + parts.push("autoIncrement".into()); + } + if !col.nullable && !is_pk { + parts.push("not null".into()); + } + if is_unique && !is_pk { + parts.push("unique".into()); + } + + match &col.r#type { + ColumnType::Simple(SimpleColumnType::Text) => parts.push("type:text".into()), + ColumnType::Simple(SimpleColumnType::Xml) => parts.push("type:xml".into()), + ColumnType::Simple(SimpleColumnType::Interval) => parts.push("type:interval".into()), + ColumnType::Simple(SimpleColumnType::Date) => parts.push("type:date".into()), + ColumnType::Simple(SimpleColumnType::Time) => parts.push("type:time".into()), + ColumnType::Simple(SimpleColumnType::Uuid) => parts.push("type:uuid".into()), + ColumnType::Complex(ComplexColumnType::Varchar { length }) => { + parts.push(format!("size:{length}")); + } + ColumnType::Complex(ComplexColumnType::Char { length }) => { + parts.push(format!("size:{length}")); + parts.push("type:char".into()); + } + ColumnType::Complex(ComplexColumnType::Numeric { precision, scale }) => { + parts.push(format!("type:numeric({precision},{scale})")); + } + ColumnType::Complex(ComplexColumnType::Custom { custom_type }) => { + parts.push(format!("type:{custom_type}")); + } + _ => {} + } + + if let Some(ref default) = col.default + && let Some(tag) = build_default_tag(default) + { + parts.push(tag); + } + + for idx in indexes { + if let Some(ref name) = idx.name { + parts.push(format!("index:{name}")); + } else { + parts.push("index".into()); + } + } + + if let Some(uq_name) = composite_unique_name { + parts.push(format!("uniqueIndex:{uq_name}")); + } + + parts.join(";") +} + +fn build_default_tag(default: &DefaultValue) -> Option { + let sql = default.to_sql(); + if sql.contains('(') { + return None; // Skip server-side function calls like NOW() + } + Some(format!("default:{sql}")) +} + +// --------------------------------------------------------------------------- +// Type mapping +// --------------------------------------------------------------------------- + +pub(super) fn go_type_for_column_mapped( + col_type: &ColumnType, + nullable: bool, + enum_map: &HashMap<&str, String>, +) -> String { + let base = match col_type { + ColumnType::Complex(ComplexColumnType::Enum { name, .. }) => { + enum_map.get(name.as_str()).cloned().unwrap_or_else(|| { + sanitize_identifier(&to_pascal_case(name), IdentifierStart::Underscore) + }) + } + _ => go_base_type(col_type), + }; + if nullable { format!("*{base}") } else { base } +} + +fn go_base_type(col_type: &ColumnType) -> String { + match col_type { + ColumnType::Simple(ty) => match SimpleColumnKind::from(*ty) { + SimpleColumnKind::SmallInt => "int16".to_string(), + SimpleColumnKind::Integer => "int32".to_string(), + SimpleColumnKind::BigInt => "int64".to_string(), + SimpleColumnKind::Real => "float32".to_string(), + SimpleColumnKind::DoublePrecision => "float64".to_string(), + SimpleColumnKind::Text + | SimpleColumnKind::Xml + | SimpleColumnKind::Inet + | SimpleColumnKind::Cidr + | SimpleColumnKind::Macaddr + | SimpleColumnKind::Interval => "string".to_string(), + SimpleColumnKind::Boolean => "bool".to_string(), + SimpleColumnKind::Date + | SimpleColumnKind::Time + | SimpleColumnKind::Timestamp + | SimpleColumnKind::Timestamptz => "time.Time".to_string(), + SimpleColumnKind::Bytea => "[]byte".to_string(), + SimpleColumnKind::Uuid => "uuid.UUID".to_string(), + SimpleColumnKind::Json => "datatypes.JSON".to_string(), + }, + ColumnType::Complex(ty) => match ty { + ComplexColumnType::Varchar { .. } | ComplexColumnType::Char { .. } => { + "string".to_string() + } + ComplexColumnType::Custom { custom_type } => { + if custom_type.to_uppercase() == "JSONB" { + "datatypes.JSON".to_string() + } else { + "string".to_string() + } + } + ComplexColumnType::Numeric { .. } => "decimal.Decimal".to_string(), + // `#[non_exhaustive]` future-variant guard; unreachable today. + #[cfg(not(tarpaulin_include))] + _ => { + unreachable!("ComplexColumnType is #[non_exhaustive]; all variants matched") + } + }, + } +} + +fn reference_action_str(action: &ReferenceAction) -> &'static str { + match ReferenceActionKind::from(action) { + ReferenceActionKind::Cascade => "CASCADE", + ReferenceActionKind::Restrict => "RESTRICT", + ReferenceActionKind::SetNull => "SET NULL", + ReferenceActionKind::SetDefault => "SET DEFAULT", + ReferenceActionKind::NoAction => "NO ACTION", + } +} + +// --------------------------------------------------------------------------- +// Naming utilities +// --------------------------------------------------------------------------- + +fn to_pascal_case(s: &str) -> String { + s.split('_') + .map(|word| { + let mut chars = word.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().chain(chars).collect(), + } + }) + .collect() +} + +pub(super) fn to_go_field_name(s: &str) -> String { + let pascal = to_pascal_case(s); + // Apply Go conventions for common abbreviations + let pascal = pascal.replace("Id", "ID"); + // Go identifiers can't start with a digit or contain non-alphanumeric + // characters; a leading `_` is legal (matches Rust module / Java field + // escaping elsewhere in the exporter). + sanitize_identifier(&pascal, IdentifierStart::Underscore) +} + +pub(super) fn infer_relation_field_name(fk_column: &str) -> String { + let base = fk_column.strip_suffix("_id").unwrap_or(fk_column); + sanitize_identifier(&to_pascal_case(base), IdentifierStart::Underscore) +} + +fn pascal_to_snake(s: &str) -> String { + let mut result = String::new(); + for c in s.chars() { + if c.is_uppercase() && !result.is_empty() { + result.push('_'); + } + result.extend(c.to_lowercase()); + } + result +} + +pub(super) fn needs_table_name_method(table_name: &str, struct_name: &str) -> bool { + let snake = pascal_to_snake(struct_name); + let gorm_default = format!("{snake}s"); + gorm_default != table_name +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests; diff --git a/crates/vespertide-exporter/src/gorm/tests/mod.rs b/crates/vespertide-exporter/src/gorm/tests/mod.rs new file mode 100644 index 00000000..5bf88e72 --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/tests/mod.rs @@ -0,0 +1,1137 @@ +use std::collections::HashMap; + +use insta::assert_snapshot; +use rstest::rstest; +use vespertide_core::schema::column::{ + ColumnType, ComplexColumnType, EnumValues, SimpleColumnType, +}; +use vespertide_core::schema::constraint::TableConstraint; +use vespertide_core::{ColumnDef, DefaultValue, NumValue, ReferenceAction, TableDef}; + +use super::{ + GormExporterWithConfig, go_type_for_column_mapped, infer_relation_field_name, + needs_table_name_method, render_entity, render_entity_with_schema, to_go_field_name, +}; + +mod relations; + +fn col(name: &str, ty: ColumnType) -> ColumnDef { + ColumnDef { + name: name.into(), + r#type: ty, + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + } +} + +// ----------------------------------------------------------------------- +// Type mapping unit tests +// ----------------------------------------------------------------------- + +#[rstest] +#[case(ColumnType::Simple(SimpleColumnType::SmallInt), false, "int16")] +#[case(ColumnType::Simple(SimpleColumnType::Integer), false, "int32")] +#[case(ColumnType::Simple(SimpleColumnType::BigInt), false, "int64")] +#[case(ColumnType::Simple(SimpleColumnType::Real), false, "float32")] +#[case( + ColumnType::Simple(SimpleColumnType::DoublePrecision), + false, + "float64" +)] +#[case(ColumnType::Simple(SimpleColumnType::Text), false, "string")] +#[case(ColumnType::Simple(SimpleColumnType::Boolean), false, "bool")] +#[case(ColumnType::Simple(SimpleColumnType::Timestamp), false, "time.Time")] +#[case(ColumnType::Simple(SimpleColumnType::Timestamptz), false, "time.Time")] +#[case(ColumnType::Simple(SimpleColumnType::Date), false, "time.Time")] +#[case(ColumnType::Simple(SimpleColumnType::Time), false, "time.Time")] +#[case(ColumnType::Simple(SimpleColumnType::Uuid), false, "uuid.UUID")] +#[case(ColumnType::Simple(SimpleColumnType::Json), false, "datatypes.JSON")] +#[case(ColumnType::Simple(SimpleColumnType::Bytea), false, "[]byte")] +#[case(ColumnType::Simple(SimpleColumnType::Inet), false, "string")] +#[case(ColumnType::Complex(ComplexColumnType::Varchar { length: 255 }), false, "string")] +#[case(ColumnType::Complex(ComplexColumnType::Numeric { precision: 10, scale: 2 }), false, "decimal.Decimal")] +#[case(ColumnType::Complex(ComplexColumnType::Custom { custom_type: "JSONB".into() }), false, "datatypes.JSON")] +#[case(ColumnType::Complex(ComplexColumnType::Custom { custom_type: "jsonb".into() }), false, "datatypes.JSON")] +#[case(ColumnType::Complex(ComplexColumnType::Custom { custom_type: "TEXT".into() }), false, "string")] +#[case(ColumnType::Simple(SimpleColumnType::Integer), true, "*int32")] +#[case(ColumnType::Simple(SimpleColumnType::Text), true, "*string")] +#[case(ColumnType::Simple(SimpleColumnType::Timestamp), true, "*time.Time")] +fn test_go_type_mapping( + #[case] col_type: ColumnType, + #[case] nullable: bool, + #[case] expected: &str, +) { + assert_eq!( + go_type_for_column_mapped(&col_type, nullable, &HashMap::new()), + expected + ); +} + +#[rstest] +#[case("user_id", "UserID")] +#[case("id", "ID")] +#[case("created_at", "CreatedAt")] +#[case("profile_image", "ProfileImage")] +#[case("media_id", "MediaID")] +fn test_to_go_field_name(#[case] input: &str, #[case] expected: &str) { + assert_eq!(to_go_field_name(input), expected); +} + +#[rstest] +#[case("user_id", "User")] +#[case("author_id", "Author")] +#[case("parent_id", "Parent")] +#[case("node", "Node")] +fn test_infer_relation_field_name(#[case] input: &str, #[case] expected: &str) { + assert_eq!(infer_relation_field_name(input), expected); +} + +#[rstest] +#[case("User", "user", true)] +#[case("User", "users", false)] +#[case("OrderItem", "order_items", false)] +#[case("OrderItem", "order_item", true)] +fn test_needs_table_name_method( + #[case] struct_name: &str, + #[case] table_name: &str, + #[case] expected: bool, +) { + assert_eq!(needs_table_name_method(table_name, struct_name), expected); +} + +// ----------------------------------------------------------------------- +// Snapshot tests (a) simple table - columns only +// ----------------------------------------------------------------------- + +#[test] +fn test_basic_table() { + let table = TableDef { + name: "users".into(), + description: Some("User accounts".into()), + columns: vec![ + ColumnDef { + name: "id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: Some("Primary key".into()), + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "email".into(), + r#type: ColumnType::Complex(ComplexColumnType::Varchar { length: 255 }), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "name".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: true, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "active".into(), + r#type: ColumnType::Simple(SimpleColumnType::Boolean), + nullable: false, + default: Some(DefaultValue::Bool(true)), + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::Unique { + name: None, + columns: vec!["email".into()], + strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates { + keep: vespertide_core::KeepPolicy::First, + }, + }, + ], + }; + let result = render_entity(&table).unwrap(); + assert_snapshot!(result); +} + +// ----------------------------------------------------------------------- +// Snapshot tests (b) FK +// ----------------------------------------------------------------------- + +#[test] +fn test_table_with_foreign_key() { + let table = TableDef { + name: "posts".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + ColumnDef { + name: "author_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + col("title", ColumnType::Simple(SimpleColumnType::Text)), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["author_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: Some(ReferenceAction::Cascade), + on_update: None, + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + TableConstraint::Index { + name: Some("ix_posts__author_id".into()), + columns: vec!["author_id".into()], + }, + ], + }; + let result = render_entity(&table).unwrap(); + assert_snapshot!(result); +} + +// ----------------------------------------------------------------------- +// Snapshot tests (c) enums +// ----------------------------------------------------------------------- + +#[test] +fn test_table_with_string_enum() { + let table = TableDef { + name: "orders".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + ColumnDef { + name: "status".into(), + r#type: ColumnType::Complex(ComplexColumnType::Enum { + name: "order_status".into(), + values: EnumValues::String(vec![ + "pending".into(), + "shipped".into(), + "delivered".into(), + ]), + }), + nullable: false, + default: Some(DefaultValue::String("'pending'".into())), + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + }; + let result = render_entity(&table).unwrap(); + assert_snapshot!(result); +} + +#[test] +fn test_table_with_integer_enum() { + let table = TableDef { + name: "tasks".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + ColumnDef { + name: "priority".into(), + r#type: ColumnType::Complex(ComplexColumnType::Enum { + name: "priority_level".into(), + values: EnumValues::Integer(vec![ + NumValue { + name: "low".into(), + value: 0, + }, + NumValue { + name: "medium".into(), + value: 10, + }, + NumValue { + name: "high".into(), + value: 20, + }, + ]), + }), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: false, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + }; + let result = render_entity(&table).unwrap(); + assert_snapshot!(result); +} + +// ----------------------------------------------------------------------- +// Snapshot tests (d) composite PK + nullable +// ----------------------------------------------------------------------- + +#[test] +fn test_composite_pk_nullable() { + let table = TableDef { + name: "order_items".into(), + description: None, + columns: vec![ + col("order_id", ColumnType::Simple(SimpleColumnType::Integer)), + col("product_id", ColumnType::Simple(SimpleColumnType::Integer)), + ColumnDef { + name: "quantity".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "note".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: true, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: false, + columns: vec!["order_id".into(), "product_id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["order_id".into()], + ref_table: "orders".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["product_id".into()], + ref_table: "products".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + TableConstraint::Unique { + name: Some("uq_order_items__order_product".into()), + columns: vec!["order_id".into(), "product_id".into()], + strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates { + keep: vespertide_core::KeepPolicy::First, + }, + }, + ], + }; + let result = render_entity(&table).unwrap(); + assert_snapshot!(result); +} + +// ----------------------------------------------------------------------- +// All simple types +// ----------------------------------------------------------------------- + +#[test] +fn test_all_simple_types() { + let table = TableDef { + name: "type_test".into(), + description: None, + columns: vec![ + col( + "col_smallint", + ColumnType::Simple(SimpleColumnType::SmallInt), + ), + col("col_integer", ColumnType::Simple(SimpleColumnType::Integer)), + col("col_bigint", ColumnType::Simple(SimpleColumnType::BigInt)), + col("col_real", ColumnType::Simple(SimpleColumnType::Real)), + col( + "col_double", + ColumnType::Simple(SimpleColumnType::DoublePrecision), + ), + col("col_text", ColumnType::Simple(SimpleColumnType::Text)), + col("col_boolean", ColumnType::Simple(SimpleColumnType::Boolean)), + col("col_date", ColumnType::Simple(SimpleColumnType::Date)), + col("col_time", ColumnType::Simple(SimpleColumnType::Time)), + col( + "col_timestamp", + ColumnType::Simple(SimpleColumnType::Timestamp), + ), + col( + "col_timestamptz", + ColumnType::Simple(SimpleColumnType::Timestamptz), + ), + col( + "col_interval", + ColumnType::Simple(SimpleColumnType::Interval), + ), + col("col_bytea", ColumnType::Simple(SimpleColumnType::Bytea)), + col("col_uuid", ColumnType::Simple(SimpleColumnType::Uuid)), + col("col_json", ColumnType::Simple(SimpleColumnType::Json)), + col("col_inet", ColumnType::Simple(SimpleColumnType::Inet)), + col("col_cidr", ColumnType::Simple(SimpleColumnType::Cidr)), + col("col_macaddr", ColumnType::Simple(SimpleColumnType::Macaddr)), + col("col_xml", ColumnType::Simple(SimpleColumnType::Xml)), + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: false, + columns: vec!["col_integer".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + }; + let result = render_entity(&table).unwrap(); + assert_snapshot!(result); +} + +// ----------------------------------------------------------------------- +// Snapshot tests (e) JSONB custom type +// ----------------------------------------------------------------------- + +#[test] +fn test_table_with_jsonb_column() { + let table = TableDef { + name: "documents".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "data", + ColumnType::Complex(ComplexColumnType::Custom { + custom_type: "JSONB".into(), + }), + ), + col("meta", ColumnType::Simple(SimpleColumnType::Json)), + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + }; + let result = render_entity(&table).unwrap(); + assert_snapshot!(result); +} + +// ----------------------------------------------------------------------- +// Snapshot tests (f) server-side default skipped +// ----------------------------------------------------------------------- + +#[test] +fn test_server_default_skipped() { + let table = TableDef { + name: "events".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + ColumnDef { + name: "created_at".into(), + r#type: ColumnType::Simple(SimpleColumnType::Timestamptz), + nullable: false, + default: Some(DefaultValue::String("NOW()".into())), + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "count".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: Some(DefaultValue::Integer(0)), + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + }; + let result = render_entity(&table).unwrap(); + // Server-side function calls must not appear as GORM default tags + assert!(!result.contains("default:NOW()")); + // Literal integer defaults are still included + assert!(result.contains("default:0")); + assert_snapshot!(result); +} + +// ----------------------------------------------------------------------- +// Conflicting enum names across tables → qualified Go type name +// ----------------------------------------------------------------------- + +#[test] +fn test_conflicting_enum_names_qualified() { + let orders = TableDef { + name: "orders".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "status", + ColumnType::Complex(ComplexColumnType::Enum { + name: "status".into(), + values: EnumValues::String(vec!["pending".into(), "done".into()]), + }), + ), + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + }; + let tasks = TableDef { + name: "tasks".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "status", + ColumnType::Complex(ComplexColumnType::Enum { + name: "status".into(), + values: EnumValues::String(vec!["open".into(), "closed".into()]), + }), + ), + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + }; + let schema = vec![orders.clone(), tasks]; + let result = render_entity_with_schema(&orders, &schema).unwrap(); + assert!( + result.contains("OrdersStatus"), + "Expected qualified enum name 'OrdersStatus' in:\n{result}" + ); +} + +// ----------------------------------------------------------------------- +// Char column → type:char + size in GORM tag +// ----------------------------------------------------------------------- + +#[test] +fn test_char_type_column() { + let table = TableDef { + name: "codes".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "code", + ColumnType::Complex(ComplexColumnType::Char { length: 3 }), + ), + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains("type:char"), + "Expected type:char in GORM tag" + ); + assert!(result.contains("size:3"), "Expected size:3 in GORM tag"); +} + +// ----------------------------------------------------------------------- +// FK field name collision: infer == go_field → disambiguate with ref struct +// ----------------------------------------------------------------------- + +#[test] +fn test_fk_relation_field_name_collision() { + // Column "user" (no _id suffix): infer→"User", go_field→"User" → same → "UserUsers" + let table = TableDef { + name: "posts".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("user", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["user".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + ], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains("UserUsers"), + "Expected disambiguated relation name 'UserUsers' in:\n{result}" + ); +} + +// ----------------------------------------------------------------------- +// Reverse relation disambiguation: two FKs to same target → ByField suffix +// ----------------------------------------------------------------------- + +#[test] +fn test_reverse_relation_disambiguation() { + let users = TableDef { + name: "users".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + }; + let events = TableDef { + name: "events".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("creator_id", ColumnType::Simple(SimpleColumnType::Integer)), + col("attendee_id", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["creator_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["attendee_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + ], + }; + let schema = vec![users.clone(), events]; + let result = render_entity_with_schema(&users, &schema).unwrap(); + assert!( + result.contains("EventsByCreatorID"), + "Expected 'EventsByCreatorID' in:\n{result}" + ); + assert!( + result.contains("EventsByAttendeeID"), + "Expected 'EventsByAttendeeID' in:\n{result}" + ); +} + +// ----------------------------------------------------------------------- +// Snapshot tests (g) HasMany with on_delete/on_update constraint +// ----------------------------------------------------------------------- + +#[test] +fn test_has_many_with_constraint() { + let users = TableDef { + name: "users".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + }; + let posts = TableDef { + name: "posts".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("user_id", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: Some(ReferenceAction::Cascade), + on_update: Some(ReferenceAction::Restrict), + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + ], + }; + let schema = vec![users.clone(), posts.clone()]; + let result = render_entity_with_schema(&users, &schema).unwrap(); + assert!(result.contains("OnDelete:CASCADE")); + assert!(result.contains("OnUpdate:RESTRICT")); + assert_snapshot!(result); +} + +// ----------------------------------------------------------------------- +// Numeric column: add_column_type needs_decimal, build_gorm_tag Numeric, decimal import +// ----------------------------------------------------------------------- + +#[test] +fn test_numeric_column_gorm() { + let table = TableDef { + name: "prices".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "amount", + ColumnType::Complex(ComplexColumnType::Numeric { + precision: 10, + scale: 2, + }), + ), + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains("type:numeric(10,2)"), + "expected numeric GORM tag" + ); + assert!( + result.contains("decimal.Decimal"), + "expected decimal.Decimal type" + ); + assert!( + result.contains("github.com/shopspring/decimal"), + "expected decimal import" + ); +} + +// ----------------------------------------------------------------------- +// Unnamed Index: build_gorm_tag unnamed index tag + collect_index_info inner body +// ----------------------------------------------------------------------- + +#[test] +fn test_unnamed_index_gorm() { + let table = TableDef { + name: "searches".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("query", ColumnType::Simple(SimpleColumnType::Text)), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::Index { + name: None, + columns: vec!["query".into()], + }, + ], + }; + let result = render_entity(&table).unwrap(); + assert!(result.contains(";index\""), "expected unnamed index tag"); +} + +// ----------------------------------------------------------------------- +// Named Index: build_gorm_tag named index tag + collect_index_info name closure +// ----------------------------------------------------------------------- + +#[test] +fn test_named_index_gorm() { + let table = TableDef { + name: "searches".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("query", ColumnType::Simple(SimpleColumnType::Text)), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::Index { + name: Some("ix_searches__query".into()), + columns: vec!["query".into()], + }, + ], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains("index:ix_searches__query"), + "expected named index tag" + ); +} + +// ----------------------------------------------------------------------- +// Unnamed composite unique: collect_composite_unique_info auto-name (uq_{cols}) +// ----------------------------------------------------------------------- + +#[test] +fn test_unnamed_composite_unique_gorm() { + let table = TableDef { + name: "order_lines".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("order_id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "sku", + ColumnType::Complex(ComplexColumnType::Varchar { length: 50 }), + ), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::Unique { + name: None, + columns: vec!["order_id".into(), "sku".into()], + strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates { + keep: vespertide_core::KeepPolicy::First, + }, + }, + ], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains("uniqueIndex:uq_order_id_sku"), + "expected auto-generated uniqueIndex name" + ); +} + +// ----------------------------------------------------------------------- +// Singular source table name: find_reverse_relations appends 's' for non-plural +// ----------------------------------------------------------------------- + +#[test] +fn test_singular_source_table_name() { + let user = TableDef { + name: "user".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + }; + let comment = TableDef { + name: "comment".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("user_id", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + ], + }; + let schema = vec![user.clone(), comment]; + let result = render_entity_with_schema(&user, &schema).unwrap(); + // "comment" → pascal "Comment" → doesn't end with 's' → appended 's' → "Comments" + assert!( + result.contains("Comments"), + "expected 'Comments' plural for singular 'comment' table" + ); +} + +// ----------------------------------------------------------------------- +// FK with on_update + nullable column: render_fk_relation_field lines 547, 559-560 +// ----------------------------------------------------------------------- + +#[test] +fn test_fk_with_on_update_and_nullable() { + let posts = TableDef { + name: "posts".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + ColumnDef { + name: "author_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: true, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["author_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: Some(ReferenceAction::Cascade), + on_update: Some(ReferenceAction::Restrict), + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + ], + }; + let result = render_entity(&posts).unwrap(); + assert!( + result.contains("OnUpdate:RESTRICT"), + "expected OnUpdate constraint" + ); + assert!( + result.contains("*Users"), + "expected nullable FK pointer type" + ); +} + +// ----------------------------------------------------------------------- +// reference_action_str: SetNull, SetDefault, NoAction (via FK on_delete) +// ----------------------------------------------------------------------- + +#[rstest] +#[case(ReferenceAction::SetNull, "SET NULL")] +#[case(ReferenceAction::SetDefault, "SET DEFAULT")] +#[case(ReferenceAction::NoAction, "NO ACTION")] +fn test_gorm_fk_on_delete_actions(#[case] action: ReferenceAction, #[case] expected: &str) { + let table = TableDef { + name: "posts".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("author_id", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["author_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: Some(action), + on_update: None, + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + ], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains(&format!("OnDelete:{expected}")), + "expected OnDelete:{expected} in:\n{result}" + ); +} + +// ----------------------------------------------------------------------- +// to_pascal_case: None arm via double-underscore table name +// ----------------------------------------------------------------------- + +#[test] +fn test_gorm_double_underscore_table_name() { + // "order__item" splits into ["order", "", "item"] → empty word hits None => String::new() + let table = TableDef { + name: "order__item".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains("type OrderItem struct"), + "expected pascal-cased struct with double underscore" + ); +} + +// ----------------------------------------------------------------------- +// collect_composite_unique_info: named branch (|n| n.as_str().to_owned()) +// ----------------------------------------------------------------------- + +#[test] +fn test_named_composite_unique_gorm() { + let table = TableDef { + name: "tenants".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)), + col("name", ColumnType::Simple(SimpleColumnType::Text)), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::Unique { + name: Some("uq_tenant_name".into()), + columns: vec!["tenant_id".into(), "name".into()], + strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates { + keep: vespertide_core::KeepPolicy::First, + }, + }, + ], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains("uniqueIndex:uq_tenant_name"), + "expected named uniqueIndex tag in GORM output" + ); +} + +// ----------------------------------------------------------------------- +// GormExporterWithConfig: package_name reaches the `package` declaration +// ----------------------------------------------------------------------- + +fn simple_table() -> TableDef { + TableDef { + name: "users".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + } +} + +#[test] +fn test_default_package_name_is_models() { + let table = simple_table(); + let exporter = GormExporterWithConfig::new("models"); + let result = exporter.render_entity(&table).unwrap(); + assert!( + result.starts_with("package models\n"), + "expected default 'package models', got:\n{result}" + ); +} + +#[test] +fn test_custom_package_name_from_config() { + let table = simple_table(); + let exporter = GormExporterWithConfig::new("entities"); + let result = exporter.render_entity(&table).unwrap(); + assert!( + result.starts_with("package entities\n"), + "expected 'package entities', got:\n{result}" + ); +} + +#[test] +fn test_custom_package_name_with_schema_context() { + let table = simple_table(); + let schema = vec![table.clone()]; + let exporter = GormExporterWithConfig::new("entities"); + let result = exporter.render_entity_with_schema(&table, &schema).unwrap(); + assert!( + result.starts_with("package entities\n"), + "expected 'package entities', got:\n{result}" + ); +} diff --git a/crates/vespertide-exporter/src/gorm/tests/relations.rs b/crates/vespertide-exporter/src/gorm/tests/relations.rs new file mode 100644 index 00000000..61b2405c --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/tests/relations.rs @@ -0,0 +1,143 @@ +use super::*; + +// ----------------------------------------------------------------------- +// Composite (multi-column) FK relation field +// ----------------------------------------------------------------------- + +fn composite_fk_table() -> TableDef { + TableDef { + name: "order_items".into(), + description: None, + columns: vec![ + col("order_id", ColumnType::Simple(SimpleColumnType::Integer)), + col("region_id", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: false, + columns: vec!["order_id".into(), "region_id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["order_id".into(), "region_id".into()], + ref_table: "order_regions".into(), + ref_columns: vec!["order_id".into(), "region_id".into()], + on_delete: Some(ReferenceAction::Cascade), + on_update: Some(ReferenceAction::Restrict), + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + ], + } +} + +#[test] +fn test_composite_fk_relation_field() { + let result = render_entity(&composite_fk_table()).unwrap(); + assert!( + result.contains( + "OrderRegions OrderRegions `gorm:\"foreignKey:OrderID,RegionID;references:OrderID,RegionID;constraint:OnDelete:CASCADE,OnUpdate:RESTRICT\" json:\"-\"`" + ), + "expected composite FK relation field in GORM output, got:\n{result}" + ); +} + +#[test] +fn test_composite_fk_relation_field_name_collision_suffixed() { + // A column already named "OrderRegions" (Go field name) collides with the + // natural composite-FK relation field name, forcing a numeric suffix. + let mut table = composite_fk_table(); + table.columns.push(col( + "order_regions", + ColumnType::Simple(SimpleColumnType::Text), + )); + let result = render_entity(&table).unwrap(); + assert!( + result.contains("OrderRegions2 OrderRegions `gorm:\"foreignKey:OrderID,RegionID"), + "expected suffixed relation field name on collision, got:\n{result}" + ); +} + +#[test] +fn test_composite_fk_relation_field_name_double_collision_increments_suffix() { + // Both "OrderRegions" and "OrderRegions2" are already taken by columns, + // so the collision loop must advance past its first candidate too. + let mut table = composite_fk_table(); + table.columns.push(col( + "order_regions", + ColumnType::Simple(SimpleColumnType::Text), + )); + table.columns.push(col( + "order_regions2", + ColumnType::Simple(SimpleColumnType::Text), + )); + let result = render_entity(&table).unwrap(); + assert!( + result.contains("OrderRegions3 OrderRegions `gorm:\"foreignKey:OrderID,RegionID"), + "expected double-suffixed relation field name on double collision, got:\n{result}" + ); +} + +// ----------------------------------------------------------------------- +// Self-referencing FK (single table referencing itself, e.g. a tree/ +// hierarchy structure: categories.parent_id -> categories.id) +// ----------------------------------------------------------------------- + +fn self_referencing_table() -> TableDef { + TableDef { + name: "categories".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + ColumnDef { + name: "parent_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: true, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["parent_id".into()], + ref_table: "categories".into(), + ref_columns: vec!["id".into()], + on_delete: Some(ReferenceAction::SetNull), + on_update: None, + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + ], + } +} + +#[test] +fn test_self_referencing_fk_forward_relation() { + let table = self_referencing_table(); + let schema = vec![table.clone()]; + let result = render_entity_with_schema(&table, &schema).unwrap(); + assert!( + result.contains("Parent *Categories `gorm:\"foreignKey:ParentID"), + "expected forward self-ref relation field, got:\n{result}" + ); +} + +#[test] +fn test_self_referencing_fk_reverse_relation() { + let table = self_referencing_table(); + let schema = vec![table.clone()]; + let result = render_entity_with_schema(&table, &schema).unwrap(); + assert!( + result.contains("Children []Categories `gorm:\"foreignKey:ParentID"), + "expected reverse (has-many) self-ref relation field, got:\n{result}" + ); +} diff --git a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__all_simple_types.snap b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__all_simple_types.snap new file mode 100644 index 00000000..c7c36956 --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__all_simple_types.snap @@ -0,0 +1,36 @@ +--- +source: crates/vespertide-exporter/src/gorm/mod.rs +expression: result +--- +package models + +import ( + "time" + + "gorm.io/datatypes" + "github.com/google/uuid" +) + +type TypeTest struct { + ColSmallint int16 `gorm:"column:col_smallint;not null" json:"col_smallint"` + ColInteger int32 `gorm:"column:col_integer;primaryKey" json:"col_integer"` + ColBigint int64 `gorm:"column:col_bigint;not null" json:"col_bigint"` + ColReal float32 `gorm:"column:col_real;not null" json:"col_real"` + ColDouble float64 `gorm:"column:col_double;not null" json:"col_double"` + ColText string `gorm:"column:col_text;not null;type:text" json:"col_text"` + ColBoolean bool `gorm:"column:col_boolean;not null" json:"col_boolean"` + ColDate time.Time `gorm:"column:col_date;not null;type:date" json:"col_date"` + ColTime time.Time `gorm:"column:col_time;not null;type:time" json:"col_time"` + ColTimestamp time.Time `gorm:"column:col_timestamp;not null" json:"col_timestamp"` + ColTimestamptz time.Time `gorm:"column:col_timestamptz;not null" json:"col_timestamptz"` + ColInterval string `gorm:"column:col_interval;not null;type:interval" json:"col_interval"` + ColBytea []byte `gorm:"column:col_bytea;not null" json:"col_bytea"` + ColUuid uuid.UUID `gorm:"column:col_uuid;not null;type:uuid" json:"col_uuid"` + ColJson datatypes.JSON `gorm:"column:col_json;not null" json:"col_json"` + ColInet string `gorm:"column:col_inet;not null" json:"col_inet"` + ColCidr string `gorm:"column:col_cidr;not null" json:"col_cidr"` + ColMacaddr string `gorm:"column:col_macaddr;not null" json:"col_macaddr"` + ColXml string `gorm:"column:col_xml;not null;type:xml" json:"col_xml"` +} + +func (TypeTest) TableName() string { return "type_test" } diff --git a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__basic_table.snap b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__basic_table.snap new file mode 100644 index 00000000..fa115d76 --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__basic_table.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/gorm/mod.rs +expression: result +--- +package models + +// User accounts +type Users struct { + // Primary key + ID int32 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + Email string `gorm:"column:email;not null;unique;size:255" json:"email"` + Name *string `gorm:"column:name;type:text" json:"name"` + Active bool `gorm:"column:active;not null;default:true" json:"active"` +} + +func (Users) TableName() string { return "users" } diff --git a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__composite_pk_nullable.snap b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__composite_pk_nullable.snap new file mode 100644 index 00000000..a70f0b44 --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__composite_pk_nullable.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/gorm/mod.rs +expression: result +--- +package models + +type OrderItems struct { + OrderID int32 `gorm:"column:order_id;primaryKey;uniqueIndex:uq_order_items__order_product" json:"order_id"` + Order Orders `gorm:"foreignKey:OrderID" json:"-"` + ProductID int32 `gorm:"column:product_id;primaryKey;uniqueIndex:uq_order_items__order_product" json:"product_id"` + Product Products `gorm:"foreignKey:ProductID" json:"-"` + Quantity int32 `gorm:"column:quantity;not null" json:"quantity"` + Note *string `gorm:"column:note;type:text" json:"note"` +} + +func (OrderItems) TableName() string { return "order_items" } diff --git a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__has_many_with_constraint.snap b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__has_many_with_constraint.snap new file mode 100644 index 00000000..fe836c1f --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__has_many_with_constraint.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/gorm/mod.rs +expression: result +--- +package models + +type Users struct { + ID int32 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + Posts []Posts `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE,OnUpdate:RESTRICT" json:"-"` +} + +func (Users) TableName() string { return "users" } diff --git a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__server_default_skipped.snap b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__server_default_skipped.snap new file mode 100644 index 00000000..cd226a79 --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__server_default_skipped.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/gorm/mod.rs +expression: result +--- +package models + +import ( + "time" +) + +type Events struct { + ID int32 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + CreatedAt time.Time `gorm:"column:created_at;not null" json:"created_at"` + Count int32 `gorm:"column:count;not null;default:0" json:"count"` +} + +func (Events) TableName() string { return "events" } diff --git a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_foreign_key.snap b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_foreign_key.snap new file mode 100644 index 00000000..37b7ce58 --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_foreign_key.snap @@ -0,0 +1,14 @@ +--- +source: crates/vespertide-exporter/src/gorm/mod.rs +expression: result +--- +package models + +type Posts struct { + ID int32 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + AuthorID int32 `gorm:"column:author_id;not null;index:ix_posts__author_id" json:"author_id"` + Author Users `gorm:"foreignKey:AuthorID;constraint:OnDelete:CASCADE" json:"-"` + Title string `gorm:"column:title;not null;type:text" json:"title"` +} + +func (Posts) TableName() string { return "posts" } diff --git a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_integer_enum.snap b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_integer_enum.snap new file mode 100644 index 00000000..f1fae1b2 --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_integer_enum.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/gorm/mod.rs +expression: result +--- +package models + +type PriorityLevel int + +const ( + PriorityLevelLow PriorityLevel = 0 + PriorityLevelMedium PriorityLevel = 10 + PriorityLevelHigh PriorityLevel = 20 +) + +type Tasks struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Priority PriorityLevel `gorm:"column:priority;not null" json:"priority"` +} + +func (Tasks) TableName() string { return "tasks" } diff --git a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_jsonb_column.snap b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_jsonb_column.snap new file mode 100644 index 00000000..73699dda --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_jsonb_column.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/gorm/mod.rs +expression: result +--- +package models + +import ( + "gorm.io/datatypes" +) + +type Documents struct { + ID int32 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + Data datatypes.JSON `gorm:"column:data;not null;type:JSONB" json:"data"` + Meta datatypes.JSON `gorm:"column:meta;not null" json:"meta"` +} + +func (Documents) TableName() string { return "documents" } diff --git a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_string_enum.snap b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_string_enum.snap new file mode 100644 index 00000000..1fbaeb7c --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_string_enum.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/gorm/mod.rs +expression: result +--- +package models + +type OrderStatus string + +const ( + OrderStatusPending OrderStatus = "pending" + OrderStatusShipped OrderStatus = "shipped" + OrderStatusDelivered OrderStatus = "delivered" +) + +type Orders struct { + ID int32 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + Status OrderStatus `gorm:"column:status;not null;default:'pending'" json:"status"` +} + +func (Orders) TableName() string { return "orders" } diff --git a/crates/vespertide-exporter/src/lib.rs b/crates/vespertide-exporter/src/lib.rs index c1d5069d..d145822d 100644 --- a/crates/vespertide-exporter/src/lib.rs +++ b/crates/vespertide-exporter/src/lib.rs @@ -1,6 +1,8 @@ //! Helpers to convert `TableDef` models into ORM-specific representations -//! such as `SeaORM`, `SQLAlchemy`, `SQLModel`, JPA, and Prisma. +//! such as `SeaORM`, `SQLAlchemy`, `SQLModel`, JPA, GORM, Django, and Prisma. +pub mod django; +pub mod gorm; pub mod jpa; pub mod orm; mod parallel_config; @@ -12,6 +14,8 @@ pub mod sqlmodel; mod tests; mod utils; +pub use django::DjangoExporter; +pub use gorm::GormExporter; pub use jpa::JpaExporter; pub use orm::{Orm, OrmExporter, render_entity, render_entity_with_schema}; pub use prisma::PrismaExporter; diff --git a/crates/vespertide-exporter/src/orm.rs b/crates/vespertide-exporter/src/orm.rs index fa410426..93fc4f05 100644 --- a/crates/vespertide-exporter/src/orm.rs +++ b/crates/vespertide-exporter/src/orm.rs @@ -1,8 +1,8 @@ use vespertide_core::TableDef; use crate::{ - jpa::JpaExporter, prisma::PrismaExporter, seaorm::SeaOrmExporter, - sqlalchemy::SqlAlchemyExporter, sqlmodel::SqlModelExporter, + django::DjangoExporter, gorm::GormExporter, jpa::JpaExporter, prisma::PrismaExporter, + seaorm::SeaOrmExporter, sqlalchemy::SqlAlchemyExporter, sqlmodel::SqlModelExporter, }; /// Supported ORM targets. @@ -15,6 +15,8 @@ pub enum Orm { SqlAlchemy, SqlModel, Jpa, + Gorm, + Django, Prisma, } @@ -23,8 +25,9 @@ impl Orm { pub fn file_extension(self) -> &'static str { match self { Orm::SeaOrm => "rs", - Orm::SqlAlchemy | Orm::SqlModel => "py", + Orm::SqlAlchemy | Orm::SqlModel | Orm::Django => "py", Orm::Jpa => "java", + Orm::Gorm => "go", Orm::Prisma => "prisma", } } @@ -52,6 +55,8 @@ pub fn render_entity(orm: Orm, table: &TableDef) -> Result { Orm::SqlAlchemy => SqlAlchemyExporter.render_entity(table), Orm::SqlModel => SqlModelExporter.render_entity(table), Orm::Jpa => JpaExporter.render_entity(table), + Orm::Gorm => GormExporter.render_entity(table), + Orm::Django => DjangoExporter.render_entity(table), Orm::Prisma => PrismaExporter.render_entity(table), } } @@ -67,6 +72,8 @@ pub fn render_entity_with_schema( Orm::SqlAlchemy => SqlAlchemyExporter.render_entity_with_schema(table, schema), Orm::SqlModel => SqlModelExporter.render_entity_with_schema(table, schema), Orm::Jpa => JpaExporter.render_entity_with_schema(table, schema), + Orm::Gorm => GormExporter.render_entity_with_schema(table, schema), + Orm::Django => DjangoExporter.render_entity_with_schema(table, schema), Orm::Prisma => PrismaExporter.render_entity_with_schema(table, schema), } } @@ -82,6 +89,8 @@ mod tests { #[case::sqlalchemy(Orm::SqlAlchemy)] #[case::sqlmodel(Orm::SqlModel)] #[case::jpa(Orm::Jpa)] + #[case::gorm(Orm::Gorm)] + #[case::django(Orm::Django)] #[case::prisma(Orm::Prisma)] fn dispatch_render_entity_succeeds(#[case] orm: Orm) { let table = basic_single_pk(); @@ -93,6 +102,8 @@ mod tests { #[case::sqlalchemy(Orm::SqlAlchemy)] #[case::sqlmodel(Orm::SqlModel)] #[case::jpa(Orm::Jpa)] + #[case::gorm(Orm::Gorm)] + #[case::django(Orm::Django)] #[case::prisma(Orm::Prisma)] fn dispatch_render_entity_with_schema_succeeds(#[case] orm: Orm) { let table = basic_single_pk(); @@ -105,6 +116,8 @@ mod tests { #[case::sqlalchemy(Orm::SqlAlchemy, "py")] #[case::sqlmodel(Orm::SqlModel, "py")] #[case::jpa(Orm::Jpa, "java")] + #[case::gorm(Orm::Gorm, "go")] + #[case::django(Orm::Django, "py")] #[case::prisma(Orm::Prisma, "prisma")] fn file_extension_matches_backend(#[case] orm: Orm, #[case] expected: &str) { assert_eq!(orm.file_extension(), expected); @@ -117,6 +130,8 @@ mod tests { #[case::sqlalchemy("sqlalchemy", Orm::SqlAlchemy)] #[case::sqlmodel("sqlmodel", Orm::SqlModel)] #[case::jpa("jpa", Orm::Jpa)] + #[case::gorm("gorm", Orm::Gorm)] + #[case::django("django", Orm::Django)] #[case::prisma("prisma", Orm::Prisma)] fn value_enum_parses_cli_name(#[case] input: &str, #[case] expected: Orm) { assert_eq!( diff --git a/crates/vespertide-exporter/src/tests/mod.rs b/crates/vespertide-exporter/src/tests/mod.rs index 4cb6fc61..644b576c 100644 --- a/crates/vespertide-exporter/src/tests/mod.rs +++ b/crates/vespertide-exporter/src/tests/mod.rs @@ -17,6 +17,12 @@ fn render_schema(orm: Orm, schema: &[TableDef]) -> Result { Orm::SqlAlchemy => crate::sqlalchemy::export(schema), Orm::SqlModel => crate::sqlmodel::render_entities(schema), Orm::Jpa => crate::jpa::render_entities(schema).map(|entities| entities.join("\n")), + Orm::Gorm => schema + .iter() + .map(crate::gorm::render_entity) + .collect::, _>>() + .map(|v| v.join("\n\n")), + Orm::Django => crate::django::export(schema), Orm::Prisma => crate::prisma::export(schema), } } @@ -30,6 +36,8 @@ macro_rules! orm_cases { #[case::sqlalchemy(Orm::SqlAlchemy)] #[case::sqlmodel(Orm::SqlModel)] #[case::jpa(Orm::Jpa)] + #[case::gorm(Orm::Gorm)] + #[case::django(Orm::Django)] #[case::prisma(Orm::Prisma)] fn $test_name(#[case] orm: Orm) { let table = $fixture(); @@ -47,6 +55,8 @@ macro_rules! orm_cases { #[case::sqlalchemy(Orm::SqlAlchemy)] #[case::sqlmodel(Orm::SqlModel)] #[case::jpa(Orm::Jpa)] + #[case::gorm(Orm::Gorm)] + #[case::django(Orm::Django)] #[case::prisma(Orm::Prisma)] fn $test_name(#[case] orm: Orm) { let schema: Vec = $fixture(); @@ -359,17 +369,19 @@ fn to_pascal_case_for(orm: Orm, s: &str) -> String { Orm::SqlAlchemy => crate::sqlalchemy::to_pascal_case_for_tests(s), Orm::SqlModel => crate::sqlmodel::to_pascal_case_for_tests(s), Orm::Jpa => crate::jpa::to_pascal_case_for_tests(s), + Orm::Gorm => crate::gorm::to_pascal_case_for_tests(s), + Orm::Django => crate::django::to_pascal_case_for_tests(s), Orm::Prisma => vespertide_naming::to_pascal_case(s), } } /// Cross-ORM `to_pascal_case` consolidation. Inputs in this matrix are /// restricted to ASCII with `_` as the only separator — the subset where all -/// five ORM implementations agree. +/// seven ORM implementations agree. /// /// Divergences intentionally NOT covered here: /// * `-` as separator: `SeaORM` and Prisma treat it as a separator (Prisma via -/// `vespertide_naming::to_pascal_case`), the other three ORMs leave it +/// `vespertide_naming::to_pascal_case`), the other five ORMs leave it /// intact (their splits operate on `_` only). /// * Non-ASCII characters: `SeaORM` and Prisma use `to_ascii_uppercase`, the /// others use `to_uppercase` (Unicode-aware). @@ -380,6 +392,8 @@ fn to_pascal_case_for(orm: Orm, s: &str) -> String { #[case::sqlalchemy(Orm::SqlAlchemy)] #[case::sqlmodel(Orm::SqlModel)] #[case::jpa(Orm::Jpa)] +#[case::gorm(Orm::Gorm)] +#[case::django(Orm::Django)] #[case::prisma(Orm::Prisma)] fn to_pascal_case_shared_semantics( #[values( @@ -407,6 +421,8 @@ fn to_pascal_case_shared_semantics( #[case::sqlalchemy(Orm::SqlAlchemy)] #[case::sqlmodel(Orm::SqlModel)] #[case::jpa(Orm::Jpa)] +#[case::gorm(Orm::Gorm)] +#[case::django(Orm::Django)] #[case::prisma(Orm::Prisma)] fn render_entity_with_schema_snapshots( #[values( @@ -435,3 +451,9 @@ fn render_entity_with_schema_snapshots( assert_snapshot!(rendered); }); } + +#[test] +#[should_panic(expected = "unknown schema scenario nonexistent_scenario")] +fn schema_scenario_panics_on_unknown_name() { + fixtures::schema_scenario("nonexistent_scenario"); +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Django.snap new file mode 100644 index 00000000..011494b7 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Django.snap @@ -0,0 +1,33 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 145 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class AllTypes(models.Model): + id = models.IntegerField(primary_key=True) + small = models.SmallIntegerField() + big = models.BigIntegerField() + real_num = models.FloatField() + double_num = models.FloatField() + text_col = models.TextField() + bool_col = models.BooleanField() + date_col = models.DateField() + time_col = models.TimeField() + ts_col = models.DateTimeField() + tstz_col = models.DateTimeField() + interval_col = models.DurationField() + bytea_col = models.BinaryField() + uuid_col = models.UUIDField() + json_col = models.JSONField() + inet_col = models.GenericIPAddressField() + cidr_col = models.GenericIPAddressField() + macaddr_col = models.CharField(max_length=17) + xml_col = models.TextField() + + class Meta: + db_table = "all_types" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Gorm.snap new file mode 100644 index 00000000..3dd60ae1 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Gorm.snap @@ -0,0 +1,37 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 145 +expression: rendered +--- +package models + +import ( + "time" + + "gorm.io/datatypes" + "github.com/google/uuid" +) + +type AllTypes struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Small int16 `gorm:"column:small;not null" json:"small"` + Big int64 `gorm:"column:big;not null" json:"big"` + RealNum float32 `gorm:"column:real_num;not null" json:"real_num"` + DoubleNum float64 `gorm:"column:double_num;not null" json:"double_num"` + TextCol string `gorm:"column:text_col;not null;type:text" json:"text_col"` + BoolCol bool `gorm:"column:bool_col;not null" json:"bool_col"` + DateCol time.Time `gorm:"column:date_col;not null;type:date" json:"date_col"` + TimeCol time.Time `gorm:"column:time_col;not null;type:time" json:"time_col"` + TsCol time.Time `gorm:"column:ts_col;not null" json:"ts_col"` + TstzCol time.Time `gorm:"column:tstz_col;not null" json:"tstz_col"` + IntervalCol string `gorm:"column:interval_col;not null;type:interval" json:"interval_col"` + ByteaCol []byte `gorm:"column:bytea_col;not null" json:"bytea_col"` + UuidCol uuid.UUID `gorm:"column:uuid_col;not null;type:uuid" json:"uuid_col"` + JsonCol datatypes.JSON `gorm:"column:json_col;not null" json:"json_col"` + InetCol string `gorm:"column:inet_col;not null" json:"inet_col"` + CidrCol string `gorm:"column:cidr_col;not null" json:"cidr_col"` + MacaddrCol string `gorm:"column:macaddr_col;not null" json:"macaddr_col"` + XmlCol string `gorm:"column:xml_col;not null;type:xml" json:"xml_col"` +} + +func (AllTypes) TableName() string { return "all_types" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_single_pk_snapshot@basic_single_pk_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_single_pk_snapshot@basic_single_pk_Django.snap new file mode 100644 index 00000000..b74c5b5c --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_single_pk_snapshot@basic_single_pk_Django.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 73 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Users(models.Model): + id = models.IntegerField(primary_key=True) + display_name = models.TextField(null=True, blank=True) + + class Meta: + db_table = "users" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_single_pk_snapshot@basic_single_pk_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_single_pk_snapshot@basic_single_pk_Gorm.snap new file mode 100644 index 00000000..d1b1a4c7 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_single_pk_snapshot@basic_single_pk_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 73 +expression: rendered +--- +package models + +type Users struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + DisplayName *string `gorm:"column:display_name;type:text" json:"display_name"` +} + +func (Users) TableName() string { return "users" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_table_with_description_snapshot@basic_table_with_description_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_table_with_description_snapshot@basic_table_with_description_Django.snap new file mode 100644 index 00000000..caea7c90 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_table_with_description_snapshot@basic_table_with_description_Django.snap @@ -0,0 +1,21 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 68 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Users(models.Model): + """User accounts table""" + + # Primary key + id = models.AutoField(primary_key=True) + # User email address + email = models.TextField(unique=True) + name = models.TextField(null=True, blank=True) + + class Meta: + db_table = "users" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_table_with_description_snapshot@basic_table_with_description_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_table_with_description_snapshot@basic_table_with_description_Gorm.snap new file mode 100644 index 00000000..c7b6683e --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_table_with_description_snapshot@basic_table_with_description_Gorm.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 68 +expression: rendered +--- +package models + +// User accounts table +type Users struct { + // Primary key + ID int32 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + // User email address + Email string `gorm:"column:email;not null;unique;type:text" json:"email"` + Name *string `gorm:"column:name;type:text" json:"name"` +} + +func (Users) TableName() string { return "users" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_Django.snap new file mode 100644 index 00000000..a5e0bfcf --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_Django.snap @@ -0,0 +1,19 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 150 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class ComplexTypes(models.Model): + id = models.IntegerField(primary_key=True) + varchar_col = models.CharField(max_length=100) + char_col = models.CharField(max_length=10) + numeric_col = models.DecimalField(max_digits=10, decimal_places=2) + custom_col = models.TextField() + + class Meta: + db_table = "complex_types" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_Gorm.snap new file mode 100644 index 00000000..d64fcac7 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_Gorm.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 150 +expression: rendered +--- +package models + +import ( + "github.com/shopspring/decimal" +) + +type ComplexTypes struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + VarcharCol string `gorm:"column:varchar_col;not null;size:100" json:"varchar_col"` + CharCol string `gorm:"column:char_col;not null;size:10;type:char" json:"char_col"` + NumericCol decimal.Decimal `gorm:"column:numeric_col;not null;type:numeric(10,2)" json:"numeric_col"` + CustomCol string `gorm:"column:custom_col;not null;type:CUSTOM_TYPE" json:"custom_col"` +} + +func (ComplexTypes) TableName() string { return "complex_types" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Django.snap new file mode 100644 index 00000000..8de47f5a --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Django.snap @@ -0,0 +1,24 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 176 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class OrderItems(models.Model): + pk = models.CompositePrimaryKey("order_id", "product_id") + order = models.ForeignKey("Orders", on_delete=models.RESTRICT, related_name="+") + product = models.ForeignKey("Products", on_delete=models.RESTRICT, related_name="+") + quantity = models.IntegerField() + + class Meta: + db_table = "order_items" + indexes = [ + models.Index(fields=["order_id"], name="ix_order_items__order_id"), + ] + constraints = [ + models.UniqueConstraint(fields=["order_id", "product_id"], name="uq_order_items__order_product"), + ] diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Gorm.snap new file mode 100644 index 00000000..4f6c7b06 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Gorm.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 176 +expression: rendered +--- +package models + +type OrderItems struct { + OrderID int32 `gorm:"column:order_id;primaryKey;index:ix_order_items__order_id;uniqueIndex:uq_order_items__order_product" json:"order_id"` + Order Orders `gorm:"foreignKey:OrderID" json:"-"` + ProductID int32 `gorm:"column:product_id;primaryKey;uniqueIndex:uq_order_items__order_product" json:"product_id"` + Product Products `gorm:"foreignKey:ProductID" json:"-"` + Quantity int32 `gorm:"column:quantity;not null" json:"quantity"` +} + +func (OrderItems) TableName() string { return "order_items" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_fk_relation_snapshot@composite_fk_relation_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_fk_relation_snapshot@composite_fk_relation_Django.snap new file mode 100644 index 00000000..94a22e2d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_fk_relation_snapshot@composite_fk_relation_Django.snap @@ -0,0 +1,26 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Orders(models.Model): + pk = models.CompositePrimaryKey("id", "version") + id = models.IntegerField() + version = models.IntegerField() + + class Meta: + db_table = "orders" + +class LineItems(models.Model): + id = models.IntegerField(primary_key=True) + order_id = models.IntegerField() + order_version = models.IntegerField() + sku = models.TextField() + # composite foreign key: (order_id, order_version) -> orders(id, version) + + class Meta: + db_table = "line_items" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_fk_relation_snapshot@composite_fk_relation_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_fk_relation_snapshot@composite_fk_relation_Gorm.snap new file mode 100644 index 00000000..e8bb06ad --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_fk_relation_snapshot@composite_fk_relation_Gorm.snap @@ -0,0 +1,25 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Orders struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Version int32 `gorm:"column:version;primaryKey" json:"version"` +} + +func (Orders) TableName() string { return "orders" } + + +package models + +type LineItems struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + OrderID int32 `gorm:"column:order_id;not null" json:"order_id"` + OrderVersion int32 `gorm:"column:order_version;not null" json:"order_version"` + Sku string `gorm:"column:sku;not null;type:text" json:"sku"` + Orders Orders `gorm:"foreignKey:OrderID,OrderVersion;references:ID,Version" json:"-"` +} + +func (LineItems) TableName() string { return "line_items" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_Django.snap new file mode 100644 index 00000000..dc5d7156 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_Django.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 186 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class CompositeIndex(models.Model): + id = models.IntegerField(primary_key=True) + tenant_id = models.IntegerField() + name = models.TextField() + + class Meta: + db_table = "composite_index" + indexes = [ + models.Index(fields=["tenant_id", "name"], name="idx_tenant_name"), + ] diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_Gorm.snap new file mode 100644 index 00000000..5992ace9 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_Gorm.snap @@ -0,0 +1,14 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 186 +expression: rendered +--- +package models + +type CompositeIndex struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + TenantID int32 `gorm:"column:tenant_id;not null;index:idx_tenant_name" json:"tenant_id"` + Name string `gorm:"column:name;not null;type:text;index:idx_tenant_name" json:"name"` +} + +func (CompositeIndex) TableName() string { return "composite_index" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_pk_snapshot@composite_pk_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_pk_snapshot@composite_pk_Django.snap new file mode 100644 index 00000000..10982611 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_pk_snapshot@composite_pk_Django.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 78 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Accounts(models.Model): + pk = models.CompositePrimaryKey("id", "tenant_id") + id = models.IntegerField() + tenant_id = models.BigIntegerField() + + class Meta: + db_table = "accounts" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_pk_snapshot@composite_pk_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_pk_snapshot@composite_pk_Gorm.snap new file mode 100644 index 00000000..771ba0a4 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_pk_snapshot@composite_pk_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 78 +expression: rendered +--- +package models + +type Accounts struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + TenantID int64 `gorm:"column:tenant_id;primaryKey" json:"tenant_id"` +} + +func (Accounts) TableName() string { return "accounts" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_Django.snap new file mode 100644 index 00000000..fa7ad260 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_Django.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 251 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Membership(models.Model): + pk = models.CompositePrimaryKey("tenant_id", "user_id") + tenant_id = models.IntegerField() + user_id = models.IntegerField() + role = models.TextField() + + class Meta: + db_table = "membership" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_Gorm.snap new file mode 100644 index 00000000..1ef7fbb5 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_Gorm.snap @@ -0,0 +1,14 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 251 +expression: rendered +--- +package models + +type Membership struct { + TenantID int32 `gorm:"column:tenant_id;primaryKey" json:"tenant_id"` + UserID int32 `gorm:"column:user_id;primaryKey" json:"user_id"` + Role string `gorm:"column:role;not null;type:text" json:"role"` +} + +func (Membership) TableName() string { return "membership" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Django.snap new file mode 100644 index 00000000..205570c3 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Django.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 256 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class AccountAliases(models.Model): + id = models.IntegerField(primary_key=True) + tenant_id = models.IntegerField() + slug = models.TextField() + + class Meta: + db_table = "account_aliases" + constraints = [ + models.UniqueConstraint(fields=["tenant_id", "slug"], name="uq_account_aliases__tenant_slug"), + ] diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Gorm.snap new file mode 100644 index 00000000..d483e014 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Gorm.snap @@ -0,0 +1,14 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 256 +expression: rendered +--- +package models + +type AccountAliases struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + TenantID int32 `gorm:"column:tenant_id;not null;uniqueIndex:uq_account_aliases__tenant_slug" json:"tenant_id"` + Slug string `gorm:"column:slug;not null;type:text;uniqueIndex:uq_account_aliases__tenant_slug" json:"slug"` +} + +func (AccountAliases) TableName() string { return "account_aliases" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Django.snap new file mode 100644 index 00000000..a7823826 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Django.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 181 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class CompositeUnique(models.Model): + id = models.IntegerField(primary_key=True) + tenant_id = models.IntegerField() + name = models.TextField() + + class Meta: + db_table = "composite_unique" + constraints = [ + models.UniqueConstraint(fields=["tenant_id", "name"], name="uq_tenant_name"), + ] diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Gorm.snap new file mode 100644 index 00000000..fbbea51d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Gorm.snap @@ -0,0 +1,14 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 181 +expression: rendered +--- +package models + +type CompositeUnique struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + TenantID int32 `gorm:"column:tenant_id;not null;uniqueIndex:uq_tenant_name" json:"tenant_id"` + Name string `gorm:"column:name;not null;type:text;uniqueIndex:uq_tenant_name" json:"name"` +} + +func (CompositeUnique) TableName() string { return "composite_unique" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Django.snap new file mode 100644 index 00000000..db2508ea --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Django.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 160 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Articles(models.Model): + id = models.AutoField(primary_key=True) + published = models.BooleanField(default=False) + view_count = models.IntegerField(default=0) + status = models.TextField(default="draft") + + class Meta: + db_table = "articles" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Gorm.snap new file mode 100644 index 00000000..95bddd92 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Gorm.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 160 +expression: rendered +--- +package models + +type Articles struct { + ID int32 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + Published bool `gorm:"column:published;not null;default:false" json:"published"` + ViewCount int32 `gorm:"column:view_count;not null;default:0" json:"view_count"` + Status string `gorm:"column:status;not null;type:text;default:'draft'" json:"status"` +} + +func (Articles) TableName() string { return "articles" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_multiple_columns_snapshot@enum_multiple_columns_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_multiple_columns_snapshot@enum_multiple_columns_Django.snap new file mode 100644 index 00000000..2257b29f --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_multiple_columns_snapshot@enum_multiple_columns_Django.snap @@ -0,0 +1,27 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 114 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class ProductCategory(models.TextChoices): + ELECTRONICS = "electronics", "electronics" + CLOTHING = "clothing", "clothing" + FOOD = "food", "food" + +class AvailabilityStatus(models.TextChoices): + IN_STOCK = "in_stock", "in_stock" + OUT_OF_STOCK = "out_of_stock", "out_of_stock" + PRE_ORDER = "pre_order", "pre_order" + +class Products(models.Model): + id = models.IntegerField() + category = models.CharField(max_length=11, choices=ProductCategory.choices) + availability = models.CharField(max_length=12, choices=AvailabilityStatus.choices) + + class Meta: + db_table = "products" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_multiple_columns_snapshot@enum_multiple_columns_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_multiple_columns_snapshot@enum_multiple_columns_Gorm.snap new file mode 100644 index 00000000..cdeb9bb7 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_multiple_columns_snapshot@enum_multiple_columns_Gorm.snap @@ -0,0 +1,30 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 114 +expression: rendered +--- +package models + +type ProductCategory string + +const ( + ProductCategoryElectronics ProductCategory = "electronics" + ProductCategoryClothing ProductCategory = "clothing" + ProductCategoryFood ProductCategory = "food" +) + +type AvailabilityStatus string + +const ( + AvailabilityStatusInStock AvailabilityStatus = "in_stock" + AvailabilityStatusOutOfStock AvailabilityStatus = "out_of_stock" + AvailabilityStatusPreOrder AvailabilityStatus = "pre_order" +) + +type Products struct { + ID int32 `gorm:"column:id;not null" json:"id"` + Category ProductCategory `gorm:"column:category;not null" json:"category"` + Availability AvailabilityStatus `gorm:"column:availability;not null" json:"availability"` +} + +func (Products) TableName() string { return "products" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_shared_snapshot@enum_shared_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_shared_snapshot@enum_shared_Django.snap new file mode 100644 index 00000000..f3697a3d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_shared_snapshot@enum_shared_Django.snap @@ -0,0 +1,22 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 119 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class DocStatus(models.TextChoices): + DRAFT = "draft", "draft" + PUBLISHED = "published", "published" + ARCHIVED = "archived", "archived" + +class Documents(models.Model): + id = models.IntegerField() + status = models.CharField(max_length=9, choices=DocStatus.choices) + review_status = models.CharField(max_length=9, choices=DocStatus.choices, null=True, blank=True) + + class Meta: + db_table = "documents" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_shared_snapshot@enum_shared_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_shared_snapshot@enum_shared_Gorm.snap new file mode 100644 index 00000000..beb022ae --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_shared_snapshot@enum_shared_Gorm.snap @@ -0,0 +1,30 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 119 +expression: rendered +--- +package models + +type DocStatus string + +const ( + DocStatusDraft DocStatus = "draft" + DocStatusPublished DocStatus = "published" + DocStatusArchived DocStatus = "archived" +) + +type DocStatus string + +const ( + DocStatusDraft DocStatus = "draft" + DocStatusPublished DocStatus = "published" + DocStatusArchived DocStatus = "archived" +) + +type Documents struct { + ID int32 `gorm:"column:id;not null" json:"id"` + Status DocStatus `gorm:"column:status;not null" json:"status"` + ReviewStatus *DocStatus `gorm:"column:review_status" json:"review_status"` +} + +func (Documents) TableName() string { return "documents" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_Django.snap new file mode 100644 index 00000000..917f512e --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_Django.snap @@ -0,0 +1,22 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 120 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class EventSeverity(models.TextChoices): + INFO_LEVEL = "info-level", "info-level" + WARNING_LEVEL = "warning_level", "warning_level" + ERROR_LEVEL = "ERROR_LEVEL", "ERROR_LEVEL" + _1CRITICAL = "1critical", "1critical" + +class Events(models.Model): + id = models.IntegerField() + severity = models.CharField(max_length=13, choices=EventSeverity.choices) + + class Meta: + db_table = "events" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_Gorm.snap new file mode 100644 index 00000000..137203d9 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_Gorm.snap @@ -0,0 +1,22 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 120 +expression: rendered +--- +package models + +type EventSeverity string + +const ( + EventSeverityInfo-level EventSeverity = "info-level" + EventSeverityWarningLevel EventSeverity = "warning_level" + EventSeverityERRORLEVEL EventSeverity = "ERROR_LEVEL" + EventSeverity1critical EventSeverity = "1critical" +) + +type Events struct { + ID int32 `gorm:"column:id;not null" json:"id"` + Severity EventSeverity `gorm:"column:severity;not null" json:"severity"` +} + +func (Events) TableName() string { return "events" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_with_default_snapshot@enum_with_default_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_with_default_snapshot@enum_with_default_Django.snap new file mode 100644 index 00000000..2f17c165 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_with_default_snapshot@enum_with_default_Django.snap @@ -0,0 +1,23 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 125 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class TaskStatus(models.TextChoices): + PENDING = "pending", "pending" + IN_PROGRESS = "in_progress", "in_progress" + COMPLETED = "completed", "completed" + +class Tasks(models.Model): + id = models.IntegerField() + status = models.CharField(max_length=11, choices=TaskStatus.choices, default="pending") + priority = models.IntegerField(default=0) + is_archived = models.BooleanField(default=False) + + class Meta: + db_table = "tasks" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_with_default_snapshot@enum_with_default_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_with_default_snapshot@enum_with_default_Gorm.snap new file mode 100644 index 00000000..5bf26f7f --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_with_default_snapshot@enum_with_default_Gorm.snap @@ -0,0 +1,23 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 125 +expression: rendered +--- +package models + +type TaskStatus string + +const ( + TaskStatusPending TaskStatus = "pending" + TaskStatusInProgress TaskStatus = "in_progress" + TaskStatusCompleted TaskStatus = "completed" +) + +type Tasks struct { + ID int32 `gorm:"column:id;not null" json:"id"` + Status TaskStatus `gorm:"column:status;not null;default:'pending'" json:"status"` + Priority int32 `gorm:"column:priority;not null;default:0" json:"priority"` + IsArchived bool `gorm:"column:is_archived;not null;default:false" json:"is_archived"` +} + +func (Tasks) TableName() string { return "tasks" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__false_boolean_default_snapshot@false_boolean_default_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__false_boolean_default_snapshot@false_boolean_default_Django.snap new file mode 100644 index 00000000..7472d6e7 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__false_boolean_default_snapshot@false_boolean_default_Django.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 216 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class BoolDefaults(models.Model): + id = models.IntegerField(primary_key=True) + is_deleted = models.BooleanField(default=False) + + class Meta: + db_table = "bool_defaults" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__false_boolean_default_snapshot@false_boolean_default_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__false_boolean_default_snapshot@false_boolean_default_Gorm.snap new file mode 100644 index 00000000..54747e88 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__false_boolean_default_snapshot@false_boolean_default_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 216 +expression: rendered +--- +package models + +type BoolDefaults struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + IsDeleted bool `gorm:"column:is_deleted;not null;default:false" json:"is_deleted"` +} + +func (BoolDefaults) TableName() string { return "bool_defaults" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_names_collide_after_id_strip_snapshot@fk_names_collide_after_id_strip_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_names_collide_after_id_strip_snapshot@fk_names_collide_after_id_strip_Django.snap new file mode 100644 index 00000000..949d8d51 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_names_collide_after_id_strip_snapshot@fk_names_collide_after_id_strip_Django.snap @@ -0,0 +1,23 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Target(models.Model): + id = models.IntegerField(primary_key=True) + alt = models.IntegerField(unique=True) + + class Meta: + db_table = "target" + +class Src(models.Model): + pk = models.IntegerField(primary_key=True) + a = models.ForeignKey("Target", on_delete=models.RESTRICT, related_name="+", null=True, blank=True) + a_2 = models.ForeignKey("Target", on_delete=models.RESTRICT, db_column="a", related_name="+", null=True, blank=True) + + class Meta: + db_table = "src" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_names_collide_after_id_strip_snapshot@fk_names_collide_after_id_strip_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_names_collide_after_id_strip_snapshot@fk_names_collide_after_id_strip_Gorm.snap new file mode 100644 index 00000000..7fa62b18 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_names_collide_after_id_strip_snapshot@fk_names_collide_after_id_strip_Gorm.snap @@ -0,0 +1,25 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Target struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Alt int32 `gorm:"column:alt;not null;unique" json:"alt"` +} + +func (Target) TableName() string { return "target" } + + +package models + +type Src struct { + Pk int32 `gorm:"column:pk;primaryKey" json:"pk"` + AID *int32 `gorm:"column:a_id" json:"a_id"` + A2 *Target `gorm:"foreignKey:AID" json:"-"` + A *int32 `gorm:"column:a" json:"a"` + ATarget *Target `gorm:"foreignKey:A" json:"-"` +} + +func (Src) TableName() string { return "src" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_with_comment_and_auto_increment_snapshot@fk_with_comment_and_auto_increment_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_with_comment_and_auto_increment_snapshot@fk_with_comment_and_auto_increment_Django.snap new file mode 100644 index 00000000..6ec25114 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_with_comment_and_auto_increment_snapshot@fk_with_comment_and_auto_increment_Django.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 231 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Child(models.Model): + # References parent table + parent = models.ForeignKey("Parent", on_delete=models.RESTRICT, related_name="+") + value = models.TextField() + + class Meta: + db_table = "child" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_with_comment_and_auto_increment_snapshot@fk_with_comment_and_auto_increment_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_with_comment_and_auto_increment_snapshot@fk_with_comment_and_auto_increment_Gorm.snap new file mode 100644 index 00000000..03daabab --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_with_comment_and_auto_increment_snapshot@fk_with_comment_and_auto_increment_Gorm.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 231 +expression: rendered +--- +package models + +type Child struct { + // References parent table + ParentID int32 `gorm:"column:parent_id;primaryKey;autoIncrement" json:"parent_id"` + Parent Parent `gorm:"foreignKey:ParentID" json:"-"` + Value string `gorm:"column:value;not null;type:text" json:"value"` +} + +func (Child) TableName() string { return "child" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__inline_pk_snapshot@inline_pk_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__inline_pk_snapshot@inline_pk_Django.snap new file mode 100644 index 00000000..19f9cbec --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__inline_pk_snapshot@inline_pk_Django.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 93 +expression: rendered +--- +from __future__ import annotations + +import uuid +from django.db import models + + +class Users(models.Model): + id = models.UUIDField(default=uuid.uuid4) + email = models.TextField() + + class Meta: + db_table = "users" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__inline_pk_snapshot@inline_pk_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__inline_pk_snapshot@inline_pk_Gorm.snap new file mode 100644 index 00000000..43e173f8 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__inline_pk_snapshot@inline_pk_Gorm.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 93 +expression: rendered +--- +package models + +import ( + "github.com/google/uuid" +) + +type Users struct { + ID uuid.UUID `gorm:"column:id;not null;type:uuid" json:"id"` + Email string `gorm:"column:email;not null;type:text" json:"email"` +} + +func (Users) TableName() string { return "users" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_all_variant_types_snapshot@integer_enum_all_variant_types_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_all_variant_types_snapshot@integer_enum_all_variant_types_Django.snap new file mode 100644 index 00000000..b6d97485 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_all_variant_types_snapshot@integer_enum_all_variant_types_Django.snap @@ -0,0 +1,22 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 261 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class EdgeState(models.IntegerChoices): + UNKNOWN = -1, "unknown" + NOT_STARTED = 0, "not_started" + IN_PROGRESS = 10, "InProgress" + HTTP_500 = 500, "HTTP_500" + +class WorkflowRuns(models.Model): + id = models.IntegerField(primary_key=True) + state = models.IntegerField(choices=EdgeState.choices) + + class Meta: + db_table = "workflow_runs" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_all_variant_types_snapshot@integer_enum_all_variant_types_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_all_variant_types_snapshot@integer_enum_all_variant_types_Gorm.snap new file mode 100644 index 00000000..8d53fd8e --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_all_variant_types_snapshot@integer_enum_all_variant_types_Gorm.snap @@ -0,0 +1,22 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 261 +expression: rendered +--- +package models + +type EdgeState int + +const ( + EdgeStateUnknown EdgeState = -1 + EdgeStateNotStarted EdgeState = 0 + EdgeStateInProgress EdgeState = 10 + EdgeStateHTTP500 EdgeState = 500 +) + +type WorkflowRuns struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + State EdgeState `gorm:"column:state;not null" json:"state"` +} + +func (WorkflowRuns) TableName() string { return "workflow_runs" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_default_snapshot@integer_enum_with_default_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_default_snapshot@integer_enum_with_default_Django.snap new file mode 100644 index 00000000..2f23b3cf --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_default_snapshot@integer_enum_with_default_Django.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 271 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class TaskStatus(models.IntegerChoices): + PENDING = 0, "Pending" + COMPLETED = 100, "Completed" + +class Tasks(models.Model): + id = models.IntegerField() + status = models.IntegerField(choices=TaskStatus.choices, default=1) + + class Meta: + db_table = "tasks" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_default_snapshot@integer_enum_with_default_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_default_snapshot@integer_enum_with_default_Gorm.snap new file mode 100644 index 00000000..63d1a046 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_default_snapshot@integer_enum_with_default_Gorm.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 271 +expression: rendered +--- +package models + +type TaskStatus int + +const ( + TaskStatusPending TaskStatus = 0 + TaskStatusCompleted TaskStatus = 100 +) + +type Tasks struct { + ID int32 `gorm:"column:id;not null" json:"id"` + Status TaskStatus `gorm:"column:status;not null;default:1" json:"status"` +} + +func (Tasks) TableName() string { return "tasks" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_variant_default_snapshot@integer_enum_with_variant_default_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_variant_default_snapshot@integer_enum_with_variant_default_Django.snap new file mode 100644 index 00000000..d010ef34 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_variant_default_snapshot@integer_enum_with_variant_default_Django.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 282 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class TaskRunStatus(models.IntegerChoices): + PENDING = 0, "Pending" + COMPLETED = 100, "Completed" + +class TaskRuns(models.Model): + id = models.IntegerField() + status = models.IntegerField(choices=TaskRunStatus.choices) + + class Meta: + db_table = "task_runs" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_variant_default_snapshot@integer_enum_with_variant_default_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_variant_default_snapshot@integer_enum_with_variant_default_Gorm.snap new file mode 100644 index 00000000..31574f55 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_variant_default_snapshot@integer_enum_with_variant_default_Gorm.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 282 +expression: rendered +--- +package models + +type TaskRunStatus int + +const ( + TaskRunStatusPending TaskRunStatus = 0 + TaskRunStatusCompleted TaskRunStatus = 100 +) + +type TaskRuns struct { + ID int32 `gorm:"column:id;not null" json:"id"` + Status TaskRunStatus `gorm:"column:status;not null;default:Completed" json:"status"` +} + +func (TaskRuns) TableName() string { return "task_runs" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__json_default_snapshot@json_default_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__json_default_snapshot@json_default_Django.snap new file mode 100644 index 00000000..033fad31 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__json_default_snapshot@json_default_Django.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 236 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Configs(models.Model): + id = models.IntegerField(primary_key=True) + data = models.JSONField() + + class Meta: + db_table = "configs" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__json_default_snapshot@json_default_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__json_default_snapshot@json_default_Gorm.snap new file mode 100644 index 00000000..f8b7e81d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__json_default_snapshot@json_default_Gorm.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 236 +expression: rendered +--- +package models + +import ( + "gorm.io/datatypes" +) + +type Configs struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Data datatypes.JSON `gorm:"column:data;not null;default:{"hello": "world"}" json:"data"` +} + +func (Configs) TableName() string { return "configs" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__jsonb_custom_type_snapshot@jsonb_custom_type_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__jsonb_custom_type_snapshot@jsonb_custom_type_Django.snap new file mode 100644 index 00000000..b7954d91 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__jsonb_custom_type_snapshot@jsonb_custom_type_Django.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 155 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class JsonStruct(models.Model): + id = models.IntegerField() + json_data = models.JSONField() + jsonb_data = models.TextField() + jsonb_nullable = models.TextField(null=True, blank=True) + + class Meta: + db_table = "json_struct" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__jsonb_custom_type_snapshot@jsonb_custom_type_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__jsonb_custom_type_snapshot@jsonb_custom_type_Gorm.snap new file mode 100644 index 00000000..5ed3de58 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__jsonb_custom_type_snapshot@jsonb_custom_type_Gorm.snap @@ -0,0 +1,19 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 155 +expression: rendered +--- +package models + +import ( + "gorm.io/datatypes" +) + +type JsonStruct struct { + ID int32 `gorm:"column:id;not null" json:"id"` + JsonData datatypes.JSON `gorm:"column:json_data;not null" json:"json_data"` + JsonbData datatypes.JSON `gorm:"column:jsonb_data;not null;type:JSONB" json:"jsonb_data"` + JsonbNullable *datatypes.JSON `gorm:"column:jsonb_nullable;type:jsonb" json:"jsonb_nullable"` +} + +func (JsonStruct) TableName() string { return "json_struct" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__no_description_snapshot@no_description_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__no_description_snapshot@no_description_Django.snap new file mode 100644 index 00000000..bcf44a76 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__no_description_snapshot@no_description_Django.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 206 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class NoDesc(models.Model): + id = models.IntegerField(primary_key=True) + + class Meta: + db_table = "no_desc" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__no_description_snapshot@no_description_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__no_description_snapshot@no_description_Gorm.snap new file mode 100644 index 00000000..226cd7c5 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__no_description_snapshot@no_description_Gorm.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 206 +expression: rendered +--- +package models + +type NoDesc struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` +} + +func (NoDesc) TableName() string { return "no_desc" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_in_constraints_snapshot@non_identifier_names_in_constraints_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_in_constraints_snapshot@non_identifier_names_in_constraints_Django.snap new file mode 100644 index 00000000..4fb1be30 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_in_constraints_snapshot@non_identifier_names_in_constraints_Django.snap @@ -0,0 +1,24 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Membership(models.Model): + pk = models.CompositePrimaryKey("1tenant_id", "2user_id") + _1tenant_id = models.IntegerField(db_column="1tenant_id") + _2user_id = models.IntegerField(db_column="2user_id") + user_email = models.TextField(db_column="user-email") + _3created = models.TextField(db_column="3created") + + class Meta: + db_table = "membership" + indexes = [ + models.Index(fields=["3created"]), + ] + constraints = [ + models.UniqueConstraint(fields=["user-email", "1tenant_id"]), + ] diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_in_constraints_snapshot@non_identifier_names_in_constraints_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_in_constraints_snapshot@non_identifier_names_in_constraints_Gorm.snap new file mode 100644 index 00000000..8c03e4d4 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_in_constraints_snapshot@non_identifier_names_in_constraints_Gorm.snap @@ -0,0 +1,14 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Membership struct { + _1tenantID int32 `gorm:"column:1tenant_id;primaryKey;uniqueIndex:uq_user-email_1tenant_id" json:"1tenant_id"` + _2userID int32 `gorm:"column:2user_id;primaryKey" json:"2user_id"` + User_email string `gorm:"column:user-email;not null;type:text;uniqueIndex:uq_user-email_1tenant_id" json:"user-email"` + _3created string `gorm:"column:3created;not null;type:text;index" json:"3created"` +} + +func (Membership) TableName() string { return "membership" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_snapshot@non_identifier_names_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_snapshot@non_identifier_names_Django.snap new file mode 100644 index 00000000..7f87cf12 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_snapshot@non_identifier_names_Django.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class _1users(models.Model): + id = models.IntegerField(primary_key=True) + _1st_place = models.IntegerField(db_column="1st_place", null=True, blank=True) + user_id = models.TextField(db_column="user-id", null=True, blank=True) + _1st_owner = models.ForeignKey("_1users", on_delete=models.RESTRICT, db_column="1st_owner_id", related_name="+", null=True, blank=True) + + class Meta: + db_table = "1users" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_snapshot@non_identifier_names_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_snapshot@non_identifier_names_Gorm.snap new file mode 100644 index 00000000..aefde068 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_snapshot@non_identifier_names_Gorm.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type _1users struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + _1stPlace *int32 `gorm:"column:1st_place" json:"1st_place"` + User_id *string `gorm:"column:user-id;type:text" json:"user-id"` + _1stOwnerID *int32 `gorm:"column:1st_owner_id" json:"1st_owner_id"` + _1stOwner *_1users `gorm:"foreignKey:_1stOwnerID" json:"-"` +} + +func (_1users) TableName() string { return "1users" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_relation_names_snapshot@non_identifier_relation_names_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_relation_names_snapshot@non_identifier_relation_names_Django.snap new file mode 100644 index 00000000..cf6dcf3e --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_relation_names_snapshot@non_identifier_relation_names_Django.snap @@ -0,0 +1,23 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class _1users(models.Model): + _1id = models.IntegerField(db_column="1id", primary_key=True) + email = models.TextField() + + class Meta: + db_table = "1users" + +class Posts(models.Model): + id = models.IntegerField(primary_key=True) + _1st_owner = models.ForeignKey("_1users", on_delete=models.RESTRICT, db_column="1st_owner_id", related_name="+", null=True, blank=True) + _2nd_owner = models.ForeignKey("_1users", on_delete=models.RESTRICT, db_column="2nd_owner_id", related_name="+", null=True, blank=True) + + class Meta: + db_table = "posts" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_relation_names_snapshot@non_identifier_relation_names_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_relation_names_snapshot@non_identifier_relation_names_Gorm.snap new file mode 100644 index 00000000..34201f34 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_relation_names_snapshot@non_identifier_relation_names_Gorm.snap @@ -0,0 +1,25 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type _1users struct { + _1id int32 `gorm:"column:1id;primaryKey" json:"1id"` + Email string `gorm:"column:email;not null;type:text" json:"email"` +} + +func (_1users) TableName() string { return "1users" } + + +package models + +type Posts struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + _1stOwnerID *int32 `gorm:"column:1st_owner_id" json:"1st_owner_id"` + _1stOwner *_1users `gorm:"foreignKey:_1stOwnerID" json:"-"` + _2ndOwnerID *int32 `gorm:"column:2nd_owner_id" json:"2nd_owner_id"` + _2ndOwner *_1users `gorm:"foreignKey:_2ndOwnerID" json:"-"` +} + +func (Posts) TableName() string { return "posts" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Django.snap new file mode 100644 index 00000000..0b48851a --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Django.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 171 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Profiles(models.Model): + id = models.AutoField(primary_key=True) + bio = models.TextField(null=True, blank=True) + avatar_url = models.CharField(max_length=500, null=True, blank=True) + + class Meta: + db_table = "profiles" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Gorm.snap new file mode 100644 index 00000000..4eb65a44 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Gorm.snap @@ -0,0 +1,14 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 171 +expression: rendered +--- +package models + +type Profiles struct { + ID int32 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + Bio *string `gorm:"column:bio;type:text" json:"bio"` + AvatarUrl *string `gorm:"column:avatar_url;size:500" json:"avatar_url"` +} + +func (Profiles) TableName() string { return "profiles" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_enum_snapshot@nullable_enum_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_enum_snapshot@nullable_enum_Django.snap new file mode 100644 index 00000000..f45f2919 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_enum_snapshot@nullable_enum_Django.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 109 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class StatusType(models.TextChoices): + ACTIVE = "active", "active" + INACTIVE = "inactive", "inactive" + +class NullableEnum(models.Model): + id = models.IntegerField(primary_key=True) + status = models.CharField(max_length=8, choices=StatusType.choices, null=True, blank=True) + + class Meta: + db_table = "nullable_enum" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_enum_snapshot@nullable_enum_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_enum_snapshot@nullable_enum_Gorm.snap new file mode 100644 index 00000000..57b06b78 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_enum_snapshot@nullable_enum_Gorm.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 109 +expression: rendered +--- +package models + +type StatusType string + +const ( + StatusTypeActive StatusType = "active" + StatusTypeInactive StatusType = "inactive" +) + +type NullableEnum struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Status *StatusType `gorm:"column:status" json:"status"` +} + +func (NullableEnum) TableName() string { return "nullable_enum" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__numeric_default_value_snapshot@numeric_default_value_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__numeric_default_value_snapshot@numeric_default_value_Django.snap new file mode 100644 index 00000000..06f667e2 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__numeric_default_value_snapshot@numeric_default_value_Django.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Products(models.Model): + id = models.IntegerField() + price = models.DecimalField(max_digits=10, decimal_places=2, default=0) + + class Meta: + db_table = "products" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__numeric_default_value_snapshot@numeric_default_value_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__numeric_default_value_snapshot@numeric_default_value_Gorm.snap new file mode 100644 index 00000000..c972d90e --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__numeric_default_value_snapshot@numeric_default_value_Gorm.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +import ( + "github.com/shopspring/decimal" +) + +type Products struct { + ID int32 `gorm:"column:id;not null" json:"id"` + Price decimal.Decimal `gorm:"column:price;not null;type:numeric(10,2);default:0" json:"price"` +} + +func (Products) TableName() string { return "products" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Django.snap new file mode 100644 index 00000000..6662038e --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Django.snap @@ -0,0 +1,26 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 94 +expression: rendered +--- +from __future__ import annotations + +from django.utils import timezone +from django.db import models + + +class ArticleUser(models.Model): + pk = models.CompositePrimaryKey("article_id", "user_id") + article = models.ForeignKey("Article", on_delete=models.CASCADE, related_name="+") + user = models.ForeignKey("User", on_delete=models.CASCADE, related_name="+") + author_order = models.IntegerField(default=1) + role = models.CharField(max_length=20, default="contributor") + is_lead = models.BooleanField(default=False) + created_at = models.DateTimeField(default=timezone.now) + + class Meta: + db_table = "article_user" + indexes = [ + models.Index(fields=["article_id"]), + models.Index(fields=["user_id"]), + ] diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Gorm.snap new file mode 100644 index 00000000..73a335b5 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Gorm.snap @@ -0,0 +1,25 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 94 +expression: rendered +--- +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type ArticleUser struct { + ArticleID uuid.UUID `gorm:"column:article_id;primaryKey;type:uuid;index" json:"article_id"` + Article Article `gorm:"foreignKey:ArticleID;constraint:OnDelete:CASCADE" json:"-"` + UserID uuid.UUID `gorm:"column:user_id;primaryKey;type:uuid;index" json:"user_id"` + User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"-"` + AuthorOrder int32 `gorm:"column:author_order;not null;default:1" json:"author_order"` + Role string `gorm:"column:role;not null;size:20;default:'contributor'" json:"role"` + IsLead bool `gorm:"column:is_lead;not null;default:false" json:"is_lead"` + CreatedAt time.Time `gorm:"column:created_at;not null" json:"created_at"` +} + +func (ArticleUser) TableName() string { return "article_user" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_name_taken_by_column_snapshot@relation_name_taken_by_column_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_name_taken_by_column_snapshot@relation_name_taken_by_column_Django.snap new file mode 100644 index 00000000..e2678a70 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_name_taken_by_column_snapshot@relation_name_taken_by_column_Django.snap @@ -0,0 +1,21 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Users(models.Model): + id = models.IntegerField(primary_key=True) + + class Meta: + db_table = "users" + +class Items(models.Model): + id = models.IntegerField(primary_key=True) + owner = models.ForeignKey("Users", on_delete=models.RESTRICT, db_column="owner", related_name="+", null=True, blank=True) + + class Meta: + db_table = "items" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_name_taken_by_column_snapshot@relation_name_taken_by_column_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_name_taken_by_column_snapshot@relation_name_taken_by_column_Gorm.snap new file mode 100644 index 00000000..57a92709 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_name_taken_by_column_snapshot@relation_name_taken_by_column_Gorm.snap @@ -0,0 +1,22 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Users struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` +} + +func (Users) TableName() string { return "users" } + + +package models + +type Items struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Owner *int32 `gorm:"column:owner" json:"owner"` + OwnerUsers *Users `gorm:"foreignKey:Owner" json:"-"` +} + +func (Items) TableName() string { return "items" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_and_single_fk_same_target_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_and_single_fk_same_target_Django.snap new file mode 100644 index 00000000..6fdb2b20 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_and_single_fk_same_target_Django.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Src(models.Model): + id = models.IntegerField(primary_key=True) + a_id = models.IntegerField() + b_id = models.IntegerField() + solo = models.ForeignKey("Target", on_delete=models.RESTRICT, db_column="solo", related_name="+") + # composite foreign key: (a_id, b_id) -> target(a, b) + + class Meta: + db_table = "src" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_and_single_fk_same_target_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_and_single_fk_same_target_Gorm.snap new file mode 100644 index 00000000..f57baa34 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_and_single_fk_same_target_Gorm.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Src struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + AID int32 `gorm:"column:a_id;not null" json:"a_id"` + BID int32 `gorm:"column:b_id;not null" json:"b_id"` + Solo int32 `gorm:"column:solo;not null" json:"solo"` + SoloTarget Target `gorm:"foreignKey:Solo" json:"-"` + Target Target `gorm:"foreignKey:AID,BID;references:A,B" json:"-"` +} + +func (Src) TableName() string { return "src" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_fk_parent_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_fk_parent_Django.snap new file mode 100644 index 00000000..646666f0 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_fk_parent_Django.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 393 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Parent(models.Model): + pk = models.CompositePrimaryKey("id1", "id2") + id1 = models.IntegerField() + id2 = models.IntegerField() + + class Meta: + db_table = "parent" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_fk_parent_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_fk_parent_Gorm.snap new file mode 100644 index 00000000..3e2d6ec6 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_fk_parent_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 393 +expression: rendered +--- +package models + +type Parent struct { + ID1 int32 `gorm:"column:id1;primaryKey" json:"id1"` + ID2 int32 `gorm:"column:id2;primaryKey" json:"id2"` +} + +func (Parent) TableName() string { return "parent" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@dual_reverse_relations_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@dual_reverse_relations_Django.snap new file mode 100644 index 00000000..d7a15771 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@dual_reverse_relations_Django.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 393 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Dual(models.Model): + username = models.TextField(primary_key=True) + + class Meta: + db_table = "dual" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@dual_reverse_relations_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@dual_reverse_relations_Gorm.snap new file mode 100644 index 00000000..42cef8f7 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@dual_reverse_relations_Gorm.snap @@ -0,0 +1,14 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 393 +expression: rendered +--- +package models + +type Dual struct { + Username string `gorm:"column:username;primaryKey;type:text" json:"username"` + DualRelsByUsername []DualRel `gorm:"foreignKey:Username" json:"-"` + DualRelsByCheckerUsername []DualRel `gorm:"foreignKey:CheckerUsername" json:"-"` +} + +func (Dual) TableName() string { return "dual" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_article_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_article_Django.snap new file mode 100644 index 00000000..9c7e0a68 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_article_Django.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 393 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Article(models.Model): + id = models.BigIntegerField(primary_key=True) + users = models.ManyToManyField("User", through="ArticleUser", related_name="+") + + class Meta: + db_table = "article" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_article_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_article_Gorm.snap new file mode 100644 index 00000000..c25f0a59 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_article_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 393 +expression: rendered +--- +package models + +type Article struct { + ID int64 `gorm:"column:id;primaryKey" json:"id"` + ArticleUsers []ArticleUser `gorm:"foreignKey:ArticleID" json:"-"` +} + +func (Article) TableName() string { return "article" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_missing_target_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_missing_target_Django.snap new file mode 100644 index 00000000..f10e2cda --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_missing_target_Django.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 393 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Article(models.Model): + id = models.BigIntegerField(primary_key=True) + + class Meta: + db_table = "article" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_missing_target_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_missing_target_Gorm.snap new file mode 100644 index 00000000..c25f0a59 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_missing_target_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 393 +expression: rendered +--- +package models + +type Article struct { + ID int64 `gorm:"column:id;primaryKey" json:"id"` + ArticleUsers []ArticleUser `gorm:"foreignKey:ArticleID" json:"-"` +} + +func (Article) TableName() string { return "article" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_multiple_junctions_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_multiple_junctions_Django.snap new file mode 100644 index 00000000..0579963f --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_multiple_junctions_Django.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 393 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class User(models.Model): + id = models.UUIDField(primary_key=True) + medias_via_user_media_role = models.ManyToManyField("Media", through="UserMediaRole", related_name="+") + medias_via_user_media_favorite = models.ManyToManyField("Media", through="UserMediaFavorite", related_name="+") + + class Meta: + db_table = "user" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_multiple_junctions_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_multiple_junctions_Gorm.snap new file mode 100644 index 00000000..50542c0c --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_multiple_junctions_Gorm.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 393 +expression: rendered +--- +package models + +import ( + "github.com/google/uuid" +) + +type User struct { + ID uuid.UUID `gorm:"column:id;primaryKey;type:uuid" json:"id"` + UserMediaRoles []UserMediaRole `gorm:"foreignKey:UserID" json:"-"` + UserMediaFavorites []UserMediaFavorite `gorm:"foreignKey:UserID" json:"-"` +} + +func (User) TableName() string { return "user" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_user_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_user_Django.snap new file mode 100644 index 00000000..394d4f7a --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_user_Django.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 393 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class User(models.Model): + id = models.UUIDField(primary_key=True) + articles = models.ManyToManyField("Article", through="ArticleUser", related_name="+") + + class Meta: + db_table = "user" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_user_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_user_Gorm.snap new file mode 100644 index 00000000..b95d670f --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_user_Gorm.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 393 +expression: rendered +--- +package models + +import ( + "github.com/google/uuid" +) + +type User struct { + ID uuid.UUID `gorm:"column:id;primaryKey;type:uuid" json:"id"` + ArticleUsers []ArticleUser `gorm:"foreignKey:UserID" json:"-"` +} + +func (User) TableName() string { return "user" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_fk_same_table_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_fk_same_table_Django.snap new file mode 100644 index 00000000..b3ad116a --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_fk_same_table_Django.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 393 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Post(models.Model): + id = models.UUIDField(primary_key=True) + creator_user = models.ForeignKey("User", on_delete=models.RESTRICT, related_name="+") + used_by_user = models.ForeignKey("User", on_delete=models.RESTRICT, related_name="+") + + class Meta: + db_table = "post" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_fk_same_table_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_fk_same_table_Gorm.snap new file mode 100644 index 00000000..67489534 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_fk_same_table_Gorm.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 393 +expression: rendered +--- +package models + +import ( + "github.com/google/uuid" +) + +type Post struct { + ID uuid.UUID `gorm:"column:id;primaryKey;type:uuid" json:"id"` + CreatorUserID uuid.UUID `gorm:"column:creator_user_id;not null;type:uuid" json:"creator_user_id"` + CreatorUser User `gorm:"foreignKey:CreatorUserID" json:"-"` + UsedByUserID uuid.UUID `gorm:"column:used_by_user_id;not null;type:uuid" json:"used_by_user_id"` + UsedByUser User `gorm:"foreignKey:UsedByUserID" json:"-"` +} + +func (Post) TableName() string { return "post" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_has_one_relations_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_has_one_relations_Django.snap new file mode 100644 index 00000000..78e59f89 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_has_one_relations_Django.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 393 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class User(models.Model): + id = models.UUIDField(primary_key=True) + + class Meta: + db_table = "user" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_has_one_relations_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_has_one_relations_Gorm.snap new file mode 100644 index 00000000..7de75686 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_has_one_relations_Gorm.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 393 +expression: rendered +--- +package models + +import ( + "github.com/google/uuid" +) + +type User struct { + ID uuid.UUID `gorm:"column:id;primaryKey;type:uuid" json:"id"` + SettingsByCreatedByUserID []Settings `gorm:"foreignKey:CreatedByUserID" json:"-"` + SettingsByUpdatedByUserID []Settings `gorm:"foreignKey:UpdatedByUserID" json:"-"` +} + +func (User) TableName() string { return "user" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_reverse_relations_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_reverse_relations_Django.snap new file mode 100644 index 00000000..78e59f89 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_reverse_relations_Django.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 393 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class User(models.Model): + id = models.UUIDField(primary_key=True) + + class Meta: + db_table = "user" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_reverse_relations_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_reverse_relations_Gorm.snap new file mode 100644 index 00000000..fd42a86a --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_reverse_relations_Gorm.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 393 +expression: rendered +--- +package models + +import ( + "github.com/google/uuid" +) + +type User struct { + ID uuid.UUID `gorm:"column:id;primaryKey;type:uuid" json:"id"` + ProfilesByPreferredUserID []Profile `gorm:"foreignKey:PreferredUserID" json:"-"` + ProfilesByBackupUserID []Profile `gorm:"foreignKey:BackupUserID" json:"-"` +} + +func (User) TableName() string { return "user" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_another_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_another_Django.snap new file mode 100644 index 00000000..a8de48c9 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_another_Django.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 393 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Another(models.Model): + id = models.IntegerField(primary_key=True) + + class Meta: + db_table = "another" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_another_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_another_Gorm.snap new file mode 100644 index 00000000..7632afa3 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_another_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 393 +expression: rendered +--- +package models + +type Another struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + NotJunctions []NotJunction `gorm:"foreignKey:AnotherID" json:"-"` +} + +func (Another) TableName() string { return "another" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_other_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_other_Django.snap new file mode 100644 index 00000000..5b865a31 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_other_Django.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 393 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Other(models.Model): + id = models.IntegerField(primary_key=True) + + class Meta: + db_table = "other" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_other_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_other_Gorm.snap new file mode 100644 index 00000000..6b238079 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_other_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 393 +expression: rendered +--- +package models + +type Other struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + NotJunctions []NotJunction `gorm:"foreignKey:OtherID" json:"-"` +} + +func (Other) TableName() string { return "other" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_single_pk_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_single_pk_Django.snap new file mode 100644 index 00000000..5b865a31 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_single_pk_Django.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 393 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Other(models.Model): + id = models.IntegerField(primary_key=True) + + class Meta: + db_table = "other" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_single_pk_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_single_pk_Gorm.snap new file mode 100644 index 00000000..ce031e59 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_single_pk_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 393 +expression: rendered +--- +package models + +type Other struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Regulars []Regular `gorm:"foreignKey:OtherID" json:"-"` +} + +func (Other) TableName() string { return "other" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@triple_reverse_relations_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@triple_reverse_relations_Django.snap new file mode 100644 index 00000000..d7a15771 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@triple_reverse_relations_Django.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 393 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Dual(models.Model): + username = models.TextField(primary_key=True) + + class Meta: + db_table = "dual" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@triple_reverse_relations_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@triple_reverse_relations_Gorm.snap new file mode 100644 index 00000000..62974585 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@triple_reverse_relations_Gorm.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 393 +expression: rendered +--- +package models + +type Dual struct { + Username string `gorm:"column:username;primaryKey;type:text" json:"username"` + TripleRelsByUsername []TripleRel `gorm:"foreignKey:Username" json:"-"` + TripleRelsByCheckerUsername []TripleRel `gorm:"foreignKey:CheckerUsername" json:"-"` + TripleRelsByOtherUsername []TripleRel `gorm:"foreignKey:OtherUsername" json:"-"` +} + +func (Dual) TableName() string { return "dual" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Django.snap new file mode 100644 index 00000000..529385a2 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Django.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 393 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Session(models.Model): + id = models.UUIDField(primary_key=True) + username = models.ForeignKey("User", on_delete=models.RESTRICT, db_column="username", related_name="+") + + class Meta: + db_table = "session" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Gorm.snap new file mode 100644 index 00000000..03b69eaa --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Gorm.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 393 +expression: rendered +--- +package models + +import ( + "github.com/google/uuid" +) + +type Session struct { + ID uuid.UUID `gorm:"column:id;primaryKey;type:uuid" json:"id"` + Username string `gorm:"column:username;not null;type:text" json:"username"` + UsernameUser User `gorm:"foreignKey:Username" json:"-"` +} + +func (Session) TableName() string { return "session" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reserved_word_identifiers_snapshot@reserved_word_identifiers_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reserved_word_identifiers_snapshot@reserved_word_identifiers_Django.snap new file mode 100644 index 00000000..762b4299 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reserved_word_identifiers_snapshot@reserved_word_identifiers_Django.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 246 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Order(models.Model): + id = models.IntegerField(primary_key=True) + user = models.TextField() + select = models.IntegerField() + + class Meta: + db_table = "order" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reserved_word_identifiers_snapshot@reserved_word_identifiers_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reserved_word_identifiers_snapshot@reserved_word_identifiers_Gorm.snap new file mode 100644 index 00000000..f50d20b3 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reserved_word_identifiers_snapshot@reserved_word_identifiers_Gorm.snap @@ -0,0 +1,14 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 246 +expression: rendered +--- +package models + +type Order struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + User string `gorm:"column:user;not null;type:text" json:"user"` + Select int32 `gorm:"column:select;not null" json:"select"` +} + +func (Order) TableName() string { return "order" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__self_referencing_fk_snapshot@self_referencing_fk_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__self_referencing_fk_snapshot@self_referencing_fk_Django.snap new file mode 100644 index 00000000..1bbcb7f1 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__self_referencing_fk_snapshot@self_referencing_fk_Django.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 241 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Employees(models.Model): + id = models.IntegerField(primary_key=True) + manager = models.ForeignKey("Employees", on_delete=models.SET_NULL, related_name="+", null=True, blank=True) + + class Meta: + db_table = "employees" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__self_referencing_fk_snapshot@self_referencing_fk_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__self_referencing_fk_snapshot@self_referencing_fk_Gorm.snap new file mode 100644 index 00000000..36e47c0c --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__self_referencing_fk_snapshot@self_referencing_fk_Gorm.snap @@ -0,0 +1,14 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 241 +expression: rendered +--- +package models + +type Employees struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + ManagerID *int32 `gorm:"column:manager_id" json:"manager_id"` + Manager *Employees `gorm:"foreignKey:ManagerID;constraint:OnDelete:SET NULL" json:"-"` +} + +func (Employees) TableName() string { return "employees" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_default_and_true_boolean_snapshot@server_default_and_true_boolean_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_default_and_true_boolean_snapshot@server_default_and_true_boolean_Django.snap new file mode 100644 index 00000000..15fefe85 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_default_and_true_boolean_snapshot@server_default_and_true_boolean_Django.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 166 +expression: rendered +--- +from __future__ import annotations + +from django.utils import timezone +from django.db import models + + +class Logs(models.Model): + id = models.AutoField(primary_key=True) + active = models.BooleanField(default=True) + created_at = models.DateTimeField(default=timezone.now) + score = models.FloatField(default=1.5) + tag = models.TextField() + + class Meta: + db_table = "logs" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_default_and_true_boolean_snapshot@server_default_and_true_boolean_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_default_and_true_boolean_snapshot@server_default_and_true_boolean_Gorm.snap new file mode 100644 index 00000000..556549b3 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_default_and_true_boolean_snapshot@server_default_and_true_boolean_Gorm.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 166 +expression: rendered +--- +package models + +import ( + "time" +) + +type Logs struct { + ID int32 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + Active bool `gorm:"column:active;not null;default:true" json:"active"` + CreatedAt time.Time `gorm:"column:created_at;not null" json:"created_at"` + Score float32 `gorm:"column:score;not null;default:1.5" json:"score"` + Tag string `gorm:"column:tag;not null;type:text;default:UNKNOWN_EXPR" json:"tag"` +} + +func (Logs) TableName() string { return "logs" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_defaults_snapshot@server_defaults_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_defaults_snapshot@server_defaults_Django.snap new file mode 100644 index 00000000..ffd1dcfb --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_defaults_snapshot@server_defaults_Django.snap @@ -0,0 +1,19 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 161 +expression: rendered +--- +from __future__ import annotations + +from django.utils import timezone +from django.db import models + + +class WithDefaults(models.Model): + id = models.IntegerField(primary_key=True) + created_at = models.DateTimeField(default=timezone.now) + status = models.TextField(default="active") + count = models.IntegerField(default=0) + + class Meta: + db_table = "with_defaults" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_defaults_snapshot@server_defaults_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_defaults_snapshot@server_defaults_Gorm.snap new file mode 100644 index 00000000..a47b006b --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_defaults_snapshot@server_defaults_Gorm.snap @@ -0,0 +1,19 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 161 +expression: rendered +--- +package models + +import ( + "time" +) + +type WithDefaults struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + CreatedAt time.Time `gorm:"column:created_at;not null" json:"created_at"` + Status string `gorm:"column:status;not null;type:text;default:'active'" json:"status"` + Count int32 `gorm:"column:count;not null;default:0" json:"count"` +} + +func (WithDefaults) TableName() string { return "with_defaults" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__small_multi_schema_sequential_snapshot@small_multi_schema_sequential_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__small_multi_schema_sequential_snapshot@small_multi_schema_sequential_Django.snap new file mode 100644 index 00000000..c76abfe5 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__small_multi_schema_sequential_snapshot@small_multi_schema_sequential_Django.snap @@ -0,0 +1,24 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 297 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Users(models.Model): + id = models.IntegerField(primary_key=True) + display_name = models.TextField(null=True, blank=True) + + class Meta: + db_table = "users" + +class Posts(models.Model): + id = models.IntegerField(primary_key=True) + user = models.ForeignKey("Users", on_delete=models.RESTRICT, related_name="+") + title = models.TextField() + + class Meta: + db_table = "posts" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__small_multi_schema_sequential_snapshot@small_multi_schema_sequential_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__small_multi_schema_sequential_snapshot@small_multi_schema_sequential_Gorm.snap new file mode 100644 index 00000000..71801d1e --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__small_multi_schema_sequential_snapshot@small_multi_schema_sequential_Gorm.snap @@ -0,0 +1,25 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 297 +expression: rendered +--- +package models + +type Users struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + DisplayName *string `gorm:"column:display_name;type:text" json:"display_name"` +} + +func (Users) TableName() string { return "users" } + + +package models + +type Posts struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + UserID int32 `gorm:"column:user_id;not null" json:"user_id"` + User Users `gorm:"foreignKey:UserID" json:"-"` + Title string `gorm:"column:title;not null;type:text" json:"title"` +} + +func (Posts) TableName() string { return "posts" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__string_default_snapshot@string_default_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__string_default_snapshot@string_default_Django.snap new file mode 100644 index 00000000..4cbe2049 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__string_default_snapshot@string_default_Django.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 211 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class StringDefaults(models.Model): + id = models.IntegerField(primary_key=True) + status = models.TextField(default="active") + + class Meta: + db_table = "string_defaults" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__string_default_snapshot@string_default_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__string_default_snapshot@string_default_Gorm.snap new file mode 100644 index 00000000..db0773c3 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__string_default_snapshot@string_default_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 211 +expression: rendered +--- +package models + +type StringDefaults struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Status string `gorm:"column:status;not null;type:text;default:'active'" json:"status"` +} + +func (StringDefaults) TableName() string { return "string_defaults" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_pk_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_pk_Django.snap new file mode 100644 index 00000000..0aa12510 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_pk_Django.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 140 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Orders(models.Model): + id = models.UUIDField(primary_key=True) + customer_id = models.UUIDField() + total = models.FloatField() + + class Meta: + db_table = "orders" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_pk_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_pk_Gorm.snap new file mode 100644 index 00000000..3aeab522 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_pk_Gorm.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 140 +expression: rendered +--- +package models + +import ( + "github.com/google/uuid" +) + +type Orders struct { + ID uuid.UUID `gorm:"column:id;primaryKey;type:uuid" json:"id"` + CustomerID uuid.UUID `gorm:"column:customer_id;not null;type:uuid" json:"customer_id"` + Total float32 `gorm:"column:total;not null" json:"total"` +} + +func (Orders) TableName() string { return "orders" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_composite_fk_snapshot@table_with_composite_fk_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_composite_fk_snapshot@table_with_composite_fk_Django.snap new file mode 100644 index 00000000..855194f2 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_composite_fk_snapshot@table_with_composite_fk_Django.snap @@ -0,0 +1,19 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 88 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class LineItems(models.Model): + id = models.IntegerField(primary_key=True) + order_id = models.IntegerField() + order_version = models.IntegerField() + sku = models.TextField() + # composite foreign key: (order_id, order_version) -> orders(id, version) + + class Meta: + db_table = "line_items" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_composite_fk_snapshot@table_with_composite_fk_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_composite_fk_snapshot@table_with_composite_fk_Gorm.snap new file mode 100644 index 00000000..b57bbdb3 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_composite_fk_snapshot@table_with_composite_fk_Gorm.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 88 +expression: rendered +--- +package models + +type LineItems struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + OrderID int32 `gorm:"column:order_id;not null" json:"order_id"` + OrderVersion int32 `gorm:"column:order_version;not null" json:"order_version"` + Sku string `gorm:"column:sku;not null;type:text" json:"sku"` + Orders Orders `gorm:"foreignKey:OrderID,OrderVersion;references:ID,Version" json:"-"` +} + +func (LineItems) TableName() string { return "line_items" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_enum_snapshot@table_with_enum_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_enum_snapshot@table_with_enum_Django.snap new file mode 100644 index 00000000..491092e2 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_enum_snapshot@table_with_enum_Django.snap @@ -0,0 +1,21 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 99 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class OrderStatus(models.TextChoices): + PENDING = "pending", "pending" + SHIPPED = "shipped", "shipped" + DELIVERED = "delivered", "delivered" + +class Orders(models.Model): + id = models.IntegerField() + status = models.CharField(max_length=9, choices=OrderStatus.choices) + + class Meta: + db_table = "orders" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_enum_snapshot@table_with_enum_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_enum_snapshot@table_with_enum_Gorm.snap new file mode 100644 index 00000000..7aebb319 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_enum_snapshot@table_with_enum_Gorm.snap @@ -0,0 +1,21 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 99 +expression: rendered +--- +package models + +type OrderStatus string + +const ( + OrderStatusPending OrderStatus = "pending" + OrderStatusShipped OrderStatus = "shipped" + OrderStatusDelivered OrderStatus = "delivered" +) + +type Orders struct { + ID int32 `gorm:"column:id;not null" json:"id"` + Status OrderStatus `gorm:"column:status;not null" json:"status"` +} + +func (Orders) TableName() string { return "orders" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_fk_snapshot@table_with_fk_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_fk_snapshot@table_with_fk_Django.snap new file mode 100644 index 00000000..aaadec36 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_fk_snapshot@table_with_fk_Django.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 83 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Posts(models.Model): + id = models.IntegerField(primary_key=True) + user = models.ForeignKey("Users", on_delete=models.RESTRICT, related_name="+") + title = models.TextField() + + class Meta: + db_table = "posts" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_fk_snapshot@table_with_fk_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_fk_snapshot@table_with_fk_Gorm.snap new file mode 100644 index 00000000..19a26be8 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_fk_snapshot@table_with_fk_Gorm.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 83 +expression: rendered +--- +package models + +type Posts struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + UserID int32 `gorm:"column:user_id;not null" json:"user_id"` + User Users `gorm:"foreignKey:UserID" json:"-"` + Title string `gorm:"column:title;not null;type:text" json:"title"` +} + +func (Posts) TableName() string { return "posts" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_Django.snap new file mode 100644 index 00000000..8a8460e8 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_Django.snap @@ -0,0 +1,21 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 135 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Articles(models.Model): + id = models.IntegerField(primary_key=True) + title = models.TextField() + created_at = models.DateTimeField() + + class Meta: + db_table = "articles" + indexes = [ + models.Index(fields=["created_at"], name="idx_articles_created_at"), + models.Index(fields=["title"]), + ] diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_Gorm.snap new file mode 100644 index 00000000..40931404 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_Gorm.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 135 +expression: rendered +--- +package models + +import ( + "time" +) + +type Articles struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Title string `gorm:"column:title;not null;type:text;index" json:"title"` + CreatedAt time.Time `gorm:"column:created_at;not null;index:idx_articles_created_at" json:"created_at"` +} + +func (Articles) TableName() string { return "articles" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_integer_enum_snapshot@table_with_integer_enum_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_integer_enum_snapshot@table_with_integer_enum_Django.snap new file mode 100644 index 00000000..ececa60a --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_integer_enum_snapshot@table_with_integer_enum_Django.snap @@ -0,0 +1,21 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 104 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class PriorityLevel(models.IntegerChoices): + LOW = 0, "low" + MEDIUM = 10, "medium" + HIGH = 20, "high" + +class Tasks(models.Model): + id = models.IntegerField(primary_key=True) + priority = models.IntegerField(choices=PriorityLevel.choices) + + class Meta: + db_table = "tasks" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_integer_enum_snapshot@table_with_integer_enum_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_integer_enum_snapshot@table_with_integer_enum_Gorm.snap new file mode 100644 index 00000000..0293298c --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_integer_enum_snapshot@table_with_integer_enum_Gorm.snap @@ -0,0 +1,21 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 104 +expression: rendered +--- +package models + +type PriorityLevel int + +const ( + PriorityLevelLow PriorityLevel = 0 + PriorityLevelMedium PriorityLevel = 10 + PriorityLevelHigh PriorityLevel = 20 +) + +type Tasks struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Priority PriorityLevel `gorm:"column:priority;not null" json:"priority"` +} + +func (Tasks) TableName() string { return "tasks" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Django.snap new file mode 100644 index 00000000..d4d8b2e7 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Django.snap @@ -0,0 +1,22 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 130 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Users(models.Model): + id = models.IntegerField() + email = models.TextField(unique=True) + username = models.TextField(unique=True) + department = models.TextField(null=True, blank=True) + status = models.TextField(default="active") + + class Meta: + db_table = "users" + indexes = [ + models.Index(fields=["department"], name="idx_department"), + ] diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Gorm.snap new file mode 100644 index 00000000..bb159efe --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Gorm.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 130 +expression: rendered +--- +package models + +type Users struct { + ID int32 `gorm:"column:id;not null" json:"id"` + Email string `gorm:"column:email;not null;unique;type:text" json:"email"` + Username string `gorm:"column:username;not null;unique;type:text" json:"username"` + Department *string `gorm:"column:department;type:text;index:idx_department" json:"department"` + Status string `gorm:"column:status;not null;type:text;default:'active'" json:"status"` +} + +func (Users) TableName() string { return "users" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_constant_default_snapshot@unknown_constant_default_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_constant_default_snapshot@unknown_constant_default_Django.snap new file mode 100644 index 00000000..5a0c1d26 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_constant_default_snapshot@unknown_constant_default_Django.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 226 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class UnknownDefault(models.Model): + id = models.IntegerField(primary_key=True) + value = models.TextField() + + class Meta: + db_table = "unknown_default" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_constant_default_snapshot@unknown_constant_default_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_constant_default_snapshot@unknown_constant_default_Gorm.snap new file mode 100644 index 00000000..e1df4229 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_constant_default_snapshot@unknown_constant_default_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 226 +expression: rendered +--- +package models + +type UnknownDefault struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Value string `gorm:"column:value;not null;type:text;default:SOME_CONSTANT" json:"value"` +} + +func (UnknownDefault) TableName() string { return "unknown_default" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_function_default_snapshot@unknown_function_default_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_function_default_snapshot@unknown_function_default_Django.snap new file mode 100644 index 00000000..a4077527 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_function_default_snapshot@unknown_function_default_Django.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 221 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class UnknownDefaults(models.Model): + id = models.IntegerField(primary_key=True) + code = models.TextField() + + class Meta: + db_table = "unknown_defaults" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_function_default_snapshot@unknown_function_default_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_function_default_snapshot@unknown_function_default_Gorm.snap new file mode 100644 index 00000000..ac26307a --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_function_default_snapshot@unknown_function_default_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 221 +expression: rendered +--- +package models + +type UnknownDefaults struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Code string `gorm:"column:code;not null;type:text" json:"code"` +} + +func (UnknownDefaults) TableName() string { return "unknown_defaults" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_index_snapshot@unnamed_composite_index_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_index_snapshot@unnamed_composite_index_Django.snap new file mode 100644 index 00000000..e7af3c04 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_index_snapshot@unnamed_composite_index_Django.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 196 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class UnnamedIndex(models.Model): + id = models.IntegerField(primary_key=True) + col_a = models.IntegerField() + col_b = models.IntegerField() + + class Meta: + db_table = "unnamed_index" + indexes = [ + models.Index(fields=["col_a", "col_b"]), + ] diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_index_snapshot@unnamed_composite_index_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_index_snapshot@unnamed_composite_index_Gorm.snap new file mode 100644 index 00000000..071ef161 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_index_snapshot@unnamed_composite_index_Gorm.snap @@ -0,0 +1,14 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 196 +expression: rendered +--- +package models + +type UnnamedIndex struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + ColA int32 `gorm:"column:col_a;not null;index" json:"col_a"` + ColB int32 `gorm:"column:col_b;not null;index" json:"col_b"` +} + +func (UnnamedIndex) TableName() string { return "unnamed_index" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_unique_snapshot@unnamed_composite_unique_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_unique_snapshot@unnamed_composite_unique_Django.snap new file mode 100644 index 00000000..8892b4d0 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_unique_snapshot@unnamed_composite_unique_Django.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 201 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class UnnamedUnique(models.Model): + id = models.IntegerField(primary_key=True) + col_a = models.IntegerField() + col_b = models.IntegerField() + + class Meta: + db_table = "unnamed_unique" + constraints = [ + models.UniqueConstraint(fields=["col_a", "col_b"]), + ] diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_unique_snapshot@unnamed_composite_unique_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_unique_snapshot@unnamed_composite_unique_Gorm.snap new file mode 100644 index 00000000..6a593c7c --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_unique_snapshot@unnamed_composite_unique_Gorm.snap @@ -0,0 +1,14 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 201 +expression: rendered +--- +package models + +type UnnamedUnique struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + ColA int32 `gorm:"column:col_a;not null;uniqueIndex:uq_col_a_col_b" json:"col_a"` + ColB int32 `gorm:"column:col_b;not null;uniqueIndex:uq_col_a_col_b" json:"col_b"` +} + +func (UnnamedUnique) TableName() string { return "unnamed_unique" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_index_and_unique_snapshot@unnamed_index_and_unique_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_index_and_unique_snapshot@unnamed_index_and_unique_Django.snap new file mode 100644 index 00000000..42c37a1d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_index_and_unique_snapshot@unnamed_index_and_unique_Django.snap @@ -0,0 +1,23 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 191 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Events(models.Model): + id = models.IntegerField(primary_key=True) + venue_id = models.IntegerField() + date = models.DateField() + + class Meta: + db_table = "events" + indexes = [ + models.Index(fields=["venue_id", "date"]), + ] + constraints = [ + models.UniqueConstraint(fields=["venue_id", "date"]), + ] diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_index_and_unique_snapshot@unnamed_index_and_unique_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_index_and_unique_snapshot@unnamed_index_and_unique_Gorm.snap new file mode 100644 index 00000000..5d9fa328 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_index_and_unique_snapshot@unnamed_index_and_unique_Gorm.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 191 +expression: rendered +--- +package models + +import ( + "time" +) + +type Events struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + VenueID int32 `gorm:"column:venue_id;not null;index;uniqueIndex:uq_venue_id_date" json:"venue_id"` + Date time.Time `gorm:"column:date;not null;type:date;index;uniqueIndex:uq_venue_id_date" json:"date"` +} + +func (Events) TableName() string { return "events" } diff --git a/crates/vespertide-exporter/tests/parallel_consolidated.rs b/crates/vespertide-exporter/tests/parallel_consolidated.rs index 423b5486..397b0be6 100644 --- a/crates/vespertide-exporter/tests/parallel_consolidated.rs +++ b/crates/vespertide-exporter/tests/parallel_consolidated.rs @@ -9,6 +9,9 @@ use vespertide_exporter::Orm; #[case::sqlalchemy(Orm::SqlAlchemy)] #[case::sqlmodel(Orm::SqlModel)] #[case::jpa(Orm::Jpa)] +#[case::gorm(Orm::Gorm)] +#[case::django(Orm::Django)] +#[case::prisma(Orm::Prisma)] fn export_is_byte_identical_across_thread_counts(#[case] orm: Orm) { let schema = large_schema(100); @@ -37,6 +40,12 @@ fn render_schema(orm: Orm, schema: &[TableDef]) -> Result { Orm::Jpa => { vespertide_exporter::jpa::render_entities(schema).map(|entities| entities.join("\n")) } + Orm::Gorm => schema + .iter() + .map(vespertide_exporter::gorm::render_entity) + .collect::, _>>() + .map(|v| v.join("\n\n")), + Orm::Django => vespertide_exporter::django::export(schema), Orm::Prisma => vespertide_exporter::prisma::export(schema), } } diff --git a/schemas/config.schema.json b/schemas/config.schema.json index fa5f2852..b5452120 100644 --- a/schemas/config.schema.json +++ b/schemas/config.schema.json @@ -7,6 +7,16 @@ "columnNamingCase": { "$ref": "#/$defs/NameCase" }, + "django": { + "description": "Django-specific export configuration.", + "$ref": "#/$defs/DjangoConfig", + "default": {} + }, + "gorm": { + "description": "GORM-specific export configuration.", + "$ref": "#/$defs/GormConfig", + "default": {} + }, "lockTimeoutMs": { "description": "Maximum time (milliseconds) to wait acquiring a lock during a runtime\nmigration before failing. When set, the `vespertide_migration!` macro\nemits a backend-appropriate session/connection timeout at the start of\nthe migration (`PostgreSQL` `lock_timeout`, `MySQL`\n`innodb_lock_wait_timeout`, `SQLite` `PRAGMA busy_timeout`). `None`\n(default) leaves backend defaults untouched. Absent from serialized\nJSON when `None` (wire-compatible).", "type": [ @@ -76,6 +86,19 @@ "columnNamingCase" ], "$defs": { + "DjangoConfig": { + "description": "Django-specific export configuration.", + "type": "object", + "properties": { + "appLabel": { + "description": "Explicit `app_label` written into every generated model's `Meta`\nclass. Needed when generated models don't live inside a standard\nDjango app package layout, where Django would otherwise infer the\nlabel from the containing package. `None` (default) omits\n`app_label` and leaves Django's normal inference in place.", + "type": [ + "string", + "null" + ] + } + } + }, "FileFormat": { "description": "Supported file formats for generated artifacts.", "type": "string", @@ -85,6 +108,19 @@ "yml" ] }, + "GormConfig": { + "description": "GORM-specific export configuration.", + "type": "object", + "properties": { + "packageName": { + "description": "Go package name emitted at the top of every generated file\n(`package `). `None` (default) infers the name from the\nexport directory's final path segment (sanitized to a valid Go\nidentifier), falling back to `\"models\"` when that segment isn't\nusable. See [`VespertideConfig::gorm_package_name`].", + "type": [ + "string", + "null" + ] + } + } + }, "NameCase": { "description": "Supported naming cases.", "type": "string",