diff --git a/.changepacks/changepack_log_5O2QrCyrfNoD1wBL403qE.json b/.changepacks/changepack_log_5O2QrCyrfNoD1wBL403qE.json new file mode 100644 index 00000000..8dc943c6 --- /dev/null +++ b/.changepacks/changepack_log_5O2QrCyrfNoD1wBL403qE.json @@ -0,0 +1 @@ +{"changes": {"crates/vespertide-cli/Cargo.toml": "Minor", "crates/vespertide-config/Cargo.toml": "Minor", "crates/vespertide-core/Cargo.toml": "Minor", "crates/vespertide-exporter/Cargo.toml": "Minor", "crates/vespertide-loader/Cargo.toml": "Minor", "crates/vespertide-lsp/Cargo.toml": "Minor", "crates/vespertide-macro/Cargo.toml": "Minor", "crates/vespertide-naming/Cargo.toml": "Minor", "crates/vespertide-planner/Cargo.toml": "Minor", "crates/vespertide-query/Cargo.toml": "Minor", "crates/vespertide/Cargo.toml": "Minor"}, "note": "GORM(Go) 익스포터를 7번째 ORM 백엔드로 추가. `Orm`이 exhaustive pub enum이라 `Orm::Gorm` 추가가 0.x 기준 breaking이고, vespertide-cli에 `export --orm gorm` 경로가 함께 들어간다(스키마 전체를 `models.go` 한 파일로 쓴다). vespertide-core의 `SimpleColumnType`·`ReferenceAction`에서 `#[non_exhaustive]`를 제거해 downstream이 exhaustive match를 쓸 수 있게 한다(기존 `_` arm은 `unreachable_patterns` 경고가 된다). published 크레이트를 전부 같은 Minor로 올리는 이유는 #185·#186과 동일하다: [workspace.dependencies]의 `=` 핀으로 물려 있어 일부만 올리면 핀과 크레이트 버전이 어긋나 resolve가 깨진다.", "date": "2026-09-15T07:40:25.0000000Z"} \ No newline at end of file diff --git a/.changepacks/changepack_log_XRCvp4xmrYU0QgPi9kMB.json b/.changepacks/changepack_log_XRCvp4xmrYU0QgPi9kMB.json new file mode 100644 index 00000000..f25786db --- /dev/null +++ b/.changepacks/changepack_log_XRCvp4xmrYU0QgPi9kMB.json @@ -0,0 +1 @@ +{"changes": {"crates/vespertide-cli/Cargo.toml": "Minor", "crates/vespertide-config/Cargo.toml": "Minor", "crates/vespertide-core/Cargo.toml": "Minor", "crates/vespertide-exporter/Cargo.toml": "Minor", "crates/vespertide-loader/Cargo.toml": "Minor", "crates/vespertide-lsp/Cargo.toml": "Minor", "crates/vespertide-macro/Cargo.toml": "Minor", "crates/vespertide-naming/Cargo.toml": "Minor", "crates/vespertide-planner/Cargo.toml": "Minor", "crates/vespertide-query/Cargo.toml": "Minor", "crates/vespertide/Cargo.toml": "Minor"}, "note": "Django(Python) 익스포터를 8번째 ORM 백엔드로 추가. `Orm`이 exhaustive pub enum이라 `Orm::Django` 추가가 0.x 기준 breaking이고, vespertide-config에 `django` 설정 섹션(`appLabel`)이, vespertide-cli에 `export --orm django` 경로가 함께 들어간다(스키마 전체를 `models.py` 한 파일로 쓰고, 모델은 `managed = False`로 나간다). 기존 파이썬 백엔드(SQLAlchemy·SQLModel)도 파이썬 키워드 컬럼명을 이스케이프한다. published 크레이트를 전부 같은 Minor로 올리는 이유는 #185·#186과 동일하다: [workspace.dependencies]의 `=` 핀으로 물려 있어 일부만 올리면 핀과 크레이트 버전이 어긋나 resolve가 깨진다.", "date": "2026-09-20T09:00:00.0000000Z"} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 4e47698a..6a2ef87e 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, Drizzle +│ ├── vespertide-exporter/ # ORM codegen: SeaORM, SQLAlchemy, SQLModel, JPA, Prisma, Drizzle, GORM, Django │ ├── vespertide-loader/ # Filesystem loading of models/migrations │ ├── vespertide-config/ # vespertide.json configuration │ ├── vespertide-lsp/ # Language server: 13 LSP capabilities + HS-7~11 caching @@ -48,7 +48,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,drizzle}/` | Backend-specific generators | +| ORM export | `vespertide-exporter/src/{seaorm,sqlalchemy,sqlmodel,jpa,prisma,drizzle,gorm,django}/` | 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 | @@ -103,7 +103,7 @@ When constructing struct literals (e.g. `TableDef { name: ... }`), prefer `.into from string literals over the explicit constructor for terseness. ### `#[non_exhaustive]` Structs (0.2.0+) -`VespertideConfig`, `SeaOrmConfig`, `MigrationOptions` are `#[non_exhaustive]`: +`VespertideConfig`, `SeaOrmConfig`, `DjangoConfig`, `MigrationOptions` are `#[non_exhaustive]`: external callers MUST construct via `..Default::default()` or the provided constructor. @@ -170,7 +170,7 @@ See `docs/clippy-allow-audit.md` for the full audit history. | `QueryError::Other(...)` in new code | Emits deprecation warning. Use `SchemaError` / `InvalidColumnType` / `BackendError` / `UnsupportedAction` | | Exhaustive struct literal for `MigrationOptions` / `VespertideConfig` | `#[non_exhaustive]` — use `..Default::default()` | | Comparing newtype with `String::eq(&name.to_string(), "user")` | `TableName: PartialEq<&str>` — use `name == "user"` directly | -| Per-ORM exporter snapshot test (single ORM) | Use the 6-ORM `orm_cases!` macro; snapshots must cross-compare all ORMs | +| Per-ORM exporter snapshot test (single ORM) | Use the 8-ORM `orm_cases!` macro; snapshots must cross-compare all ORMs | ## COMMANDS @@ -235,7 +235,7 @@ Files near the ceiling (next split candidates — line counts as of the | `query/src/sql/delete_column/mod.rs` | 1138 | prod+inline-tests (≤1200) | DROP COLUMN with SQLite rebuild | | `query/src/sql/add_constraint/mod.rs` | 1138 | prod+inline-tests (≤1200) | ADD CONSTRAINT | | `core/src/schema/table/tests/mod.rs` | 1137 | test-file (≤1200) | Table normalization tests | -| `exporter/src/tests/fixtures/mod.rs` | 1146 | test-file (≤1200) | Shared 6-ORM fixture schemas | +| `exporter/src/tests/fixtures/mod.rs` | 1173 | test-file (≤1200) | Shared 8-ORM fixture schemas | | `planner/src/validate/check_strengthening.rs` | 1121 | prod+inline-tests (≤1200) | CHECK strengthening analysis | | `query/src/sql/helpers.rs` | 1109 | prod+inline-tests (≤1200) | Identifier quoting / type-cast helpers | | `lsp/src/code_actions.rs` | 1107 | prod+inline-tests (≤1200) | LSP code actions (incl. CHECK BETWEEN-swap) | @@ -369,7 +369,7 @@ alongside what the dialect emits *in place of* the missing construct. **How many cases:** Where the axis has a documented matrix — `vespertide-query`'s -`{PG, MySQL, SQLite}` triple and the exporter's six-ORM `orm_cases!` — fan out +`{PG, MySQL, SQLite}` triple and the exporter's eight-ORM `orm_cases!` — fan out **always**, even when every case renders the same bytes: identity across the matrix is itself the assertion (`uniform_sql_is_emitted_byte_for_byte`), and a lone single-backend snapshot is a fault (`vespertide-query/AGENTS.md`). @@ -397,14 +397,14 @@ fn create_table_snapshot(#[case] backend: DatabaseBackend) { ``` This is the same pattern used by `vespertide-query` (3 backends, 564 snapshots) -and `vespertide-exporter` (6 ORMs via `Orm` enum, 414 cross-ORM snapshots). When +and `vespertide-exporter` (8 ORMs via `Orm` enum, 632 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 six ORMs** (`Orm::SeaOrm`, `Orm::SqlAlchemy`, `Orm::SqlModel`, `Orm::Jpa`, `Orm::Prisma`, `Orm::Drizzle`). A new export scenario = ONE fixture + ONE `orm_cases!(...)` line, producing exactly six 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 eight ORMs** (`Orm::SeaOrm`, `Orm::SqlAlchemy`, `Orm::SqlModel`, `Orm::Jpa`, `Orm::Prisma`, `Orm::Drizzle`, `Orm::Gorm`, `Orm::Django`). A new export scenario = ONE fixture + ONE `orm_cases!(...)` line, producing exactly eight snapshots (one per ORM) in the single shared `crates/vespertide-exporter/src/tests/snapshots/` directory. -FORBIDDEN: per-ORM `#[test]` snapshot functions inside `src/seaorm/`, `src/sqlalchemy/`, `src/sqlmodel/`, `src/jpa/`, `src/prisma/`, `src/drizzle/`, 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 six. 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/`, `src/drizzle/`, `src/gorm/`, `src/django/`, 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 eight. 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 (Prisma's single-file `render_schema`, which deduplicates enums globally; Drizzle's dialect-aware `render_schema`, whose axis is the SQL dialect rather than the ORM) 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/README.md b/README.md index 0b1ac708..9b91a077 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, Drizzle +- **ORM Export**: Export schemas to SeaORM, SQLAlchemy, SQLModel, JPA, Prisma, Drizzle, GORM, Django - **Language Server**: First-class editor support via the bundled `vespertide-lsp` — see [LSP Features](#lsp-features) below ## What's new in 0.2.0 @@ -24,7 +24,7 @@ Declarative database schema management. Define your schemas in JSON, and Vespert API stability pass with a byte-identical JSON wire format — existing models and migration files load unchanged. - **Newtype identifiers**: `TableName`, `ColumnName`, `IndexName` in `vespertide-core` (`crates/vespertide-core/src/schema/names.rs`). `#[serde(transparent)]` keeps JSON identical; `Deref` means most call sites need no edit. -- **`#[non_exhaustive]` configs**: `VespertideConfig`, `SeaOrmConfig`, and `MigrationOptions` must be built with `..Default::default()` (or `MigrationOptions::new()`), so future fields don't break semver. +- **`#[non_exhaustive]` configs**: `VespertideConfig`, `SeaOrmConfig`, `DjangoConfig`, and `MigrationOptions` must be built with `..Default::default()` (or `MigrationOptions::new()`), so future fields don't break semver. - **Decomposed `QueryError`**: new `InvalidColumnType`, `SchemaError`, `BackendError`, and `UnsupportedAction` variants. `QueryError::Other(String)` is `#[deprecated]` but still compiles. - **Cloneable `MigrationError`**: backed by `Arc`, so retry loops can re-emit errors without re-running the planner. - **Faster LSP**: every editor hot path (diagnostics, symbols, drift) is now `RingCache`-backed in `vespertide-lsp`. No API change; -99% latency on the synthetic `tools/lsp-profile/` workload. @@ -245,6 +245,8 @@ vespertide export --orm sqlmodel # Python - SQLModel (FastAPI) vespertide export --orm jpa # Java - JPA/Hibernate entities vespertide export --orm prisma # Prisma - schema.prisma models vespertide export --orm drizzle # TypeScript - Drizzle ORM (pg/mysql/sqlite files) +vespertide export --orm gorm # Go - GORM models (models.go) +vespertide export --orm django # Python - Django models (models.py) ``` ## Runtime Migrations (Macro) diff --git a/bridge/node/README.md b/bridge/node/README.md index 864c4738..2080745b 100644 --- a/bridge/node/README.md +++ b/bridge/node/README.md @@ -2,7 +2,7 @@ Declarative database schema management: define tables in JSON or YAML, diff them against the migration history, and generate SQL for PostgreSQL, MySQL -and SQLite plus ORM code (SeaORM, SQLAlchemy, SQLModel, JPA, Prisma, Drizzle). +and SQLite plus ORM code (SeaORM, SQLAlchemy, SQLModel, JPA, Prisma, Drizzle, GORM, Django). This package is the `vespertide` command-line tool as a native Node addon, so no Rust toolchain is needed. diff --git a/crates/vespertide-cli/AGENTS.md b/crates/vespertide-cli/AGENTS.md index 118ce130..a8ea03d4 100644 --- a/crates/vespertide-cli/AGENTS.md +++ b/crates/vespertide-cli/AGENTS.md @@ -21,8 +21,9 @@ src/ │ # choices_and_apply/), tests/ ├── status.rs # Show config and sync status ├── log.rs # List applied migrations with SQL - ├── export/ # Export to ORM code (SeaORM/SQLAlchemy/SQLModel/JPA/Prisma/Drizzle) — - │ # mod.rs + tests/ (mod.rs, prisma.rs, drizzle.rs) + ├── export/ # Export to ORM code (SeaORM/SQLAlchemy/SQLModel/JPA/Prisma/Drizzle/GORM/Django) — + │ # mod.rs + tests/ (mod.rs, prisma.rs, drizzle.rs, gorm.rs, django.rs, + │ # models_file.rs) └── erd/ # ERD diagram export — mod.rs, mermaid.rs, dot.rs, svg/ (style, model, # layout, edges, render, util), tests/ ``` @@ -38,7 +39,7 @@ src/ | `revision -m` | `cmd_revision(msg, fill_with)` | Interactive prompts via `dialoguer::Input` | | `status` | `cmd_status()` | Display config paths and migration count | | `log` | `cmd_log(backend)` | Iterate applied migrations, print SQL | -| `export --orm` | `cmd_export(orm, dir)` | `render_entity_with_schema()` + mod.rs wiring | +| `export --orm` | `cmd_export(orm, dir)` | per-table `render_entity_with_schema()` + mod.rs wiring; Prisma/Drizzle/GORM/Django render the whole schema into fixed file names | | `erd -f svg\|mermaid\|dot` | `cmd_erd_with_filters(format, output, include, exclude, depth)` | FK-graph filtered ERD rendering | ## WHERE TO LOOK @@ -55,7 +56,7 @@ src/ ## NOTES - **revision/**: Most complex command — handles interactive `--fill-with` prompts for NOT NULL columns without defaults; long ago split from a single 3064-line file into `revision/{mod,parse,emit,write,timezones}.rs` + `prompts/` + `tests/` -- **export/**: Generates the `mod.rs` chain for SeaORM exports; Python/Java ORMs skip it. Prisma and Drizzle take separate single-file paths rather than one file per model — Prisma writes one `models.prisma`, Drizzle one file per dialect (`models.pg.ts` / `models.mysql.ts` / `models.sqlite.ts`) +- **export/**: Generates the `mod.rs` chain for SeaORM exports; Python/Java ORMs skip it. Prisma, Drizzle, GORM and Django take separate single-file paths rather than one file per model — Prisma writes one `models.prisma`, Drizzle one file per dialect (`models.pg.ts` / `models.mysql.ts` / `models.sqlite.ts`), GORM `models.go` and Django `models.py`. The last two skip the extension sweep like Drizzle, and refuse to overwrite a `models.*` that does not open with the `Code generated by vespertide. DO NOT EDIT.` line - All commands use `load_config()`, `load_models()`, `load_migrations()` from `vespertide_loader` - YAML and JSON are both fully supported for models and migrations; `new -f yaml` creates YAML templates. - Prefer typed `MigrationAction` enums; `RawSql` exists as a documented emergency escape hatch, but is not recommended for normal use. diff --git a/crates/vespertide-cli/src/commands/export/mod.rs b/crates/vespertide-cli/src/commands/export/mod.rs index 3d5e4f9b..09740768 100644 --- a/crates/vespertide-cli/src/commands/export/mod.rs +++ b/crates/vespertide-cli/src/commands/export/mod.rs @@ -8,8 +8,8 @@ use tokio::fs; use vespertide_config::VespertideConfig; use vespertide_core::TableDef; use vespertide_exporter::{ - Orm, drizzle, prisma, python_naming::to_pascal_case, render_entity_with_schema, - seaorm::SeaOrmExporterWithConfig, + Orm, django::DjangoExporterWithConfig, drizzle, gorm::GormExporterWithConfig, prisma, + python_naming::to_pascal_case, render_entity_with_schema, seaorm::SeaOrmExporterWithConfig, }; use vespertide_naming::{IdentifierStart, sanitize_identifier, seaorm_module_name}; @@ -35,13 +35,16 @@ pub async fn cmd_export(orm: Orm, export_dir: Option) -> Result<()> { let target_root = resolve_export_dir(export_dir, &config); - // Prisma and Drizzle use a single-file output strategy + // Prisma, Drizzle, GORM and Django use a single-file output strategy if matches!(orm, Orm::Prisma) { return cmd_export_prisma(normalized_models, target_root).await; } if matches!(orm, Orm::Drizzle) { return cmd_export_drizzle(normalized_models, target_root).await; } + if matches!(orm, Orm::Gorm | Orm::Django) { + return cmd_export_models_file(orm, &config, normalized_models, target_root).await; + } // Clean the export directory before regenerating prepare_export_dir(&target_root, orm).await?; @@ -465,6 +468,62 @@ async fn cmd_export_drizzle( Ok(()) } +/// First line of the file [`cmd_export_models_file`] writes, after the +/// language's comment token. +const GENERATED_MARKER: &str = "Code generated by vespertide. DO NOT EDIT."; + +/// GORM and Django get the whole schema as one `models.go` / `models.py`. Go +/// reads a directory as one package and a relation is rendered from both of +/// its ends, so models spread over directories would import each other in a +/// cycle; Django loads an app's models from its one `models` module. +/// +/// A fixed file name also leaves nothing to sweep, so the user's own `.go` and +/// `.py` files are never touched. The file itself is only overwritten when it +/// starts with [`GENERATED_MARKER`]: `models.py` is the name `startapp` gives +/// the user's own module. +async fn cmd_export_models_file( + orm: Orm, + config: &VespertideConfig, + normalized_models: Vec<(TableDef, PathBuf)>, + target_root: PathBuf, +) -> Result<()> { + let all_tables: Vec = normalized_models.iter().map(|(t, _)| t.clone()).collect(); + let (comment, code) = if matches!(orm, Orm::Gorm) { + let exporter = GormExporterWithConfig::for_export_dir(&target_root); + ("//", exporter.export(&all_tables)) + } else { + let exporter = DjangoExporterWithConfig::new(config.django()); + ("#", exporter.export(&all_tables)) + }; + let code = code.map_err(|e| anyhow::anyhow!(e))?; + let marker = format!("{comment} {GENERATED_MARKER}"); + + let out_path = target_root.join(format!("models.{}", orm.file_extension())); + if let Ok(existing) = fs::read(&out_path).await + && !existing.starts_with(marker.as_bytes()) + { + anyhow::bail!( + "{} was not generated by vespertide; move it or choose another --export-dir", + out_path.display() + ); + } + + fs::create_dir_all(&target_root) + .await + .with_context(|| format!("create export dir {}", target_root.display()))?; + fs::write(&out_path, format!("{marker}\n\n{code}")) + .await + .with_context(|| format!("write {}", out_path.display()))?; + + println!( + "Exported {} model(s) -> {}", + normalized_models.len(), + out_path.display() + ); + + Ok(()) +} + #[async_recursion::async_recursion] async fn walk_models( root: &Path, diff --git a/crates/vespertide-cli/src/commands/export/tests/django.rs b/crates/vespertide-cli/src/commands/export/tests/django.rs new file mode 100644 index 00000000..60ff7c6a --- /dev/null +++ b/crates/vespertide-cli/src/commands/export/tests/django.rs @@ -0,0 +1,25 @@ +use super::*; +use insta::assert_snapshot; + +/// `app_label` is the one `django` config setting, and it only reaches the +/// generated `Meta` class through `DjangoExporterWithConfig`. Exporting with it +/// set is what proves the CLI takes that path. +#[tokio::test] +#[serial] +async fn export_django_writes_the_configured_app_label_into_meta() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + let mut cfg = serde_json::to_value(VespertideConfig::default()).unwrap(); + cfg["django"] = serde_json::json!({ "appLabel": "storefront" }); + std_fs::write( + "vespertide.json", + serde_json::to_string_pretty(&cfg).unwrap(), + ) + .unwrap(); + write_model(Path::new("models/gadgets.json"), &sample_table("gadgets")); + + cmd_export(Orm::Django, None).await.unwrap(); + + let written = std_fs::read_to_string(PathBuf::from("src/models/models.py")).unwrap(); + assert_snapshot!(written); +} diff --git a/crates/vespertide-cli/src/commands/export/tests/gorm.rs b/crates/vespertide-cli/src/commands/export/tests/gorm.rs new file mode 100644 index 00000000..9cae6006 --- /dev/null +++ b/crates/vespertide-cli/src/commands/export/tests/gorm.rs @@ -0,0 +1,22 @@ +use super::*; +use insta::assert_snapshot; + +/// Go requires the `package` clause to match the directory the file lives in, +/// so the effective package name comes from the real write target rather than +/// the config's static default. Exporting into a non-default directory is what +/// tells the two apart. +#[tokio::test] +#[serial] +async fn export_gorm_takes_its_package_name_from_the_export_directory() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + write_model(Path::new("models/widgets.json"), &sample_table("widgets")); + + cmd_export(Orm::Gorm, Some(PathBuf::from("generated/store"))) + .await + .unwrap(); + + let written = std_fs::read_to_string(PathBuf::from("generated/store/models.go")).unwrap(); + assert_snapshot!(written); +} diff --git a/crates/vespertide-cli/src/commands/export/tests/mod.rs b/crates/vespertide-cli/src/commands/export/tests/mod.rs index 5dc489fa..fb7fa9bd 100644 --- a/crates/vespertide-cli/src/commands/export/tests/mod.rs +++ b/crates/vespertide-cli/src/commands/export/tests/mod.rs @@ -6,7 +6,10 @@ pub(super) use std::fs as std_fs; pub(super) use tempfile::tempdir; pub(super) use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType, TableConstraint}; +mod django; mod drizzle; +mod gorm; +mod models_file; mod prisma; fn write_config() { diff --git a/crates/vespertide-cli/src/commands/export/tests/models_file.rs b/crates/vespertide-cli/src/commands/export/tests/models_file.rs new file mode 100644 index 00000000..caabf618 --- /dev/null +++ b/crates/vespertide-cli/src/commands/export/tests/models_file.rs @@ -0,0 +1,129 @@ +use super::*; +use insta::{assert_snapshot, with_settings}; + +/// `user`, and a `post` referencing it from another model directory. +fn write_models_across_directories() { + write_model(Path::new("models/admin/user.json"), &sample_table("user")); + + let mut post = sample_table("post"); + post.columns.push(ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }); + post.constraints.push(TableConstraint::ForeignKey { + name: Some("fk_post__user_id".into()), + columns: vec!["user_id".into()], + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + orphan_strategy: Default::default(), + }); + write_model(Path::new("models/blog/post.json"), &post); +} + +/// Model directories do not reach the output. A Go directory is one package +/// and Django loads one `models` module, so both tables land in the same file +/// and the relation between them resolves without an import. +#[rstest] +#[case::gorm(Orm::Gorm, "models.go")] +#[case::django(Orm::Django, "models.py")] +#[serial] +#[tokio::test] +async fn export_writes_nested_model_directories_into_one_file( + #[case] orm: Orm, + #[case] file: &str, +) { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + write_models_across_directories(); + + cmd_export(orm, None).await.unwrap(); + + let root = PathBuf::from("src/models"); + assert_eq!(std_fs::read_dir(&root).unwrap().count(), 1); + let written = std_fs::read_to_string(root.join(file)).unwrap(); + with_settings!({ snapshot_suffix => format!("{orm:?}") }, { + assert_snapshot!(written); + }); +} + +/// The export root doubles as a source directory — a Go package, a Django app +/// — and nothing in it is swept: a re-export replaces the one file an earlier +/// export wrote and leaves the user's own files, nested ones included, alone. +#[rstest] +#[case::gorm(Orm::Gorm, "models.go", "repository.go", "cache/store.go")] +#[case::django(Orm::Django, "models.py", "admin.py", "migrations/0001_initial.py")] +#[serial] +#[tokio::test] +async fn export_replaces_only_its_own_models_file( + #[case] orm: Orm, + #[case] file: &str, + #[case] sibling: &str, + #[case] nested: &str, +) { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + write_model(Path::new("models/user.json"), &sample_table("user")); + + let root = PathBuf::from("src/models"); + cmd_export(orm, None).await.unwrap(); + let first = std_fs::read_to_string(root.join(file)).unwrap(); + + std_fs::create_dir_all(root.join(nested).parent().unwrap()).unwrap(); + std_fs::write(root.join(sibling), "user code").unwrap(); + std_fs::write(root.join(nested), "user code").unwrap(); + write_model(Path::new("models/post.json"), &sample_table("post")); + cmd_export(orm, None).await.unwrap(); + + assert_ne!(std_fs::read_to_string(root.join(file)).unwrap(), first); + assert_eq!( + std_fs::read_to_string(root.join(sibling)).unwrap(), + "user code" + ); + assert_eq!( + std_fs::read_to_string(root.join(nested)).unwrap(), + "user code" + ); +} + +/// `models.go` and `models.py` are names the user may already own — `startapp` +/// writes the latter — so a file that does not open with the generated marker +/// is reported instead of overwritten. +#[rstest] +#[case::gorm(Orm::Gorm, "models.go", "package models\n")] +#[case::django( + Orm::Django, + "models.py", + "from django.db import models\n\n# Create your models here.\n" +)] +#[serial] +#[tokio::test] +async fn export_refuses_a_models_file_it_did_not_write( + #[case] orm: Orm, + #[case] file: &str, + #[case] own: &str, +) { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + write_model(Path::new("models/user.json"), &sample_table("user")); + + let out = PathBuf::from("src/models").join(file); + std_fs::create_dir_all("src/models").unwrap(); + std_fs::write(&out, own).unwrap(); + + let err = cmd_export(orm, None).await.unwrap_err(); + + assert!(err.to_string().contains("was not generated by vespertide")); + assert_eq!(std_fs::read_to_string(&out).unwrap(), own); +} diff --git a/crates/vespertide-cli/src/commands/export/tests/snapshots/vespertide_cli__commands__export__tests__django__export_django_writes_the_configured_app_label_into_meta.snap b/crates/vespertide-cli/src/commands/export/tests/snapshots/vespertide_cli__commands__export__tests__django__export_django_writes_the_configured_app_label_into_meta.snap new file mode 100644 index 00000000..d7ac673a --- /dev/null +++ b/crates/vespertide-cli/src/commands/export/tests/snapshots/vespertide_cli__commands__export__tests__django__export_django_writes_the_configured_app_label_into_meta.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-cli/src/commands/export/tests/django.rs +expression: written +--- +# Code generated by vespertide. DO NOT EDIT. + +from __future__ import annotations + +from django.db import models + + +class Gadgets(models.Model): + id = models.IntegerField(primary_key=True) + + class Meta: + managed = False + db_table = "gadgets" + app_label = "storefront" diff --git a/crates/vespertide-cli/src/commands/export/tests/snapshots/vespertide_cli__commands__export__tests__gorm__export_gorm_takes_its_package_name_from_the_export_directory.snap b/crates/vespertide-cli/src/commands/export/tests/snapshots/vespertide_cli__commands__export__tests__gorm__export_gorm_takes_its_package_name_from_the_export_directory.snap new file mode 100644 index 00000000..8114ddf7 --- /dev/null +++ b/crates/vespertide-cli/src/commands/export/tests/snapshots/vespertide_cli__commands__export__tests__gorm__export_gorm_takes_its_package_name_from_the_export_directory.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-cli/src/commands/export/tests/gorm.rs +expression: written +--- +// Code generated by vespertide. DO NOT EDIT. + +package store + +type Widgets struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` +} + +func (Widgets) TableName() string { return "widgets" } diff --git a/crates/vespertide-cli/src/commands/export/tests/snapshots/vespertide_cli__commands__export__tests__models_file__export_writes_nested_model_directories_into_one_file@Django.snap b/crates/vespertide-cli/src/commands/export/tests/snapshots/vespertide_cli__commands__export__tests__models_file__export_writes_nested_model_directories_into_one_file@Django.snap new file mode 100644 index 00000000..1f8c08f1 --- /dev/null +++ b/crates/vespertide-cli/src/commands/export/tests/snapshots/vespertide_cli__commands__export__tests__models_file__export_writes_nested_model_directories_into_one_file@Django.snap @@ -0,0 +1,25 @@ +--- +source: crates/vespertide-cli/src/commands/export/tests/models_file.rs +expression: written +--- +# Code generated by vespertide. DO NOT EDIT. + +from __future__ import annotations + +from django.db import models + + +class User(models.Model): + id = models.IntegerField(primary_key=True) + + class Meta: + managed = False + db_table = "user" + +class Post(models.Model): + id = models.IntegerField(primary_key=True) + user = models.ForeignKey("User", on_delete=models.RESTRICT, related_name="+") + + class Meta: + managed = False + db_table = "post" diff --git a/crates/vespertide-cli/src/commands/export/tests/snapshots/vespertide_cli__commands__export__tests__models_file__export_writes_nested_model_directories_into_one_file@Gorm.snap b/crates/vespertide-cli/src/commands/export/tests/snapshots/vespertide_cli__commands__export__tests__models_file__export_writes_nested_model_directories_into_one_file@Gorm.snap new file mode 100644 index 00000000..03abe44e --- /dev/null +++ b/crates/vespertide-cli/src/commands/export/tests/snapshots/vespertide_cli__commands__export__tests__models_file__export_writes_nested_model_directories_into_one_file@Gorm.snap @@ -0,0 +1,22 @@ +--- +source: crates/vespertide-cli/src/commands/export/tests/models_file.rs +expression: written +--- +// Code generated by vespertide. DO NOT EDIT. + +package models + +type User struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Posts []Post `gorm:"foreignKey:UserID" json:"-"` +} + +func (User) TableName() string { return "user" } + +type Post struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + UserID int32 `gorm:"column:user_id;not null" json:"user_id"` + User *User `gorm:"foreignKey:UserID" json:"-"` +} + +func (Post) TableName() string { return "post" } diff --git a/crates/vespertide-config/src/config.rs b/crates/vespertide-config/src/config.rs index 126c28c3..1cef00fe 100644 --- a/crates/vespertide-config/src/config.rs +++ b/crates/vespertide-config/src/config.rs @@ -78,6 +78,28 @@ 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() + } +} + /// Top-level vespertide configuration. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] @@ -100,6 +122,9 @@ pub struct VespertideConfig { /// SeaORM-specific export configuration. #[serde(default)] pub seaorm: SeaOrmConfig, + /// Django-specific export configuration. + #[serde(default)] + pub django: DjangoConfig, /// Prefix to add to all table names (including migration version table). /// Default: "" (no prefix) #[serde(default)] @@ -138,6 +163,7 @@ impl Default for VespertideConfig { migration_filename_pattern: default_migration_filename_pattern(), model_export_dir: default_model_export_dir(), seaorm: SeaOrmConfig::default(), + django: DjangoConfig::default(), prefix: String::new(), lock_timeout_ms: None, statement_timeout_ms: None, @@ -191,6 +217,11 @@ impl VespertideConfig { &self.seaorm } + /// Django-specific export configuration. + pub fn django(&self) -> &DjangoConfig { + &self.django + } + /// 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 57ebace6..72ef8093 100644 --- a/crates/vespertide-config/src/lib.rs +++ b/crates/vespertide-config/src/lib.rs @@ -7,7 +7,9 @@ pub mod config; pub mod file_format; pub mod name_case; -pub use config::{SeaOrmConfig, VespertideConfig, default_migration_filename_pattern}; +pub use config::{ + DjangoConfig, SeaOrmConfig, VespertideConfig, default_migration_filename_pattern, +}; pub use file_format::FileFormat; pub use name_case::NameCase; @@ -100,4 +102,59 @@ 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(); + assert_eq!(serde_json::to_value(&cfg).unwrap(), serde_json::json!({})); + } + + #[test] + fn vespertide_config_django_accessor() { + let cfg = VespertideConfig::default(); + assert_eq!(cfg.django().app_label(), None); + } + + #[test] + fn vespertide_config_deserialize_with_django() { + let json = r#"{ + "modelsDir": "models", + "migrationsDir": "migrations", + "tableNamingCase": "snake", + "columnNamingCase": "snake", + "django": { + "appLabel": "myapp" + } + }"#; + let cfg: VespertideConfig = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.django().app_label(), Some("myapp")); + } } diff --git a/crates/vespertide-core/src/schema/column.rs b/crates/vespertide-core/src/schema/column.rs index 9bdcb6e5..6827ca07 100644 --- a/crates/vespertide-core/src/schema/column.rs +++ b/crates/vespertide-core/src/schema/column.rs @@ -258,13 +258,9 @@ impl ColumnDef { /// /// Each variant maps directly to a standard SQL type. Use these via /// [`ColumnType::Simple`] when no length, precision, or scale is needed. -/// -/// This enum is `#[non_exhaustive]`: new variants may be added in future releases. -/// Downstream `match` expressions should include a wildcard arm. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] -#[non_exhaustive] pub enum SimpleColumnType { /// 16-bit signed integer (`SMALLINT`). SmallInt, diff --git a/crates/vespertide-core/src/schema/reference.rs b/crates/vespertide-core/src/schema/reference.rs index d4d246ef..63e153ff 100644 --- a/crates/vespertide-core/src/schema/reference.rs +++ b/crates/vespertide-core/src/schema/reference.rs @@ -5,13 +5,9 @@ use serde::{Deserialize, Serialize}; /// Used in `ForeignKeyDef::on_delete` and `ForeignKeyDef::on_update` to control cascading /// behaviour. In JSON model files these are written in `snake_case` /// (e.g. `"on_delete": "cascade"`). -/// -/// This enum is `#[non_exhaustive]`: new variants may be added in future releases. -/// Downstream `match` expressions should include a wildcard arm. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] -#[non_exhaustive] pub enum ReferenceAction { /// Automatically delete or update child rows when the parent row is deleted or updated (`CASCADE`). Cascade, @@ -46,9 +42,7 @@ impl ReferenceAction { #[cfg(test)] mod tests { - //! Coverage-closure tests for `ReferenceAction::to_sql_keyword`. - //! Targets `uncovered-detail.json` lines 40, 41, 42 - //! (`SetNull` / `SetDefault` / `NoAction` match arms). + //! `ReferenceAction::to_sql_keyword` emits one SQL keyword per variant. use super::*; use rstest::rstest; @@ -62,9 +56,6 @@ mod tests { #[case] action: ReferenceAction, #[case] expected: &'static str, ) { - // Each rstest case visits one match arm of to_sql_keyword. The - // SetNull/SetDefault/NoAction cases cover the previously-uncovered - // lines 40, 41, 42. assert_eq!(action.to_sql_keyword(), expected); } } diff --git a/crates/vespertide-exporter/AGENTS.md b/crates/vespertide-exporter/AGENTS.md index 77d6275d..be58ca0d 100644 --- a/crates/vespertide-exporter/AGENTS.md +++ b/crates/vespertide-exporter/AGENTS.md @@ -1,19 +1,21 @@ # vespertide-exporter -ORM code generation from `TableDef` schemas → SeaORM (Rust), SQLAlchemy (Python), SQLModel (Python), JPA (Java), Prisma (schema.prisma), Drizzle (TypeScript). +ORM code generation from `TableDef` schemas → SeaORM (Rust), SQLAlchemy (Python), SQLModel (Python), JPA (Java), Prisma (schema.prisma), Drizzle (TypeScript), GORM (Go), Django (Python). ## STRUCTURE ``` src/ ├── lib.rs # Re-exports all backends -├── orm.rs # OrmExporter trait, Orm enum (SeaOrm/SqlAlchemy/SqlModel/Jpa/Prisma/Drizzle), +├── orm.rs # OrmExporter trait, Orm enum (SeaOrm/SqlAlchemy/SqlModel/Jpa/Prisma/Drizzle/Gorm/Django), │ # Orm::file_extension(), dispatch ├── constraint_scan.rs # Shared constraint scans + FK relation naming -│ # (fk_relation_names/relation_segment/collect_back_relations) -├── enum_scan.rs # Shared per-table enum-column scan (Prisma/Drizzle) +│ # (single_column_fk_details/junction_targets/fk_relation_names/relation_segment/ +│ # collect_back_relations) +├── enum_scan.rs # Shared enum-column scans (Prisma/Drizzle/GORM/Django) ├── parallel_config.rs # Rayon parallelism thresholds -├── python_naming.rs # Shared Python PascalCase naming (SQLAlchemy/SQLModel/JPA/CLI) +├── python_naming.rs # Shared PascalCase naming (SQLAlchemy/SQLModel/JPA/Django/GORM/CLI) +├── scope_names.rs # Top-level names claimed once per schema (GORM package / Django module) ├── seaorm/ # mod.rs, render.rs, types.rs, enums.rs, imports.rs, │ # relations/ (fk_resolve, naming, self_ref, reverse), tests/ ├── sqlalchemy/ # mod.rs, render.rs, types.rs, enums.rs — declarative_base models @@ -21,18 +23,30 @@ src/ ├── jpa/ # mod.rs, render.rs, types.rs — JPA/Hibernate entities ├── prisma/ # mod.rs, render.rs, types.rs, enums.rs — schema.prisma models ├── drizzle/ # mod.rs, render.rs, types.rs, enums.rs — Drizzle TypeScript models -├── utils/ # common.rs (join_quoted/unquote/claim_field_name), python.rs, -│ # typescript.rs (ts_binding/ts_string) +├── gorm/ # mod.rs, render.rs, types.rs, enums.rs — GORM structs +├── django/ # mod.rs, render.rs, types.rs, enums.rs — Django models.Model classes +├── utils/ # common.rs (join_quoted/string_literal/unquote/claim_field_name/claim_binding/collect_composite_fks/is_jsonb_custom_type), +│ # python.rs (render_enum/enum_member_name/unmangled/column_type_to_python), +│ # typescript.rs (ts_binding) └── tests/ # Shared orm_cases! cross-ORM snapshot suite + fixtures/ + snapshots/ ``` Identifier escaping is centralized in `vespertide-naming`: `sanitize_identifier` -with `IdentifierStart::Underscore` (Java, SQLAlchemy, ERD) or -`IdentifierStart::Letter` (SeaORM, SQLModel/Pydantic, Prisma, Drizzle), plus +with `IdentifierStart::Underscore` (Java, SQLAlchemy, Django fields and choices +classes, ERD) or `IdentifierStart::Letter` (SeaORM, SQLModel/Pydantic, Prisma, +Drizzle, Django model classes, and GORM, which also upper-cases the first letter +because Go exports by case), plus `seaorm_module_name` and `to_screaming_snake_case`. A backend that renames an identifier MUST also emit the original database name (`@map`, `column_name`, SQLAlchemy's positional column name). +Python keywords are escaped by `utils/python.rs::escape_python_keyword` (PEP 8's trailing +`_`) in SQLAlchemy and SQLModel. Django cannot use that form — fields.E001 forbids a +trailing `_` — so `django/render.rs::django_field_name` applies Django's field checks +(no `__`, no trailing `_`, not a keyword, not one of the model's own attributes in +`MODEL_ATTRIBUTES` — `pk`, `save`, `check`, `objects`, `Meta`, …) with `inspectdb`'s `_field` +repairs. + ## WHERE TO LOOK | Task | Location | @@ -69,6 +83,125 @@ SQLAlchemy's positional column name). - Enum types render as Java `enum` + `@Enumerated` - FK columns render as `@ManyToOne`/`@JoinColumn` relations +### 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 single-column key names `references:` too + when it points at anything but the target's primary key, which is what GORM would assume. + The field is always a pointer (`*User`), nullable or not: held by value, a struct could not + reference itself or a struct that references it back (`invalid recursive type`) +- **Reverse (has-one / has-many)**: built on the shared `constraint_scan::collect_back_relations`, + so composite FKs get a reverse side and a one-to-one — a key that is the source's whole + primary key, or that a unique covers exactly — renders as `*T` under the source struct's + name instead of `[]T` under its plural. Tags mirror the forward side. A **self-referencing FK** + (e.g. `categories.parent_id -> categories.id`) is named `Children` rather than a pluralized + table name to avoid colliding with the struct's own name; names that would repeat gain a + `By{key fields}` suffix (`SettingsByCreatedByUserID`) +- **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 +- **Identifiers**: every struct, field and type name is an exported Go name (`exported_go_name`: + `1users` → `X1users`), `Id` becomes `ID` only where it ends a word (`UserID`, but `Identity`), + and one taken set per struct covers the columns first and then every relation field, so a + has-many or belongs-to never takes a column's name (`Posts2`, `OrderRegions3`). That set starts + with `TableName`, the method every struct gets (a `table_name` column becomes `TableName2`, and + so does the belongs-to of a `table_name_id` key) +- **Package scope**: structs, enum types and enum constants all live in one Go package, so + `scope_names::ScopeNames` claims them once for the whole schema — structs first, then enum + types (bare while nothing else holds the identifier, otherwise `{Struct}{Enum}`), then + constants (`{Type}{Variant}`; values that fold onto one name are numbered). A table `role` next + to an enum `role`, or `Status` + `code` next to a `status_code` table, no longer redeclares. + A single-table render claims the same way over `scope_names::scope_of` — the schema when it + holds the table, the table alone otherwise +- **Tags**: `index:`/`uniqueIndex:` names come from the naming builders, so they match what the + SQL layer creates and GORM groups a composite index by them; `char(N)` and the PG network types + carry an explicit `type:`; a default GORM's tag syntax cannot hold (`;`, a function call) + is omitted, an integer enum's variant-name default becomes its value, and a string field's + default loses the doubled SQL quote (`'it''s'` → `'it's'`): GORM reads it as the value, while + every other field's default stays the SQL it is. GORM trims every quote off both ends of that + value, so a string default that starts or ends with `'` or `"` is omitted too. `struct_tag` quotes + each tag value as the Go string `reflect.StructTag` reads, so a `"` or `\` in a column name or + default is escaped, and the whole tag is an interpreted string when a value holds a backtick +- **Package name**: there is no `gorm` config section. `GormExporterWithConfig::for_export_dir` + derives it from the directory the file is written to (`go_package_name`) — the export + directory's final path segment sanitized into a Go identifier, falling back to `"models"`. The + CLI passes the real write target (`--export-dir` override or `model_export_dir`) because Go + expects `package` to name the directory the file lives in. +- **One file**: `GormExporterWithConfig::export` renders the whole schema as one source file, and + that is what the CLI writes (`models.go`). A Go directory is one package and a relation is + rendered from both of its ends, so models spread over directories would import each other in + a cycle +- **Layout**: `gofmt_layout` is the last step of every render — tab indents, struct-field and + constant columns padded the way `gofmt` aligns them, single blank lines — and `render_header` + lists each import group in sorted order, so the file passes a project's `gofmt -l` check as + written +- **Tests**: rendered output is pinned by the shared `orm_cases!` suite; the inline + `#[cfg(test)] mod tests` blocks hold only function-level unit tests (`types.rs` Go type + mapping; `render.rs` field and relation naming, package-scope constants, struct-tag escaping, + default tags; `mod.rs` package-name inference) + +### Django (Python) +- **Module scope**: model classes and choices classes share one module, so they are claimed + through the same `scope_names::ScopeNames` (models first; a choices class is bare while + nothing else holds the identifier, otherwise `{Model}{Enum}`). Members are scoped to their + class and numbered there when two values fold onto one name — Python's `Enum` refuses a + repeated member at import time. A choices class or member led by `__` keeps a single `_` + (`utils/python.rs::unmangled`): Python mangles such a name inside a class body, so the member + would be no member and the model could not name the class +- Renders `models.Model` classes with a `class Meta` (`managed = False` — vespertide owns the DDL, + so `makemigrations` must not create or alter these tables — `db_table`, `indexes`, `constraints`). + `UniqueConstraint` names come from `build_unique_constraint_name` with the source name as the + key, matching the SQL layer; `Meta.indexes` use `build_index_name` the same way while the + result fits Django's 30-character cap on index names (models.E034), and carry no `name=` + past it — Django never creates the index of an unmanaged model, so its own name will do. + The built name is `ix_{table}__{key}`, so a long table name reaches the cap on its own and + even a short source name then goes unnamed +- **JSONB**: a `Custom` column type spelled `jsonb` maps to `models.JSONField` (the shared + `is_jsonb_custom_type`); other custom types fall back to `TextField` +- **M2M junction detection**: `constraint_scan::junction_targets` (shared with SeaORM) recognizes + composite-PK, 2+ FK junction tables; each side gets `ManyToManyField(..., through=..., + related_name="+")`, named after the pluralized target (`{target}_via_{junction}` when two + junctions reach one target) and run through `django_field_name` after the columns, so it never + shadows a scalar field. Purely self-referential junctions are skipped rather than guessed at, + and so is a junction that reaches either end by a composite key: that key renders as a + comment, a `through` model needs a real `ForeignKey` to both ends (fields.E336), and Django + cannot relate to the composite-key model it points at (fields.E347) +- **Names and actions Django's checks reject**: a model class never starts with `_` (models.E023; + `1users` → `x1users`, the same letter escape SQLModel uses), and `on_delete=SET_DEFAULT` is only + emitted when the FK column has a default, which then renders as `default=`; without one it + falls back to `DO_NOTHING` (fields.E321), and so does `SET_NULL` on a field that is not null + (fields.E320) — the table is unmanaged, so the database keeps applying its own rule + (`types.rs::on_delete_for`) +- **Composite (multi-column) FK**: Django has no native multi-column FK field, so + `collect_composite_fks` (from `utils/common.rs`, shared with SQLAlchemy, SQLModel and GORM) + emits 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. A quoted default becomes the Python string it spells + (`'it''s'` → `"it's"`), and a `JSONField` gets none: Django wants a callable there + (fields.E010), and the SQL literal is the document's text rather than its value +- **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 +- **FK fields**: a FK column that is the table's (non-composite) PK or carries a single-column + unique renders as `models.OneToOneField` — `ForeignKey(unique=True)` is only fields.W342 pointing + at that class — and the PK one keeps `primary_key=True`. A key that references anything but + the target's primary key carries `to_field=` (Django would otherwise join on the primary key + and silently return the wrong rows), named through the target's own `column_field_names`. A + key into a model with a composite primary key, or onto a column that is neither the target's + primary key nor unique on its own, stays a plain column plus a + `# foreign key: (col) -> table(ref)` comment: Django cannot relate to such a model + (fields.E347) or through such a field (fields.E311). A key claims its attname along with its + field name (`claim_relation_field_name`): Django stores it under `{field}_id`, which a plain + column of that name would clash with (models.E006) +- **Config**: `DjangoExporterWithConfig` for `app_label` (omitted from `Meta` when unset); its + `export` renders the whole schema as one module, which is what the CLI writes (`models.py`) — + Django loads an app's models from its one `models` module +- **Tests**: rendered output is pinned by the shared `orm_cases!` suite; the inline + `#[cfg(test)] mod tests` blocks hold only function-level unit tests (`mod.rs` field-class, + `on_delete` and string-default mappings; `render.rs` field-name repairs, attname claims, + relatable keys, choices-class names; `enums.rs` member numbering) + ### Prisma (schema.prisma) - Emits models only — no `datasource`/`generator` block, so the output drops into an existing schema - Backend-neutral: no provider-specific `@db.*` native attributes are emitted @@ -107,7 +240,7 @@ cargo insta accept - Snapshot testing with `insta` crate (YAML format) - `rstest` for parameterized tests across all ORM backends - Drizzle's cross-ORM snapshots carry the dialect the trait path renders (`…_Drizzle_pg.snap`); the other two dialects live in the module's own `render_schema_full_file_per_dialect@{pg,mysql,sqlite}` snapshots -- 428 snapshot files, all in the single shared `src/tests/snapshots/` directory; every export scenario goes through the shared `orm_cases!` macro in `src/tests/mod.rs`, producing one snapshot per ORM (all six) — a scenario snapshotted for only one ORM is a defect +- 646 snapshot files, all in the single shared `src/tests/snapshots/` directory; every export scenario goes through the shared `orm_cases!` macro in `src/tests/mod.rs`, producing one snapshot per ORM (all eight) — a scenario snapshotted for only one ORM is a defect ## NOTES diff --git a/crates/vespertide-exporter/benches/codegen_benchmarks.rs b/crates/vespertide-exporter/benches/codegen_benchmarks.rs index a18f2cca..99002c54 100644 --- a/crates/vespertide-exporter/benches/codegen_benchmarks.rs +++ b/crates/vespertide-exporter/benches/codegen_benchmarks.rs @@ -9,8 +9,25 @@ use vespertide_core::{ }; use vespertide_exporter::{Orm, render_entity_with_schema}; -const ALL_ORMS: [Orm; 4] = [Orm::SeaOrm, Orm::SqlAlchemy, Orm::SqlModel, Orm::Jpa]; -const ENUM_ORMS: [Orm; 3] = [Orm::SeaOrm, Orm::SqlAlchemy, Orm::SqlModel]; +const ALL_ORMS: [Orm; 8] = [ + Orm::SeaOrm, + Orm::SqlAlchemy, + Orm::SqlModel, + Orm::Jpa, + Orm::Prisma, + Orm::Drizzle, + Orm::Gorm, + Orm::Django, +]; +const ENUM_ORMS: [Orm; 7] = [ + Orm::SeaOrm, + Orm::SqlAlchemy, + Orm::SqlModel, + Orm::Prisma, + Orm::Drizzle, + Orm::Gorm, + Orm::Django, +]; const FK_COLUMNS_PER_TABLE: usize = 20; fn simple_type(ty: SimpleColumnType) -> ColumnType { diff --git a/crates/vespertide-exporter/src/constraint_scan.rs b/crates/vespertide-exporter/src/constraint_scan.rs index 0eacc4cf..55900968 100644 --- a/crates/vespertide-exporter/src/constraint_scan.rs +++ b/crates/vespertide-exporter/src/constraint_scan.rs @@ -8,7 +8,7 @@ use std::collections::{HashMap, HashSet}; -use vespertide_core::{ColumnName, TableConstraint, TableDef}; +use vespertide_core::{ColumnName, ReferenceAction, TableConstraint, TableDef, TableName}; use vespertide_naming::{infer_relation_field_name, to_pascal_case}; /// Collect the column names from every single-column constraint that `extract` @@ -73,20 +73,30 @@ pub(crate) fn single_column_indexes(constraints: &[TableConstraint]) -> HashSet< }) } -/// Map each single-column foreign key's column name to its -/// `(ref_table, ref_col)` target. Only foreign keys with exactly one owning -/// column and one referenced column are included; composite FKs are skipped. +/// A single-column foreign key's target and referential actions. +pub(crate) struct FkDetails<'a> { + pub(crate) ref_table: &'a str, + pub(crate) ref_column: &'a str, + pub(crate) on_delete: Option<&'a ReferenceAction>, + pub(crate) on_update: Option<&'a ReferenceAction>, +} + +/// Map each single-column foreign key's column name to its target and +/// referential actions. Only foreign keys with exactly one owning column and +/// one referenced column are included; composite FKs are skipped. /// /// Lookup-only, ordering unused. -pub(crate) fn single_column_fk_targets( +pub(crate) fn single_column_fk_details( constraints: &[TableConstraint], -) -> HashMap<&str, (&str, &str)> { +) -> HashMap<&str, FkDetails<'_>> { let mut map = HashMap::new(); for constraint in constraints { if let TableConstraint::ForeignKey { columns, ref_table, ref_columns, + on_delete, + on_update, .. } = constraint && columns.len() == 1 @@ -94,13 +104,59 @@ pub(crate) fn single_column_fk_targets( { map.insert( columns[0].as_str(), - (ref_table.as_str(), ref_columns[0].as_str()), + FkDetails { + ref_table: ref_table.as_str(), + ref_column: ref_columns[0].as_str(), + on_delete: on_delete.as_ref(), + on_update: on_update.as_ref(), + }, ); } } map } +/// The tables `junction` links `current` to, when `junction` is a many-to-many +/// junction: a composite primary key (`junction_pk`, two or more columns), two +/// or more foreign keys whose columns all lie in that key, and one of them +/// pointing at `current`. The targets are the other keys' tables in constraint +/// order — empty when every key points back at `current`, which is a +/// self-relation rather than a link. `None` when `junction` is not such a +/// table. Callers decide whether a target outside their schema counts. +pub(crate) fn junction_targets<'a>( + current: &TableDef, + junction: &'a TableDef, + junction_pk: &HashSet<&str>, +) -> Option> { + if junction_pk.len() < 2 { + return None; + } + let fks: Vec<(&[ColumnName], &TableName)> = junction + .constraints + .iter() + .filter_map(|c| match c { + TableConstraint::ForeignKey { + columns, ref_table, .. + } => Some((columns.as_slice(), ref_table)), + _ => None, + }) + .collect(); + if fks.len() < 2 + || !fks + .iter() + .all(|(cols, _)| cols.iter().all(|c| junction_pk.contains(c.as_str()))) + { + return None; + } + fks.iter().find(|(_, target)| **target == current.name)?; + Some( + fks.into_iter() + .filter(|(_, target)| **target != current.name) + .map(|(_, target)| target) + .collect(), + ) +} + /// Name segment a relation derives from its FK columns. /// /// Every column takes part — two composite FKs to the same target can share a @@ -159,6 +215,10 @@ pub(crate) fn fk_relation_names(table: &TableDef) -> HashMap { /// name both ends must agree on — is the same everywhere. pub(crate) struct BackRelation { pub(crate) source_table: String, + pub(crate) fk_columns: Vec, + pub(crate) ref_columns: Vec, + pub(crate) on_delete: Option, + pub(crate) on_update: Option, pub(crate) rel_segment: String, pub(crate) is_one_to_one: bool, pub(crate) relation_name: Option, @@ -170,65 +230,67 @@ pub(crate) fn collect_back_relations(target_table: &str, schema: &[TableDef]) -> let mut result = Vec::new(); for source in schema { - let fks_to_target: Vec<(usize, &[ColumnName])> = source + let fks_to_target = source .constraints .iter() - .enumerate() - .filter_map(|(idx, c)| { - if let TableConstraint::ForeignKey { - columns, ref_table, .. - } = c - { - if ref_table.as_str() == target_table { - Some((idx, columns.as_slice())) - } else { - None - } - } else { - None - } + .filter(|c| { + matches!(c, TableConstraint::ForeignKey { ref_table, .. } + if ref_table.as_str() == target_table) }) - .collect(); + .count(); - if fks_to_target.is_empty() { + if fks_to_target == 0 { continue; } let source_relation_names = fk_relation_names(source); - let multi_fk = fks_to_target.len() > 1; + let multi_fk = fks_to_target > 1; let is_self_ref = source.name.as_str() == target_table; - for (constraint_idx, fk_cols) in &fks_to_target { - let is_one_to_one = if let [fk_col] = fk_cols { - source.constraints.iter().any(|c| { - matches!(c, TableConstraint::Unique { columns, .. } - if columns.len() == 1 && columns[0] == *fk_col) - }) - } else { - // A composite FK is one-to-one when the source can hold at - // most one row per target key: its FK columns are exactly its - // own PK, or a composite unique covers exactly that set. - let fk_set: HashSet<&str> = fk_cols.iter().map(ColumnName::as_str).collect(); - let pk_cols = primary_key(&source.constraints) - .map(TableConstraint::columns) - .unwrap_or_default(); - pk_cols.len() == fk_set.len() && pk_cols.iter().all(|c| fk_set.contains(c.as_str())) - || source.constraints.iter().any(|c| { - matches!(c, TableConstraint::Unique { columns, .. } - if columns.len() == fk_set.len() - && columns.iter().all(|col| fk_set.contains(col.as_str()))) - }) + for (constraint_idx, constraint) in source.constraints.iter().enumerate() { + let TableConstraint::ForeignKey { + columns: fk_cols, + ref_table, + ref_columns, + on_delete, + on_update, + .. + } = constraint + else { + continue; }; + if ref_table.as_str() != target_table { + continue; + } + + // A key is one-to-one when the source can hold at most one row + // per target key: its FK columns are exactly its own PK, or a + // unique covers exactly that set. + let fk_set: HashSet<&str> = fk_cols.iter().map(ColumnName::as_str).collect(); + let pk_cols = primary_key(&source.constraints) + .map(TableConstraint::columns) + .unwrap_or_default(); + let is_one_to_one = pk_cols.len() == fk_set.len() + && pk_cols.iter().all(|c| fk_set.contains(c.as_str())) + || source.constraints.iter().any(|c| { + matches!(c, TableConstraint::Unique { columns, .. } + if columns.len() == fk_set.len() + && columns.iter().all(|col| fk_set.contains(col.as_str()))) + }); let rel_segment = relation_segment(fk_cols); let relation_name = if multi_fk || is_self_ref { - source_relation_names.get(constraint_idx).cloned() + source_relation_names.get(&constraint_idx).cloned() } else { None }; result.push(BackRelation { source_table: source.name.as_str().to_string(), + fk_columns: fk_cols.iter().map(ToString::to_string).collect(), + ref_columns: ref_columns.iter().map(ToString::to_string).collect(), + on_delete: on_delete.clone(), + on_update: on_update.clone(), rel_segment, is_one_to_one, relation_name, diff --git a/crates/vespertide-exporter/src/django/enums.rs b/crates/vespertide-exporter/src/django/enums.rs new file mode 100644 index 00000000..4b3fab26 --- /dev/null +++ b/crates/vespertide-exporter/src/django/enums.rs @@ -0,0 +1,59 @@ +use std::collections::HashSet; + +use vespertide_core::schema::column::EnumValues; + +use crate::enum_scan::variant_names; +use crate::utils::common::{claim_binding, string_literal}; +use crate::utils::python::enum_member_name; + +/// Members carry only their value. Django derives the human label from the +/// member name (`PENDING` -> "Pending"); passing the raw database value as an +/// explicit label would pin a worse one ("pending"). +pub(super) fn render_enum(lines: &mut Vec, class_name: &str, values: &EnumValues) { + let members = member_names(values); + match values { + EnumValues::String(vals) => { + lines.push(format!("class {class_name}(models.TextChoices):")); + for (val, member) in vals.iter().zip(members) { + lines.push(format!(" {member} = {}", string_literal(val))); + } + } + EnumValues::Integer(vals) => { + lines.push(format!("class {class_name}(models.IntegerChoices):")); + for (val, member) in vals.iter().zip(members) { + lines.push(format!(" {member} = {}", val.value)); + } + } + } +} + +/// One member name per variant, in order. Distinct values can fold onto one +/// name (`in progress`, `in-progress`), and Python's `Enum` refuses to define +/// a member twice — at import, which takes the whole module down. +fn member_names(values: &EnumValues) -> Vec { + let mut taken = HashSet::new(); + variant_names(values) + .into_iter() + .map(|variant| claim_binding(enum_member_name(variant), &mut taken)) + .collect() +} + +#[cfg(test)] +mod tests { + use vespertide_core::schema::column::EnumValues; + + use super::member_names; + + #[test] + fn values_that_fold_onto_one_member_name_are_numbered() { + let values = EnumValues::String(vec![ + "in progress".into(), + "in-progress".into(), + "done".into(), + ]); + assert_eq!( + member_names(&values), + ["IN_PROGRESS", "IN_PROGRESS2", "DONE"] + ); + } +} diff --git a/crates/vespertide-exporter/src/django/mod.rs b/crates/vespertide-exporter/src/django/mod.rs new file mode 100644 index 00000000..101ec3ea --- /dev/null +++ b/crates/vespertide-exporter/src/django/mod.rs @@ -0,0 +1,143 @@ +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}; + +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> { + config: &'a DjangoConfig, +} + +impl<'a> DjangoExporterWithConfig<'a> { + pub fn new(config: &'a DjangoConfig) -> Self { + Self { config } + } + + /// [`export`] with the configured `app_label`. + pub fn export(&self, schema: &[TableDef]) -> Result { + export_with_config(schema, self.config.app_label()) + } +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + use vespertide_core::ReferenceAction; + use vespertide_core::schema::column::{ColumnType, SimpleColumnType}; + + use super::types::{UsedImports, build_default, django_field_type, on_delete_for}; + + /// A SQL string default becomes the Python value it spells: the outer + /// quotes go, a doubled `''` is one quote, and what Python's literal + /// cannot hold is escaped. + #[rstest] + #[case::plain("'draft'", r#""draft""#)] + #[case::doubled_sql_quote("'it''s'", r#""it's""#)] + #[case::double_quote_inside(r#"'say "hi"'"#, r#""say \"hi\"""#)] + #[case::backslash(r"'a\b'", r#""a\\b""#)] + #[case::empty("''", r#""""#)] + fn string_defaults_become_python_literals(#[case] sql: &str, #[case] expected: &str) { + let text = ColumnType::Simple(SimpleColumnType::Text); + let default = build_default(&text, sql, &mut UsedImports::default()); + assert_eq!(default.as_deref(), Some(expected)); + } + + #[test] + fn a_json_default_is_left_to_the_database() { + let json = ColumnType::Simple(SimpleColumnType::Json); + let default = build_default(&json, r#"'{"a": 1}'"#, &mut UsedImports::default()); + assert_eq!(default, None); + } + + #[rstest] + #[case::small_int(SimpleColumnType::SmallInt, "models.SmallIntegerField")] + #[case::integer(SimpleColumnType::Integer, "models.IntegerField")] + #[case::big_int(SimpleColumnType::BigInt, "models.BigIntegerField")] + #[case::real(SimpleColumnType::Real, "models.FloatField")] + #[case::double_precision(SimpleColumnType::DoublePrecision, "models.FloatField")] + #[case::text(SimpleColumnType::Text, "models.TextField")] + #[case::xml(SimpleColumnType::Xml, "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::timestamptz(SimpleColumnType::Timestamptz, "models.DateTimeField")] + #[case::interval(SimpleColumnType::Interval, "models.DurationField")] + #[case::bytea(SimpleColumnType::Bytea, "models.BinaryField")] + #[case::uuid(SimpleColumnType::Uuid, "models.UUIDField")] + #[case::json(SimpleColumnType::Json, "models.JSONField")] + #[case::inet(SimpleColumnType::Inet, "models.GenericIPAddressField")] + #[case::cidr(SimpleColumnType::Cidr, "models.GenericIPAddressField")] + #[case::macaddr(SimpleColumnType::Macaddr, "models.CharField")] + fn simple_types_map_to_field_classes(#[case] ty: SimpleColumnType, #[case] expected: &str) { + assert_eq!( + django_field_type(&ColumnType::Simple(ty), false, false), + expected + ); + } + + #[rstest] + #[case::small_int(SimpleColumnType::SmallInt, "models.SmallAutoField")] + #[case::integer(SimpleColumnType::Integer, "models.AutoField")] + #[case::big_int(SimpleColumnType::BigInt, "models.BigAutoField")] + fn auto_increment_primary_keys_map_to_auto_fields( + #[case] ty: SimpleColumnType, + #[case] expected: &str, + ) { + assert_eq!( + django_field_type(&ColumnType::Simple(ty), true, true), + expected + ); + } + + #[rstest] + #[case::cascade(Some(ReferenceAction::Cascade), true, true, "models.CASCADE")] + #[case::restrict(Some(ReferenceAction::Restrict), true, true, "models.RESTRICT")] + #[case::set_null(Some(ReferenceAction::SetNull), true, true, "models.SET_NULL")] + #[case::set_default(Some(ReferenceAction::SetDefault), true, true, "models.SET_DEFAULT")] + #[case::no_action(Some(ReferenceAction::NoAction), true, true, "models.DO_NOTHING")] + #[case::no_action_given(None, true, true, "models.RESTRICT")] + #[case::set_null_on_a_field_that_is_not_null( + Some(ReferenceAction::SetNull), + true, + false, + "models.DO_NOTHING" + )] + #[case::set_default_without_a_default( + Some(ReferenceAction::SetDefault), + false, + true, + "models.DO_NOTHING" + )] + fn reference_actions_map_to_on_delete( + #[case] action: Option, + #[case] has_default: bool, + #[case] null: bool, + #[case] expected: &str, + ) { + assert_eq!(on_delete_for(action.as_ref(), has_default, null), expected); + } +} diff --git a/crates/vespertide-exporter/src/django/render.rs b/crates/vespertide-exporter/src/django/render.rs new file mode 100644 index 00000000..69791a13 --- /dev/null +++ b/crates/vespertide-exporter/src/django/render.rs @@ -0,0 +1,743 @@ +use std::collections::{HashMap, HashSet}; + +use super::enums::render_enum; +use super::types::{ + UsedImports, build_default, build_field_kwargs, django_field_type, on_delete_for, +}; +use crate::constraint_scan::{ + FkDetails, junction_targets, primary_key, primary_key_columns, single_column_fk_details, + single_column_uniques, +}; +use crate::python_naming::to_pascal_case; +use crate::scope_names::{ScopeNames, scope_of}; +use crate::utils::common::{claim_binding, collect_composite_fks, string_literal}; +use crate::utils::python::{is_python_keyword, unmangled}; +use vespertide_core::schema::column::{ColumnType, ComplexColumnType}; +use vespertide_core::schema::constraint::TableConstraint; +use vespertide_core::{ReferenceAction, TableDef}; +use vespertide_naming::{ + IdentifierStart, build_index_name, build_unique_constraint_name, pluralize, sanitize_identifier, +}; + +pub fn render_entity(table: &TableDef) -> Result { + let mut used = UsedImports::default(); + let names = module_names(std::slice::from_ref(table)); + let body = render_entity_part(table, &[], &mut used, &names, 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 { + let mut used = UsedImports::default(); + let names = module_names(scope_of(table, schema)); + let body = render_entity_part(table, schema, &mut used, &names, None); + 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` (from +/// `vespertide.json`'s `django` config) 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 names = module_names(schema); + let parts: Vec = schema + .iter() + .map(|t| render_entity_part(t, schema, &mut used, &names, app_label)) + .collect(); + Ok(assemble_with_imports(&used, &parts)) +} + +/// The other side of every many-to-many junction that links `table`: each +/// `(target, junction)` pair whose target `schema` also knows, in schema +/// order. Purely self-referential junctions yield no pairs, and neither does +/// a junction that reaches either end by a composite key: that key renders as +/// a comment, and a `through` model needs a real `ForeignKey` to both ends +/// (fields.E336) — nor can Django relate to the composite-key model such a +/// key points at (fields.E347). +fn many_to_many_targets<'a>(table: &TableDef, schema: &'a [TableDef]) -> Vec<(&'a str, &'a str)> { + let mut pairs = Vec::new(); + for junction in schema { + if junction.name == table.name { + continue; + } + let junction_pk = primary_key_columns(&junction.constraints); + let Some(targets) = junction_targets(table, junction, &junction_pk) else { + continue; + }; + let reached_by_foreign_key: HashSet<&str> = single_column_fk_details(&junction.constraints) + .values() + .filter(|fk| is_relatable(fk, schema)) + .map(|fk| fk.ref_table) + .collect(); + if !reached_by_foreign_key.contains(table.name.as_str()) { + continue; + } + for target in targets { + if reached_by_foreign_key.contains(target.as_str()) + && schema.iter().any(|t| t.name == *target) + { + pairs.push((target.as_str(), junction.name.as_str())); + } + } + } + pairs +} + +fn render_entity_part( + table: &TableDef, + schema: &[TableDef], + used: &mut UsedImports, + names: &ScopeNames, + app_label: Option<&str>, +) -> String { + let mut lines: Vec = Vec::new(); + let m2m = many_to_many_targets(table, schema); + + // --- Constraint lookups --- + let pk_columns = primary_key_columns(&table.constraints); + + 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 = primary_key(&table.constraints) + .map(TableConstraint::columns) + .unwrap_or_default(); + + let single_unique_cols = single_column_uniques(&table.constraints); + let fk_map = single_column_fk_details(&table.constraints); + + let class_name = class_of(names, &table.name); + + // Enum class names for this table's columns, as claimed in the module. + let enum_class_map: HashMap<&str, String> = table + .columns + .iter() + .filter_map(|col| { + if let ColumnType::Complex(ComplexColumnType::Enum { name, .. }) = &col.r#type { + let class = names.enum_type(&table.name, name).to_string(); + Some((col.name.as_str(), class)) + } else { + None + } + }) + .collect(); + + // --- Enum class definitions --- + let mut seen_enums: HashSet<&str> = HashSet::new(); + for col in &table.columns { + if let ColumnType::Complex(ComplexColumnType::Enum { values, .. }) = &col.r#type { + let enum_class = enum_class_map[col.name.as_str()].as_str(); + if seen_enums.insert(enum_class) { + render_enum(&mut lines, enum_class, values); + lines.push(String::new()); + } + } + } + + // --- Class declaration --- + if let Some(ref desc) = table.description { + lines.push(format!("class {class_name}(models.Model):")); + // A docstring keeps its triple quotes: `string_literal` supplies the + // inner pair and escapes every `\` and `"` of the text, the two + // characters that could end it early. + let docstring = string_literal(&desc.replace('\n', " ")); + lines.push(format!(" \"\"{docstring}\"\"")); + 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. + // Rendered after the fields, which is where the attnames come from, + // but emitted here at the top of the class body. + let composite_pk_at = lines.len(); + + // --- 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 (field_names, mut used_field_names) = column_field_names(table, schema); + let mut attnames: HashMap<&str, String> = HashMap::new(); + let mut unrelatable: Vec = Vec::new(); + for col in &table.columns { + let field_name = field_names[col.name.as_str()].as_str(); + 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', " "))); + } + + let effective_pk = is_pk && !is_composite_pk; + let fk = fk_map.get(col.name.as_str()); + if let Some(fk) = fk.filter(|fk| !is_relatable(fk, schema)) { + unrelatable.push(format!( + " # foreign key: ({}) -> {}({})", + col.name, fk.ref_table, fk.ref_column + )); + } + let attname = if let Some(fk) = fk.filter(|fk| is_relatable(fk, schema)) { + let field = ForeignKeyField { + column: &col.name, + name: field_name, + target_class: class_of(names, fk.ref_table), + to_field: to_field(fk, schema), + on_delete: fk.on_delete, + default: col + .default + .as_ref() + .and_then(|dv| build_default(&col.r#type, &dv.to_sql(), used)), + is_pk: effective_pk, + is_unique, + nullable: col.nullable, + }; + lines.push(field.render()); + // A ForeignKey's attname is `{field}_id` whatever `db_column` says. + format!("{field_name}_id") + } else { + let field_type = django_field_type( + &col.r#type, + effective_pk, + auto_increment && !is_composite_pk, + ); + 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})")); + } + field_name.to_string() + }; + attnames.insert(col.name.as_str(), attname); + } + + if is_composite_pk { + let args = pk_columns_ordered + .iter() + .map(|col| format!("\"{}\"", attname_of(&attnames, col.as_str()))) + .collect::>() + .join(", "); + lines.insert( + composite_pk_at, + format!(" pk = models.CompositePrimaryKey({args})"), + ); + } + + // --- Many-to-many fields: the other side of each junction linking this + // table. Named after the pluralized target, or `{target}_via_{junction}` + // when two junctions reach one target; claimed after the columns so a + // field never shadows a scalar of the same name. + let mut target_counts: HashMap<&str, usize> = HashMap::new(); + for (target, _) in &m2m { + *target_counts.entry(target).or_default() += 1; + } + for (target, junction) in &m2m { + let base = pluralize(target); + let raw = if target_counts[target] > 1 { + format!("{base}_via_{junction}") + } else { + base + }; + let field_name = django_field_name(&raw, &mut used_field_names); + let target_class = class_of(names, target); + let junction_class = class_of(names, junction); + lines.push(format!( + " {field_name} = models.ManyToManyField(\"{target_class}\", through=\"{junction_class}\", related_name=\"+\")" + )); + } + + // Composite (multi-column) FKs have no native Django ORM field, and neither + // has a key into a composite-key model — 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. + lines.extend(unrelatable); + 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()); + // vespertide owns the DDL; `makemigrations` must not try to create or + // alter these tables. + lines.push(" managed = False".into()); + lines.push(format!( + " db_table = {}", + string_literal(&table.name) + )); + if let Some(label) = app_label { + lines.push(format!(" app_label = {}", string_literal(label))); + } + + if !indexes.is_empty() { + lines.push(" indexes = [".into()); + for (name, cols) in &indexes { + let fields = cols + .iter() + .map(|c| format!("\"{}\"", attname_of(&attnames, c))) + .collect::>() + .join(", "); + // The name the SQL layer gave the index — a source name is the + // builder's key, not the final name — while it fits Django's + // 30-character cap (models.E034). Past the cap the index goes + // unnamed: Django never creates the index of an unmanaged model, + // so the name it makes up is never used. + let n = build_index_name(&table.name, cols, *name); + if n.len() <= 30 { + lines.push(format!( + " models.Index(fields=[{fields}], name={}),", + string_literal(&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!("\"{}\"", attname_of(&attnames, c))) + .collect::>() + .join(", "); + // Likewise the database's name. A constraint must carry one, and + // Django puts no cap on it. + let n = build_unique_constraint_name(&table.name, cols, *name); + lines.push(format!( + " models.UniqueConstraint(fields=[{fields}], name={}),", + string_literal(&n) + )); + } + lines.push(" ]".into()); + } + + lines.push(String::new()); + lines.join("\n") +} + +/// A single-column foreign key Django can express, as the field it renders. +struct ForeignKeyField<'a> { + column: &'a str, + name: &'a str, + target_class: String, + to_field: Option, + on_delete: Option<&'a ReferenceAction>, + default: Option, + is_pk: bool, + is_unique: bool, + nullable: bool, +} + +impl ForeignKeyField<'_> { + fn render(&self) -> String { + // Django reads a ForeignKey through `{field}_id`, so the column keeps + // its database name exactly when the stripped base survives every + // rename. + let db_column = (format!("{}_id", self.name) != self.column).then_some(self.column); + let null = self.nullable && !self.is_pk; + // `ON UPDATE` has no counterpart on a Django ForeignKey. + let on_delete = on_delete_for(self.on_delete, self.default.is_some(), null); + + let mut kwargs = vec![ + format!("\"{}\"", self.target_class), + format!("on_delete={on_delete}"), + ]; + if let Some(to_field) = &self.to_field { + kwargs.push(format!("to_field={}", string_literal(to_field))); + } + if self.is_pk { + kwargs.push("primary_key=True".into()); + } + if let Some(default) = &self.default { + kwargs.push(format!("default={default}")); + } + if let Some(db_column) = db_column { + kwargs.push(format!("db_column={}", string_literal(db_column))); + } + kwargs.push("related_name=\"+\"".into()); + if null { + kwargs.push("null=True".into()); + kwargs.push("blank=True".into()); + } + + // A FK that is the PK or unique holds at most one row per target: + // Django's one-to-one. `ForeignKey(unique=True)` only draws fields.W342 + // pointing here. + let field_class = if self.is_pk || self.is_unique { + "models.OneToOneField" + } else { + "models.ForeignKey" + }; + format!(" {} = {field_class}({})", self.name, kwargs.join(", ")) + } +} + +/// Whether Django can express `fk` as a relation. It cannot relate to a model +/// with a composite primary key (fields.E347), and the field a key references +/// must be unique (fields.E311): the target's primary key, or a column with a +/// unique of its own. Such a key stays a plain column. A target outside +/// `schema` is taken at its word. +fn is_relatable(fk: &FkDetails, schema: &[TableDef]) -> bool { + schema + .iter() + .find(|t| t.name.as_str() == fk.ref_table) + .is_none_or(|target| { + let pk = primary_key_columns(&target.constraints); + pk.len() < 2 + && (pk.contains(fk.ref_column) + || single_column_uniques(&target.constraints).contains(fk.ref_column)) + }) +} + +/// The `to_field` a foreign key needs: the target's field for the referenced +/// column, whenever that column is not the target's primary key — which is +/// what Django would otherwise join on. +fn to_field(fk: &FkDetails, schema: &[TableDef]) -> Option { + let target = schema.iter().find(|t| t.name.as_str() == fk.ref_table)?; + if primary_key_columns(&target.constraints).contains(fk.ref_column) { + return None; + } + let (target_fields, _) = column_field_names(target, schema); + Some( + target_fields + .get(fk.ref_column) + .map_or(fk.ref_column, String::as_str) + .to_string(), + ) +} + +/// The Django field name of every column of `table`, claimed in declaration +/// order, with the set those claims filled. A foreign key Django can express +/// is named after its relation (`user_id` -> `user`), every other column after +/// itself. Sanitizing distinct columns (`a_id` -> `a`, `a` -> `a`) can land two +/// of them on one attribute; the later one is numbered. +fn column_field_names<'a>( + table: &'a TableDef, + schema: &[TableDef], +) -> (HashMap<&'a str, String>, HashSet) { + let fk_map = single_column_fk_details(&table.constraints); + let mut taken = HashSet::new(); + let names = table + .columns + .iter() + .map(|col| { + let is_relation = fk_map + .get(col.name.as_str()) + .is_some_and(|fk| is_relatable(fk, schema)); + let name = if is_relation { + let base = vespertide_naming::infer_relation_field_name(&col.name); + claim_relation_field_name(&django_identifier(base), &mut taken) + } else { + django_field_name(&col.name, &mut taken) + }; + (col.name.as_str(), name) + }) + .collect(); + (names, taken) +} + +/// Claim a relation's field name together with its attname: Django stores the +/// key under `{field}_id`, so a plain `owner_id` column next to an `owner` key +/// would share that attribute with it (models.E006). The first numbered name +/// with both free wins. +fn claim_relation_field_name(preferred: &str, taken: &mut HashSet) -> String { + let mut name = preferred.to_string(); + let mut n = 2usize; + while taken.contains(&name) || taken.contains(&format!("{name}_id")) { + name = format!("{preferred}{n}"); + n += 1; + } + taken.insert(format!("{name}_id")); + taken.insert(name.clone()); + name +} + +/// Every class `tables` declare in their module: models, then choices classes. +fn module_names(tables: &[TableDef]) -> ScopeNames { + ScopeNames::collect(tables, model_class_name, enum_class_name) +} + +/// The class a table is declared as; a table outside the module's schema — a +/// foreign key may point there — keeps its natural name. +fn class_of(names: &ScopeNames, table: &str) -> String { + names + .table(table) + .map_or_else(|| model_class_name(table), str::to_string) +} + +/// A table's model class. Django rejects a model name that starts with `_` +/// (models.E023), so a name that cannot lead with its own first character +/// gains a letter instead. +fn model_class_name(table: &str) -> String { + sanitize_identifier(&to_pascal_case(table), IdentifierStart::Letter) +} + +/// An enum's choices class. A model names it from inside its own class body, +/// where Python would mangle a `__`-led name. +fn enum_class_name(name: &str) -> String { + unmangled(sanitize_identifier( + &to_pascal_case(name), + IdentifierStart::Underscore, + )) +} + +/// What Django calls a column inside `Meta.indexes`, `Meta.constraints` and +/// `CompositePrimaryKey`: the declared field name, or a ForeignKey's attname. +/// Those three resolve against field names only — never `db_column` — so a +/// column whose name had to be escaped is unreachable under its database +/// spelling. +fn attname_of<'a>(attnames: &'a HashMap<&str, String>, column: &'a str) -> &'a str { + attnames.get(column).map_or(column, String::as_str) +} + +/// Attributes every Django model already has: `Model`'s public API and what its +/// metaclass adds. A field of the same name replaces the method (`save`, +/// `clean`: a `TypeError` at the first call), fails the checks (`pk`: +/// fields.E003, `check`: models.E020), stops the module importing (`objects`) +/// or is rebound by the `class Meta` written below the fields (`Meta`). +const MODEL_ATTRIBUTES: &[&str] = &[ + "DoesNotExist", + "Meta", + "MultipleObjectsReturned", + "NotUpdated", + "adelete", + "arefresh_from_db", + "asave", + "check", + "clean", + "clean_fields", + "date_error_message", + "delete", + "from_db", + "full_clean", + "get_constraints", + "get_deferred_fields", + "objects", + "pk", + "prepare_database_save", + "refresh_from_db", + "save", + "save_base", + "serializable_value", + "unique_error_message", + "validate_constraints", + "validate_unique", +]; + +/// A column's Django field name: its [`django_identifier`], claimed against +/// `taken`. Callers emit `db_column` whenever the result differs from the +/// column. +fn django_field_name(column: &str, taken: &mut HashSet) -> String { + claim_binding(django_identifier(column), taken) +} + +/// A Python identifier that also passes Django's field checks — no `__` (the +/// lookup separator, fields.E002), no trailing `_` (fields.E001), not a +/// keyword and not one of the model's own attributes. The repairs are +/// `inspectdb`'s, so a renamed field reads the way Django's own tooling would +/// spell it. +fn django_identifier(column: &str) -> String { + let mut name = sanitize_identifier(column, IdentifierStart::Underscore); + while name.contains("__") { + name = name.replace("__", "_"); + } + if name.ends_with('_') { + name.push_str("field"); + } + if MODEL_ATTRIBUTES.contains(&name.as_str()) || is_python_keyword(&name) { + name.push_str("_field"); + } + name +} + +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") +} + +#[cfg(test)] +mod tests { + use vespertide_core::schema::column::SimpleColumnType; + + use super::*; + use crate::tests::fixtures::{fk, pk, simple}; + + #[rstest::rstest] + #[case::plain("author", "author")] + #[case::keyword("from", "from_field")] + #[case::reserved_pk("pk", "pk_field")] + #[case::model_method("save", "save_field")] + #[case::model_check("check", "check_field")] + #[case::default_manager("objects", "objects_field")] + #[case::options_class("Meta", "Meta_field")] + #[case::lookup_separator("user__name", "user_name")] + #[case::trailing_underscore("total_", "total_field")] + #[case::separator_from_sanitizing("a--b", "a_b")] + #[case::digit_led("1st", "_1st")] + fn django_field_name_passes_the_field_checks(#[case] column: &str, #[case] expected: &str) { + let mut taken = HashSet::new(); + assert_eq!(django_field_name(column, &mut taken), expected); + } + + fn owners(constraints: Vec) -> TableDef { + TableDef { + name: "owners".into(), + description: None, + columns: vec![ + simple("id", SimpleColumnType::Integer), + simple("region", SimpleColumnType::Integer), + simple("code", SimpleColumnType::Integer), + ], + constraints, + } + } + + fn unique(column: &str) -> TableConstraint { + TableConstraint::Unique { + name: None, + columns: vec![column.into()], + strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates { + keep: vespertide_core::KeepPolicy::First, + }, + } + } + + /// A key's attname is `{field}_id`, so the key and a column of that name + /// cannot both keep theirs, whichever the table declares first. + #[rstest::rstest] + #[case::key_first(&["owner", "owner_id"], &["owner", "owner_id2"])] + #[case::column_first(&["owner_id", "owner"], &["owner_id", "owner2"])] + fn a_key_claims_its_attname_with_its_field_name( + #[case] columns: &[&str], + #[case] expected: &[&str], + ) { + let table = TableDef { + name: "pets".into(), + description: None, + columns: columns + .iter() + .map(|name| simple(name, SimpleColumnType::Integer)) + .collect(), + constraints: vec![fk(&["owner"], "owners", &["id"])], + }; + let (names, _) = column_field_names(&table, &[]); + let names: Vec<&str> = columns.iter().map(|c| names[c].as_str()).collect(); + assert_eq!(names, expected); + } + + #[rstest::rstest] + #[case::primary_key(vec![pk(&["id"])], "id", true)] + #[case::unique_column(vec![pk(&["id"]), unique("code")], "code", true)] + #[case::column_that_is_not_unique(vec![pk(&["id"])], "code", false)] + #[case::part_of_a_composite_key(vec![pk(&["id", "region"])], "id", false)] + fn a_key_is_a_relation_only_onto_a_unique_field_of_a_single_key_model( + #[case] target_constraints: Vec, + #[case] ref_column: &str, + #[case] expected: bool, + ) { + let schema = [owners(target_constraints)]; + let key = fk(&["owner_id"], "owners", &[ref_column]); + let fk_map = single_column_fk_details(std::slice::from_ref(&key)); + assert_eq!(is_relatable(&fk_map["owner_id"], &schema), expected); + // A target the schema does not hold is taken at its word. + assert!(is_relatable(&fk_map["owner_id"], &[])); + } + + #[rstest::rstest] + #[case::plain("order_status", "OrderStatus")] + #[case::digit_led("1st", "_1st")] + #[case::leading_run_python_would_mangle("--kind", "_kind")] + fn enum_class_name_can_be_named_from_a_model(#[case] name: &str, #[case] expected: &str) { + assert_eq!(enum_class_name(name), expected); + } +} diff --git a/crates/vespertide-exporter/src/django/types.rs b/crates/vespertide-exporter/src/django/types.rs new file mode 100644 index 00000000..25a3107c --- /dev/null +++ b/crates/vespertide-exporter/src/django/types.rs @@ -0,0 +1,230 @@ +use vespertide_core::schema::column::{ + ColumnType, ComplexColumnType, EnumValues, SimpleColumnType, +}; +use vespertide_core::{DefaultValue, ReferenceAction}; + +use crate::utils::common::{is_jsonb_custom_type, string_literal, unquote}; + +#[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 ty { + SimpleColumnType::SmallInt => { + if is_pk && auto_increment { + "models.SmallAutoField" + } else { + "models.SmallIntegerField" + } + } + SimpleColumnType::Integer => { + if is_pk && auto_increment { + "models.AutoField" + } else { + "models.IntegerField" + } + } + SimpleColumnType::BigInt => { + if is_pk && auto_increment { + "models.BigAutoField" + } else { + "models.BigIntegerField" + } + } + SimpleColumnType::Real | SimpleColumnType::DoublePrecision => "models.FloatField", + SimpleColumnType::Text | SimpleColumnType::Xml => "models.TextField", + SimpleColumnType::Boolean => "models.BooleanField", + SimpleColumnType::Date => "models.DateField", + SimpleColumnType::Time => "models.TimeField", + SimpleColumnType::Timestamp | SimpleColumnType::Timestamptz => "models.DateTimeField", + SimpleColumnType::Interval => "models.DurationField", + SimpleColumnType::Bytea => "models.BinaryField", + SimpleColumnType::Uuid => "models.UUIDField", + SimpleColumnType::Json => "models.JSONField", + SimpleColumnType::Inet | SimpleColumnType::Cidr => "models.GenericIPAddressField", + SimpleColumnType::Macaddr => "models.CharField", + }, + ColumnType::Complex(ty) => match ty { + ComplexColumnType::Varchar { .. } | ComplexColumnType::Char { .. } => { + "models.CharField" + } + ComplexColumnType::Numeric { .. } => "models.DecimalField", + // Postgres has no implicit `text -> jsonb` cast, so a `TextField` on a + // JSONB column fails every write. + ComplexColumnType::Custom { custom_type } if is_jsonb_custom_type(custom_type) => { + "models.JSONField" + } + ComplexColumnType::Custom { .. } => "models.TextField", + ComplexColumnType::Enum { values, .. } => match values { + EnumValues::String(_) => "models.CharField", + EnumValues::Integer(_) => "models.IntegerField", + }, + _ => unreachable!( + "ComplexColumnType is #[non_exhaustive]; all variants are matched above" + ), + }, + } +} + +#[expect( + clippy::too_many_arguments, + reason = "independent field-kwarg inputs, read once at a single call site" +)] +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={}", string_literal(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 { + // A `JSONField` default has to be a callable (fields.E010), and the SQL + // literal is the document's text, not its value. The database keeps its + // own default. + if django_field_type(col_type, false, false) == "models.JSONField" { + return None; + } + + 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.len() >= 2 && sql.starts_with('\'') && sql.ends_with('\'') { + // `unquote` keeps the doubled SQL escape (its other consumers re-emit + // into SQL); a Python string wants the actual value. + return Some(string_literal(&unquote(sql).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 +} + +/// The `on_delete` a ForeignKey can carry; a key without an action restricts. +/// Django emulates the action itself and rejects one the field cannot carry +/// out: SET_DEFAULT without a default (fields.E321), SET_NULL on a field that +/// is not null (fields.E320). The table is unmanaged, so the database still +/// applies its own rule; DO_NOTHING leaves it to. +pub(super) fn on_delete_for( + action: Option<&ReferenceAction>, + has_default: bool, + null: bool, +) -> &'static str { + match action { + Some(ReferenceAction::SetDefault) if !has_default => "models.DO_NOTHING", + Some(ReferenceAction::SetNull) if !null => "models.DO_NOTHING", + Some(action) => reference_action_str(action), + None => "models.RESTRICT", + } +} + +fn reference_action_str(action: &ReferenceAction) -> &'static str { + match action { + ReferenceAction::Cascade => "models.CASCADE", + ReferenceAction::Restrict => "models.RESTRICT", + ReferenceAction::SetNull => "models.SET_NULL", + ReferenceAction::SetDefault => "models.SET_DEFAULT", + ReferenceAction::NoAction => "models.DO_NOTHING", + } +} diff --git a/crates/vespertide-exporter/src/drizzle/enums.rs b/crates/vespertide-exporter/src/drizzle/enums.rs index a755dab7..f6e145fb 100644 --- a/crates/vespertide-exporter/src/drizzle/enums.rs +++ b/crates/vespertide-exporter/src/drizzle/enums.rs @@ -13,7 +13,7 @@ use vespertide_naming::build_enum_type_name; -use crate::utils::typescript::ts_string; +use crate::utils::common::string_literal; /// The natural `const` binding an enum declaration and its columns share, /// derived from the database type name so the two stay recognisably paired. @@ -31,10 +31,10 @@ pub(super) fn enum_const_name(table: &str, enum_name: &str) -> String { /// them to PostgreSQL as written, so there is no variant-name normalization /// and nothing that would need a `@map` equivalent. pub(super) fn render_enum_decl(const_name: &str, db_name: &str, values: &[String]) -> String { - let variants: Vec = values.iter().map(|v| ts_string(v)).collect(); + let variants: Vec = values.iter().map(|v| string_literal(v)).collect(); format!( "export const {const_name} = pgEnum({}, [{}]);", - ts_string(db_name), + string_literal(db_name), variants.join(", ") ) } diff --git a/crates/vespertide-exporter/src/drizzle/render.rs b/crates/vespertide-exporter/src/drizzle/render.rs index 34d3d168..f468e780 100644 --- a/crates/vespertide-exporter/src/drizzle/render.rs +++ b/crates/vespertide-exporter/src/drizzle/render.rs @@ -15,8 +15,7 @@ use super::bindings::FileBindings; use super::types::column_ctor; use super::{DrizzleDialect, Imports, js_name}; use crate::constraint_scan::{collect_back_relations, fk_relation_names, relation_segment}; -use crate::utils::common::{claim_field_name, unquote}; -use crate::utils::typescript::ts_string; +use crate::utils::common::{claim_field_name, integer_enum_variant_value, string_literal, unquote}; // ─── Constraint lookups ────────────────────────────────────────────────────── @@ -108,11 +107,17 @@ pub(super) fn render_table( // stores no name and its kit compares by columns alone. let name_field = match dialect { DrizzleDialect::Pg => { - format!("name: {}, ", ts_string(&format!("{}_pkey", table.name))) + format!( + "name: {}, ", + string_literal(&format!("{}_pkey", table.name)) + ) } DrizzleDialect::Mysql => { let joined = pk_columns.join("_"); - format!("name: {}, ", ts_string(&format!("{}_{joined}", table.name))) + format!( + "name: {}, ", + string_literal(&format!("{}_{joined}", table.name)) + ) } DrizzleDialect::Sqlite => String::new(), }; @@ -174,7 +179,7 @@ pub(super) fn render_table( // so the model does too. constraint_lines.push(format!( " check({}, sql`{}`),", - ts_string(name), + string_literal(name), escape_backtick(expr) )); } @@ -200,7 +205,7 @@ pub(super) fn render_table( "export const {} = {}({}, {{", bindings.table_const(&table.name), dialect.table_fn(), - ts_string(&table.name) + string_literal(&table.name) )); lines.extend(col_lines); if constraint_lines.is_empty() { @@ -247,7 +252,7 @@ fn column_refs>(columns: &[T]) -> String { fn table_level_entry(builder: &str, name: &str, columns: &[ColumnName]) -> String { format!( " {builder}({}).on({}),", - ts_string(name), + string_literal(name), column_refs(columns) ) } @@ -294,7 +299,7 @@ fn foreign_key_entry( .join(", ") }; - let name_field = name.map_or_else(String::new, |n| format!(", name: {}", ts_string(n))); + let name_field = name.map_or_else(String::new, |n| format!(", name: {}", string_literal(n))); let mut parts = vec![format!( " foreignKey({{ columns: [{}], foreignColumns: [{foreign_cols}]{name_field} }})", column_refs(columns) @@ -302,13 +307,13 @@ fn foreign_key_entry( if let Some(action) = on_delete { parts.push(format!( ".onDelete({})", - ts_string(reference_action_to_drizzle(action)) + string_literal(reference_action_to_drizzle(action)) )); } if let Some(action) = on_update { parts.push(format!( ".onUpdate({})", - ts_string(reference_action_to_drizzle(action)) + string_literal(reference_action_to_drizzle(action)) )); } parts.push(",".to_string()); @@ -393,7 +398,7 @@ pub(super) fn render_relations_block( .is_some_and(|n| *n > 1) || ref_table.as_str() == table.name.as_str(); if ambiguous && let Some(name) = relation_names.get(&constraint_idx) { - opts.push(format!("relationName: {}", ts_string(name))); + opts.push(format!("relationName: {}", string_literal(name))); } rel_lines.push(format!( @@ -411,7 +416,7 @@ pub(super) fn render_relations_block( }; let field = claim_field_name(preferred, &mut field_names); let opts = br.relation_name.as_ref().map_or_else(String::new, |n| { - format!(", {{ relationName: {} }}", ts_string(n)) + format!(", {{ relationName: {} }}", string_literal(n)) }); let builder = if br.is_one_to_one { "one" } else { "many" }; rel_lines.push(format!(" {field}: {builder}({source}{opts}),")); @@ -542,7 +547,7 @@ pub(super) fn default_chain( } else { inner.to_string() }; - return DefaultChain::literal(format!(".default({})", ts_string(&value))); + return DefaultChain::literal(format!(".default({})", string_literal(&value))); } if default_sql.parse::().is_ok() { @@ -553,7 +558,7 @@ pub(super) fn default_chain( ColumnType::Complex(ComplexColumnType::Numeric { .. }) ); return DefaultChain::literal(if numeric_col { - format!(".default({})", ts_string(default_sql)) + format!(".default({})", string_literal(default_sql)) } else { format!(".default({default_sql})") }); @@ -564,9 +569,9 @@ pub(super) fn default_chain( values: EnumValues::Integer(variants), .. }) = col_type - && let Some(variant) = variants.iter().find(|v| v.name == default_sql) + && let Some(value) = integer_enum_variant_value(variants, default_sql) { - return DefaultChain::literal(format!(".default({})", variant.value)); + return DefaultChain::literal(format!(".default({value})")); } // A bare keyword such as `CURRENT_USER`. @@ -581,10 +586,7 @@ fn reference_action_to_drizzle(action: &ReferenceAction) -> &'static str { ReferenceAction::Restrict => "restrict", ReferenceAction::SetNull => "set null", ReferenceAction::SetDefault => "set default", - // `NoAction`, plus — `ReferenceAction` is `#[non_exhaustive]` — any - // action added later, which falls back to the SQL default rather than - // to a keyword Drizzle cannot parse. - _ => "no action", + ReferenceAction::NoAction => "no action", } } diff --git a/crates/vespertide-exporter/src/drizzle/types.rs b/crates/vespertide-exporter/src/drizzle/types.rs index 2af9d086..e6f55f33 100644 --- a/crates/vespertide-exporter/src/drizzle/types.rs +++ b/crates/vespertide-exporter/src/drizzle/types.rs @@ -11,7 +11,7 @@ use vespertide_core::schema::column::{ use super::bindings::FileBindings; use super::{DrizzleDialect, js_name}; -use crate::utils::typescript::ts_string; +use crate::utils::common::string_literal; /// One resolved Drizzle column constructor. pub(super) struct ColumnCtor { @@ -34,7 +34,7 @@ impl ColumnCtor { format!( "{}({}{}){}", self.symbol, - ts_string(col_db), + string_literal(col_db), self.args, self.note ) @@ -70,7 +70,7 @@ fn widened(symbol: &str, args: String, source: &str) -> ColumnCtor { /// `["draft", "published"]` — the variant list MySQL and SQLite inline into the /// column, since neither declares an enum type separately. fn enum_value_list(values: &[String]) -> String { - let items: Vec = values.iter().map(|v| ts_string(v)).collect(); + let items: Vec = values.iter().map(|v| string_literal(v)).collect(); format!("[{}]", items.join(", ")) } @@ -115,7 +115,7 @@ pub(super) fn render_custom_type_decl(decl: &CustomTypeDecl, const_name: &str) - format!( "const {const_name} = customType<{{ data: {} }}>({{ dataType() {{ return {}; }} }});", decl.ts_data, - ts_string(&decl.data_type) + string_literal(&decl.data_type) ) } @@ -201,9 +201,6 @@ fn pg_ctor(ty: &ColumnType, table: &str, bindings: &FileBindings) -> ColumnCtor SimpleColumnType::Bytea => custom_ctor(&pg_bytea(), bindings), SimpleColumnType::Xml => custom_ctor(&pg_xml(), bindings), SimpleColumnType::Text => ctor("text"), - _ => unreachable!( - "SimpleColumnType is #[non_exhaustive]; all variants are matched above" - ), }, ColumnType::Complex(c) => complex_ctor(c, DrizzleDialect::Pg, table, bindings), } @@ -236,9 +233,6 @@ fn mysql_ctor(ty: &ColumnType, table: &str, bindings: &FileBindings) -> ColumnCt SimpleColumnType::Macaddr => widened("text", String::new(), "macaddr"), SimpleColumnType::Xml => widened("text", String::new(), "xml"), SimpleColumnType::Text => ctor("text"), - _ => unreachable!( - "SimpleColumnType is #[non_exhaustive]; all variants are matched above" - ), }, ColumnType::Complex(c) => complex_ctor(c, DrizzleDialect::Mysql, table, bindings), } @@ -273,9 +267,6 @@ fn sqlite_ctor(ty: &ColumnType, table: &str, bindings: &FileBindings) -> ColumnC SimpleColumnType::Macaddr => widened("text", String::new(), "macaddr"), SimpleColumnType::Xml => widened("text", String::new(), "xml"), SimpleColumnType::Text => ctor("text"), - _ => unreachable!( - "SimpleColumnType is #[non_exhaustive]; all variants are matched above" - ), }, ColumnType::Complex(c) => complex_ctor(c, DrizzleDialect::Sqlite, table, bindings), } diff --git a/crates/vespertide-exporter/src/enum_scan.rs b/crates/vespertide-exporter/src/enum_scan.rs index 461b88c1..87ba5cca 100644 --- a/crates/vespertide-exporter/src/enum_scan.rs +++ b/crates/vespertide-exporter/src/enum_scan.rs @@ -1,10 +1,11 @@ -//! Shared enum-column scan for the single-file ORM renderers. +//! Shared enum-column scans for the renderers that put a whole schema into +//! one scope. //! -//! Backends that write one file per table get enum scoping for free; Prisma -//! and Drizzle emit one file for the whole schema and both start from the same -//! per-table scan. What they do with it differs — Prisma deduplicates -//! identifiers globally (see `prisma::enums`), Drizzle table-prefixes every -//! type — so only the scan itself lives here. +//! Backends that write one file per table get enum scoping for free; Prisma, +//! Drizzle, GORM and Django do not, and all start from the same per-table +//! scan. What they do with it differs — Prisma deduplicates identifiers +//! globally (see `prisma::enums`), Drizzle table-prefixes every type, GORM and +//! Django claim them in the file's one scope (see `scope_names`). use vespertide_core::TableDef; use vespertide_core::schema::column::{ColumnType, ComplexColumnType, EnumValues}; @@ -24,3 +25,12 @@ pub(crate) fn collect_table_enums(table: &TableDef) -> Vec<(&str, &EnumValues)> } result } + +/// An enum's variant names in declaration order: the values of a string +/// enum, the member names of an integer one. +pub(crate) fn variant_names(values: &EnumValues) -> Vec<&str> { + match values { + EnumValues::String(values) => values.iter().map(String::as_str).collect(), + EnumValues::Integer(values) => values.iter().map(|v| v.name.as_str()).collect(), + } +} diff --git a/crates/vespertide-exporter/src/gorm/enums.rs b/crates/vespertide-exporter/src/gorm/enums.rs new file mode 100644 index 00000000..6ebcc74a --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/enums.rs @@ -0,0 +1,62 @@ +use vespertide_core::schema::column::EnumValues; +use vespertide_naming::{IdentifierStart, sanitize_identifier}; + +use super::render::to_pascal_case; +use crate::utils::common::string_literal; + +/// `type_name` and `const_names` are the names claimed for this enum in the +/// package's scope, one constant per value in declaration order. +pub(super) fn render_enum( + lines: &mut Vec, + type_name: &str, + const_names: &[String], + values: &EnumValues, +) { + 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, const_name) in vals.iter().zip(const_names) { + rendered.push(format!( + " {const_name} {type_name} = {}", + string_literal(val) + )); + } + } + EnumValues::Integer(vals) => { + for (val, const_name) in vals.iter().zip(const_names) { + rendered.push(format!(" {const_name} {type_name} = {}", val.value)); + } + } + } + + rendered.push(")".into()); + lines.extend(rendered); +} + +/// The natural Go constant name for one enum member, before it is claimed in +/// the package's scope. The value is arbitrary text — `info-level` and +/// `1critical` are legal in the database — so it is escaped the same way +/// column names are. The `type_name` prefix already supplies a leading letter, +/// so only interior characters can need replacing. +pub(super) fn const_name(type_name: &str, value: &str) -> String { + sanitize_identifier( + &format!("{type_name}{}", to_pascal_case(value)), + IdentifierStart::Letter, + ) +} diff --git a/crates/vespertide-exporter/src/gorm/mod.rs b/crates/vespertide-exporter/src/gorm/mod.rs new file mode 100644 index 00000000..a7d831af --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/mod.rs @@ -0,0 +1,173 @@ +mod enums; +mod render; +mod types; + +use std::path::Path; + +use crate::orm::OrmExporter; +use crate::scope_names::scope_of; +use render::{gofmt_layout, imports_for, package_names, render_header, render_table_body}; +use vespertide_core::TableDef; + +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) + } +} + +/// Go package name for renders that have no export directory to derive one +/// from, and the fallback when the directory's name does not yield a usable +/// Go identifier. +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) +} + +/// Go package name for a GORM export: the export directory's final path +/// segment, sanitized into a Go identifier, or [`DEFAULT_GORM_PACKAGE_NAME`] +/// when that segment yields nothing usable. +fn go_package_name(export_dir: &Path) -> String { + export_dir + .file_name() + .and_then(|s| s.to_str()) + .and_then(sanitize_go_package_name) + .unwrap_or_else(|| DEFAULT_GORM_PACKAGE_NAME.to_string()) +} + +/// GORM exporter whose `package` clause names the directory the file is +/// written to, which is what Go expects of it. +pub struct GormExporterWithConfig { + package_name: String, +} + +impl GormExporterWithConfig { + /// `export_dir` is the directory `models.go` is actually written to — + /// whatever wins after resolving `--export-dir`. + pub fn for_export_dir(export_dir: &Path) -> Self { + Self { + package_name: go_package_name(export_dir), + } + } + + /// [`export`] under the directory's package name. + pub fn export(&self, schema: &[TableDef]) -> Result { + Ok(export_with_package(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, which the reverse side of +/// every relation (has-one / has-many) needs. +pub fn render_entity_with_schema(table: &TableDef, schema: &[TableDef]) -> Result { + Ok(render_entity_inner(table, schema)) +} + +fn render_entity_inner(table: &TableDef, schema: &[TableDef]) -> String { + let mut lines = render_header( + DEFAULT_GORM_PACKAGE_NAME, + &imports_for(std::slice::from_ref(table)), + ); + let names = package_names(scope_of(table, schema)); + lines.extend(render_table_body(table, schema, &names)); + gofmt_layout(&lines) +} + +/// Render a whole schema as one Go source file: a single `package` clause, +/// one import block covering every table, then each table's declarations. +/// Concatenating per-table files instead would repeat the `package` clause, +/// which Go rejects. +pub fn export(schema: &[TableDef]) -> Result { + Ok(export_with_package(schema, DEFAULT_GORM_PACKAGE_NAME)) +} + +fn export_with_package(schema: &[TableDef], package_name: &str) -> String { + let mut lines = render_header(package_name, &imports_for(schema)); + let names = package_names(schema); + for (i, table) in schema.iter().enumerate() { + if i > 0 { + lines.push(String::new()); + } + lines.extend(render_table_body(table, schema, &names)); + } + gofmt_layout(&lines) +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use rstest::rstest; + + use super::go_package_name; + + #[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 go_package_name_inferred_from_export_dir(#[case] export_dir: &str, #[case] expected: &str) { + assert_eq!(go_package_name(Path::new(export_dir)), expected); + } +} diff --git a/crates/vespertide-exporter/src/gorm/render.rs b/crates/vespertide-exporter/src/gorm/render.rs new file mode 100644 index 00000000..0b951f90 --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/render.rs @@ -0,0 +1,840 @@ +use std::collections::{HashMap, HashSet}; + +use super::enums::{const_name, render_enum}; +use super::types::{UsedImports, go_type_for_column_mapped, is_go_string}; +use crate::constraint_scan::{ + BackRelation, FkDetails, collect_back_relations, primary_key_columns, single_column_fk_details, + single_column_uniques, +}; +use crate::enum_scan::{collect_table_enums, variant_names}; +use crate::scope_names::ScopeNames; +use crate::utils::common::{ + CompositeFk, claim_binding, collect_composite_fks, integer_enum_variant_value, string_literal, + unquote, +}; +use vespertide_core::schema::column::{ + ColumnType, ComplexColumnType, EnumValues, SimpleColumnType, +}; +use vespertide_core::schema::constraint::TableConstraint; +use vespertide_core::{ColumnDef, DefaultValue, ReferenceAction, TableDef}; +use vespertide_naming::{ + IdentifierStart, build_index_name, build_unique_constraint_name, pluralize, sanitize_identifier, +}; + +/// The Go imports the columns of `tables` need. +pub(super) fn imports_for(tables: &[TableDef]) -> UsedImports { + let mut used = UsedImports::default(); + for col in tables.iter().flat_map(|table| &table.columns) { + used.add_column_type(&col.r#type); + } + used +} + +/// The `package` clause and the import block, stdlib first. +pub(super) fn render_header(package_name: &str, used_imports: &UsedImports) -> Vec { + let mut lines = vec![format!("package {package_name}"), String::new()]; + + 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_uuid { + lines.push(" \"github.com/google/uuid\"".into()); + } + if used_imports.needs_decimal { + lines.push(" \"github.com/shopspring/decimal\"".into()); + } + if used_imports.needs_datatypes { + lines.push(" \"gorm.io/datatypes\"".into()); + } + lines.push(")".into()); + lines.push(String::new()); + } + lines +} + +/// Lay rendered `lines` out the way `gofmt` does, so the file passes a +/// project's `gofmt -l` check untouched: tab indents, the name / type / rest +/// cells of consecutive struct fields or constants padded into columns, and +/// no doubled blank lines. A comment or an import path has no cells and ends +/// the run of lines being aligned, as it does in `gofmt`. +pub(super) fn gofmt_layout(lines: &[String]) -> String { + fn flush(run: &mut Vec<[&str; 3]>, out: &mut Vec) { + let name_width = run.iter().map(|cells| cells[0].len()).max().unwrap_or(0); + let type_width = run.iter().map(|cells| cells[1].len()).max().unwrap_or(0); + for [name, ty, rest] in run.drain(..) { + out.push(format!("\t{name: = Vec::new(); + let mut run: Vec<[&str; 3]> = Vec::new(); + for line in lines { + let cells = line + .strip_prefix(" ") + .filter(|body| !body.starts_with("//")) + .and_then(|body| { + let (name, rest) = body.split_once(' ')?; + let (ty, rest) = rest.split_once(' ')?; + Some([name, ty, rest]) + }); + if let Some(cells) = cells { + run.push(cells); + continue; + } + flush(&mut run, &mut out); + if let Some(body) = line.strip_prefix(" ") { + out.push(format!("\t{body}")); + } else if !(line.is_empty() && out.last().is_some_and(String::is_empty)) { + out.push(line.clone()); + } + } + out.join("\n") +} + +/// Every name `tables` declare in their package: structs, enum types, then +/// enum constants, which Go also puts at package scope (`Status` + `code` is +/// `StatusCode`, and so is the struct of a `status_code` table). +pub(super) fn package_names(tables: &[TableDef]) -> ScopeNames { + let mut names = ScopeNames::collect(tables, exported_go_name, exported_go_name); + for table in tables { + for (enum_name, values) in collect_table_enums(table) { + let type_name = names.enum_type(&table.name, enum_name).to_string(); + for (index, variant) in variant_names(values).into_iter().enumerate() { + names.claim_member( + &table.name, + enum_name, + index, + const_name(&type_name, variant), + ); + } + } + } + names +} + +/// The struct a table is declared as; a table outside the package's schema — +/// a foreign key may point there — keeps its natural name. +fn struct_name_of(names: &ScopeNames, table: &str) -> String { + names + .table(table) + .map_or_else(|| exported_go_name(table), str::to_string) +} + +/// Everything below the header for one table: enum types, the struct, and +/// its methods. +pub(super) fn render_table_body( + table: &TableDef, + schema: &[TableDef], + names: &ScopeNames, +) -> Vec { + let mut lines: Vec = Vec::new(); + + let struct_name = struct_name_of(names, &table.name); + + let enums = collect_table_enums(table); + let enum_name_map: HashMap<&str, String> = enums + .iter() + .map(|(name, _)| (*name, names.enum_type(&table.name, name).to_string())) + .collect(); + + let fk_by_column = single_column_fk_details(&table.constraints); + let pk_columns = primary_key_columns(&table.constraints); + + 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 = single_column_uniques(&table.constraints); + + let index_map = collect_index_names(table); + let composite_unique_map = collect_composite_unique_names(table); + + // --- Enum type declarations --- + for (enum_name, values) in &enums { + let const_names: Vec = (0..variant_names(values).len()) + .map(|index| names.member(&table.name, enum_name, index).to_string()) + .collect(); + render_enum(&mut lines, &enum_name_map[enum_name], &const_names, 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 {{")); + + // One set of taken names for the whole struct, columns first: no relation + // field — belongs-to, composite or has-many — may take a column's name, + // whichever order the table declares them in. + let (field_names, mut taken) = column_field_names(table); + + 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, + &field_names[col.name.as_str()], + 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, + &field_names[col.name.as_str()], + fk, + schema, + names, + &mut taken, + ); + } + } + + // Composite (multi-column) FK relation fields, through GORM's + // comma-separated `foreignKey`/`references` tags. + for fk in collect_composite_fks(table) { + render_composite_fk_relation_field( + &mut lines, + &fk, + &field_names, + schema, + names, + &mut taken, + ); + } + + // Reverse relation fields (has-one / has-many) derived from schema context + let back_relations = collect_back_relations(&table.name, schema); + let reverse_names = reverse_field_names(&table.name, &back_relations); + for (rel, field_name) in back_relations.iter().zip(reverse_names) { + let foreign_key: Vec = rel + .fk_columns + .iter() + .map(|c| field_name_in(schema, &rel.source_table, c)) + .collect(); + let ref_columns: Vec<&str> = rel.ref_columns.iter().map(String::as_str).collect(); + let gorm_tag = relation_tag( + &foreign_key, + &reference_fields(schema, &table.name, &ref_columns), + rel.on_delete.as_ref(), + rel.on_update.as_ref(), + ); + let source_struct = struct_name_of(names, &rel.source_table); + let go_type = if rel.is_one_to_one { + format!("*{source_struct}") + } else { + format!("[]{source_struct}") + }; + lines.push(format!( + " {field_name} {go_type} {tag}", + field_name = claim_binding(field_name, &mut taken), + tag = struct_tag(&gorm_tag, "-"), + )); + } + + lines.push("}".into()); + lines.push(String::new()); + + // GORM would otherwise derive the table name by pluralizing the struct + // name, which does not reproduce an arbitrary database name. + lines.push(format!( + "func ({struct_name}) TableName() string {{ return {name} }}", + name = string_literal(&table.name), + )); + lines.push(String::new()); + + lines +} + +// --------------------------------------------------------------------------- +// Index / unique names +// --------------------------------------------------------------------------- + +/// Index names per column, spelled as the SQL layer spells them so +/// `AutoMigrate` finds the index the migration created instead of adding a +/// second one. Every column of a composite index carries the same name, which +/// is how GORM groups them. +fn collect_index_names(table: &TableDef) -> HashMap<&str, Vec> { + let mut map: HashMap<&str, Vec> = HashMap::new(); + for c in &table.constraints { + if let TableConstraint::Index { name, columns } = c { + let index_name = build_index_name(&table.name, columns, name.as_deref()); + for col in columns { + map.entry(col.as_str()) + .or_default() + .push(index_name.clone()); + } + } + } + map +} + +/// Composite unique-index name per column, spelled as the SQL layer spells it. +fn collect_composite_unique_names(table: &TableDef) -> HashMap<&str, String> { + let mut map = HashMap::new(); + for c in &table.constraints { + if let TableConstraint::Unique { name, columns, .. } = c + && columns.len() > 1 + { + let uq_name = build_unique_constraint_name(&table.name, columns, name.as_deref()); + for col in columns { + map.insert(col.as_str(), uq_name.clone()); + } + } + } + map +} + +// --------------------------------------------------------------------------- +// Reverse relation naming +// --------------------------------------------------------------------------- + +/// Go field names for `rels`, in order: `Children` for a self-reference, +/// otherwise the source struct — as is for a has-one, pluralized for a +/// has-many. A name more than one relation would take is told apart by the +/// key it hangs on (`SettingsByCreatedByUserID`). +fn reverse_field_names(target: &str, rels: &[BackRelation]) -> Vec { + let bases: Vec = rels + .iter() + .map(|rel| { + if rel.source_table == target { + "Children".to_string() + } else if rel.is_one_to_one { + exported_go_name(&rel.source_table) + } else { + exported_go_name(&pluralize(&rel.source_table)) + } + }) + .collect(); + + rels.iter() + .zip(&bases) + .map(|(rel, base)| { + if bases.iter().filter(|other| *other == base).count() > 1 { + let key: String = rel.fk_columns.iter().map(|c| to_go_field_name(c)).collect(); + format!("{base}By{key}") + } else { + base.clone() + } + }) + .collect() +} + +// --------------------------------------------------------------------------- +// Field rendering +// --------------------------------------------------------------------------- + +#[expect( + clippy::too_many_arguments, + reason = "independent field-rendering inputs, read once at a single call site" +)] +fn render_column_field( + lines: &mut Vec, + col: &ColumnDef, + field_name: &str, + is_pk: bool, + auto_increment: bool, + is_unique: bool, + indexes: &[String], + 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 gorm_tag = build_gorm_tag( + col, + is_pk, + auto_increment, + is_unique, + indexes, + composite_unique_name, + ); + + lines.push(format!( + " {field_name} {go_type} {tag}", + tag = struct_tag(&gorm_tag, &col.name), + )); +} + +fn render_fk_relation_field( + lines: &mut Vec, + col: &ColumnDef, + fk_field_name: &str, + fk: &FkDetails, + schema: &[TableDef], + names: &ScopeNames, + taken: &mut HashSet, +) { + let ref_struct = struct_name_of(names, fk.ref_table); + let mut relation_field_name = go_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. + let relation_field_name = claim_binding(relation_field_name, taken); + + let gorm_tag = relation_tag( + &[fk_field_name.to_string()], + &reference_fields(schema, fk.ref_table, &[fk.ref_column]), + fk.on_delete, + fk.on_update, + ); + + // Always a pointer, nullable or not: a struct that held its target by + // value could not hold itself (`parent_id NOT NULL`), nor a target that + // holds it back, and Go rejects both as an invalid recursive type. + lines.push(format!( + " {relation_field_name} *{ref_struct} {tag}", + tag = struct_tag(&gorm_tag, "-"), + )); +} + +/// 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: &CompositeFk, + field_names: &HashMap<&str, String>, + schema: &[TableDef], + names: &ScopeNames, + taken: &mut HashSet, +) { + let ref_struct = struct_name_of(names, fk.ref_table); + + let relation_field_name = claim_binding(ref_struct.clone(), taken); + + let fk_fields: Vec = fk + .local_cols + .iter() + .map(|c| { + field_names + .get(*c) + .map_or_else(|| to_go_field_name(c), Clone::clone) + }) + .collect(); + let gorm_tag = relation_tag( + &fk_fields, + &reference_fields(schema, fk.ref_table, &fk.ref_cols), + fk.on_delete, + fk.on_update, + ); + + lines.push(format!( + " {relation_field_name} *{ref_struct} {tag}", + tag = struct_tag(&gorm_tag, "-"), + )); +} + +// --------------------------------------------------------------------------- +// GORM tag building +// --------------------------------------------------------------------------- + +/// A field's struct tag. `reflect.StructTag` reads each value as a quoted Go +/// string, so a `\` or `"` from a column name or default is escaped there; the +/// tag as a whole is a raw string unless a value holds a backtick, the one +/// character a raw string cannot. +fn struct_tag(gorm: &str, json: &str) -> String { + let tag = format!( + "gorm:{} json:{}", + string_literal(gorm), + string_literal(json) + ); + if tag.contains('`') { + string_literal(&tag) + } else { + format!("`{tag}`") + } +} + +/// The `references` fields of a relation on `ref_table`: none when the key is +/// the target's primary key, which GORM assumes. A composite key is always +/// spelled out, since GORM pairs its fields by position. +fn reference_fields(schema: &[TableDef], ref_table: &str, ref_columns: &[&str]) -> Vec { + if let [column] = ref_columns { + let is_primary_key = schema + .iter() + .find(|t| t.name.as_str() == ref_table) + .is_none_or(|target| { + let pk = primary_key_columns(&target.constraints); + pk.len() == 1 && pk.contains(column) + }); + if is_primary_key { + return Vec::new(); + } + } + ref_columns + .iter() + .map(|c| field_name_in(schema, ref_table, c)) + .collect() +} + +/// The `gorm:"..."` tag of a relation field: the fields on the foreign-key +/// side, the fields they reference when GORM could not infer them, and the +/// referential actions. +fn relation_tag( + foreign_key: &[String], + references: &[String], + on_delete: Option<&ReferenceAction>, + on_update: Option<&ReferenceAction>, +) -> String { + let mut parts = vec![format!("foreignKey:{}", foreign_key.join(","))]; + if !references.is_empty() { + parts.push(format!("references:{}", references.join(","))); + } + let actions: Vec = [("OnDelete", on_delete), ("OnUpdate", on_update)] + .into_iter() + .filter_map(|(key, action)| Some(format!("{key}:{}", action?.to_sql_keyword()))) + .collect(); + if !actions.is_empty() { + parts.push(format!("constraint:{}", actions.join(","))); + } + parts.join(";") +} + +fn build_gorm_tag( + col: &ColumnDef, + is_pk: bool, + auto_increment: bool, + is_unique: bool, + indexes: &[String], + 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::Simple(SimpleColumnType::Inet) => parts.push("type:inet".into()), + ColumnType::Simple(SimpleColumnType::Cidr) => parts.push("type:cidr".into()), + ColumnType::Simple(SimpleColumnType::Macaddr) => parts.push("type:macaddr".into()), + ColumnType::Complex(ComplexColumnType::Varchar { length }) => { + parts.push(format!("size:{length}")); + } + // GORM only applies `size` to its built-in string type; a bare `type:char` + // is `char(1)` on every database. + ColumnType::Complex(ComplexColumnType::Char { length }) => { + parts.push(format!("type:char({length})")); + } + 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, &col.r#type) + { + parts.push(tag); + } + + for name in indexes { + parts.push(format!("index:{name}")); + } + + if let Some(uq_name) = composite_unique_name { + parts.push(format!("uniqueIndex:{uq_name}")); + } + + parts.join(";") +} + +fn build_default_tag(default: &DefaultValue, col_type: &ColumnType) -> Option { + let sql = default.to_sql(); + // A function call has no literal to pin, and `;` would end the gorm + // setting early, taking every later setting with it. + if sql.contains(['(', ';']) { + return None; + } + // An integer enum's default may name a variant; the column stores its value. + if let ColumnType::Complex(ComplexColumnType::Enum { + values: EnumValues::Integer(variants), + .. + }) = col_type + && let Some(value) = integer_enum_variant_value(variants, unquote(&sql)) + { + return Some(format!("default:{value}")); + } + // GORM takes a string field's default for the value itself, so the doubled + // SQL escape must not reach it. It also trims every quote off both ends + // rather than one pair, so a value that starts or ends with a quote has no + // spelling. Every other kind of field keeps the tag as the SQL it was + // written in. + if is_go_string(col_type) && sql.len() >= 2 && sql.starts_with('\'') && sql.ends_with('\'') { + let value = unquote(&sql).replace("''", "'"); + if value.starts_with(['\'', '"']) || value.ends_with(['\'', '"']) { + return None; + } + return Some(format!("default:'{value}'")); + } + Some(format!("default:{sql}")) +} + +// --------------------------------------------------------------------------- +// Naming utilities +// --------------------------------------------------------------------------- + +pub(super) use crate::python_naming::to_pascal_case; + +/// Exported Go identifier for a database name: PascalCase, with a digit-led +/// start given an upper-case letter prefix. GORM skips unexported struct +/// fields, and a `_`-led type is unreachable from other packages. +pub(super) fn exported_go_name(s: &str) -> String { + exported(&to_pascal_case(s)) +} + +/// Go field name for a column: [`exported_go_name`] with Go's `ID` initialism. +fn to_go_field_name(s: &str) -> String { + exported(&go_initialisms(&to_pascal_case(s))) +} + +/// Go field name for every column of `table`, claimed in declaration order so +/// two columns that map to one Go name (`user_id`, `userId`) get distinct +/// fields, with the set those claims filled — the struct's relation fields +/// claim against the same one. +fn column_field_names(table: &TableDef) -> (HashMap<&str, String>, HashSet) { + // Every struct gets a `TableName` method, and Go rejects a field of the + // same name. + let mut taken = HashSet::from(["TableName".to_string()]); + let names = table + .columns + .iter() + .map(|col| { + ( + col.name.as_str(), + claim_binding(to_go_field_name(&col.name), &mut taken), + ) + }) + .collect(); + (names, taken) +} + +/// The field name `column` has in `table_name`'s struct: its claimed name when +/// that table is part of `schema`, the plain derivation otherwise (a +/// single-table render knows nothing about its FK targets). +fn field_name_in(schema: &[TableDef], table_name: &str, column: &str) -> String { + schema + .iter() + .find(|t| t.name.as_str() == table_name) + .and_then(|t| column_field_names(t).0.remove(column)) + .unwrap_or_else(|| to_go_field_name(column)) +} + +/// Go field name for a belongs-to relation: the FK column without its `_id` +/// suffix, in PascalCase. +fn go_relation_field_name(fk_column: &str) -> String { + exported_go_name(vespertide_naming::infer_relation_field_name(fk_column)) +} + +fn exported(pascal: &str) -> String { + let mut name = sanitize_identifier(pascal, IdentifierStart::Letter); + // `Letter` copies the case of the first letter it finds (`x1users`), and Go + // exports by case. The first byte is always an ASCII letter after + // sanitizing, so the slice cannot split a character. + name[..1].make_ascii_uppercase(); + name +} + +/// Every `Id` that ends a PascalCase word becomes `ID`, as Go spells the +/// initialism; `Identity` and `Idx` keep their words. +fn go_initialisms(pascal: &str) -> String { + let chars: Vec = pascal.chars().collect(); + let mut out = String::with_capacity(pascal.len()); + let mut i = 0; + while i < chars.len() { + let ends_word = chars.get(i + 2).is_none_or(|c| !c.is_ascii_lowercase()); + if chars[i] == 'I' && chars.get(i + 1) == Some(&'d') && ends_word { + out.push_str("ID"); + i += 2; + } else { + out.push(chars[i]); + i += 1; + } + } + out +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + use vespertide_core::schema::column::{ + ColumnType, ComplexColumnType, EnumValues, SimpleColumnType, + }; + use vespertide_core::{ColumnDef, TableDef}; + + use super::{ + build_default_tag, column_field_names, go_relation_field_name, package_names, struct_tag, + to_go_field_name, + }; + + #[rstest] + #[case("user_id", "UserID")] + #[case("id", "ID")] + #[case("created_at", "CreatedAt")] + #[case("profile_image", "ProfileImage")] + #[case("media_id", "MediaID")] + #[case("identity", "Identity")] + #[case("idx", "Idx")] + #[case("1st_place", "X1stPlace")] + fn column_names_become_exported_go_fields(#[case] input: &str, #[case] expected: &str) { + assert_eq!(to_go_field_name(input), expected); + } + + /// Two columns that map to one Go name get distinct fields, in declaration + /// order, and none takes the name of the struct's own `TableName` method. + #[test] + fn column_field_names_disambiguate_go_collisions() { + let integer = || ColumnType::Simple(SimpleColumnType::Integer); + let table = TableDef { + name: "sessions".into(), + description: None, + columns: vec![ + ColumnDef::new("user_id", integer(), false), + ColumnDef::new("userId", integer(), false), + ColumnDef::new("table_name", integer(), false), + ], + constraints: vec![], + }; + let (names, _) = column_field_names(&table); + assert_eq!(names["user_id"], "UserID"); + assert_eq!(names["userId"], "UserID2"); + assert_eq!(names["table_name"], "TableName2"); + } + + /// Constants sit at package scope next to the types: values that fold onto + /// one name are numbered, and an empty value does not spell its own type. + #[test] + fn enum_constants_are_claimed_in_the_package_scope() { + let state = ColumnType::Complex(ComplexColumnType::Enum { + name: "state".into(), + values: EnumValues::String(vec![ + "in progress".into(), + "in-progress".into(), + String::new(), + ]), + }); + let table = TableDef { + name: "ticket".into(), + description: None, + columns: vec![ColumnDef::new("state", state, false)], + constraints: vec![], + }; + let names = package_names(std::slice::from_ref(&table)); + assert_eq!(names.member("ticket", "state", 0), "StateIn_progress"); + assert_eq!(names.member("ticket", "state", 1), "StateIn_progress2"); + assert_eq!(names.member("ticket", "state", 2), "State2"); + } + + #[rstest] + #[case::plain( + "column:id;primaryKey", + "id", + r#"`gorm:"column:id;primaryKey" json:"id"`"# + )] + #[case::quote_and_backslash( + r#"column:a"b\c"#, + r#"a"b\c"#, + r#"`gorm:"column:a\"b\\c" json:"a\"b\\c"`"# + )] + #[case::backtick("column:a`b", "a`b", r#""gorm:\"column:a`b\" json:\"a`b\"""#)] + fn struct_tags_escape_what_their_literal_cannot_hold( + #[case] gorm: &str, + #[case] json: &str, + #[case] expected: &str, + ) { + assert_eq!(struct_tag(gorm, json), expected); + } + + /// GORM reads a string field's default as the value and every other + /// field's as SQL, so only the former loses the doubled quote — and, as + /// GORM trims every quote off its ends, a value that ends in one. + #[rstest] + #[case::string(SimpleColumnType::Text, "'draft'", Some("default:'draft'"))] + #[case::doubled_quote_in_a_string(SimpleColumnType::Text, "'it''s'", Some("default:'it's'"))] + #[case::quotes_inside_a_string( + SimpleColumnType::Text, + r#"'a "b" c'"#, + Some(r#"default:'a "b" c'"#) + )] + #[case::string_ending_in_a_quote(SimpleColumnType::Text, r#"'say "hi"'"#, None)] + #[case::string_starting_with_a_quote(SimpleColumnType::Text, "'''tis'", None)] + #[case::doubled_quote_in_json( + SimpleColumnType::Json, + r#"'{"a": "it''s"}'"#, + Some(r#"default:'{"a": "it''s"}'"#) + )] + #[case::number(SimpleColumnType::Integer, "0", Some("default:0"))] + #[case::function_call(SimpleColumnType::Timestamp, "now()", None)] + #[case::setting_separator(SimpleColumnType::Text, "'a;b'", None)] + fn defaults_become_gorm_default_tags( + #[case] ty: SimpleColumnType, + #[case] sql: &str, + #[case] expected: Option<&str>, + ) { + let tag = build_default_tag(&sql.into(), &ColumnType::Simple(ty)); + assert_eq!(tag.as_deref(), expected); + } + + #[rstest] + #[case("user_id", "User")] + #[case("author_id", "Author")] + #[case("parent_id", "Parent")] + #[case("node", "Node")] + fn fk_columns_name_their_relation_field(#[case] input: &str, #[case] expected: &str) { + assert_eq!(go_relation_field_name(input), expected); + } +} diff --git a/crates/vespertide-exporter/src/gorm/types.rs b/crates/vespertide-exporter/src/gorm/types.rs new file mode 100644 index 00000000..d38c1022 --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/types.rs @@ -0,0 +1,169 @@ +use std::collections::HashMap; + +use super::render::exported_go_name; +use crate::utils::common::is_jsonb_custom_type; +use vespertide_core::schema::column::{ + ColumnType, ComplexColumnType, EnumValues, SimpleColumnType, +}; + +/// 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)] +pub(super) struct UsedImports { + pub(super) needs_time: bool, + pub(super) needs_uuid: bool, + pub(super) needs_datatypes: bool, + pub(super) needs_decimal: bool, +} + +impl UsedImports { + pub(super) 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 + && is_jsonb_custom_type(custom_type) + { + self.needs_datatypes = true; + } + } + } + } +} + +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(|| exported_go_name(name)), + _ => go_base_type(col_type), + }; + if nullable { format!("*{base}") } else { base } +} + +/// Whether the column's Go field is a `string`, or an enum type declared over +/// one. +pub(super) fn is_go_string(col_type: &ColumnType) -> bool { + match col_type { + ColumnType::Complex(ComplexColumnType::Enum { values, .. }) => { + matches!(values, EnumValues::String(_)) + } + _ => go_base_type(col_type) == "string", + } +} + +fn go_base_type(col_type: &ColumnType) -> String { + match col_type { + ColumnType::Simple(ty) => match ty { + SimpleColumnType::SmallInt => "int16".to_string(), + SimpleColumnType::Integer => "int32".to_string(), + SimpleColumnType::BigInt => "int64".to_string(), + SimpleColumnType::Real => "float32".to_string(), + SimpleColumnType::DoublePrecision => "float64".to_string(), + SimpleColumnType::Text + | SimpleColumnType::Xml + | SimpleColumnType::Inet + | SimpleColumnType::Cidr + | SimpleColumnType::Macaddr + | SimpleColumnType::Interval => "string".to_string(), + SimpleColumnType::Boolean => "bool".to_string(), + SimpleColumnType::Date + | SimpleColumnType::Time + | SimpleColumnType::Timestamp + | SimpleColumnType::Timestamptz => "time.Time".to_string(), + SimpleColumnType::Bytea => "[]byte".to_string(), + SimpleColumnType::Uuid => "uuid.UUID".to_string(), + SimpleColumnType::Json => "datatypes.JSON".to_string(), + }, + ColumnType::Complex(ty) => match ty { + ComplexColumnType::Varchar { .. } | ComplexColumnType::Char { .. } => { + "string".to_string() + } + ComplexColumnType::Custom { custom_type } => { + if is_jsonb_custom_type(custom_type) { + "datatypes.JSON".to_string() + } else { + "string".to_string() + } + } + ComplexColumnType::Numeric { .. } => "decimal.Decimal".to_string(), + _ => unreachable!( + "ComplexColumnType is #[non_exhaustive]; all variants are matched above" + ), + }, + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use rstest::rstest; + use vespertide_core::schema::column::{ColumnType, ComplexColumnType, SimpleColumnType}; + + use super::go_type_for_column_mapped; + + #[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 column_types_map_to_go_types( + #[case] col_type: ColumnType, + #[case] nullable: bool, + #[case] expected: &str, + ) { + assert_eq!( + go_type_for_column_mapped(&col_type, nullable, &HashMap::new()), + expected + ); + } +} diff --git a/crates/vespertide-exporter/src/jpa/types.rs b/crates/vespertide-exporter/src/jpa/types.rs index 7978a099..952e7b02 100644 --- a/crates/vespertide-exporter/src/jpa/types.rs +++ b/crates/vespertide-exporter/src/jpa/types.rs @@ -67,9 +67,6 @@ pub(super) fn column_type_to_java(col_type: &ColumnType) -> &'static str { SimpleColumnType::Timestamptz => "OffsetDateTime", SimpleColumnType::Bytea => "byte[]", SimpleColumnType::Uuid => "UUID", - _ => unreachable!( - "SimpleColumnType is #[non_exhaustive]; all variants are matched above" - ), }, ColumnType::Complex(ty) => match ty { ComplexColumnType::Numeric { .. } => "BigDecimal", diff --git a/crates/vespertide-exporter/src/lib.rs b/crates/vespertide-exporter/src/lib.rs index c860a920..f678cd1a 100644 --- a/crates/vespertide-exporter/src/lib.rs +++ b/crates/vespertide-exporter/src/lib.rs @@ -1,14 +1,17 @@ //! Helpers to convert `TableDef` models into ORM-specific representations -//! such as `SeaORM`, `SQLAlchemy`, `SQLModel`, JPA, Prisma, and Drizzle. +//! such as `SeaORM`, `SQLAlchemy`, `SQLModel`, JPA, Prisma, Drizzle, GORM, and Django. mod constraint_scan; +pub mod django; pub mod drizzle; mod enum_scan; +pub mod gorm; pub mod jpa; pub mod orm; mod parallel_config; pub mod prisma; pub mod python_naming; +mod scope_names; pub mod seaorm; pub mod sqlalchemy; pub mod sqlmodel; @@ -16,7 +19,9 @@ pub mod sqlmodel; mod tests; mod utils; +pub use django::DjangoExporter; pub use drizzle::DrizzleExporter; +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 c520b5fa..38676908 100644 --- a/crates/vespertide-exporter/src/orm.rs +++ b/crates/vespertide-exporter/src/orm.rs @@ -1,8 +1,9 @@ use vespertide_core::TableDef; use crate::{ - drizzle::DrizzleExporter, jpa::JpaExporter, prisma::PrismaExporter, seaorm::SeaOrmExporter, - sqlalchemy::SqlAlchemyExporter, sqlmodel::SqlModelExporter, + django::DjangoExporter, drizzle::DrizzleExporter, gorm::GormExporter, jpa::JpaExporter, + prisma::PrismaExporter, seaorm::SeaOrmExporter, sqlalchemy::SqlAlchemyExporter, + sqlmodel::SqlModelExporter, }; /// Supported ORM targets. @@ -17,6 +18,8 @@ pub enum Orm { Jpa, Prisma, Drizzle, + Gorm, + Django, } impl Orm { @@ -24,10 +27,11 @@ 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::Prisma => "prisma", Orm::Drizzle => "ts", + Orm::Gorm => "go", } } } @@ -56,6 +60,8 @@ pub fn render_entity(orm: Orm, table: &TableDef) -> Result { Orm::Jpa => JpaExporter.render_entity(table), Orm::Prisma => PrismaExporter.render_entity(table), Orm::Drizzle => DrizzleExporter.render_entity(table), + Orm::Gorm => GormExporter.render_entity(table), + Orm::Django => DjangoExporter.render_entity(table), } } @@ -72,6 +78,8 @@ pub fn render_entity_with_schema( Orm::Jpa => JpaExporter.render_entity_with_schema(table, schema), Orm::Prisma => PrismaExporter.render_entity_with_schema(table, schema), Orm::Drizzle => DrizzleExporter.render_entity_with_schema(table, schema), + Orm::Gorm => GormExporter.render_entity_with_schema(table, schema), + Orm::Django => DjangoExporter.render_entity_with_schema(table, schema), } } @@ -88,6 +96,8 @@ mod tests { #[case::jpa(Orm::Jpa)] #[case::prisma(Orm::Prisma)] #[case::drizzle(Orm::Drizzle)] + #[case::gorm(Orm::Gorm)] + #[case::django(Orm::Django)] fn dispatch_render_entity_succeeds(#[case] orm: Orm) { let table = basic_single_pk(); assert!(render_entity(orm, &table).is_ok()); @@ -100,6 +110,8 @@ mod tests { #[case::jpa(Orm::Jpa)] #[case::prisma(Orm::Prisma)] #[case::drizzle(Orm::Drizzle)] + #[case::gorm(Orm::Gorm)] + #[case::django(Orm::Django)] fn dispatch_render_entity_with_schema_succeeds(#[case] orm: Orm) { let table = basic_single_pk(); let schema = vec![table.clone()]; @@ -113,6 +125,8 @@ mod tests { #[case::jpa(Orm::Jpa, "java")] #[case::prisma(Orm::Prisma, "prisma")] #[case::drizzle(Orm::Drizzle, "ts")] + #[case::gorm(Orm::Gorm, "go")] + #[case::django(Orm::Django, "py")] fn file_extension_matches_backend(#[case] orm: Orm, #[case] expected: &str) { assert_eq!(orm.file_extension(), expected); } @@ -126,6 +140,8 @@ mod tests { #[case::jpa("jpa", Orm::Jpa)] #[case::prisma("prisma", Orm::Prisma)] #[case::drizzle("drizzle", Orm::Drizzle)] + #[case::gorm("gorm", Orm::Gorm)] + #[case::django("django", Orm::Django)] fn value_enum_parses_cli_name(#[case] input: &str, #[case] expected: Orm) { assert_eq!( clap::ValueEnum::from_str(input, false), diff --git a/crates/vespertide-exporter/src/prisma/render.rs b/crates/vespertide-exporter/src/prisma/render.rs index 0eb2f812..6bd05df0 100644 --- a/crates/vespertide-exporter/src/prisma/render.rs +++ b/crates/vespertide-exporter/src/prisma/render.rs @@ -410,8 +410,7 @@ fn reference_action_to_prisma(action: &ReferenceAction) -> &'static str { ReferenceAction::Restrict => "Restrict", ReferenceAction::SetNull => "SetNull", ReferenceAction::SetDefault => "SetDefault", - // Includes NoAction and unknown/future referential actions. - _ => "NoAction", + ReferenceAction::NoAction => "NoAction", } } diff --git a/crates/vespertide-exporter/src/prisma/types.rs b/crates/vespertide-exporter/src/prisma/types.rs index 94f8ff4c..bfc2231e 100644 --- a/crates/vespertide-exporter/src/prisma/types.rs +++ b/crates/vespertide-exporter/src/prisma/types.rs @@ -40,9 +40,6 @@ pub(super) fn column_type_to_prisma( | SimpleColumnType::Cidr | SimpleColumnType::Macaddr | SimpleColumnType::Xml => "String", - _ => unreachable!( - "SimpleColumnType is #[non_exhaustive]; all variants are matched above" - ), }; format!("{base}{q}") } diff --git a/crates/vespertide-exporter/src/python_naming.rs b/crates/vespertide-exporter/src/python_naming.rs index 6a5b4b29..ececcd59 100644 --- a/crates/vespertide-exporter/src/python_naming.rs +++ b/crates/vespertide-exporter/src/python_naming.rs @@ -1,14 +1,18 @@ -//! Shared naming helpers for the Python-targeted ORM exporters (SQLAlchemy, -//! SQLModel). Both backends share an identical, snake-case-aware -//! `to_pascal_case`. +//! Shared `to_pascal_case`: split on `_`, upper-case the first character of +//! each segment, keep the rest verbatim. SQLAlchemy, SQLModel, JPA, Django, +//! GORM and the CLI's filename derivation all want exactly that rule, which +//! is a naming convention rather than a language feature — which is why the +//! Java and Go backends share it instead of carrying copies. //! //! Enum member names go through `vespertide_naming::to_screaming_snake_case` + //! `sanitize_identifier` instead — that pair is shared with the Prisma backend, //! so the case rule lives in `vespertide-naming` rather than here. //! -//! `seaorm` deliberately keeps its own `to_pascal_case` in -//! `seaorm/imports.rs` — that variant carries reserved-keyword guards and a -//! different allocation pattern and is NOT in scope for this consolidation. +//! `seaorm` keeps its own `to_pascal_case` in `seaorm/imports.rs`: that variant +//! also treats `-` as a separator and upper-cases with `to_ascii_uppercase` +//! rather than Unicode-aware `char::to_uppercase`, so the two are not +//! interchangeable. Reserved-keyword escaping is a separate concern, handled +//! by `seaorm::imports::sanitize_field_name`. /// Convert snake_case (or single-word) input to PascalCase. Splits on /// underscores, upper-cases the first character of each segment, and diff --git a/crates/vespertide-exporter/src/scope_names.rs b/crates/vespertide-exporter/src/scope_names.rs new file mode 100644 index 00000000..80fff93b --- /dev/null +++ b/crates/vespertide-exporter/src/scope_names.rs @@ -0,0 +1,258 @@ +//! Top-level names of a generated file that holds a whole schema in one scope. +//! +//! GORM writes every struct, enum type and enum constant into one Go package; +//! Django writes every model and choices class into one module. A table and an +//! enum that share a name (`role` and `user.role`), two names that fold onto +//! one identifier, or an enum constant that spells a struct (`Status` + `code` +//! next to `status_code`) would otherwise be declared twice. Names are claimed +//! here once for the whole schema and looked up by what they name, so every +//! reference agrees with its declaration. +//! +//! Drizzle's `drizzle::bindings` is a different claim order over a different +//! scope: it starts from the dialect's import symbols and the callback +//! parameters, claims `customType` helpers and `relations` consts as well, and +//! always qualifies an enum with its table. + +use std::collections::{HashMap, HashSet}; + +use vespertide_core::TableDef; + +use crate::enum_scan::collect_table_enums; +use crate::utils::common::claim_binding; + +#[derive(PartialEq, Eq, Hash)] +enum Named { + Table(String), + Enum(String, String), + Member(String, String, usize), +} + +#[derive(Default)] +pub(crate) struct ScopeNames { + names: HashMap, + taken: HashSet, +} + +impl ScopeNames { + /// Claim a name for every table, then for every table's enums. Tables go + /// first so a table always keeps its natural name. An enum keeps its bare + /// identifier only while no other table declares the same one and nothing + /// holds it yet; otherwise it is qualified with the name its table claimed. + pub(crate) fn collect( + schema: &[TableDef], + table_identifier: impl Fn(&str) -> String, + enum_identifier: impl Fn(&str) -> String, + ) -> Self { + let mut scope = Self::default(); + for table in schema { + scope.claim( + Named::Table(table.name.to_string()), + table_identifier(&table.name), + ); + } + + let shared = identifiers_shared_across_tables(schema, &enum_identifier); + for table in schema { + for (enum_name, _) in collect_table_enums(table) { + let bare = enum_identifier(enum_name); + let natural = if shared.contains(&bare) || scope.taken.contains(&bare) { + format!( + "{}{bare}", + scope.names[&Named::Table(table.name.to_string())] + ) + } else { + bare + }; + scope.claim( + Named::Enum(table.name.to_string(), enum_name.to_string()), + natural, + ); + } + } + scope + } + + /// Claim the `index`-th member of a table's enum, for a backend whose enum + /// members share the file's scope. + pub(crate) fn claim_member( + &mut self, + table: &str, + enum_name: &str, + index: usize, + natural: String, + ) { + self.claim( + Named::Member(table.to_string(), enum_name.to_string(), index), + natural, + ); + } + + /// The first claim for a key stands: two columns of one table may share an + /// enum, and a schema may list a table twice. + fn claim(&mut self, key: Named, natural: String) { + if !self.names.contains_key(&key) { + let name = claim_binding(natural, &mut self.taken); + self.names.insert(key, name); + } + } + + /// `None` for a table outside the schema the names were collected from — + /// a foreign key may point there — which callers answer with the natural + /// name. + pub(crate) fn table(&self, table: &str) -> Option<&str> { + self.names + .get(&Named::Table(table.to_string())) + .map(String::as_str) + } + + /// The type of an enum `table` declares. Only that table's own render asks, + /// and every render collects its names from a slice that holds the table: + /// the schema for a whole file, [`scope_of`] for a single table. + pub(crate) fn enum_type(&self, table: &str, enum_name: &str) -> &str { + &self.names[&Named::Enum(table.to_string(), enum_name.to_string())] + } + + /// The `index`-th member of an enum `table` declares, as claimed through + /// [`Self::claim_member`]. + pub(crate) fn member(&self, table: &str, enum_name: &str, index: usize) -> &str { + &self.names[&Named::Member(table.to_string(), enum_name.to_string(), index)] + } +} + +/// The tables whose names share a file with `table`'s: `schema` when it holds +/// the table, the table alone otherwise — a render without schema context +/// still claims the table's own names against each other. +pub(crate) fn scope_of<'a>(table: &'a TableDef, schema: &'a [TableDef]) -> &'a [TableDef] { + if schema.contains(table) { + schema + } else { + std::slice::from_ref(table) + } +} + +/// Identifiers more than one table of `schema` declares an enum under. Names +/// are compared after `identifier` has converted them, since distinct names +/// can collapse onto the same one (`doc_status` and `docStatus`). +fn identifiers_shared_across_tables( + schema: &[TableDef], + identifier: impl Fn(&str) -> String, +) -> HashSet { + let mut tables_declaring: HashMap = HashMap::new(); + for table in schema { + let declared: HashSet = collect_table_enums(table) + .into_iter() + .map(|(name, _)| identifier(name)) + .collect(); + for ident in declared { + *tables_declaring.entry(ident).or_default() += 1; + } + } + tables_declaring + .into_iter() + .filter(|(_, tables)| *tables > 1) + .map(|(ident, _)| ident) + .collect() +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + use vespertide_core::schema::column::{ColumnType, ComplexColumnType, EnumValues}; + use vespertide_core::{ColumnDef, TableDef}; + + use super::{ScopeNames, scope_of}; + use crate::python_naming::to_pascal_case; + + fn table(name: &str, enums: &[&str]) -> TableDef { + TableDef { + name: name.into(), + description: None, + columns: enums + .iter() + .map(|enum_name| { + let ty = ColumnType::Complex(ComplexColumnType::Enum { + name: (*enum_name).to_string(), + values: EnumValues::String(vec!["a".into()]), + }); + ColumnDef::new(*enum_name, ty, false) + }) + .collect(), + constraints: vec![], + } + } + + fn collect(schema: &[TableDef]) -> ScopeNames { + ScopeNames::collect(schema, to_pascal_case, to_pascal_case) + } + + #[rstest] + // Nothing else wants the identifier: the enum keeps it bare. + #[case::unique(&[("orders", &["status"][..])], "orders", "status", "Status")] + // A table of the same name holds it, whichever is declared first. + #[case::taken_by_a_later_table(&[("user", &["role"][..]), ("role", &[])], "user", "role", "UserRole")] + #[case::taken_by_an_earlier_table(&[("role", &[][..]), ("user", &["role"])], "user", "role", "UserRole")] + // Two tables declare it: both are qualified. + #[case::shared(&[("orders", &["status"][..]), ("tasks", &["status"])], "tasks", "status", "TasksStatus")] + // The qualified name is itself a table: numbered. + #[case::qualified_name_taken( + &[("orders", &["status"][..]), ("tasks", &["status"]), ("tasks_status", &[])], + "tasks", + "status", + "TasksStatus2" + )] + // Two names of one table fold onto one identifier: the second finds it + // held, so each still gets a type of its own. + #[case::folds_within_a_table(&[("docs", &["doc_status", "docStatus"][..])], "docs", "docStatus", "DocsDocStatus")] + // The qualifier is the name the table claimed, not the one it wanted. + #[case::qualified_by_a_numbered_table( + &[("user_data", &["status"][..]), ("userData", &["status"])], + "userData", + "status", + "UserData2Status" + )] + fn enums_are_named_clear_of_everything_else_in_the_scope( + #[case] schema: &[(&str, &[&str])], + #[case] table_name: &str, + #[case] enum_name: &str, + #[case] expected: &str, + ) { + let schema: Vec = schema + .iter() + .map(|(name, enums)| table(name, enums)) + .collect(); + assert_eq!(collect(&schema).enum_type(table_name, enum_name), expected); + } + + #[test] + fn tables_that_fold_onto_one_identifier_are_numbered() { + let names = collect(&[table("user_data", &[]), table("userData", &[])]); + assert_eq!(names.table("user_data"), Some("UserData")); + assert_eq!(names.table("userData"), Some("UserData2")); + } + + #[test] + fn members_share_the_scope_with_tables_and_enums() { + let mut names = collect(&[table("status_code", &[]), table("ticket", &["status"])]); + names.claim_member("ticket", "status", 0, "StatusCode".into()); + names.claim_member("ticket", "status", 1, "Status".into()); + assert_eq!(names.member("ticket", "status", 0), "StatusCode2"); + assert_eq!(names.member("ticket", "status", 1), "Status2"); + } + + #[test] + fn a_table_outside_the_schema_is_unknown() { + let names = collect(&[table("orders", &["status"])]); + assert_eq!(names.table("users"), None); + } + + /// A schema that does not hold the table is no scope for it. + #[test] + fn a_table_is_its_own_scope_outside_its_schema() { + let orders = table("orders", &["status"]); + let schema = [table("users", &[]), orders.clone()]; + let alone = std::slice::from_ref(&orders); + assert_eq!(scope_of(&orders, &schema), schema); + assert_eq!(scope_of(&orders, &schema[..1]), alone); + assert_eq!(scope_of(&orders, &[]), alone); + } +} diff --git a/crates/vespertide-exporter/src/seaorm/relations/reverse.rs b/crates/vespertide-exporter/src/seaorm/relations/reverse.rs index dd13b086..149b08a8 100644 --- a/crates/vespertide-exporter/src/seaorm/relations/reverse.rs +++ b/crates/vespertide-exporter/src/seaorm/relations/reverse.rs @@ -6,7 +6,7 @@ use std::collections::{BTreeMap, HashMap, HashSet}; -use vespertide_core::{ColumnName, TableConstraint, TableDef, TableName}; +use vespertide_core::{TableConstraint, TableDef}; use vespertide_naming::seaorm_module_name; use super::super::imports::{ @@ -15,7 +15,7 @@ use super::super::imports::{ }; use super::super::render::primary_key_columns; use super::naming::{generate_relation_enum_name, pluralize, unique_relation_enum_name}; -use crate::constraint_scan::single_column_uniques; +use crate::constraint_scan::{junction_targets, single_column_uniques}; /// Information about a reverse relation to be generated. struct ReverseRelation { @@ -75,63 +75,21 @@ pub(super) fn collect_reverse_relation_targets( targets } -/// Collect target entities from a junction table for M2M relations. +/// Collect target entities from a junction table for M2M relations: the +/// junction itself, then every linked table `schema` knows. fn collect_many_to_many_targets( current_table: &TableDef, junction_table: &TableDef, junction_pk: &HashSet<&str>, schema: &[TableDef], ) -> Option> { - if junction_pk.len() < 2 { - return None; - } - - let fks: Vec<(&[ColumnName], &TableName)> = junction_table - .constraints - .iter() - .filter_map(|c| { - if let TableConstraint::ForeignKey { - columns, ref_table, .. - } = c - { - Some((columns.as_slice(), ref_table)) - } else { - None - } - }) - .collect(); - - if fks.len() < 2 { - return None; - } - - let all_fk_cols_in_pk = fks - .iter() - .all(|(cols, _)| cols.iter().all(|c| junction_pk.contains(c.as_str()))); - - if !all_fk_cols_in_pk { - return None; - } - - fks.iter() - .find(|(_, ref_table)| **ref_table == current_table.name)?; - - let mut targets = Vec::new(); - - // Junction table itself - targets.push(junction_table.name.to_string()); - - // Target tables via M2M - for (_, ref_table) in &fks { - if **ref_table == current_table.name { - continue; - } - let target_exists = schema.iter().any(|t| &t.name == *ref_table); - if target_exists { - targets.push(ref_table.to_string()); - } - } - + let m2m = junction_targets(current_table, junction_table, junction_pk)?; + let mut targets = vec![junction_table.name.to_string()]; + targets.extend( + m2m.into_iter() + .filter(|target| schema.iter().any(|t| &t.name == *target)) + .map(ToString::to_string), + ); Some(targets) } @@ -363,56 +321,15 @@ fn collect_many_to_many_relations( junction_pk: &HashSet<&str>, schema: &[TableDef], ) -> Option> { - // Junction table must have composite PK (2+ columns) - if junction_pk.len() < 2 { - return None; - } - - // Collect all FKs from the junction table - let fks: Vec<(&[ColumnName], &TableName)> = junction_table - .constraints - .iter() - .filter_map(|c| { - if let TableConstraint::ForeignKey { - columns, ref_table, .. - } = c - { - Some((columns.as_slice(), ref_table)) - } else { - None - } - }) - .collect(); - - // Must have at least 2 FKs to be a junction table - if fks.len() < 2 { + let m2m = junction_targets(current_table, junction_table, junction_pk)?; + // Every FK points back at the current table: a self-relation, not an M2M + // junction linking two distinct tables. + if m2m.is_empty() { return None; } - // Check if all FK columns are part of the PK (typical junction table pattern) - let all_fk_cols_in_pk = fks - .iter() - .all(|(cols, _)| cols.iter().all(|c| junction_pk.contains(c.as_str()))); - - if !all_fk_cols_in_pk { - return None; - } - - // Find which FK references the current table - fks.iter() - .find(|(_, ref_table)| **ref_table == current_table.name)?; - let mut relations = Vec::new(); - // All FKs point back at the current table ⇒ pure self-ref junction, not an - // M2M junction linking two distinct tables. - if fks - .iter() - .all(|(_, ref_table)| **ref_table == current_table.name) - { - return None; - } - // First, add has_many to the junction table itself (direct relation, not M2M) let junction_pascal = to_pascal_case(&junction_table.name); let junction_base = pluralize(&sanitize_field_name(&junction_table.name)); @@ -429,12 +346,8 @@ fn collect_many_to_many_relations( }); // Then add has_many with via for the target tables (M2M relations) - for (_columns, ref_table) in &fks { - if **ref_table == current_table.name { - continue; - } - - let target_exists = schema.iter().any(|t| &t.name == *ref_table); + for ref_table in m2m { + let target_exists = schema.iter().any(|t| &t.name == ref_table); if !target_exists { continue; } diff --git a/crates/vespertide-exporter/src/seaorm/render.rs b/crates/vespertide-exporter/src/seaorm/render.rs index 6c81aa02..7272d47c 100644 --- a/crates/vespertide-exporter/src/seaorm/render.rs +++ b/crates/vespertide-exporter/src/seaorm/render.rs @@ -9,6 +9,7 @@ use super::relations::{ relation_field_defs_with_schema, render_self_ref_link_helpers, render_self_ref_query_helpers, }; use super::types::{column_type_supports_eq, format_default_value}; +use crate::utils::common::is_jsonb_custom_type; /// Render a single table into `SeaORM` entity code with schema context, configuration, /// and module path mappings for correct cross-directory relation paths. @@ -218,7 +219,7 @@ pub(super) fn render_column( } // JSONB custom type should use Json rust type ColumnType::Complex(ComplexColumnType::Custom { custom_type }) - if custom_type.eq_ignore_ascii_case("JSONB") => + if is_jsonb_custom_type(custom_type) => { if column.nullable { "Option".to_string() diff --git a/crates/vespertide-exporter/src/sqlalchemy/render.rs b/crates/vespertide-exporter/src/sqlalchemy/render.rs index 442c1781..bf005efa 100644 --- a/crates/vespertide-exporter/src/sqlalchemy/render.rs +++ b/crates/vespertide-exporter/src/sqlalchemy/render.rs @@ -1,10 +1,11 @@ use super::enums::render_enum; use super::types::{UsedTypes, column_type_to_python, column_type_to_sqlalchemy}; +use crate::constraint_scan::FkDetails; use crate::parallel_config::{ PYTHON_EXPORT_PAR_TABLE_MIN_LEN, SQLALCHEMY_EXPORT_PAR_TABLE_THRESHOLD, }; -use crate::utils::common::{join_qualified_refs, join_quoted, push_attr}; -use crate::utils::python::collect_composite_fks; +use crate::utils::common::{collect_composite_fks, join_qualified_refs, join_quoted, push_attr}; +use crate::utils::python::escape_python_keyword; use rayon::prelude::*; use vespertide_core::schema::column::{ColumnType, ComplexColumnType, EnumValues}; use vespertide_core::schema::constraint::TableConstraint; @@ -76,7 +77,7 @@ fn render_entity_part(table: &TableDef, used_types: &mut UsedTypes<'static>) -> // Collect single-column foreign key targets once; the import flag below and // the per-column render lookups both read from this single scan. - let fk_info = crate::constraint_scan::single_column_fk_targets(&table.constraints); + let fk_info = crate::constraint_scan::single_column_fk_details(&table.constraints); // Check for single-column foreign keys if !fk_info.is_empty() { @@ -272,7 +273,7 @@ fn render_column( col: &ColumnDef, is_pk: bool, is_unique: bool, - fk_info: Option<&(&str, &str)>, + fk_info: Option<&FkDetails>, ) { // Add column comment if let Some(ref comment) = col.comment { @@ -291,10 +292,10 @@ fn render_column( push_attr(&mut attrs, &sa_type); // Foreign key - if let Some((ref_table, ref_col)) = fk_info { + if let Some(fk) = fk_info { push_attr( &mut attrs, - &format!("ForeignKey(\"{ref_table}.{ref_col}\")"), + &format!("ForeignKey(\"{}.{}\")", fk.ref_table, fk.ref_column), ); } @@ -340,7 +341,10 @@ fn render_column( // buffer (see `push_attr`), so the positional name is spliced in at the // front instead of `Vec::insert(0, ..)`; output is byte-identical to // prepending the fragment and re-joining with ", ". - let attr_name = sanitize_identifier(col.name.as_str(), IdentifierStart::Underscore); + let attr_name = escape_python_keyword(sanitize_identifier( + col.name.as_str(), + IdentifierStart::Underscore, + )); if attr_name != col.name.as_str() { attrs.insert_str(0, &format!("\"{}\", ", col.name)); } diff --git a/crates/vespertide-exporter/src/sqlalchemy/types.rs b/crates/vespertide-exporter/src/sqlalchemy/types.rs index 766c6898..fb086130 100644 --- a/crates/vespertide-exporter/src/sqlalchemy/types.rs +++ b/crates/vespertide-exporter/src/sqlalchemy/types.rs @@ -88,9 +88,6 @@ impl UsedTypes<'_> { SimpleColumnType::Inet | SimpleColumnType::Cidr | SimpleColumnType::Macaddr => { self.sa_types.insert("String"); } - _ => unreachable!( - "SimpleColumnType is #[non_exhaustive]; all variants are matched above" - ), } } @@ -139,9 +136,6 @@ pub(super) fn column_type_to_sqlalchemy(col_type: &ColumnType) -> String { SimpleColumnType::Inet | SimpleColumnType::Cidr | SimpleColumnType::Macaddr => { "String(255)".into() } - _ => unreachable!( - "SimpleColumnType is #[non_exhaustive]; all variants are matched above" - ), }, ColumnType::Complex(ty) => match ty { ComplexColumnType::Numeric { precision, scale } => { diff --git a/crates/vespertide-exporter/src/sqlmodel/render.rs b/crates/vespertide-exporter/src/sqlmodel/render.rs index 8fb6e5b2..827fd974 100644 --- a/crates/vespertide-exporter/src/sqlmodel/render.rs +++ b/crates/vespertide-exporter/src/sqlmodel/render.rs @@ -1,10 +1,13 @@ use rayon::prelude::*; +use crate::constraint_scan::FkDetails; use crate::parallel_config::{ PYTHON_EXPORT_PAR_TABLE_MIN_LEN, SQLMODEL_EXPORT_PAR_TABLE_THRESHOLD, }; -use crate::utils::common::{join_qualified_refs, join_quoted, unquote}; -use crate::utils::python::{CompositeFk, collect_composite_fks}; +use crate::utils::common::{ + CompositeFk, collect_composite_fks, join_qualified_refs, join_quoted, unquote, +}; +use crate::utils::python::escape_python_keyword; use vespertide_core::schema::column::{ColumnType, ComplexColumnType, EnumValues}; use vespertide_core::schema::constraint::TableConstraint; use vespertide_core::{ColumnDef, TableDef}; @@ -255,7 +258,7 @@ fn render_entity_body(table: &TableDef, composite_fks: &[CompositeFk<'_>]) -> Ve let indexed_columns = crate::constraint_scan::single_column_indexes(&table.constraints); // Collect foreign key info; lookup-only, ordering unused. - let fk_info = crate::constraint_scan::single_column_fk_targets(&table.constraints); + let fk_info = crate::constraint_scan::single_column_fk_details(&table.constraints); // Render columns for col in &table.columns { @@ -348,7 +351,7 @@ pub(super) fn render_column( is_pk: bool, is_unique: bool, is_indexed: bool, - fk_info: Option<&(&str, &str)>, + fk_info: Option<&FkDetails>, ) { // Add column comment if let Some(ref comment) = col.comment { @@ -395,8 +398,11 @@ pub(super) fn render_column( } // Foreign key - if let Some((ref_table, ref_col)) = fk_info { - field_args.push(format!("foreign_key=\"{ref_table}.{ref_col}\"")); + if let Some(fk) = fk_info { + field_args.push(format!( + "foreign_key=\"{}.{}\"", + fk.ref_table, fk.ref_column + )); } // Unique @@ -412,7 +418,10 @@ pub(super) fn render_column( // Build field definition // Pydantic rejects a leading `_` on model fields, so the escape is a letter. // A renamed field no longer points at its column, so name it explicitly. - let field_name = sanitize_identifier(col.name.as_str(), IdentifierStart::Letter); + let field_name = escape_python_keyword(sanitize_identifier( + col.name.as_str(), + IdentifierStart::Letter, + )); if field_name != col.name.as_str() { field_args.push(format!("sa_column_kwargs={{\"name\": \"{}\"}}", col.name)); } diff --git a/crates/vespertide-exporter/src/tests/fixtures/identifiers.rs b/crates/vespertide-exporter/src/tests/fixtures/identifiers.rs new file mode 100644 index 00000000..9a71f2a3 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/fixtures/identifiers.rs @@ -0,0 +1,137 @@ +//! Names that collide, or need escaping, once mapped into a host language. + +use vespertide_core::schema::column::SimpleColumnType; +use vespertide_core::schema::constraint::TableConstraint; +use vespertide_core::{ReferenceAction, TableDef}; + +use super::{col, fk, nullable_simple, pk, simple, string_enum}; + +/// Relation field names that run into a struct's own columns: a composite FK +/// whose target struct name is already taken by two columns (`order_regions`, +/// `order_regions2`), a has-many whose pluralized source name is a column +/// (`users.posts`), a belongs-to that spells the `TableName` method GORM gives +/// every struct (`posts.table_name_id`), and a self-reference, whose reverse +/// side only appears when the table is rendered with itself in the schema. +pub(crate) fn relation_field_names() -> Vec { + let order_regions = TableDef { + name: "order_regions".into(), + description: None, + columns: vec![ + simple("order_id", SimpleColumnType::Integer), + simple("region_id", SimpleColumnType::Integer), + ], + constraints: vec![pk(&["order_id", "region_id"])], + }; + let order_items = TableDef { + name: "order_items".into(), + description: None, + columns: vec![ + simple("id", SimpleColumnType::Integer), + simple("order_id", SimpleColumnType::Integer), + simple("region_id", SimpleColumnType::Integer), + simple("order_regions", SimpleColumnType::Text), + simple("order_regions2", SimpleColumnType::Text), + ], + constraints: vec![ + pk(&["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: Some(ReferenceAction::Cascade), + on_update: Some(ReferenceAction::Restrict), + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + ], + }; + let users = TableDef { + name: "users".into(), + description: None, + columns: vec![ + simple("id", SimpleColumnType::Integer), + simple("posts", SimpleColumnType::Text), + ], + constraints: vec![pk(&["id"])], + }; + let posts = TableDef { + name: "posts".into(), + description: None, + columns: vec![ + simple("id", SimpleColumnType::Integer), + simple("user_id", SimpleColumnType::Integer), + simple("table_name_id", SimpleColumnType::Integer), + ], + constraints: vec![ + pk(&["id"]), + fk(&["user_id"], "users", &["id"]), + fk(&["table_name_id"], "categories", &["id"]), + ], + }; + let categories = TableDef { + name: "categories".into(), + description: None, + columns: vec![ + simple("id", SimpleColumnType::Integer), + nullable_simple("parent_id", SimpleColumnType::Integer), + ], + constraints: vec![pk(&["id"]), fk(&["parent_id"], "categories", &["id"])], + }; + [order_regions, order_items, users, posts, categories] + .into_iter() + .map(|t| t.normalize().expect("relation_field_names normalizes")) + .collect() +} + +/// Two tables declare an enum with the same name and different values. A +/// backend whose enum types share one namespace (GORM's package, Prisma's +/// single file) has to qualify the type names by table. +pub(crate) fn enum_name_shared_across_tables() -> Vec { + let orders = TableDef { + name: "orders".into(), + description: None, + columns: vec![ + simple("id", SimpleColumnType::Integer), + col("status", string_enum("status", &["pending", "shipped"])), + ], + constraints: vec![pk(&["id"])], + }; + let tasks = TableDef { + name: "tasks".into(), + description: None, + columns: vec![ + simple("id", SimpleColumnType::Integer), + col("status", string_enum("status", &["todo", "done"])), + ], + constraints: vec![pk(&["id"])], + }; + [orders, tasks] + .into_iter() + .map(|t| { + t.normalize() + .expect("enum_name_shared_across_tables normalizes") + }) + .collect() +} + +/// Column names that are legal in SQL but not as Python attributes or Django +/// fields: keywords, Django's reserved `pk`, the `__` lookup separator, a +/// trailing `_`, a separator the sanitizer itself introduces, and a FK whose +/// `_id`-stripped base is a keyword. The table name carries `__` too. +pub(crate) fn python_reserved_names() -> TableDef { + TableDef { + name: "event__log".into(), + description: None, + columns: vec![ + simple("id", SimpleColumnType::Integer), + simple("from", SimpleColumnType::Text), + simple("def", SimpleColumnType::Text), + simple("pk", SimpleColumnType::Integer), + simple("user__name", SimpleColumnType::Text), + simple("total_", SimpleColumnType::Integer), + simple("a--b", SimpleColumnType::Text), + simple("pass_id", SimpleColumnType::Integer), + ], + constraints: vec![pk(&["id"]), fk(&["pass_id"], "targets", &["id"])], + } +} diff --git a/crates/vespertide-exporter/src/tests/fixtures/junctions.rs b/crates/vespertide-exporter/src/tests/fixtures/junctions.rs new file mode 100644 index 00000000..5d0713e6 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/fixtures/junctions.rs @@ -0,0 +1,49 @@ +//! Junction tables a backend's many-to-many support has to tell apart. + +use vespertide_core::TableDef; +use vespertide_core::schema::column::SimpleColumnType; + +use super::{fk, pk, simple}; + +/// A junction whose far end has a composite primary key, so it reaches that +/// end by a composite foreign key. Whether that still counts as a +/// many-to-many is up to the backend: Django cannot relate to a composite-key +/// model at all, and leaves all three as plain models. +pub(crate) fn junction_over_composite_key() -> Vec { + let user = TableDef { + name: "user".into(), + description: None, + columns: vec![simple("id", SimpleColumnType::Integer)], + constraints: vec![pk(&["id"])], + }; + let article = TableDef { + name: "article".into(), + description: None, + columns: vec![ + simple("media_id", SimpleColumnType::Integer), + simple("id", SimpleColumnType::Integer), + ], + constraints: vec![pk(&["media_id", "id"])], + }; + let article_user = TableDef { + name: "article_user".into(), + description: None, + columns: vec![ + simple("media_id", SimpleColumnType::Integer), + simple("article_id", SimpleColumnType::Integer), + simple("user_id", SimpleColumnType::Integer), + ], + constraints: vec![ + pk(&["media_id", "article_id", "user_id"]), + fk(&["media_id", "article_id"], "article", &["media_id", "id"]), + fk(&["user_id"], "user", &["id"]), + ], + }; + [user, article, article_user] + .into_iter() + .map(|t| { + t.normalize() + .expect("junction_over_composite_key normalizes") + }) + .collect() +} diff --git a/crates/vespertide-exporter/src/tests/fixtures/mod.rs b/crates/vespertide-exporter/src/tests/fixtures/mod.rs index 7f3797ab..c4666cab 100644 --- a/crates/vespertide-exporter/src/tests/fixtures/mod.rs +++ b/crates/vespertide-exporter/src/tests/fixtures/mod.rs @@ -11,6 +11,17 @@ use vespertide_core::{ mod collisions; pub(crate) use collisions::binding_collisions; +mod reference_actions; +pub(crate) use reference_actions::reference_actions; + +mod junctions; +pub(crate) use junctions::junction_over_composite_key; + +mod identifiers; +pub(crate) use identifiers::{ + enum_name_shared_across_tables, python_reserved_names, relation_field_names, +}; + pub(crate) fn col(name: &str, ty: ColumnType) -> ColumnDef { ColumnDef::new(name, ty, false) } @@ -23,7 +34,7 @@ pub(crate) fn simple(name: &str, ty: SimpleColumnType) -> ColumnDef { col(name, ColumnType::Simple(ty)) } -fn nullable_simple(name: &str, ty: SimpleColumnType) -> ColumnDef { +pub(crate) fn nullable_simple(name: &str, ty: SimpleColumnType) -> ColumnDef { nullable_col(name, ColumnType::Simple(ty)) } @@ -331,7 +342,7 @@ pub(crate) fn enum_special_values() -> TableDef { ) } -fn string_enum(name: &str, values: &[&str]) -> ColumnType { +pub(crate) fn string_enum(name: &str, values: &[&str]) -> ColumnType { ColumnType::Complex(ComplexColumnType::Enum { name: name.into(), values: EnumValues::String(values.iter().copied().map(Into::into).collect()), @@ -767,6 +778,19 @@ pub(crate) fn json_default() -> TableDef { ) } +/// A default carrying `;`, which GORM's tag syntax cannot hold. +pub(crate) fn semicolon_default() -> TableDef { + table( + "notes", + vec![ + simple("id", SimpleColumnType::Integer), + simple("note", SimpleColumnType::Text).default("'a;b'".into()), + simple("body", SimpleColumnType::Text), + ], + vec![pk(&["id"])], + ) +} + pub(crate) fn self_referencing_fk() -> TableDef { let raw = TableDef { name: "employees".into(), diff --git a/crates/vespertide-exporter/src/tests/fixtures/reference_actions.rs b/crates/vespertide-exporter/src/tests/fixtures/reference_actions.rs new file mode 100644 index 00000000..74b4acec --- /dev/null +++ b/crates/vespertide-exporter/src/tests/fixtures/reference_actions.rs @@ -0,0 +1,87 @@ +//! Foreign keys carrying both referential actions. + +use vespertide_core::schema::column::SimpleColumnType; +use vespertide_core::schema::constraint::TableConstraint; +use vespertide_core::{ReferenceAction, TableDef}; + +use super::{nullable_simple, pk, simple}; + +/// Foreign keys that set `ON UPDATE` as well as `ON DELETE`, one action each. +/// GORM, Prisma and Drizzle render both, Django renders `on_delete` alone +/// (its `ForeignKey` has no update action), and the remaining four drop them +/// — a spread this fixture pins. Between the two children every action +/// but `SET NULL` (pinned by `self_referencing_fk`) appears, each paired with a +/// different one so a backend that emits one in the other's place is visible; +/// `comments.post_id` is nullable so a relation on a nullable key carries +/// actions too. `SET DEFAULT` appears with a column default +/// (`comments.author_id`) and without one: Django accepts only the former. +pub(crate) fn reference_actions() -> Vec { + let users = TableDef { + name: "users".into(), + description: None, + columns: vec![simple("id", SimpleColumnType::Integer)], + constraints: vec![pk(&["id"])], + }; + let posts = TableDef { + name: "posts".into(), + description: None, + columns: vec![ + simple("id", SimpleColumnType::Integer), + simple("user_id", SimpleColumnType::Integer), + ], + constraints: vec![ + pk(&["id"]), + fk_with_actions( + "user_id", + "users", + ReferenceAction::Cascade, + ReferenceAction::Restrict, + ), + ], + }; + let comments = TableDef { + name: "comments".into(), + description: None, + columns: vec![ + simple("id", SimpleColumnType::Integer), + nullable_simple("post_id", SimpleColumnType::Integer), + simple("author_id", SimpleColumnType::Integer).default("1".into()), + ], + constraints: vec![ + pk(&["id"]), + fk_with_actions( + "post_id", + "posts", + ReferenceAction::SetDefault, + ReferenceAction::NoAction, + ), + fk_with_actions( + "author_id", + "users", + ReferenceAction::SetDefault, + ReferenceAction::Cascade, + ), + ], + }; + [users, posts, comments] + .into_iter() + .map(|t| t.normalize().expect("reference_actions normalizes")) + .collect() +} + +fn fk_with_actions( + column: &str, + ref_table: &str, + on_delete: ReferenceAction, + on_update: ReferenceAction, +) -> TableConstraint { + TableConstraint::ForeignKey { + name: None, + columns: vec![column.into()], + ref_table: ref_table.into(), + ref_columns: vec!["id".into()], + on_delete: Some(on_delete), + on_update: Some(on_update), + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + } +} diff --git a/crates/vespertide-exporter/src/tests/fixtures/schemas.rs b/crates/vespertide-exporter/src/tests/fixtures/schemas.rs index 691b7ce2..662719da 100644 --- a/crates/vespertide-exporter/src/tests/fixtures/schemas.rs +++ b/crates/vespertide-exporter/src/tests/fixtures/schemas.rs @@ -131,6 +131,119 @@ pub(crate) fn schema_scenario(name: &str) -> (TableDef, Vec) { &["created_by_user_id", "updated_by_user_id"], true, ), + // A junction whose target pluralizes to a keyword (`pass`), a junction + // name that needs sanitizing, and a scalar column already named after + // the other target (`tags`). + "many_to_many_reserved_names" => { + let user = table_with_named_pk( + "user", + vec![ + simple("id", SimpleColumnType::Integer), + simple("tags", SimpleColumnType::Text), + ], + &["id"], + ); + let tags = table_with_named_pk( + "tags", + vec![simple("id", SimpleColumnType::Integer)], + &["id"], + ); + let pass = table_with_named_pk( + "pass", + vec![simple("id", SimpleColumnType::Integer)], + &["id"], + ); + let user_tags = table_with_fk_constraints( + "user_tags", + vec![ + simple("user_id", SimpleColumnType::Integer), + simple("tag_id", SimpleColumnType::Integer), + ], + &["user_id", "tag_id"], + vec![ + (vec!["user_id"], "user", vec!["id"]), + (vec!["tag_id"], "tags", vec!["id"]), + ], + ); + let user_pass = table_with_fk_constraints( + "user-pass", + vec![ + simple("user_id", SimpleColumnType::Integer), + simple("pass_id", SimpleColumnType::Integer), + ], + &["user_id", "pass_id"], + vec![ + (vec!["user_id"], "user", vec!["id"]), + (vec!["pass_id"], "pass", vec!["id"]), + ], + ); + (user.clone(), vec![user, tags, pass, user_tags, user_pass]) + } + // A self-junction (both keys -> `user`) and a junction between two other + // tables: neither is a many-to-many of `user`. + "many_to_many_uninvolved" => { + let user = table_with_named_pk( + "user", + vec![simple("id", SimpleColumnType::Integer)], + &["id"], + ); + let user_friends = table_with_fk_constraints( + "user_friends", + vec![ + simple("user_id", SimpleColumnType::Integer), + simple("friend_id", SimpleColumnType::Integer), + ], + &["user_id", "friend_id"], + vec![ + (vec!["user_id"], "user", vec!["id"]), + (vec!["friend_id"], "user", vec!["id"]), + ], + ); + let groups = table_with_named_pk( + "groups", + vec![simple("id", SimpleColumnType::Integer)], + &["id"], + ); + let tags = table_with_named_pk( + "tags", + vec![simple("id", SimpleColumnType::Integer)], + &["id"], + ); + let group_tags = table_with_fk_constraints( + "group_tags", + vec![ + simple("group_id", SimpleColumnType::Integer), + simple("tag_id", SimpleColumnType::Integer), + ], + &["group_id", "tag_id"], + vec![ + (vec!["group_id"], "groups", vec!["id"]), + (vec!["tag_id"], "tags", vec!["id"]), + ], + ); + ( + user.clone(), + vec![user, user_friends, groups, tags, group_tags], + ) + } + // The unique-FK side of a one-to-one, rendered as the focus table. + "one_to_one_source" => { + let (_, schema) = reverse_user_schema("profile", &["user_id"], true); + (schema[1].clone(), schema) + } + // A one-to-one whose key is the source's whole primary key, rendered + // from the target: the reverse side holds at most one row. + "one_to_one_shared_primary_key" => { + let user = + table_with_named_pk("user", vec![simple("id", SimpleColumnType::Uuid)], &["id"]); + let profile = table_with_fk_constraints( + "profile", + vec![simple("user_id", SimpleColumnType::Uuid)], + &["user_id"], + vec![(vec!["user_id"], "user", vec!["id"])], + ); + (user.clone(), vec![user, profile]) + } "composite_and_single_fk_same_target" => { let target = table( "target", diff --git a/crates/vespertide-exporter/src/tests/mod.rs b/crates/vespertide-exporter/src/tests/mod.rs index 1c25fe0e..0a78aa1d 100644 --- a/crates/vespertide-exporter/src/tests/mod.rs +++ b/crates/vespertide-exporter/src/tests/mod.rs @@ -23,10 +23,10 @@ fn orm_label(orm: Orm) -> String { } /// Dispatch the per-ORM **multi-table** entry point so the cross-ORM -/// `orm_cases!(multi ...)` arm renders a `Vec` schema for all six +/// `orm_cases!(multi ...)` arm renders a `Vec` schema for all eight /// ORMs through a single call. JPA's `render_entities` returns `Vec` /// (one entry per entity); we join with `"\n"` to match the -/// `String`-returning shape of the other four. +/// `String`-returning shape of the other seven. fn render_schema(orm: Orm, schema: &[TableDef]) -> Result { match orm { Orm::SeaOrm => crate::seaorm::export(schema), @@ -35,6 +35,8 @@ fn render_schema(orm: Orm, schema: &[TableDef]) -> Result { Orm::Jpa => crate::jpa::render_entities(schema).map(|entities| entities.join("\n")), Orm::Prisma => crate::prisma::export(schema), Orm::Drizzle => crate::drizzle::export(schema), + Orm::Gorm => crate::gorm::export(schema), + Orm::Django => crate::django::export(schema), } } @@ -49,6 +51,8 @@ macro_rules! orm_cases { #[case::jpa(Orm::Jpa)] #[case::prisma(Orm::Prisma)] #[case::drizzle(Orm::Drizzle)] + #[case::gorm(Orm::Gorm)] + #[case::django(Orm::Django)] fn $test_name(#[case] orm: Orm) { let table = $fixture(); let rendered = render_entity(orm, &table).unwrap(); @@ -67,6 +71,8 @@ macro_rules! orm_cases { #[case::jpa(Orm::Jpa)] #[case::prisma(Orm::Prisma)] #[case::drizzle(Orm::Drizzle)] + #[case::gorm(Orm::Gorm)] + #[case::django(Orm::Django)] fn $test_name(#[case] orm: Orm) { let schema: Vec = $fixture(); let rendered = render_schema(orm, &schema).unwrap(); @@ -255,6 +261,11 @@ orm_cases!( "json_default", fixtures::json_default ); +orm_cases!( + semicolon_default_snapshot, + "semicolon_default", + fixtures::semicolon_default +); orm_cases!( self_referencing_fk_snapshot, "self_referencing_fk", @@ -292,7 +303,7 @@ orm_cases!( ); // Cross-ORM comparison of identifier escaping. Each language starts identifiers // differently — Prisma and Pydantic reject a leading `_`, the rest accept it — -// so the six snapshots must differ, and every one has to carry the original +// so the eight snapshots must differ, and every one has to carry the original // name (`@@map` / `@map`, `column_name`, the positional column name, // `sa_column_kwargs`, `@Table`/`@Column`). orm_cases!( @@ -320,9 +331,12 @@ orm_cases!( fixtures::non_identifier_relation_names ); // A composite FK becomes a relation only where the backend can express one -// (`SeaORM`'s tuple `from`/`to`, Prisma's multi-column `fields`/`references`); -// the Python backends keep it as a `ForeignKeyConstraint` and JPA currently -// drops it, so the six outputs disagree in a way worth pinning. +// (`SeaORM`'s tuple `from`/`to`, Prisma's multi-column `fields`/`references`, +// Drizzle's `foreignKey({columns, foreignColumns})` plus a `one(...)` relation, +// GORM's comma-separated `foreignKey`/`references`); SQLAlchemy and SQLModel +// keep it as a `ForeignKeyConstraint`, Django emits a `# composite foreign key:` +// comment, and JPA currently drops it, so the eight outputs disagree in a way +// worth pinning. orm_cases!( multi composite_fk_relation_snapshot, "composite_fk_relation", @@ -376,12 +390,37 @@ orm_cases!( "binding_collisions", fixtures::binding_collisions ); +// Every referential action, `ON UPDATE` included, across both nullabilities. +orm_cases!( + multi reference_actions_snapshot, + "reference_actions", + fixtures::reference_actions +); +orm_cases!( + multi relation_field_names_snapshot, + "relation_field_names", + fixtures::relation_field_names +); +orm_cases!( + multi junction_over_composite_key_snapshot, + "junction_over_composite_key", + fixtures::junction_over_composite_key +); +orm_cases!( + multi enum_name_shared_across_tables_snapshot, + "enum_name_shared_across_tables", + fixtures::enum_name_shared_across_tables +); +orm_cases!( + python_reserved_names_snapshot, + "python_reserved_names", + fixtures::python_reserved_names +); /// Dispatch the per-ORM `to_pascal_case` helper from a single entry point so /// the cross-ORM consolidation test can exercise every implementation without -/// leaking the helper as a generally-public crate API. Prisma has no local -/// implementation — it calls `vespertide_naming::to_pascal_case` directly, so -/// this arm exercises the shared crate helper. +/// leaking a backend's private helper as a crate-public API. The backends that +/// have no local implementation name the shared helper they delegate to. fn to_pascal_case_for(orm: Orm, s: &str) -> String { match orm { Orm::SeaOrm => crate::seaorm::to_pascal_case_for_tests(s), @@ -389,20 +428,21 @@ fn to_pascal_case_for(orm: Orm, s: &str) -> String { Orm::SqlModel => crate::sqlmodel::to_pascal_case_for_tests(s), Orm::Jpa => crate::jpa::to_pascal_case_for_tests(s), Orm::Prisma | Orm::Drizzle => vespertide_naming::to_pascal_case(s), + Orm::Gorm | Orm::Django => crate::python_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 -/// six ORM implementations agree. +/// eight ORM implementations agree. /// /// Divergences intentionally NOT covered here: /// * `-` as separator: `SeaORM`, Prisma and Drizzle treat it as a separator -/// (the latter two via `vespertide_naming`), the other three ORMs leave it +/// (the latter two via `vespertide_naming`), 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). -/// These divergences are exercised in the per-ORM `tests.rs` files where +/// * Non-ASCII characters: `SeaORM`, Prisma and Drizzle use +/// `to_ascii_uppercase`, the other five use `to_uppercase` (Unicode-aware). +/// These divergences are exercised in each backend's own test module where /// applicable. #[rstest] #[case::seaorm(Orm::SeaOrm)] @@ -411,6 +451,8 @@ fn to_pascal_case_for(orm: Orm, s: &str) -> String { #[case::jpa(Orm::Jpa)] #[case::prisma(Orm::Prisma)] #[case::drizzle(Orm::Drizzle)] +#[case::gorm(Orm::Gorm)] +#[case::django(Orm::Django)] fn to_pascal_case_shared_semantics( #[values( ("", ""), @@ -422,6 +464,8 @@ fn to_pascal_case_shared_semantics( ("user_id", "UserId"), ("a_b_c", "ABC"), ("a__b", "AB"), + ("_leading", "Leading"), + ("trailing_", "Trailing"), ("order_item", "OrderItem"), ("user_profile_image", "UserProfileImage") )] @@ -439,6 +483,8 @@ fn to_pascal_case_shared_semantics( #[case::jpa(Orm::Jpa)] #[case::prisma(Orm::Prisma)] #[case::drizzle(Orm::Drizzle)] +#[case::gorm(Orm::Gorm)] +#[case::django(Orm::Django)] fn render_entity_with_schema_snapshots( #[values( "many_to_many_article", @@ -455,7 +501,11 @@ fn render_entity_with_schema_snapshots( "multiple_reverse_relations", "dual_reverse_relations", "triple_reverse_relations", - "multiple_has_one_relations" + "multiple_has_one_relations", + "one_to_one_source", + "one_to_one_shared_primary_key", + "many_to_many_reserved_names", + "many_to_many_uninvolved" )] scenario: &str, #[case] orm: Orm, @@ -466,3 +516,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..d5da780e --- /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 +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: + managed = False + 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..384e300c --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Gorm.snap @@ -0,0 +1,36 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/datatypes" +) + +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;type:inet" json:"inet_col"` + CidrCol string `gorm:"column:cidr_col;not null;type:cidr" json:"cidr_col"` + MacaddrCol string `gorm:"column:macaddr_col;not null;type:macaddr" 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..7cbe13e2 --- /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 +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: + managed = False + 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..fbb03960 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_single_pk_snapshot@basic_single_pk_Gorm.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..1c5e86a7 --- /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 +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: + managed = False + 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..f4bc41a9 --- /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,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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__binding_collisions_snapshot@binding_collisions_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_Django.snap new file mode 100644 index 00000000..98d48c1b --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_Django.snap @@ -0,0 +1,39 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class UserRelations(models.Model): + id = models.IntegerField(primary_key=True) + kind = models.TextField() + + class Meta: + managed = False + db_table = "user_relations" + +class User(models.Model): + id = models.IntegerField(primary_key=True) + + class Meta: + managed = False + db_table = "user" + +class Sql(models.Model): + id = models.IntegerField(primary_key=True) + amount = models.IntegerField() + + class Meta: + managed = False + db_table = "sql" + +class Posts(models.Model): + id = models.IntegerField(primary_key=True) + user = models.ForeignKey("User", on_delete=models.RESTRICT, related_name="+") + + class Meta: + managed = False + db_table = "posts" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_Gorm.snap new file mode 100644 index 00000000..8c13c5f8 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_Gorm.snap @@ -0,0 +1,34 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type UserRelations struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Kind string `gorm:"column:kind;not null;type:integer" json:"kind"` +} + +func (UserRelations) TableName() string { return "user_relations" } + +type User struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Posts []Posts `gorm:"foreignKey:UserID" json:"-"` +} + +func (User) TableName() string { return "user" } + +type Sql struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Amount int32 `gorm:"column:amount;not null" json:"amount"` +} + +func (Sql) TableName() string { return "sql" } + +type Posts struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + UserID int32 `gorm:"column:user_id;not null" json:"user_id"` + User *User `gorm:"foreignKey:UserID" json:"-"` +} + +func (Posts) TableName() string { return "posts" } 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..30d0cfdb --- /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 +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: + managed = False + 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..4da498ba --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_Gorm.snap @@ -0,0 +1,19 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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;type:char(10)" 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..572e36b4 --- /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 +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: + managed = False + db_table = "order_items" + indexes = [ + models.Index(fields=["order_id"]), + ] + constraints = [ + models.UniqueConstraint(fields=["order_id", "product_id"], name="uq_order_items__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..41b71949 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Gorm.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type OrderItems struct { + OrderID int32 `gorm:"column:order_id;primaryKey;index:ix_order_items__ix_order_items__order_id;uniqueIndex:uq_order_items__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__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..69f23c7d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_fk_relation_snapshot@composite_fk_relation_Django.snap @@ -0,0 +1,28 @@ +--- +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: + managed = False + 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: + managed = False + 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..2361d5fd --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_fk_relation_snapshot@composite_fk_relation_Gorm.snap @@ -0,0 +1,23 @@ +--- +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"` + LineItems []LineItems `gorm:"foreignKey:OrderID,OrderVersion;references:ID,Version" json:"-"` +} + +func (Orders) TableName() string { return "orders" } + +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..55908f0a --- /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 +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: + managed = False + db_table = "composite_index" + indexes = [ + models.Index(fields=["tenant_id", "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..937f2ec1 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type CompositeIndex struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + TenantID int32 `gorm:"column:tenant_id;not null;index:ix_composite_index__idx_tenant_name" json:"tenant_id"` + Name string `gorm:"column:name;not null;type:text;index:ix_composite_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..78532020 --- /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 +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: + managed = False + 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..defdd2ad --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_pk_snapshot@composite_pk_Gorm.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..06cbb69f --- /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 +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: + managed = False + 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..e3eabf08 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..a2801dff --- /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 +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: + managed = False + db_table = "account_aliases" + constraints = [ + models.UniqueConstraint(fields=["tenant_id", "slug"], name="uq_account_aliases__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..7eab50c0 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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__uq_account_aliases__tenant_slug" json:"tenant_id"` + Slug string `gorm:"column:slug;not null;type:text;uniqueIndex:uq_account_aliases__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..9f5a0fc6 --- /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 +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: + managed = False + db_table = "composite_unique" + constraints = [ + models.UniqueConstraint(fields=["tenant_id", "name"], name="uq_composite_unique__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..fb9800f7 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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_composite_unique__uq_tenant_name" json:"tenant_id"` + Name string `gorm:"column:name;not null;type:text;uniqueIndex:uq_composite_unique__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..ce4a381a --- /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 +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: + managed = False + 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..e3bbb610 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Gorm.snap @@ -0,0 +1,14 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..b11532e4 --- /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 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class ProductCategory(models.TextChoices): + ELECTRONICS = "electronics" + CLOTHING = "clothing" + FOOD = "food" + +class AvailabilityStatus(models.TextChoices): + IN_STOCK = "in_stock" + OUT_OF_STOCK = "out_of_stock" + 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: + managed = False + 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..050c4e1c --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_multiple_columns_snapshot@enum_multiple_columns_Gorm.snap @@ -0,0 +1,29 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Django.snap new file mode 100644 index 00000000..f5e4e8b9 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Django.snap @@ -0,0 +1,32 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class OrdersStatus(models.TextChoices): + PENDING = "pending" + SHIPPED = "shipped" + +class Orders(models.Model): + id = models.IntegerField(primary_key=True) + status = models.CharField(max_length=7, choices=OrdersStatus.choices) + + class Meta: + managed = False + db_table = "orders" + +class TasksStatus(models.TextChoices): + TODO = "todo" + DONE = "done" + +class Tasks(models.Model): + id = models.IntegerField(primary_key=True) + status = models.CharField(max_length=4, choices=TasksStatus.choices) + + class Meta: + managed = False + db_table = "tasks" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Drizzle_pg.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Drizzle_pg.snap new file mode 100644 index 00000000..62956756 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Drizzle_pg.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +export const ordersStatus = pgEnum("orders_status", ["pending", "shipped"]); + +export const orders = pgTable("orders", { + id: integer("id").primaryKey(), + status: ordersStatus("status").notNull(), +}); + +export const tasksStatus = pgEnum("tasks_status", ["todo", "done"]); + +export const tasks = pgTable("tasks", { + id: integer("id").primaryKey(), + status: tasksStatus("status").notNull(), +}); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Gorm.snap new file mode 100644 index 00000000..e2f1bfd1 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Gorm.snap @@ -0,0 +1,33 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type OrdersStatus string + +const ( + OrdersStatusPending OrdersStatus = "pending" + OrdersStatusShipped OrdersStatus = "shipped" +) + +type Orders struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Status OrdersStatus `gorm:"column:status;not null" json:"status"` +} + +func (Orders) TableName() string { return "orders" } + +type TasksStatus string + +const ( + TasksStatusTodo TasksStatus = "todo" + TasksStatusDone TasksStatus = "done" +) + +type Tasks struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Status TasksStatus `gorm:"column:status;not null" json:"status"` +} + +func (Tasks) TableName() string { return "tasks" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Jpa.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Jpa.snap new file mode 100644 index 00000000..49c30405 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Jpa.snap @@ -0,0 +1,49 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +import jakarta.persistence.*; + +enum Status { + pending, + shipped; +} + +@Entity +@Table(name = "orders") +public class Orders { + + @Id + @Column(name = "id") + private Integer id; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private Status status; + + protected Orders() { + } +} + +import jakarta.persistence.*; + +enum Status { + todo, + done; +} + +@Entity +@Table(name = "tasks") +public class Tasks { + + @Id + @Column(name = "id") + private Integer id; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private Status status; + + protected Tasks() { + } +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Prisma.snap new file mode 100644 index 00000000..e09beade --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Prisma.snap @@ -0,0 +1,27 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +enum OrdersStatus { + PENDING @map("pending") + SHIPPED @map("shipped") +} + +model Orders { + id Int @id + status OrdersStatus + + @@map("orders") +} + +enum TasksStatus { + TODO @map("todo") + DONE @map("done") +} + +model Tasks { + id Int @id + status TasksStatus + + @@map("tasks") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_SeaOrm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_SeaOrm.snap new file mode 100644 index 00000000..c3fac09e --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_SeaOrm.snap @@ -0,0 +1,54 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize, vespera::Schema)] +#[serde(rename_all = "camelCase")] +#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "orders_status")] +pub enum Status { + #[sea_orm(string_value = "pending")] + Pending, + #[sea_orm(string_value = "shipped")] + Shipped, +} + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "orders")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub status: Status, +} + +vespera::schema_type!(Schema from Model, name = "OrdersSchema"); +impl ActiveModelBehavior for ActiveModel {} + + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize, vespera::Schema)] +#[serde(rename_all = "camelCase")] +#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "tasks_status")] +pub enum Status { + #[sea_orm(string_value = "todo")] + Todo, + #[sea_orm(string_value = "done")] + Done, +} + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "tasks")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub status: Status, +} + +vespera::schema_type!(Schema from Model, name = "TasksSchema"); +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_SqlAlchemy.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_SqlAlchemy.snap new file mode 100644 index 00000000..11574b9b --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_SqlAlchemy.snap @@ -0,0 +1,31 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +import enum + +from sqlalchemy import Enum, Integer +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Status(str, enum.Enum): + PENDING = "pending" + SHIPPED = "shipped" + +class Orders(DeclarativeBase): + __tablename__ = "orders" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + status: Mapped[Status] = mapped_column(Enum(Status), nullable=False) + +class Status(str, enum.Enum): + TODO = "todo" + DONE = "done" + +class Tasks(DeclarativeBase): + __tablename__ = "tasks" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + status: Mapped[Status] = mapped_column(Enum(Status), nullable=False) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_SqlModel.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_SqlModel.snap new file mode 100644 index 00000000..b079eb52 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_SqlModel.snap @@ -0,0 +1,30 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +import enum + +from sqlmodel import Field, SQLModel + + +class Status(str, enum.Enum): + PENDING = "pending" + SHIPPED = "shipped" + +class Orders(SQLModel, table=True): + __tablename__ = "orders" + + id: int = Field(primary_key=True) + status: Status = Field(...) + +class Status(str, enum.Enum): + TODO = "todo" + DONE = "done" + +class Tasks(SQLModel, table=True): + __tablename__ = "tasks" + + id: int = Field(primary_key=True) + status: Status = Field(...) 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..33466094 --- /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 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class DocStatus(models.TextChoices): + DRAFT = "draft" + PUBLISHED = "published" + 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: + managed = False + 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..3562f672 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_shared_snapshot@enum_shared_Gorm.snap @@ -0,0 +1,21 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +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..bc2b9dd3 --- /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 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class EventSeverity(models.TextChoices): + INFO_LEVEL = "info-level" + WARNING_LEVEL = "warning_level" + ERROR_LEVEL = "ERROR_LEVEL" + _1CRITICAL = "1critical" + +class Events(models.Model): + id = models.IntegerField() + severity = models.CharField(max_length=13, choices=EventSeverity.choices) + + class Meta: + managed = False + 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..50802d97 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_Gorm.snap @@ -0,0 +1,21 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..59a18476 --- /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 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class TaskStatus(models.TextChoices): + PENDING = "pending" + IN_PROGRESS = "in_progress" + 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: + managed = False + 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..731b70ee --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_with_default_snapshot@enum_with_default_Gorm.snap @@ -0,0 +1,22 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..891ab8a9 --- /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 +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: + managed = False + 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..0156e396 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__false_boolean_default_snapshot@false_boolean_default_Gorm.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..bb3f8097 --- /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,25 @@ +--- +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: + managed = False + db_table = "target" + +class Src(models.Model): + pk_field = models.IntegerField(db_column="pk", primary_key=True) + a = models.ForeignKey("Target", on_delete=models.RESTRICT, related_name="+", null=True, blank=True) + a2 = models.ForeignKey("Target", on_delete=models.RESTRICT, to_field="alt", db_column="a", related_name="+", null=True, blank=True) + + class Meta: + managed = False + 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..0b68b57a --- /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,24 @@ +--- +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"` + SrcsByAID []Src `gorm:"foreignKey:AID" json:"-"` + SrcsByA []Src `gorm:"foreignKey:A;references:Alt" json:"-"` +} + +func (Target) TableName() string { return "target" } + +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;references:Alt" 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..394c4f2f --- /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 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Child(models.Model): + # References parent table + parent = models.OneToOneField("Parent", on_delete=models.RESTRICT, primary_key=True, related_name="+") + value = models.TextField() + + class Meta: + managed = False + 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..6209f910 --- /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,14 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..4e3df777 --- /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 +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: + managed = False + 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..466c1f1d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__inline_pk_snapshot@inline_pk_Gorm.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..8738e1bf --- /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 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class EdgeState(models.IntegerChoices): + UNKNOWN = -1 + NOT_STARTED = 0 + IN_PROGRESS = 10 + HTTP_500 = 500 + +class WorkflowRuns(models.Model): + id = models.IntegerField(primary_key=True) + state = models.IntegerField(choices=EdgeState.choices) + + class Meta: + managed = False + 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..b96b5aca --- /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,21 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..e54f4909 --- /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 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class TaskStatus(models.IntegerChoices): + PENDING = 0 + COMPLETED = 100 + +class Tasks(models.Model): + id = models.IntegerField() + status = models.IntegerField(choices=TaskStatus.choices, default=1) + + class Meta: + managed = False + 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..0ff275f6 --- /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,19 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..bdc0c413 --- /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 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class TaskRunStatus(models.IntegerChoices): + PENDING = 0 + COMPLETED = 100 + +class TaskRuns(models.Model): + id = models.IntegerField() + status = models.IntegerField(choices=TaskRunStatus.choices) + + class Meta: + managed = False + 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..690be71a --- /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,19 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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:100" 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..5ad4bd22 --- /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 +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: + managed = False + 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..6641de16 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__json_default_snapshot@json_default_Gorm.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..bc483096 --- /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 +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.JSONField() + jsonb_nullable = models.JSONField(null=True, blank=True) + + class Meta: + managed = False + 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..8a994feb --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__jsonb_custom_type_snapshot@jsonb_custom_type_Gorm.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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__junction_over_composite_key_snapshot@junction_over_composite_key_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Django.snap new file mode 100644 index 00000000..273e4222 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Django.snap @@ -0,0 +1,35 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class User(models.Model): + id = models.IntegerField(primary_key=True) + + class Meta: + managed = False + db_table = "user" + +class Article(models.Model): + pk = models.CompositePrimaryKey("media_id", "id") + media_id = models.IntegerField() + id = models.IntegerField() + + class Meta: + managed = False + db_table = "article" + +class ArticleUser(models.Model): + pk = models.CompositePrimaryKey("media_id", "article_id", "user_id") + media_id = models.IntegerField() + article_id = models.IntegerField() + user = models.ForeignKey("User", on_delete=models.RESTRICT, related_name="+") + # composite foreign key: (media_id, article_id) -> article(media_id, id) + + class Meta: + managed = False + db_table = "article_user" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Drizzle_pg.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Drizzle_pg.snap new file mode 100644 index 00000000..7116b542 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Drizzle_pg.snap @@ -0,0 +1,37 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +export const user = pgTable("user", { + id: integer("id").primaryKey(), +}); + +export const userRelations = relations(user, ({ one, many }) => ({ + articleUser: many(articleUser), +})); + +export const article = pgTable("article", { + mediaId: integer("media_id").notNull(), + id: integer("id").notNull(), +}, (t) => [ + primaryKey({ name: "article_pkey", columns: [t.mediaId, t.id] }), +]); + +export const articleRelations = relations(article, ({ one, many }) => ({ + articleUser: many(articleUser), +})); + +export const articleUser = pgTable("article_user", { + mediaId: integer("media_id").notNull(), + articleId: integer("article_id").notNull(), + userId: integer("user_id").notNull(), +}, (t) => [ + primaryKey({ name: "article_user_pkey", columns: [t.mediaId, t.articleId, t.userId] }), + foreignKey({ columns: [t.mediaId, t.articleId], foreignColumns: [article.mediaId, article.id], name: "fk_article_user__article_id_media_id" }), + foreignKey({ columns: [t.userId], foreignColumns: [user.id], name: "fk_article_user__user_id" }), +]); + +export const articleUserRelations = relations(articleUser, ({ one, many }) => ({ + mediaArticle: one(article, { fields: [articleUser.mediaId, articleUser.articleId], references: [article.mediaId, article.id] }), + user: one(user, { fields: [articleUser.userId], references: [user.id] }), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Gorm.snap new file mode 100644 index 00000000..eebf0d95 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Gorm.snap @@ -0,0 +1,30 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type User struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + ArticleUsers []ArticleUser `gorm:"foreignKey:UserID" json:"-"` +} + +func (User) TableName() string { return "user" } + +type Article struct { + MediaID int32 `gorm:"column:media_id;primaryKey" json:"media_id"` + ID int32 `gorm:"column:id;primaryKey" json:"id"` + ArticleUsers []ArticleUser `gorm:"foreignKey:MediaID,ArticleID;references:MediaID,ID" json:"-"` +} + +func (Article) TableName() string { return "article" } + +type ArticleUser struct { + MediaID int32 `gorm:"column:media_id;primaryKey" json:"media_id"` + ArticleID int32 `gorm:"column:article_id;primaryKey" json:"article_id"` + UserID int32 `gorm:"column:user_id;primaryKey" json:"user_id"` + User *User `gorm:"foreignKey:UserID" json:"-"` + Article *Article `gorm:"foreignKey:MediaID,ArticleID;references:MediaID,ID" json:"-"` +} + +func (ArticleUser) TableName() string { return "article_user" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Jpa.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Jpa.snap new file mode 100644 index 00000000..5d2453ee --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Jpa.snap @@ -0,0 +1,58 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +import jakarta.persistence.*; + +@Entity +@Table(name = "user") +public class User { + + @Id + @Column(name = "id") + private Integer id; + + protected User() { + } +} + +import jakarta.persistence.*; + +@Entity +@Table(name = "article") +public class Article { + + @Id + @Column(name = "media_id") + private Integer mediaId; + + @Id + @Column(name = "id") + private Integer id; + + protected Article() { + } +} + +import jakarta.persistence.*; + +@Entity +@Table(name = "article_user") +public class ArticleUser { + + @Id + @Column(name = "media_id") + private Integer mediaId; + + @Id + @Column(name = "article_id") + private Integer articleId; + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + protected ArticleUser() { + } +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Prisma.snap new file mode 100644 index 00000000..4e823aac --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Prisma.snap @@ -0,0 +1,30 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model User { + id Int @id + article_user ArticleUser[] + + @@map("user") +} + +model Article { + media_id Int + id Int + article_user ArticleUser[] + + @@id([media_id, id]) + @@map("article") +} + +model ArticleUser { + media_id Int + article_id Int + user_id Int + user User @relation(fields: [user_id], references: [id], onDelete: NoAction, onUpdate: NoAction) + article Article @relation(fields: [media_id, article_id], references: [media_id, id], onDelete: NoAction, onUpdate: NoAction) + + @@id([media_id, article_id, user_id]) + @@map("article_user") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_SeaOrm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_SeaOrm.snap new file mode 100644 index 00000000..04018f70 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_SeaOrm.snap @@ -0,0 +1,62 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "user")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(has_many)] + pub article_users: HasMany, + #[sea_orm(has_many, via = "article_user")] + pub articles_via_article_user: HasMany, +} + +vespera::schema_type!(Schema from Model, name = "UserSchema"); +impl ActiveModelBehavior for ActiveModel {} + + +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "article")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub media_id: i32, + #[sea_orm(primary_key, auto_increment = false)] + pub id: i32, + #[sea_orm(has_many)] + pub article_users: HasMany, + #[sea_orm(has_many, via = "article_user")] + pub users_via_article_user: HasMany, +} + +vespera::schema_type!(Schema from Model, name = "ArticleSchema"); +impl ActiveModelBehavior for ActiveModel {} + + +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "article_user")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub media_id: i32, + #[sea_orm(primary_key, auto_increment = false)] + pub article_id: i32, + #[sea_orm(primary_key, auto_increment = false)] + pub user_id: i32, + #[sea_orm(belongs_to, from = "(media_id, article_id)", to = "(media_id, id)")] + pub article: HasOne, + #[sea_orm(belongs_to, from = "user_id", to = "id")] + pub user: HasOne, +} + +vespera::schema_type!(Schema from Model, name = "ArticleUserSchema"); +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_SqlAlchemy.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_SqlAlchemy.snap new file mode 100644 index 00000000..51e91100 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_SqlAlchemy.snap @@ -0,0 +1,32 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + + +from sqlalchemy import ForeignKey, ForeignKeyConstraint, Integer +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class User(DeclarativeBase): + __tablename__ = "user" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + +class Article(DeclarativeBase): + __tablename__ = "article" + + media_id: Mapped[int] = mapped_column(Integer, primary_key=True) + id: Mapped[int] = mapped_column(Integer, primary_key=True) + +class ArticleUser(DeclarativeBase): + __tablename__ = "article_user" + + media_id: Mapped[int] = mapped_column(Integer, primary_key=True) + article_id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("user.id"), primary_key=True) + + __table_args__ = ( + ForeignKeyConstraint(["media_id", "article_id"], ["article.media_id", "article.id"]), + ) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_SqlModel.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_SqlModel.snap new file mode 100644 index 00000000..a0ec1a3f --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_SqlModel.snap @@ -0,0 +1,32 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + + +from sqlmodel import Field, SQLModel +from sqlalchemy import ForeignKeyConstraint + + +class User(SQLModel, table=True): + __tablename__ = "user" + + id: int = Field(primary_key=True) + +class Article(SQLModel, table=True): + __tablename__ = "article" + + media_id: int = Field(primary_key=True) + id: int = Field(primary_key=True) + +class ArticleUser(SQLModel, table=True): + __tablename__ = "article_user" + + media_id: int = Field(primary_key=True) + article_id: int = Field(primary_key=True) + user_id: int = Field(primary_key=True, foreign_key="user.id") + + __table_args__ = ( + ForeignKeyConstraint(["media_id", "article_id"], ["article.media_id", "article.id"]), + ) 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..d44428bf --- /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 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class NoDesc(models.Model): + id = models.IntegerField(primary_key=True) + + class Meta: + managed = False + 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..0c576b1b --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__no_description_snapshot@no_description_Gorm.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..112cf22b --- /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,25 @@ +--- +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: + managed = False + db_table = "membership" + indexes = [ + models.Index(fields=["_3created"], name="ix_membership__3created"), + ] + constraints = [ + models.UniqueConstraint(fields=["user_email", "_1tenant_id"], name="uq_membership__1tenant_id_user-email"), + ] 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..ee7e3dc9 --- /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 { + X1tenantID int32 `gorm:"column:1tenant_id;primaryKey;uniqueIndex:uq_membership__1tenant_id_user-email" json:"1tenant_id"` + X2userID int32 `gorm:"column:2user_id;primaryKey" json:"2user_id"` + User_email string `gorm:"column:user-email;not null;type:text;uniqueIndex:uq_membership__1tenant_id_user-email" json:"user-email"` + X3created string `gorm:"column:3created;not null;type:text;index:ix_membership__3created" 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..bfcd9230 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_snapshot@non_identifier_names_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 x1users(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("x1users", on_delete=models.RESTRICT, db_column="1st_owner_id", related_name="+", null=True, blank=True) + + class Meta: + managed = False + 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..b1742e56 --- /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 X1users struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + X1stPlace *int32 `gorm:"column:1st_place" json:"1st_place"` + User_id *string `gorm:"column:user-id;type:text" json:"user-id"` + X1stOwnerID *int32 `gorm:"column:1st_owner_id" json:"1st_owner_id"` + X1stOwner *X1users `gorm:"foreignKey:X1stOwnerID" json:"-"` +} + +func (X1users) 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..2e2b522b --- /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,25 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class x1users(models.Model): + _1id = models.IntegerField(db_column="1id", primary_key=True) + email = models.TextField() + + class Meta: + managed = False + db_table = "1users" + +class Posts(models.Model): + id = models.IntegerField(primary_key=True) + _1st_owner = models.ForeignKey("x1users", on_delete=models.RESTRICT, db_column="1st_owner_id", related_name="+", null=True, blank=True) + _2nd_owner = models.ForeignKey("x1users", on_delete=models.RESTRICT, db_column="2nd_owner_id", related_name="+", null=True, blank=True) + + class Meta: + managed = False + 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..bfa86e0d --- /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,24 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type X1users struct { + X1id int32 `gorm:"column:1id;primaryKey" json:"1id"` + Email string `gorm:"column:email;not null;type:text" json:"email"` + PostsByX1stOwnerID []Posts `gorm:"foreignKey:X1stOwnerID" json:"-"` + PostsByX2ndOwnerID []Posts `gorm:"foreignKey:X2ndOwnerID" json:"-"` +} + +func (X1users) TableName() string { return "1users" } + +type Posts struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + X1stOwnerID *int32 `gorm:"column:1st_owner_id" json:"1st_owner_id"` + X1stOwner *X1users `gorm:"foreignKey:X1stOwnerID" json:"-"` + X2ndOwnerID *int32 `gorm:"column:2nd_owner_id" json:"2nd_owner_id"` + X2ndOwner *X1users `gorm:"foreignKey:X2ndOwnerID" 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..07b5abc5 --- /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 +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: + managed = False + 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..bfa63a61 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..3c3439b1 --- /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 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class StatusType(models.TextChoices): + ACTIVE = "active" + 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: + managed = False + 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..4ae98892 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_enum_snapshot@nullable_enum_Gorm.snap @@ -0,0 +1,19 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..a44cdbda --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__numeric_default_value_snapshot@numeric_default_value_Django.snap @@ -0,0 +1,16 @@ +--- +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: + managed = False + 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..0dd91868 --- /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..9e55add3 --- /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 +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: + managed = False + db_table = "article_user" + indexes = [ + models.Index(fields=["article_id"], name="ix_article_user__article_id"), + models.Index(fields=["user_id"], name="ix_article_user__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..49a064a3 --- /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,24 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type ArticleUser struct { + ArticleID uuid.UUID `gorm:"column:article_id;primaryKey;type:uuid;index:ix_article_user__article_id" json:"article_id"` + Article *Article `gorm:"foreignKey:ArticleID;constraint:OnDelete:CASCADE" json:"-"` + UserID uuid.UUID `gorm:"column:user_id;primaryKey;type:uuid;index:ix_article_user__user_id" 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__python_reserved_names_snapshot@python_reserved_names_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Django.snap new file mode 100644 index 00000000..4fd67ca1 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Django.snap @@ -0,0 +1,22 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class EventLog(models.Model): + id = models.IntegerField(primary_key=True) + from_field = models.TextField(db_column="from") + def_field = models.TextField(db_column="def") + pk_field = models.IntegerField(db_column="pk") + user_name = models.TextField(db_column="user__name") + total_field = models.IntegerField(db_column="total_") + a_b = models.TextField(db_column="a--b") + pass_field = models.ForeignKey("Targets", on_delete=models.RESTRICT, db_column="pass_id", related_name="+") + + class Meta: + managed = False + db_table = "event__log" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Drizzle_pg.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Drizzle_pg.snap new file mode 100644 index 00000000..1c5f9674 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Drizzle_pg.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +export const eventLog = pgTable("event__log", { + id: integer("id").primaryKey(), + from: text("from").notNull(), + def: text("def").notNull(), + pk: integer("pk").notNull(), + userName: text("user__name").notNull(), + total: integer("total_").notNull(), + aB: text("a--b").notNull(), + passId: integer("pass_id").notNull(), +}, (t) => [ + foreignKey({ columns: [t.passId], foreignColumns: [targets.id], name: "fk_event__log__pass_id" }), +]); + +export const eventLogRelations = relations(eventLog, ({ one, many }) => ({ + pass: one(targets, { fields: [eventLog.passId], references: [targets.id] }), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Gorm.snap new file mode 100644 index 00000000..3726c235 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Gorm.snap @@ -0,0 +1,19 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type EventLog struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + From string `gorm:"column:from;not null;type:text" json:"from"` + Def string `gorm:"column:def;not null;type:text" json:"def"` + Pk int32 `gorm:"column:pk;not null" json:"pk"` + UserName string `gorm:"column:user__name;not null;type:text" json:"user__name"` + Total int32 `gorm:"column:total_;not null" json:"total_"` + A__b string `gorm:"column:a--b;not null;type:text" json:"a--b"` + PassID int32 `gorm:"column:pass_id;not null" json:"pass_id"` + Pass *Targets `gorm:"foreignKey:PassID" json:"-"` +} + +func (EventLog) TableName() string { return "event__log" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Jpa.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Jpa.snap new file mode 100644 index 00000000..6ec08eca --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Jpa.snap @@ -0,0 +1,39 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +import jakarta.persistence.*; + +@Entity +@Table(name = "event__log") +public class EventLog { + + @Id + @Column(name = "id") + private Integer id; + + @Column(name = "from", nullable = false, columnDefinition = "TEXT") + private String from; + + @Column(name = "def", nullable = false, columnDefinition = "TEXT") + private String def; + + @Column(name = "pk", nullable = false) + private Integer pk; + + @Column(name = "user__name", nullable = false, columnDefinition = "TEXT") + private String userName; + + @Column(name = "total_", nullable = false) + private Integer total; + + @Column(name = "a--b", nullable = false, columnDefinition = "TEXT") + private String a__b; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "pass_id", nullable = false) + private Targets pass; + + protected EventLog() { + } +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Prisma.snap new file mode 100644 index 00000000..60e78c8f --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Prisma.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model EventLog { + id Int @id + from String + def String + pk Int + user__name String + total_ Int + a__b String @map("a--b") + pass_id Int + pass Targets @relation(fields: [pass_id], references: [id], onDelete: NoAction, onUpdate: NoAction) + + @@map("event__log") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_SeaOrm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_SeaOrm.snap new file mode 100644 index 00000000..ebf8f7d1 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_SeaOrm.snap @@ -0,0 +1,26 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "event__log")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub from: String, + pub def: String, + pub pk: i32, + pub user__name: String, + pub total_: i32, + #[sea_orm(column_name = "a--b")] + pub a__b: String, + pub pass_id: i32, + #[sea_orm(belongs_to, from = "pass_id", to = "id")] + pub pass: HasOne, +} + +vespera::schema_type!(Schema from Model, name = "EventLogSchema"); +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_SqlAlchemy.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_SqlAlchemy.snap new file mode 100644 index 00000000..a9293e2f --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_SqlAlchemy.snap @@ -0,0 +1,22 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + + +from sqlalchemy import ForeignKey, Integer, Text +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class EventLog(DeclarativeBase): + __tablename__ = "event__log" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + from_: Mapped[str] = mapped_column("from", Text, nullable=False) + def_: Mapped[str] = mapped_column("def", Text, nullable=False) + pk: Mapped[int] = mapped_column(Integer, nullable=False) + user__name: Mapped[str] = mapped_column(Text, nullable=False) + total_: Mapped[int] = mapped_column(Integer, nullable=False) + a__b: Mapped[str] = mapped_column("a--b", Text, nullable=False) + pass_id: Mapped[int] = mapped_column(Integer, ForeignKey("targets.id"), nullable=False) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_SqlModel.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_SqlModel.snap new file mode 100644 index 00000000..6f99db63 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_SqlModel.snap @@ -0,0 +1,21 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + + +from sqlmodel import Field, SQLModel + + +class EventLog(SQLModel, table=True): + __tablename__ = "event__log" + + id: int = Field(primary_key=True) + from_: str = Field(sa_column_kwargs={"name": "from"}) + def_: str = Field(sa_column_kwargs={"name": "def"}) + pk: int = Field(...) + user__name: str = Field(...) + total_: int = Field(...) + a__b: str = Field(sa_column_kwargs={"name": "a--b"}) + pass_id: int = Field(foreign_key="targets.id") diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Django.snap new file mode 100644 index 00000000..b2b46ed4 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Django.snap @@ -0,0 +1,32 @@ +--- +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: + managed = False + db_table = "users" + +class Posts(models.Model): + id = models.IntegerField(primary_key=True) + user = models.ForeignKey("Users", on_delete=models.CASCADE, related_name="+") + + class Meta: + managed = False + db_table = "posts" + +class Comments(models.Model): + id = models.IntegerField(primary_key=True) + post = models.ForeignKey("Posts", on_delete=models.DO_NOTHING, related_name="+", null=True, blank=True) + author = models.ForeignKey("Users", on_delete=models.SET_DEFAULT, default=1, related_name="+") + + class Meta: + managed = False + db_table = "comments" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Drizzle_pg.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Drizzle_pg.snap new file mode 100644 index 00000000..4276eac6 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Drizzle_pg.snap @@ -0,0 +1,38 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +export const users = pgTable("users", { + id: integer("id").primaryKey(), +}); + +export const usersRelations = relations(users, ({ one, many }) => ({ + posts: many(posts), + comments: many(comments), +})); + +export const posts = pgTable("posts", { + id: integer("id").primaryKey(), + userId: integer("user_id").notNull(), +}, (t) => [ + foreignKey({ columns: [t.userId], foreignColumns: [users.id], name: "fk_posts__user_id" }).onDelete("cascade").onUpdate("restrict"), +]); + +export const postsRelations = relations(posts, ({ one, many }) => ({ + user: one(users, { fields: [posts.userId], references: [users.id] }), + comments: many(comments), +})); + +export const comments = pgTable("comments", { + id: integer("id").primaryKey(), + postId: integer("post_id"), + authorId: integer("author_id").notNull().default(1), +}, (t) => [ + foreignKey({ columns: [t.postId], foreignColumns: [posts.id], name: "fk_comments__post_id" }).onDelete("set default").onUpdate("no action"), + foreignKey({ columns: [t.authorId], foreignColumns: [users.id], name: "fk_comments__author_id" }).onDelete("set default").onUpdate("cascade"), +]); + +export const commentsRelations = relations(comments, ({ one, many }) => ({ + post: one(posts, { fields: [comments.postId], references: [posts.id] }), + author: one(users, { fields: [comments.authorId], references: [users.id] }), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Gorm.snap new file mode 100644 index 00000000..91afa408 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Gorm.snap @@ -0,0 +1,32 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Users struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Posts []Posts `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE,OnUpdate:RESTRICT" json:"-"` + Comments []Comments `gorm:"foreignKey:AuthorID;constraint:OnDelete:SET DEFAULT,OnUpdate:CASCADE" json:"-"` +} + +func (Users) TableName() string { return "users" } + +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;constraint:OnDelete:CASCADE,OnUpdate:RESTRICT" json:"-"` + Comments []Comments `gorm:"foreignKey:PostID;constraint:OnDelete:SET DEFAULT,OnUpdate:NO ACTION" json:"-"` +} + +func (Posts) TableName() string { return "posts" } + +type Comments struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + PostID *int32 `gorm:"column:post_id" json:"post_id"` + Post *Posts `gorm:"foreignKey:PostID;constraint:OnDelete:SET DEFAULT,OnUpdate:NO ACTION" json:"-"` + AuthorID int32 `gorm:"column:author_id;not null;default:1" json:"author_id"` + Author *Users `gorm:"foreignKey:AuthorID;constraint:OnDelete:SET DEFAULT,OnUpdate:CASCADE" json:"-"` +} + +func (Comments) TableName() string { return "comments" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Jpa.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Jpa.snap new file mode 100644 index 00000000..815a986d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Jpa.snap @@ -0,0 +1,57 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +import jakarta.persistence.*; + +@Entity +@Table(name = "users") +public class Users { + + @Id + @Column(name = "id") + private Integer id; + + protected Users() { + } +} + +import jakarta.persistence.*; + +@Entity +@Table(name = "posts") +public class Posts { + + @Id + @Column(name = "id") + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private Users user; + + protected Posts() { + } +} + +import jakarta.persistence.*; + +@Entity +@Table(name = "comments") +public class Comments { + + @Id + @Column(name = "id") + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") + private Posts post; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "author_id", nullable = false) + private Users author; + + protected Comments() { + } +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Prisma.snap new file mode 100644 index 00000000..435d31d2 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Prisma.snap @@ -0,0 +1,30 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Users { + id Int @id + posts Posts[] + comments Comments[] + + @@map("users") +} + +model Posts { + id Int @id + user_id Int + user Users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: Restrict) + comments Comments[] + + @@map("posts") +} + +model Comments { + id Int @id + post_id Int? + post Posts? @relation(fields: [post_id], references: [id], onDelete: SetDefault, onUpdate: NoAction) + author_id Int @default(1) + author Users @relation(fields: [author_id], references: [id], onDelete: SetDefault, onUpdate: Cascade) + + @@map("comments") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_SeaOrm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_SeaOrm.snap new file mode 100644 index 00000000..61063a99 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_SeaOrm.snap @@ -0,0 +1,60 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "users")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(has_many)] + pub posts: HasMany, + #[sea_orm(has_many)] + pub comments: HasMany, +} + +vespera::schema_type!(Schema from Model, name = "UsersSchema"); +impl ActiveModelBehavior for ActiveModel {} + + +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "posts")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub user_id: i32, + #[sea_orm(belongs_to, from = "user_id", to = "id")] + pub user: HasOne, + #[sea_orm(has_many)] + pub comments: HasMany, +} + +vespera::schema_type!(Schema from Model, name = "PostsSchema"); +impl ActiveModelBehavior for ActiveModel {} + + +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "comments")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub post_id: Option, + #[sea_orm(default_value = 1)] + pub author_id: i32, + #[sea_orm(belongs_to, from = "post_id", to = "id")] + pub post: HasOne, + #[sea_orm(belongs_to, from = "author_id", to = "id")] + pub author: HasOne, +} + +vespera::schema_type!(Schema from Model, name = "CommentsSchema"); +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_SqlAlchemy.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_SqlAlchemy.snap new file mode 100644 index 00000000..0bc9b096 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_SqlAlchemy.snap @@ -0,0 +1,29 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from typing import Optional + +from sqlalchemy import ForeignKey, Integer +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Users(DeclarativeBase): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + +class Posts(DeclarativeBase): + __tablename__ = "posts" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False) + +class Comments(DeclarativeBase): + __tablename__ = "comments" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + post_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("posts.id"), nullable=True) + author_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, server_default="1") diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_SqlModel.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_SqlModel.snap new file mode 100644 index 00000000..52c71bbd --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_SqlModel.snap @@ -0,0 +1,28 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from typing import Optional + +from sqlmodel import Field, SQLModel + + +class Users(SQLModel, table=True): + __tablename__ = "users" + + id: int = Field(primary_key=True) + +class Posts(SQLModel, table=True): + __tablename__ = "posts" + + id: int = Field(primary_key=True) + user_id: int = Field(foreign_key="users.id") + +class Comments(SQLModel, table=True): + __tablename__ = "comments" + + id: int = Field(primary_key=True) + post_id: Optional[int] = Field(default=None, foreign_key="posts.id") + author_id: int = Field(default=1, foreign_key="users.id") diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Django.snap new file mode 100644 index 00000000..ed9dc949 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Django.snap @@ -0,0 +1,54 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class OrderRegions(models.Model): + pk = models.CompositePrimaryKey("order_id", "region_id") + order_id = models.IntegerField() + region_id = models.IntegerField() + + class Meta: + managed = False + db_table = "order_regions" + +class OrderItems(models.Model): + id = models.IntegerField(primary_key=True) + order_id = models.IntegerField() + region_id = models.IntegerField() + order_regions = models.TextField() + order_regions2 = models.TextField() + # composite foreign key: (order_id, region_id) -> order_regions(order_id, region_id) + + class Meta: + managed = False + db_table = "order_items" + +class Users(models.Model): + id = models.IntegerField(primary_key=True) + posts = models.TextField() + + class Meta: + managed = False + db_table = "users" + +class Posts(models.Model): + id = models.IntegerField(primary_key=True) + user = models.ForeignKey("Users", on_delete=models.RESTRICT, related_name="+") + table_name = models.ForeignKey("Categories", on_delete=models.RESTRICT, related_name="+") + + class Meta: + managed = False + db_table = "posts" + +class Categories(models.Model): + id = models.IntegerField(primary_key=True) + parent = models.ForeignKey("Categories", on_delete=models.RESTRICT, related_name="+", null=True, blank=True) + + class Meta: + managed = False + db_table = "categories" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Drizzle_pg.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Drizzle_pg.snap new file mode 100644 index 00000000..dc566826 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Drizzle_pg.snap @@ -0,0 +1,64 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +export const orderRegions = pgTable("order_regions", { + orderId: integer("order_id").notNull(), + regionId: integer("region_id").notNull(), +}, (t) => [ + primaryKey({ name: "order_regions_pkey", columns: [t.orderId, t.regionId] }), +]); + +export const orderRegionsRelations = relations(orderRegions, ({ one, many }) => ({ + orderItems: many(orderItems), +})); + +export const orderItems = pgTable("order_items", { + id: integer("id").primaryKey(), + orderId: integer("order_id").notNull(), + regionId: integer("region_id").notNull(), + orderRegions: text("order_regions").notNull(), + orderRegions2: text("order_regions2").notNull(), +}, (t) => [ + foreignKey({ columns: [t.orderId, t.regionId], foreignColumns: [orderRegions.orderId, orderRegions.regionId], name: "fk_order_items__order_id_region_id" }).onDelete("cascade").onUpdate("restrict"), +]); + +export const orderItemsRelations = relations(orderItems, ({ one, many }) => ({ + orderRegion: one(orderRegions, { fields: [orderItems.orderId, orderItems.regionId], references: [orderRegions.orderId, orderRegions.regionId] }), +})); + +export const users = pgTable("users", { + id: integer("id").primaryKey(), + posts: text("posts").notNull(), +}); + +export const usersRelations = relations(users, ({ one, many }) => ({ + posts_rel: many(posts), +})); + +export const posts = pgTable("posts", { + id: integer("id").primaryKey(), + userId: integer("user_id").notNull(), + tableNameId: integer("table_name_id").notNull(), +}, (t) => [ + foreignKey({ columns: [t.userId], foreignColumns: [users.id], name: "fk_posts__user_id" }), + foreignKey({ columns: [t.tableNameId], foreignColumns: [categories.id], name: "fk_posts__table_name_id" }), +]); + +export const postsRelations = relations(posts, ({ one, many }) => ({ + user: one(users, { fields: [posts.userId], references: [users.id] }), + tableName: one(categories, { fields: [posts.tableNameId], references: [categories.id] }), +})); + +export const categories = pgTable("categories", { + id: integer("id").primaryKey(), + parentId: integer("parent_id"), +}, (t) => [ + foreignKey({ columns: [t.parentId], foreignColumns: [t.id], name: "fk_categories__parent_id" }), +]); + +export const categoriesRelations = relations(categories, ({ one, many }) => ({ + parent: one(categories, { fields: [categories.parentId], references: [categories.id], relationName: "CategoriesParent" }), + posts: many(posts), + parentCategories: many(categories, { relationName: "CategoriesParent" }), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Gorm.snap new file mode 100644 index 00000000..47c79db0 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Gorm.snap @@ -0,0 +1,52 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type OrderRegions struct { + OrderID int32 `gorm:"column:order_id;primaryKey" json:"order_id"` + RegionID int32 `gorm:"column:region_id;primaryKey" json:"region_id"` + OrderItems []OrderItems `gorm:"foreignKey:OrderID,RegionID;references:OrderID,RegionID;constraint:OnDelete:CASCADE,OnUpdate:RESTRICT" json:"-"` +} + +func (OrderRegions) TableName() string { return "order_regions" } + +type OrderItems struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + OrderID int32 `gorm:"column:order_id;not null" json:"order_id"` + RegionID int32 `gorm:"column:region_id;not null" json:"region_id"` + OrderRegions string `gorm:"column:order_regions;not null;type:text" json:"order_regions"` + OrderRegions2 string `gorm:"column:order_regions2;not null;type:text" json:"order_regions2"` + OrderRegions3 *OrderRegions `gorm:"foreignKey:OrderID,RegionID;references:OrderID,RegionID;constraint:OnDelete:CASCADE,OnUpdate:RESTRICT" json:"-"` +} + +func (OrderItems) TableName() string { return "order_items" } + +type Users struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Posts string `gorm:"column:posts;not null;type:text" json:"posts"` + Posts2 []Posts `gorm:"foreignKey:UserID" json:"-"` +} + +func (Users) TableName() string { return "users" } + +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:"-"` + TableNameID int32 `gorm:"column:table_name_id;not null" json:"table_name_id"` + TableName2 *Categories `gorm:"foreignKey:TableNameID" json:"-"` +} + +func (Posts) TableName() string { return "posts" } + +type Categories struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + ParentID *int32 `gorm:"column:parent_id" json:"parent_id"` + Parent *Categories `gorm:"foreignKey:ParentID" json:"-"` + Posts []Posts `gorm:"foreignKey:TableNameID" json:"-"` + Children []Categories `gorm:"foreignKey:ParentID" json:"-"` +} + +func (Categories) TableName() string { return "categories" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Jpa.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Jpa.snap new file mode 100644 index 00000000..44ce8612 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Jpa.snap @@ -0,0 +1,104 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +import jakarta.persistence.*; + +@Entity +@Table(name = "order_regions") +public class OrderRegions { + + @Id + @Column(name = "order_id") + private Integer orderId; + + @Id + @Column(name = "region_id") + private Integer regionId; + + protected OrderRegions() { + } +} + +import jakarta.persistence.*; + +@Entity +@Table(name = "order_items") +public class OrderItems { + + @Id + @Column(name = "id") + private Integer id; + + @Column(name = "order_id", nullable = false) + private Integer orderId; + + @Column(name = "region_id", nullable = false) + private Integer regionId; + + @Column(name = "order_regions", nullable = false, columnDefinition = "TEXT") + private String orderRegions; + + @Column(name = "order_regions2", nullable = false, columnDefinition = "TEXT") + private String orderRegions2; + + protected OrderItems() { + } +} + +import jakarta.persistence.*; + +@Entity +@Table(name = "users") +public class Users { + + @Id + @Column(name = "id") + private Integer id; + + @Column(name = "posts", nullable = false, columnDefinition = "TEXT") + private String posts; + + protected Users() { + } +} + +import jakarta.persistence.*; + +@Entity +@Table(name = "posts") +public class Posts { + + @Id + @Column(name = "id") + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private Users user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "table_name_id", nullable = false) + private Categories tableName; + + protected Posts() { + } +} + +import jakarta.persistence.*; + +@Entity +@Table(name = "categories") +public class Categories { + + @Id + @Column(name = "id") + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id") + private Categories parent; + + protected Categories() { + } +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Prisma.snap new file mode 100644 index 00000000..3b64d2f8 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Prisma.snap @@ -0,0 +1,51 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model OrderRegions { + order_id Int + region_id Int + order_items OrderItems[] + + @@id([order_id, region_id]) + @@map("order_regions") +} + +model OrderItems { + id Int @id + order_id Int + region_id Int + order_regions String + order_regions2 String + order_regions_rel OrderRegions @relation(fields: [order_id, region_id], references: [order_id, region_id], onDelete: Cascade, onUpdate: Restrict) + + @@map("order_items") +} + +model Users { + id Int @id + posts String + posts_rel Posts[] + + @@map("users") +} + +model Posts { + id Int @id + user_id Int + user Users @relation(fields: [user_id], references: [id], onDelete: NoAction, onUpdate: NoAction) + table_name_id Int + table_name Categories @relation(fields: [table_name_id], references: [id], onDelete: NoAction, onUpdate: NoAction) + + @@map("posts") +} + +model Categories { + id Int @id + parent_id Int? + parent Categories? @relation("CategoriesParent", fields: [parent_id], references: [id], onDelete: NoAction, onUpdate: NoAction) + posts Posts[] + parent_categories Categories[] @relation("CategoriesParent") + + @@map("categories") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_SeaOrm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_SeaOrm.snap new file mode 100644 index 00000000..5d1383ee --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_SeaOrm.snap @@ -0,0 +1,96 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "order_regions")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub order_id: i32, + #[sea_orm(primary_key, auto_increment = false)] + pub region_id: i32, + #[sea_orm(has_many)] + pub order_items: HasMany, +} + +vespera::schema_type!(Schema from Model, name = "OrderRegionsSchema"); +impl ActiveModelBehavior for ActiveModel {} + + +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "order_items")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub order_id: i32, + pub region_id: i32, + pub order_regions: String, + pub order_regions2: String, + #[sea_orm(belongs_to, from = "(order_id, region_id)", to = "(order_id, region_id)")] + pub order_regions_1: HasOne, +} + +vespera::schema_type!(Schema from Model, name = "OrderItemsSchema"); +impl ActiveModelBehavior for ActiveModel {} + + +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "users")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub posts: String, + #[sea_orm(has_many)] + pub posts_1: HasMany, +} + +vespera::schema_type!(Schema from Model, name = "UsersSchema"); +impl ActiveModelBehavior for ActiveModel {} + + +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "posts")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub user_id: i32, + pub table_name_id: i32, + #[sea_orm(belongs_to, from = "user_id", to = "id")] + pub user: HasOne, + #[sea_orm(belongs_to, from = "table_name_id", to = "id")] + pub table_name: HasOne, +} + +vespera::schema_type!(Schema from Model, name = "PostsSchema"); +impl ActiveModelBehavior for ActiveModel {} + + +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "categories")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub parent_id: Option, + #[sea_orm(belongs_to, from = "parent_id", to = "id")] + pub parent: HasOne, + #[sea_orm(has_many)] + pub posts: HasMany, +} + +vespera::schema_type!(Schema from Model, name = "CategoriesSchema"); +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_SqlAlchemy.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_SqlAlchemy.snap new file mode 100644 index 00000000..fe9b9bfb --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_SqlAlchemy.snap @@ -0,0 +1,49 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from typing import Optional + +from sqlalchemy import ForeignKey, ForeignKeyConstraint, Integer, Text +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class OrderRegions(DeclarativeBase): + __tablename__ = "order_regions" + + order_id: Mapped[int] = mapped_column(Integer, primary_key=True) + region_id: Mapped[int] = mapped_column(Integer, primary_key=True) + +class OrderItems(DeclarativeBase): + __tablename__ = "order_items" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + order_id: Mapped[int] = mapped_column(Integer, nullable=False) + region_id: Mapped[int] = mapped_column(Integer, nullable=False) + order_regions: Mapped[str] = mapped_column(Text, nullable=False) + order_regions2: Mapped[str] = mapped_column(Text, nullable=False) + + __table_args__ = ( + ForeignKeyConstraint(["order_id", "region_id"], ["order_regions.order_id", "order_regions.region_id"]), + ) + +class Users(DeclarativeBase): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + posts: Mapped[str] = mapped_column(Text, nullable=False) + +class Posts(DeclarativeBase): + __tablename__ = "posts" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False) + table_name_id: Mapped[int] = mapped_column(Integer, ForeignKey("categories.id"), nullable=False) + +class Categories(DeclarativeBase): + __tablename__ = "categories" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + parent_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("categories.id"), nullable=True) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_SqlModel.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_SqlModel.snap new file mode 100644 index 00000000..7cfc6aca --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_SqlModel.snap @@ -0,0 +1,49 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from typing import Optional + +from sqlmodel import Field, SQLModel +from sqlalchemy import ForeignKeyConstraint + + +class OrderRegions(SQLModel, table=True): + __tablename__ = "order_regions" + + order_id: int = Field(primary_key=True) + region_id: int = Field(primary_key=True) + +class OrderItems(SQLModel, table=True): + __tablename__ = "order_items" + + id: int = Field(primary_key=True) + order_id: int = Field(...) + region_id: int = Field(...) + order_regions: str = Field(...) + order_regions2: str = Field(...) + + __table_args__ = ( + ForeignKeyConstraint(["order_id", "region_id"], ["order_regions.order_id", "order_regions.region_id"]), + ) + +class Users(SQLModel, table=True): + __tablename__ = "users" + + id: int = Field(primary_key=True) + posts: str = Field(...) + +class Posts(SQLModel, table=True): + __tablename__ = "posts" + + id: int = Field(primary_key=True) + user_id: int = Field(foreign_key="users.id") + table_name_id: int = Field(foreign_key="categories.id") + +class Categories(SQLModel, table=True): + __tablename__ = "categories" + + id: int = Field(primary_key=True) + parent_id: Optional[int] = Field(default=None, foreign_key="categories.id") 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..1d81d165 --- /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,23 @@ +--- +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: + managed = False + 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: + managed = False + 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..142def26 --- /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,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Users struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Items []Items `gorm:"foreignKey:Owner" json:"-"` +} + +func (Users) TableName() string { return "users" } + +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..da93dbab --- /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,20 @@ +--- +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.IntegerField() + # foreign key: (solo) -> target(u) + # composite foreign key: (a_id, b_id) -> target(a, b) + + class Meta: + managed = False + 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..91fb8d13 --- /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;references:U" 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..2d920e69 --- /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 +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: + managed = False + 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..c18a70b3 --- /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,14 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Parent struct { + ID1 int32 `gorm:"column:id1;primaryKey" json:"id1"` + ID2 int32 `gorm:"column:id2;primaryKey" json:"id2"` + ChildOne *ChildOne `gorm:"foreignKey:ParentID1,ParentID2;references:ID1,ID2" json:"-"` + ChildManies []ChildMany `gorm:"foreignKey:ParentID1,ParentID2;references:ID1,ID2" json:"-"` +} + +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..f2e79fb5 --- /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 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Dual(models.Model): + username = models.TextField(primary_key=True) + + class Meta: + managed = False + 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..63c212b6 --- /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,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..487f0ef1 --- /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 +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: + managed = False + 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..0d37a920 --- /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,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..c21f34e6 --- /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 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Article(models.Model): + id = models.BigIntegerField(primary_key=True) + + class Meta: + managed = False + 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..0d37a920 --- /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,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..e5574d34 --- /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 +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: + managed = False + 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..e738b272 --- /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,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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_reserved_names_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_Django.snap new file mode 100644 index 00000000..064ee15a --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_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 User(models.Model): + id = models.IntegerField(primary_key=True) + tags = models.TextField() + tags2 = models.ManyToManyField("Tags", through="UserTags", related_name="+") + pass_field = models.ManyToManyField("Pass", through="User_pass", related_name="+") + + class Meta: + managed = False + db_table = "user" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_Drizzle_pg.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_Drizzle_pg.snap new file mode 100644 index 00000000..4fb461b8 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_Drizzle_pg.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +export const user = pgTable("user", { + id: integer("id").primaryKey(), + tags: text("tags").notNull(), +}); + +export const userRelations = relations(user, ({ one, many }) => ({ + userTags: many(userTags), + userPass: many(userPass), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_Gorm.snap new file mode 100644 index 00000000..b5dfcfb8 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_Gorm.snap @@ -0,0 +1,14 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type User struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Tags string `gorm:"column:tags;not null;type:text" json:"tags"` + UserTags []UserTags `gorm:"foreignKey:UserID" json:"-"` + User_pass []User_pass `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_reserved_names_Jpa.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_Jpa.snap new file mode 100644 index 00000000..03fbef11 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_Jpa.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +import jakarta.persistence.*; + +@Entity +@Table(name = "user") +public class User { + + @Id + @Column(name = "id") + private Integer id; + + @Column(name = "tags", nullable = false, columnDefinition = "TEXT") + private String tags; + + protected User() { + } +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_Prisma.snap new file mode 100644 index 00000000..1fc5cfeb --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_Prisma.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model User { + id Int @id + tags String + user_tags UserTags[] + user_pass UserPass[] + + @@map("user") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_SeaOrm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_SeaOrm.snap new file mode 100644 index 00000000..afdbdbaf --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_SeaOrm.snap @@ -0,0 +1,25 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "user")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub tags: String, + #[sea_orm(has_many)] + pub user_tags: HasMany, + #[sea_orm(has_many, via = "user_tags")] + pub tags_via_user_tags: HasMany, + #[sea_orm(has_many)] + pub user_pass: HasMany, + #[sea_orm(has_many, via = "user_pass")] + pub pass_via_user_pass: HasMany, +} + +vespera::schema_type!(Schema from Model, name = "UserSchema"); +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_SqlAlchemy.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_SqlAlchemy.snap new file mode 100644 index 00000000..c7b5b5f2 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_SqlAlchemy.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + + +from sqlalchemy import Integer, Text +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class User(DeclarativeBase): + __tablename__ = "user" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + tags: Mapped[str] = mapped_column(Text, nullable=False) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_SqlModel.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_SqlModel.snap new file mode 100644 index 00000000..33816b27 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_SqlModel.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + + +from sqlmodel import Field, SQLModel + + +class User(SQLModel, table=True): + __tablename__ = "user" + + id: int = Field(primary_key=True) + tags: str = Field(...) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_Django.snap new file mode 100644 index 00000000..ca6c93aa --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_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 User(models.Model): + id = models.IntegerField(primary_key=True) + + class Meta: + managed = False + db_table = "user" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_Drizzle_pg.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_Drizzle_pg.snap new file mode 100644 index 00000000..7496d4e2 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_Drizzle_pg.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +export const user = pgTable("user", { + id: integer("id").primaryKey(), +}); + +export const userRelations = relations(user, ({ one, many }) => ({ + userUserFriends: many(userFriends, { relationName: "UserFriendsUser" }), + friendUserFriends: many(userFriends, { relationName: "UserFriendsFriend" }), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_Gorm.snap new file mode 100644 index 00000000..8116ff4e --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type User struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + UserFriendsByUserID []UserFriends `gorm:"foreignKey:UserID" json:"-"` + UserFriendsByFriendID []UserFriends `gorm:"foreignKey:FriendID" 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_uninvolved_Jpa.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_Jpa.snap new file mode 100644 index 00000000..d286c7a7 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_Jpa.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +import jakarta.persistence.*; + +@Entity +@Table(name = "user") +public class User { + + @Id + @Column(name = "id") + private Integer id; + + protected User() { + } +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_Prisma.snap new file mode 100644 index 00000000..8230b515 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_Prisma.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model User { + id Int @id + user_user_friends UserFriends[] @relation("UserFriendsUser") + friend_user_friends UserFriends[] @relation("UserFriendsFriend") + + @@map("user") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_SeaOrm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_SeaOrm.snap new file mode 100644 index 00000000..f9fe3922 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_SeaOrm.snap @@ -0,0 +1,56 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "user")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(has_many, relation_enum = "UserFriends", via_rel = "User")] + pub user_user_friends: HasMany, + #[sea_orm(has_many, relation_enum = "Friend", via_rel = "Friend")] + pub friend_user_friends: HasMany, +} + +vespera::schema_type!(Schema from Model, name = "UserSchema"); +impl ActiveModelBehavior for ActiveModel {} + +pub struct UserIdToFriendIdViaUserFriends; +impl Linked for UserIdToFriendIdViaUserFriends { + type FromEntity = Entity; + type ToEntity = Entity; + + fn link(&self) -> Vec { + vec![ + super::user_friends::Relation::User.def().rev(), + super::user_friends::Relation::Friend.def(), + ] + } +} + +pub struct FriendIdToUserIdViaUserFriends; +impl Linked for FriendIdToUserIdViaUserFriends { + type FromEntity = Entity; + type ToEntity = Entity; + + fn link(&self) -> Vec { + vec![ + super::user_friends::Relation::Friend.def().rev(), + super::user_friends::Relation::User.def(), + ] + } +} + +impl Model { + pub fn find_friend_ids_via_user_friends_from_user_id(&self) -> Select { + self.find_linked(UserIdToFriendIdViaUserFriends) + } + + pub fn find_user_ids_via_user_friends_from_friend_id(&self) -> Select { + self.find_linked(FriendIdToUserIdViaUserFriends) + } +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_SqlAlchemy.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_SqlAlchemy.snap new file mode 100644 index 00000000..c79b8d6a --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_SqlAlchemy.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + + +from sqlalchemy import Integer +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class User(DeclarativeBase): + __tablename__ = "user" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_SqlModel.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_SqlModel.snap new file mode 100644 index 00000000..6b517060 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_SqlModel.snap @@ -0,0 +1,14 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + + +from sqlmodel import Field, SQLModel + + +class User(SQLModel, table=True): + __tablename__ = "user" + + id: int = Field(primary_key=True) 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..85e61db7 --- /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 +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: + managed = False + 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..5d12cead --- /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,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..f960596c --- /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 +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: + managed = False + 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..153d38ef --- /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,19 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..319f805d --- /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 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class User(models.Model): + id = models.UUIDField(primary_key=True) + + class Meta: + managed = False + 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..f1d5bdb4 --- /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,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..319f805d --- /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 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class User(models.Model): + id = models.UUIDField(primary_key=True) + + class Meta: + managed = False + 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..17e350d1 --- /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,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..a6794f46 --- /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 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Another(models.Model): + id = models.IntegerField(primary_key=True) + + class Meta: + managed = False + 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..cfee814f --- /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,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..f0e9547c --- /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 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Other(models.Model): + id = models.IntegerField(primary_key=True) + + class Meta: + managed = False + 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..06f4da02 --- /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,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..f0e9547c --- /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 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Other(models.Model): + id = models.IntegerField(primary_key=True) + + class Meta: + managed = False + 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..6a869972 --- /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,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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@one_to_one_shared_primary_key_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_Django.snap new file mode 100644 index 00000000..319f805d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_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 User(models.Model): + id = models.UUIDField(primary_key=True) + + class Meta: + managed = False + db_table = "user" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_Drizzle_pg.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_Drizzle_pg.snap new file mode 100644 index 00000000..bae07af0 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_Drizzle_pg.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +export const user = pgTable("user", { + id: uuid("id").primaryKey(), +}); + +export const userRelations = relations(user, ({ one, many }) => ({ + profile: one(profile), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_Gorm.snap new file mode 100644 index 00000000..f0640e09 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_Gorm.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +import ( + "github.com/google/uuid" +) + +type User struct { + ID uuid.UUID `gorm:"column:id;primaryKey;type:uuid" json:"id"` + Profile *Profile `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@one_to_one_shared_primary_key_Jpa.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_Jpa.snap new file mode 100644 index 00000000..247cd774 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_Jpa.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +import jakarta.persistence.*; +import java.util.UUID; + +@Entity +@Table(name = "user") +public class User { + + @Id + @Column(name = "id") + private UUID id; + + protected User() { + } +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_Prisma.snap new file mode 100644 index 00000000..d19632cb --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_Prisma.snap @@ -0,0 +1,10 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model User { + id String @id + profile Profile? + + @@map("user") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_SeaOrm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_SeaOrm.snap new file mode 100644 index 00000000..dd497c41 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_SeaOrm.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "user")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: Uuid, + #[sea_orm(has_one)] + pub profile: HasOne, +} + +vespera::schema_type!(Schema from Model, name = "UserSchema"); +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_SqlAlchemy.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_SqlAlchemy.snap new file mode 100644 index 00000000..98172029 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_SqlAlchemy.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import Uuid +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class User(DeclarativeBase): + __tablename__ = "user" + + id: Mapped[UUID] = mapped_column(Uuid, primary_key=True) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_SqlModel.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_SqlModel.snap new file mode 100644 index 00000000..b25f7d83 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_SqlModel.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from uuid import UUID + +from sqlmodel import Field, SQLModel + + +class User(SQLModel, table=True): + __tablename__ = "user" + + id: UUID = Field(primary_key=True) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Django.snap new file mode 100644 index 00000000..f5b64207 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Django.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Profile(models.Model): + id = models.UUIDField(primary_key=True) + user = models.OneToOneField("User", on_delete=models.RESTRICT, related_name="+") + + class Meta: + managed = False + db_table = "profile" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Drizzle_pg.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Drizzle_pg.snap new file mode 100644 index 00000000..5141a50e --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Drizzle_pg.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +export const profile = pgTable("profile", { + id: uuid("id").primaryKey(), + userId: uuid("user_id").notNull(), +}, (t) => [ + foreignKey({ columns: [t.userId], foreignColumns: [user.id], name: "fk_profile__user_id" }), + uniqueIndex("uq_profile__user_id").on(t.userId), +]); + +export const profileRelations = relations(profile, ({ one, many }) => ({ + user: one(user, { fields: [profile.userId], references: [user.id] }), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Gorm.snap new file mode 100644 index 00000000..9c691fab --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Gorm.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +import ( + "github.com/google/uuid" +) + +type Profile struct { + ID uuid.UUID `gorm:"column:id;primaryKey;type:uuid" json:"id"` + UserID uuid.UUID `gorm:"column:user_id;not null;unique;type:uuid" json:"user_id"` + User *User `gorm:"foreignKey:UserID" json:"-"` +} + +func (Profile) TableName() string { return "profile" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Jpa.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Jpa.snap new file mode 100644 index 00000000..3430660c --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Jpa.snap @@ -0,0 +1,22 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +import jakarta.persistence.*; +import java.util.UUID; + +@Entity +@Table(name = "profile") +public class Profile { + + @Id + @Column(name = "id") + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + protected Profile() { + } +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Prisma.snap new file mode 100644 index 00000000..d489ce7b --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Prisma.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Profile { + id String @id + user_id String @unique(map: "uq_profile__user_id") + user User @relation(fields: [user_id], references: [id], onDelete: NoAction, onUpdate: NoAction) + + @@map("profile") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_SeaOrm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_SeaOrm.snap new file mode 100644 index 00000000..d708c0f0 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_SeaOrm.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "profile")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: Uuid, + #[sea_orm(unique)] + pub user_id: Uuid, + #[sea_orm(belongs_to, from = "user_id", to = "id")] + pub user: HasOne, +} + +vespera::schema_type!(Schema from Model, name = "ProfileSchema"); +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_SqlAlchemy.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_SqlAlchemy.snap new file mode 100644 index 00000000..195aa999 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_SqlAlchemy.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import ForeignKey, Uuid +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Profile(DeclarativeBase): + __tablename__ = "profile" + + id: Mapped[UUID] = mapped_column(Uuid, primary_key=True) + user_id: Mapped[UUID] = mapped_column(Uuid, ForeignKey("user.id"), nullable=False, unique=True) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_SqlModel.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_SqlModel.snap new file mode 100644 index 00000000..656f2c4d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_SqlModel.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from uuid import UUID + +from sqlmodel import Field, SQLModel + + +class Profile(SQLModel, table=True): + __tablename__ = "profile" + + id: UUID = Field(primary_key=True) + user_id: UUID = Field(foreign_key="user.id", unique=True) 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..f2e79fb5 --- /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 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Dual(models.Model): + username = models.TextField(primary_key=True) + + class Meta: + managed = False + 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..65f25b10 --- /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,14 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..a0cac5bf --- /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 +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: + managed = False + 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..6648185d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Gorm.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..67fc1cea --- /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 +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: + managed = False + 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..1e360b7d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reserved_word_identifiers_snapshot@reserved_word_identifiers_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..c8af5e84 --- /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 +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: + managed = False + 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..2d158abd --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__self_referencing_fk_snapshot@self_referencing_fk_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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__semicolon_default_snapshot@semicolon_default_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_Django.snap new file mode 100644 index 00000000..65ef9b95 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_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 Notes(models.Model): + id = models.IntegerField(primary_key=True) + note = models.TextField(default="a;b") + body = models.TextField() + + class Meta: + managed = False + db_table = "notes" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_Drizzle_pg.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_Drizzle_pg.snap new file mode 100644 index 00000000..9daa70dc --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_Drizzle_pg.snap @@ -0,0 +1,9 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +export const notes = pgTable("notes", { + id: integer("id").primaryKey(), + note: text("note").notNull().default("a;b"), + body: text("body").notNull(), +}); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_Gorm.snap new file mode 100644 index 00000000..c694a7c7 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Notes struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Note string `gorm:"column:note;not null;type:text" json:"note"` + Body string `gorm:"column:body;not null;type:text" json:"body"` +} + +func (Notes) TableName() string { return "notes" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_Jpa.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_Jpa.snap new file mode 100644 index 00000000..51e05134 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_Jpa.snap @@ -0,0 +1,23 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +import jakarta.persistence.*; + +@Entity +@Table(name = "notes") +public class Notes { + + @Id + @Column(name = "id") + private Integer id; + + @Column(name = "note", nullable = false, columnDefinition = "TEXT") + private String note = "a;b"; + + @Column(name = "body", nullable = false, columnDefinition = "TEXT") + private String body; + + protected Notes() { + } +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_Prisma.snap new file mode 100644 index 00000000..28eacb9a --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_Prisma.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Notes { + id Int @id + note String @default("a;b") + body String + + @@map("notes") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_SeaOrm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_SeaOrm.snap new file mode 100644 index 00000000..13b09613 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_SeaOrm.snap @@ -0,0 +1,19 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "notes")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(default_value = "a;b")] + pub note: String, + pub body: String, +} + +vespera::schema_type!(Schema from Model, name = "NotesSchema"); +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_SqlAlchemy.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_SqlAlchemy.snap new file mode 100644 index 00000000..407ef820 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_SqlAlchemy.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + + +from sqlalchemy import Integer, Text +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Notes(DeclarativeBase): + __tablename__ = "notes" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + note: Mapped[str] = mapped_column(Text, nullable=False, server_default='a;b') + body: Mapped[str] = mapped_column(Text, nullable=False) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_SqlModel.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_SqlModel.snap new file mode 100644 index 00000000..ef542948 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_SqlModel.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + + +from sqlmodel import Field, SQLModel + + +class Notes(SQLModel, table=True): + __tablename__ = "notes" + + id: int = Field(primary_key=True) + note: str = Field(default="a;b") + body: str = Field(...) 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..1a1f68e3 --- /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 +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: + managed = False + 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..202bd5ab --- /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,19 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..b60031a1 --- /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 +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: + managed = False + 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..d63f75b3 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_defaults_snapshot@server_defaults_Gorm.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..e8b19d93 --- /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,25 @@ +--- +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) + display_name = models.TextField(null=True, blank=True) + + class Meta: + managed = False + 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: + managed = False + 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..facce268 --- /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,22 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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"` + Posts []Posts `gorm:"foreignKey:UserID" json:"-"` +} + +func (Users) TableName() string { return "users" } + +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..2136de4b --- /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 +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: + managed = False + 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..1787ade7 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__string_default_snapshot@string_default_Gorm.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..ceb1eda6 --- /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 +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: + managed = False + 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..ee02262c --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_pk_Gorm.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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_check_snapshot@table_with_check_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_Django.snap new file mode 100644 index 00000000..b93d1de3 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_Django.snap @@ -0,0 +1,16 @@ +--- +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(primary_key=True) + price = models.IntegerField() + + class Meta: + managed = False + db_table = "products" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_Gorm.snap new file mode 100644 index 00000000..8971a648 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_Gorm.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Products struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Price int32 `gorm:"column:price;not null" json:"price"` +} + +func (Products) TableName() string { return "products" } 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..daf490d7 --- /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 +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: + managed = False + 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..cb1df18c --- /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,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..2ed46d62 --- /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 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class OrderStatus(models.TextChoices): + PENDING = "pending" + SHIPPED = "shipped" + DELIVERED = "delivered" + +class Orders(models.Model): + id = models.IntegerField() + status = models.CharField(max_length=9, choices=OrderStatus.choices) + + class Meta: + managed = False + 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..37d30f8e --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_enum_snapshot@table_with_enum_Gorm.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..c64d3aa3 --- /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 +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: + managed = False + 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..4557fe39 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_fk_snapshot@table_with_fk_Gorm.snap @@ -0,0 +1,14 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..9e5761ea --- /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 +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: + managed = False + db_table = "articles" + indexes = [ + models.Index(fields=["created_at"]), + models.Index(fields=["title"], name="ix_articles__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..60349eb6 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_Gorm.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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:ix_articles__title" json:"title"` + CreatedAt time.Time `gorm:"column:created_at;not null;index:ix_articles__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..c5a30ae5 --- /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 +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class PriorityLevel(models.IntegerChoices): + LOW = 0 + MEDIUM = 10 + HIGH = 20 + +class Tasks(models.Model): + id = models.IntegerField(primary_key=True) + priority = models.IntegerField(choices=PriorityLevel.choices) + + class Meta: + managed = False + 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..dd7299f9 --- /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,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..e1590207 --- /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 +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: + managed = False + db_table = "users" + indexes = [ + models.Index(fields=["department"], name="ix_users__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..3bd61fcf --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Gorm.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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:ix_users__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..8911f5e3 --- /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 +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: + managed = False + 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..ce1d0604 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_constant_default_snapshot@unknown_constant_default_Gorm.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..b90ebd72 --- /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 +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: + managed = False + 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..99bafbbf --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_function_default_snapshot@unknown_function_default_Gorm.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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..3127c056 --- /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 +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: + managed = False + db_table = "unnamed_index" + indexes = [ + models.Index(fields=["col_a", "col_b"], name="ix_unnamed_index__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..47701521 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_index_snapshot@unnamed_composite_index_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type UnnamedIndex struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + ColA int32 `gorm:"column:col_a;not null;index:ix_unnamed_index__col_a_col_b" json:"col_a"` + ColB int32 `gorm:"column:col_b;not null;index:ix_unnamed_index__col_a_col_b" 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..2846e265 --- /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 +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: + managed = False + db_table = "unnamed_unique" + constraints = [ + models.UniqueConstraint(fields=["col_a", "col_b"], name="uq_unnamed_unique__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..2d0972a6 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_unique_snapshot@unnamed_composite_unique_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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_unnamed_unique__col_a_col_b" json:"col_a"` + ColB int32 `gorm:"column:col_b;not null;uniqueIndex:uq_unnamed_unique__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..e82541ca --- /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 +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: + managed = False + db_table = "events" + indexes = [ + models.Index(fields=["venue_id", "date"], name="ix_events__date_venue_id"), + ] + constraints = [ + models.UniqueConstraint(fields=["venue_id", "date"], name="uq_events__date_venue_id"), + ] 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..40ee814f --- /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,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +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:ix_events__date_venue_id;uniqueIndex:uq_events__date_venue_id" json:"venue_id"` + Date time.Time `gorm:"column:date;not null;type:date;index:ix_events__date_venue_id;uniqueIndex:uq_events__date_venue_id" json:"date"` +} + +func (Events) TableName() string { return "events" } diff --git a/crates/vespertide-exporter/src/utils/common.rs b/crates/vespertide-exporter/src/utils/common.rs index 48d47bec..3ac072a4 100644 --- a/crates/vespertide-exporter/src/utils/common.rs +++ b/crates/vespertide-exporter/src/utils/common.rs @@ -1,5 +1,7 @@ //! Cross-language helpers shared by every ORM exporter backend. +use vespertide_core::{NumValue, ReferenceAction, TableConstraint, TableDef}; + /// Join items as a double-quoted, comma-separated list: `"a", "b", "c"`. /// /// Consolidates the quoted-comma-join pattern previously copy-pasted across @@ -70,6 +72,32 @@ pub(crate) fn join_qualified_refs(ref_table: &str, ref_cols: &[&str]) -> String out } +/// Quote `value` as a double-quoted string literal. +/// +/// Backslashes, quotes and the line terminators are escaped so a database name +/// or enum value containing any of them cannot end the literal early. The +/// escapes are the ones TypeScript, Go and Python share, so every literal the +/// Drizzle, GORM and Django renderers emit — table names, column names, enum +/// values — goes through here. +pub(crate) fn string_literal(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for ch in value.chars() { + match ch { + '\\' | '"' => { + out.push('\\'); + out.push(ch); + } + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + _ => out.push(ch), + } + } + out.push('"'); + out +} + /// Strip one matching pair of surrounding quotes from a SQL literal. /// /// Only an outer pair is removed, so quotes *inside* the literal survive: @@ -88,6 +116,55 @@ pub(crate) fn unquote(s: &str) -> &str { s } +/// The stored value of the integer-enum variant named `name`, if there is one. +/// A model may write an integer enum's default as the variant name; the column +/// stores the value. +pub(crate) fn integer_enum_variant_value(variants: &[NumValue], name: &str) -> Option { + variants.iter().find(|v| v.name == name).map(|v| v.value) +} + +/// `JSONB` is the one custom column type the backends map to a native JSON +/// type instead of a plain string; the model may spell it in any case. +pub(crate) fn is_jsonb_custom_type(custom_type: &str) -> bool { + custom_type.eq_ignore_ascii_case("JSONB") +} + +/// A composite (multi-column) foreign key: its owning columns, target and +/// referential actions. Backends with no native composite relation +/// (SQLAlchemy, SQLModel, Django) surface it as a comment; GORM renders it as +/// a relation field with comma-separated `foreignKey`/`references`. +pub(crate) struct CompositeFk<'a> { + pub(crate) local_cols: Vec<&'a str>, + pub(crate) ref_table: &'a str, + pub(crate) ref_cols: Vec<&'a str>, + pub(crate) on_delete: Option<&'a ReferenceAction>, + pub(crate) on_update: Option<&'a ReferenceAction>, +} + +pub(crate) fn collect_composite_fks(table: &TableDef) -> Vec> { + table + .constraints + .iter() + .filter_map(|constraint| match constraint { + TableConstraint::ForeignKey { + columns, + ref_table, + ref_columns, + on_delete, + on_update, + .. + } if columns.len() > 1 && columns.len() == ref_columns.len() => Some(CompositeFk { + local_cols: columns.iter().map(AsRef::as_ref).collect(), + ref_table: ref_table.as_str(), + ref_cols: ref_columns.iter().map(AsRef::as_ref).collect(), + on_delete: on_delete.as_ref(), + on_update: on_update.as_ref(), + }), + _ => None, + }) + .collect() +} + /// Claim a relation field name, recording it in `taken` so later fields /// avoid it. Seed `taken` with the table's column field names first — relation /// names are derived from column/table names, so a relation must not take a @@ -228,4 +305,16 @@ mod tests { fn unquote_removes_only_a_matching_outer_pair(#[case] input: &str, #[case] expected: &str) { assert_eq!(unquote(input), expected); } + + #[rstest] + #[case::plain("users", r#""users""#)] + #[case::double_quote("say \"hi\"", r#""say \"hi\"""#)] + #[case::backslash("back\\slash", r#""back\\slash""#)] + #[case::newline("two\nlines", r#""two\nlines""#)] + #[case::carriage_return("a\rb", r#""a\rb""#)] + #[case::tab("a\tb", r#""a\tb""#)] + #[case::empty("", r#""""#)] + fn string_literal_escapes_literal_terminators(#[case] input: &str, #[case] expected: &str) { + assert_eq!(string_literal(input), expected); + } } diff --git a/crates/vespertide-exporter/src/utils/python.rs b/crates/vespertide-exporter/src/utils/python.rs index 77888214..858bdf06 100644 --- a/crates/vespertide-exporter/src/utils/python.rs +++ b/crates/vespertide-exporter/src/utils/python.rs @@ -1,9 +1,6 @@ -use vespertide_core::TableDef; use vespertide_core::schema::column::{ ColumnType, ComplexColumnType, EnumValues, SimpleColumnType, }; -use vespertide_core::schema::constraint::TableConstraint; - use vespertide_naming::{IdentifierStart, sanitize_identifier, to_screaming_snake_case}; use crate::python_naming::to_pascal_case; @@ -19,10 +16,7 @@ pub(crate) fn render_enum(lines: &mut Vec, name: &str, values: &EnumValu EnumValues::String(vals) => { lines.push(format!("class {class_name}(str, enum.Enum):")); for val in vals { - // Python accepts a leading `_` in a member name, so the - // digit escape is `_` rather than the letter Prisma needs. - let variant_name = - sanitize_identifier(&to_screaming_snake_case(val), IdentifierStart::Underscore); + let variant_name = enum_member_name(val); lines.push(format!(" {variant_name} = \"{val}\"")); } } @@ -35,6 +29,50 @@ pub(crate) fn render_enum(lines: &mut Vec, name: &str, values: &EnumValu } } +/// Python's hard keywords (`keyword.kwlist`, 3.12). Soft keywords (`match`, +/// `case`, `type`, `_`) stay valid identifiers and need no escape. +const PYTHON_KEYWORDS: [&str; 35] = [ + "False", "None", "True", "and", "as", "assert", "async", "await", "break", "class", "continue", + "def", "del", "elif", "else", "except", "finally", "for", "from", "global", "if", "import", + "in", "is", "lambda", "nonlocal", "not", "or", "pass", "raise", "return", "try", "while", + "with", "yield", +]; + +pub(crate) fn is_python_keyword(name: &str) -> bool { + PYTHON_KEYWORDS.contains(&name) +} + +/// PEP 8's escape for a name that is a Python keyword: a trailing `_`. The +/// callers already emit the database column name whenever the attribute +/// differs from it. +pub(crate) fn escape_python_keyword(mut name: String) -> String { + if is_python_keyword(&name) { + name.push('_'); + } + name +} + +/// Member name for a Python enum class: `SCREAMING_SNAKE_CASE` of the value. +/// Python accepts a leading `_` in a member name, so the digit escape is `_` +/// rather than the letter Prisma needs. +pub(crate) fn enum_member_name(value: &str) -> String { + unmangled(sanitize_identifier( + &to_screaming_snake_case(value), + IdentifierStart::Underscore, + )) +} + +/// Inside a class body Python rewrites a name led by `__` into +/// `_Class__name`: an enum member spelled that way is no member, and a class +/// spelled that way cannot be named from another class. One `_` stays. +pub(crate) fn unmangled(name: String) -> String { + let body = name.trim_start_matches('_'); + if name.len() - body.len() < 2 { + return name; + } + format!("_{body}") +} + /// Map a `ColumnType` to its Python type annotation string, shared verbatim by /// the SQLAlchemy and SQLModel backends (both produce identical /// `int`/`float`/`str`/`datetime`/`Decimal`/`Optional[...]`/enum-PascalCase @@ -60,9 +98,6 @@ pub(crate) fn column_type_to_python(col_type: &ColumnType, nullable: bool) -> St SimpleColumnType::Bytea => "bytes", SimpleColumnType::Uuid => "UUID", SimpleColumnType::Json => "dict", - _ => unreachable!( - "SimpleColumnType is #[non_exhaustive]; all variants are matched above" - ), }, ColumnType::Complex(ty) => match ty { ComplexColumnType::Numeric { .. } => "Decimal", @@ -89,28 +124,19 @@ pub(crate) fn column_type_to_python(col_type: &ColumnType, nullable: bool) -> St } } -pub(crate) struct CompositeFk<'a> { - pub local_cols: Vec<&'a str>, - pub ref_table: &'a str, - pub ref_cols: Vec<&'a str>, -} +#[cfg(test)] +mod tests { + use rstest::rstest; -pub(crate) fn collect_composite_fks(table: &TableDef) -> Vec> { - table - .constraints - .iter() - .filter_map(|constraint| match constraint { - TableConstraint::ForeignKey { - columns, - ref_table, - ref_columns, - .. - } if columns.len() > 1 && columns.len() == ref_columns.len() => Some(CompositeFk { - local_cols: columns.iter().map(AsRef::as_ref).collect(), - ref_table: ref_table.as_str(), - ref_cols: ref_columns.iter().map(AsRef::as_ref).collect(), - }), - _ => None, - }) - .collect() + use super::enum_member_name; + + #[rstest] + #[case::plain("pending", "PENDING")] + #[case::words("in progress", "IN_PROGRESS")] + #[case::digit_led("1st", "_1ST")] + #[case::one_leading_separator("-x", "_X")] + #[case::leading_run_python_would_mangle("--x", "_X")] + fn enum_values_become_member_names(#[case] value: &str, #[case] expected: &str) { + assert_eq!(enum_member_name(value), expected); + } } diff --git a/crates/vespertide-exporter/src/utils/typescript.rs b/crates/vespertide-exporter/src/utils/typescript.rs index 9270609d..713fb0cd 100644 --- a/crates/vespertide-exporter/src/utils/typescript.rs +++ b/crates/vespertide-exporter/src/utils/typescript.rs @@ -86,31 +86,6 @@ pub(crate) fn ts_binding(name: &str) -> String { sanitized } -/// Quote `value` as a double-quoted TypeScript string literal. -/// -/// Backslashes, quotes and the line terminators are escaped so a database name -/// or enum value containing any of them cannot end the literal early. Every -/// literal Drizzle emits — table names, column names, enum values — goes -/// through here. -pub(crate) fn ts_string(value: &str) -> String { - let mut out = String::with_capacity(value.len() + 2); - out.push('"'); - for ch in value.chars() { - match ch { - '\\' | '"' => { - out.push('\\'); - out.push(ch); - } - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - _ => out.push(ch), - } - } - out.push('"'); - out -} - #[cfg(test)] mod tests { use super::*; @@ -130,16 +105,4 @@ mod tests { fn ts_binding_escapes_digits_and_reserved_words(#[case] input: &str, #[case] expected: &str) { assert_eq!(ts_binding(input), expected); } - - #[rstest] - #[case::plain("users", r#""users""#)] - #[case::double_quote("say \"hi\"", r#""say \"hi\"""#)] - #[case::backslash("back\\slash", r#""back\\slash""#)] - #[case::newline("two\nlines", r#""two\nlines""#)] - #[case::carriage_return("a\rb", r#""a\rb""#)] - #[case::tab("a\tb", r#""a\tb""#)] - #[case::empty("", r#""""#)] - fn ts_string_escapes_literal_terminators(#[case] input: &str, #[case] expected: &str) { - assert_eq!(ts_string(input), expected); - } } diff --git a/crates/vespertide-exporter/tests/parallel_consolidated.rs b/crates/vespertide-exporter/tests/parallel_consolidated.rs index 693711e4..0867ee15 100644 --- a/crates/vespertide-exporter/tests/parallel_consolidated.rs +++ b/crates/vespertide-exporter/tests/parallel_consolidated.rs @@ -9,6 +9,10 @@ use vespertide_exporter::Orm; #[case::sqlalchemy(Orm::SqlAlchemy)] #[case::sqlmodel(Orm::SqlModel)] #[case::jpa(Orm::Jpa)] +#[case::prisma(Orm::Prisma)] +#[case::drizzle(Orm::Drizzle)] +#[case::gorm(Orm::Gorm)] +#[case::django(Orm::Django)] fn export_is_byte_identical_across_thread_counts(#[case] orm: Orm) { let schema = large_schema(100); @@ -39,6 +43,8 @@ fn render_schema(orm: Orm, schema: &[TableDef]) -> Result { } Orm::Prisma => vespertide_exporter::prisma::export(schema), Orm::Drizzle => vespertide_exporter::drizzle::export(schema), + Orm::Gorm => vespertide_exporter::gorm::export(schema), + Orm::Django => vespertide_exporter::django::export(schema), } } diff --git a/crates/vespertide-planner/src/validate/fk_policy_changes.rs b/crates/vespertide-planner/src/validate/fk_policy_changes.rs index ca9d601b..9f5b772f 100644 --- a/crates/vespertide-planner/src/validate/fk_policy_changes.rs +++ b/crates/vespertide-planner/src/validate/fk_policy_changes.rs @@ -180,9 +180,6 @@ pub fn render_reference_action(action: Option<&ReferenceAction>) -> &'static str Some(ReferenceAction::SetNull) => "SET NULL", Some(ReferenceAction::SetDefault) => "SET DEFAULT", Some(ReferenceAction::NoAction) | None => "NO ACTION", - // reason: unreachable - exhaustive over current ReferenceAction variants; fallback required only for #[non_exhaustive] future variants - #[cfg(not(tarpaulin_include))] - Some(_) => "(unknown)", } } diff --git a/crates/vespertide-query/src/sql/helpers.rs b/crates/vespertide-query/src/sql/helpers.rs index 1906c911..2e87ebce 100644 --- a/crates/vespertide-query/src/sql/helpers.rs +++ b/crates/vespertide-query/src/sql/helpers.rs @@ -108,7 +108,6 @@ fn apply_simple_column_type( SimpleColumnType::Cidr => apply_postgres_text_fallback_type(col, backend, "CIDR"), SimpleColumnType::Macaddr => apply_postgres_text_fallback_type(col, backend, "MACADDR"), SimpleColumnType::Xml => apply_postgres_text_fallback_type(col, backend, "XML"), - _ => unreachable!("SimpleColumnType is #[non_exhaustive]; all variants are matched above"), } } @@ -219,7 +218,6 @@ pub(crate) fn to_sea_fk_action(action: &ReferenceAction) -> ForeignKeyAction { ReferenceAction::SetNull => ForeignKeyAction::SetNull, ReferenceAction::SetDefault => ForeignKeyAction::SetDefault, ReferenceAction::NoAction => ForeignKeyAction::NoAction, - _ => unreachable!("ReferenceAction is #[non_exhaustive]; all variants are matched above"), } } diff --git a/crates/vespertide-query/src/sql/tests/helpers.rs b/crates/vespertide-query/src/sql/tests/helpers.rs index 49b1848d..45551cdf 100644 --- a/crates/vespertide-query/src/sql/tests/helpers.rs +++ b/crates/vespertide-query/src/sql/tests/helpers.rs @@ -16,7 +16,6 @@ fn reference_action_sql(action: &ReferenceAction) -> &'static str { ReferenceAction::SetNull => "SET NULL", ReferenceAction::SetDefault => "SET DEFAULT", ReferenceAction::NoAction => "NO ACTION", - _ => unreachable!("ReferenceAction is #[non_exhaustive]; all variants are matched above"), } } diff --git a/crates/vespertide/src/lib.rs b/crates/vespertide/src/lib.rs index df057694..fae55438 100644 --- a/crates/vespertide/src/lib.rs +++ b/crates/vespertide/src/lib.rs @@ -2,7 +2,7 @@ //! //! Declarative database schema management for Rust. Define schemas in JSON, //! generate migration plans, emit SQL for PostgreSQL/MySQL/SQLite, and export -//! ORM models for SeaORM/SQLAlchemy/SQLModel/JPA. +//! ORM models for SeaORM/SQLAlchemy/SQLModel/JPA/Prisma/Drizzle/GORM/Django. //! //! This is the facade crate; runtime migrations use [`vespertide_migration!`]. //! Advanced users may depend on `vespertide-core` directly for typed data structures. diff --git a/schemas/config.schema.json b/schemas/config.schema.json index fa5f2852..3975cc7c 100644 --- a/schemas/config.schema.json +++ b/schemas/config.schema.json @@ -7,6 +7,11 @@ "columnNamingCase": { "$ref": "#/$defs/NameCase" }, + "django": { + "description": "Django-specific export configuration.", + "$ref": "#/$defs/DjangoConfig", + "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 +81,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", diff --git a/schemas/migration.schema.json b/schemas/migration.schema.json index c48e9db7..8ea13714 100644 --- a/schemas/migration.schema.json +++ b/schemas/migration.schema.json @@ -1019,7 +1019,7 @@ ] }, "ReferenceAction": { - "description": "The referential action taken on child rows when the referenced parent row changes.\n\nUsed in `ForeignKeyDef::on_delete` and `ForeignKeyDef::on_update` to control cascading\nbehaviour. In JSON model files these are written in `snake_case`\n(e.g. `\"on_delete\": \"cascade\"`).\n\nThis enum is `#[non_exhaustive]`: new variants may be added in future releases.\nDownstream `match` expressions should include a wildcard arm.", + "description": "The referential action taken on child rows when the referenced parent row changes.\n\nUsed in `ForeignKeyDef::on_delete` and `ForeignKeyDef::on_update` to control cascading\nbehaviour. In JSON model files these are written in `snake_case`\n(e.g. `\"on_delete\": \"cascade\"`).", "oneOf": [ { "description": "Automatically delete or update child rows when the parent row is deleted or updated (`CASCADE`).", @@ -1082,7 +1082,7 @@ ] }, "SimpleColumnType": { - "description": "Parameter-free SQL column types supported across all backends.\n\nEach variant maps directly to a standard SQL type. Use these via\n[`ColumnType::Simple`] when no length, precision, or scale is needed.\n\nThis enum is `#[non_exhaustive]`: new variants may be added in future releases.\nDownstream `match` expressions should include a wildcard arm.", + "description": "Parameter-free SQL column types supported across all backends.\n\nEach variant maps directly to a standard SQL type. Use these via\n[`ColumnType::Simple`] when no length, precision, or scale is needed.", "oneOf": [ { "description": "16-bit signed integer (`SMALLINT`).", diff --git a/schemas/model.schema.json b/schemas/model.schema.json index 7a286e24..6baa3264 100644 --- a/schemas/model.schema.json +++ b/schemas/model.schema.json @@ -450,7 +450,7 @@ "description": "Inline primary key declaration on a [`ColumnDef`], supporting both shorthand and full syntax.\n\nIn JSON model files you can write either:\n- `\"primary_key\": true` (shorthand, no auto-increment)\n- `\"primary_key\": {\"auto_increment\": true}` (full object syntax)\n\n[`ColumnDef`]: crate::schema::ColumnDef" }, "ReferenceAction": { - "description": "The referential action taken on child rows when the referenced parent row changes.\n\nUsed in `ForeignKeyDef::on_delete` and `ForeignKeyDef::on_update` to control cascading\nbehaviour. In JSON model files these are written in `snake_case`\n(e.g. `\"on_delete\": \"cascade\"`).\n\nThis enum is `#[non_exhaustive]`: new variants may be added in future releases.\nDownstream `match` expressions should include a wildcard arm.", + "description": "The referential action taken on child rows when the referenced parent row changes.\n\nUsed in `ForeignKeyDef::on_delete` and `ForeignKeyDef::on_update` to control cascading\nbehaviour. In JSON model files these are written in `snake_case`\n(e.g. `\"on_delete\": \"cascade\"`).", "oneOf": [ { "const": "cascade", @@ -513,7 +513,7 @@ "type": "object" }, "SimpleColumnType": { - "description": "Parameter-free SQL column types supported across all backends.\n\nEach variant maps directly to a standard SQL type. Use these via\n[`ColumnType::Simple`] when no length, precision, or scale is needed.\n\nThis enum is `#[non_exhaustive]`: new variants may be added in future releases.\nDownstream `match` expressions should include a wildcard arm.", + "description": "Parameter-free SQL column types supported across all backends.\n\nEach variant maps directly to a standard SQL type. Use these via\n[`ColumnType::Simple`] when no length, precision, or scale is needed.", "oneOf": [ { "const": "small_int",