diff --git a/.changepacks/changepack_log_5O2QrCyrfNoD1wBL403qE.json b/.changepacks/changepack_log_5O2QrCyrfNoD1wBL403qE.json new file mode 100644 index 00000000..09ef3c00 --- /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)·Django(Python) 익스포터를 7·8번째 ORM 백엔드로 추가. `Orm`이 exhaustive pub enum이라 `Orm::Gorm`/`Orm::Django` 추가가 0.x 기준 breaking이고, vespertide-config에 `gorm`/`django` 설정 섹션이, vespertide-cli에 `export --orm gorm|django` 경로가 함께 들어간다. published 크레이트를 전부 같은 Minor로 올리는 이유는 #185·#186과 동일하다: [workspace.dependencies]의 `=` 핀으로 물려 있어 일부만 올리면 핀과 크레이트 버전이 어긋나 resolve가 깨진다.", "date": "2026-09-15T07:40:25.0000000Z"} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 4e47698a..430b70b8 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 | @@ -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` | 1146 | 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, 560 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/Cargo.lock b/Cargo.lock index ef6cc004..e46895f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2880,6 +2880,7 @@ name = "vespertide-config" version = "0.3.0" dependencies = [ "clap", + "rstest", "schemars", "serde", "serde_json", diff --git a/README.md b/README.md index 0b1ac708..db2b2447 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 @@ -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 +vespertide export --orm django # Python - Django models ``` ## Runtime Migrations (Macro) diff --git a/apps/landing/devup.json b/apps/landing/devup.json index b100ac5f..b6db3ff7 100644 --- a/apps/landing/devup.json +++ b/apps/landing/devup.json @@ -492,7 +492,21 @@ }, null, null - ] + ], + "code": { + "fontFamily": "D2Coding", + "fontWeight": 400, + "fontSize": "13px", + "lineHeight": 1.5, + "letterSpacing": "0em" + }, + "eyebrow": { + "fontFamily": "D2Coding", + "fontWeight": 400, + "fontSize": "11px", + "lineHeight": 1.4, + "letterSpacing": "0.14em" + } } } } \ No newline at end of file diff --git a/apps/landing/src/app/_components/code-tabs.tsx b/apps/landing/src/app/_components/code-tabs.tsx new file mode 100644 index 00000000..506b1d7d --- /dev/null +++ b/apps/landing/src/app/_components/code-tabs.tsx @@ -0,0 +1,238 @@ +import { Box, Text } from '@devup-ui/react' + +import { CodeWindow, HighlightedCode } from './code-window' + +export interface CodeExample { + key: string + label: string + file: string + html: string +} + +export type CodeExampleQuad = [CodeExample, CodeExample, CodeExample, CodeExample] + +// devup-ui extracts `selectors` strings at build time, so each of the 4 +// fixed tabs is written out literally rather than generated by a .map() +// over a runtime key — a template-literal selector key can't be statically +// extracted into CSS. +// +// Each target element (label / title / panel) declares its own "when am I +// shown" condition anchored on the checkbox's id (`#code-tab-N:checked ~ * +// &`), rather than the checkbox declaring rules for elements elsewhere in +// the tree. The checkbox's own `:checked` state isn't observable from the +// checkbox's position in isolation, so ownership of the visibility rule +// belongs on the element it affects. +export function CodeTabs({ examples: [a, b, c, d] }: { examples: CodeExampleQuad }) { + return ( + + + + + + + + {a.label} + + + {b.label} + + + {c.label} + + + {d.label} + + + } + title={ + <> + + {a.file} + + + {b.file} + + + {c.file} + + + {d.file} + + + } + > + + + + + + + + + + + + + + + ) +} diff --git a/apps/landing/src/app/_components/code-window.tsx b/apps/landing/src/app/_components/code-window.tsx new file mode 100644 index 00000000..2200a9ff --- /dev/null +++ b/apps/landing/src/app/_components/code-window.tsx @@ -0,0 +1,108 @@ +import { Box, Flex, globalCss, Text } from '@devup-ui/react' +import type { ComponentProps, ReactNode } from 'react' + +globalCss({ + '.shiki, .shiki span': { + fontFamily: 'D2Coding', + fontSize: '13px', + lineHeight: '1.65', + }, + '.shiki': { + background: 'transparent !important', + padding: '0', + margin: '0', + overflowX: 'auto', + }, + '[data-theme="dark"] .shiki, [data-theme="dark"] .shiki span': { + color: 'var(--shiki-dark) !important', + backgroundColor: 'transparent !important', + }, +}) + +export function CodeWindow({ + title, + tabs, + children, + ...props +}: { + title: ReactNode + tabs?: ReactNode + children: ReactNode +} & Omit>, 'title'>) { + return ( + + + + {Array.from({ length: 3 }, (_, i) => ( + + ))} + + + {title} + + {tabs && ( + + {tabs} + + )} + + + {children} + + + ) +} + +export function HighlightedCode({ html }: { html: string }) { + return
+} + +export function StaticCodeBlock({ + title, + html, +}: { + title: string + html: string +}) { + return ( + + + + ) +} + +export function HeroCodeWrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/apps/landing/src/app/_components/copy-install.tsx b/apps/landing/src/app/_components/copy-install.tsx new file mode 100644 index 00000000..b602c7f6 --- /dev/null +++ b/apps/landing/src/app/_components/copy-install.tsx @@ -0,0 +1,60 @@ +'use client' + +import { Box, Flex, Text } from '@devup-ui/react' +import { useState } from 'react' + +export function CopyInstall({ + command = 'cargo install vespertide-cli', +}: { + command?: string +}) { + const [copied, setCopied] = useState(false) + + const copy = () => { + navigator.clipboard?.writeText(command) + setCopied(true) + setTimeout(() => setCopied(false), 1400) + } + + return ( + + + $ + + + {command} + + { + e.stopPropagation() + copy() + }} + px="9px" + py="5px" + transition="color .15s, border-color .15s" + typography="eyebrow" + > + {copied ? 'copied' : 'copy'} + + + ) +} diff --git a/apps/landing/src/app/_components/example.tsx b/apps/landing/src/app/_components/example.tsx deleted file mode 100644 index 6e69244b..00000000 --- a/apps/landing/src/app/_components/example.tsx +++ /dev/null @@ -1,102 +0,0 @@ -'use client' - -import { Flex, Image } from '@devup-ui/react' -import { ComponentProps, createContext, useContext, useState } from 'react' - -const ExampleContext = createContext<{ - selected: string - setSelected: (selected: string) => void - selectedExample?: { - id: string - title: string - description: string - imageUrl: string - } -} | null>(null) - -export function useExample() { - const context = useContext(ExampleContext) - if (!context) { - throw new Error('useExample must be used within a ExampleProvider') - } - return context -} - -export function ExampleProvider({ - defaultSelected = '', - examples, - children, -}: { - defaultSelected?: string - examples: { - id: string - title: string - description: string - imageUrl: string - }[] - children: React.ReactNode -}) { - const [selected, setSelected] = useState(defaultSelected) - const selectedExample = examples.find((example) => example.id === selected) - return ( - - {children} - - ) -} - -export function ExampleContainer({ - value, - ...props -}: ComponentProps> & { value?: string }) { - const { selected, setSelected } = useExample() - const isSelected = selected === value - return ( - setSelected(value) : undefined} - overflow="hidden" - px="$spacingSpacing24" - py="$spacingSpacing20" - styleOrder={1} - transition="all .1s" - {...props} - /> - ) -} - -export function ExampleImage({ - ...props -}: Omit>, 'src'>) { - const { selectedExample } = useExample() - return ( - {selectedExample?.title - ) -} - -export function Example() {} diff --git a/apps/landing/src/app/_components/join-icon-button.tsx b/apps/landing/src/app/_components/join-icon-button.tsx deleted file mode 100644 index 24128d88..00000000 --- a/apps/landing/src/app/_components/join-icon-button.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Flex } from '@devup-ui/react' -import { ComponentProps } from 'react' - -export function JoinIconButton(props: ComponentProps>) { - return ( - - ) -} diff --git a/apps/landing/src/app/_lib/highlight.ts b/apps/landing/src/app/_lib/highlight.ts new file mode 100644 index 00000000..82e8cf23 --- /dev/null +++ b/apps/landing/src/app/_lib/highlight.ts @@ -0,0 +1,19 @@ +import { codeToHtml } from 'shiki' + +export type CodeLang = 'json' | 'shell' | 'rust' + +const LANG_MAP: Record = { + json: 'json', + shell: 'bash', + rust: 'rust', +} + +export async function highlight(code: string, lang: CodeLang): Promise { + return codeToHtml(code, { + lang: LANG_MAP[lang], + themes: { + light: 'github-light', + dark: 'github-dark', + }, + }) +} diff --git a/apps/landing/src/app/page.tsx b/apps/landing/src/app/page.tsx index b61eaf4c..2ec1823c 100644 --- a/apps/landing/src/app/page.tsx +++ b/apps/landing/src/app/page.tsx @@ -1,18 +1,14 @@ -import { JoinIconButton } from '@app/_components/join-icon-button' -import { Box, Center, css, Flex, Text, VStack } from '@devup-ui/react' -import { Image } from '@devup-ui/react' +import { Box, Flex, Text, VStack } from '@devup-ui/react' import type { Metadata } from 'next' import Link from 'next/link' +import type { ComponentProps } from 'react' import { Button } from '@/components/button' -import { GnbIcon } from '@/components/header/gnb-icon' -import { HeaderSentinel } from '@/components/header/header-sentinel' -import { - ExampleContainer, - ExampleImage, - ExampleProvider, -} from './_components/example' +import { CodeTabs, type CodeExampleQuad } from './_components/code-tabs' +import { CodeWindow, HighlightedCode } from './_components/code-window' +import { CopyInstall } from './_components/copy-install' +import { highlight } from './_lib/highlight' export const metadata: Metadata = { alternates: { @@ -20,290 +16,969 @@ export const metadata: Metadata = { }, } -const EXAMPLES = [ +const VERSION = '0.1.61' +const GITHUB_URL = 'https://github.com/dev-five-git/vespertide' +const DOCS_URL = '/documentation' +const DISCORD_URL = 'https://discord.com/invite/8zjcGc7cWh' +const KAKAO_URL = 'https://open.kakao.com/o/giONwVAh' +const CRATES_URL = 'https://crates.io/crates/vespertide-cli' + +const HERO_MODEL_JSON = `{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/main/schemas/model.schema.json", + "name": "user", + "columns": [ + { "name": "id", "type": "integer", "primary_key": true }, + { "name": "email", "type": "text", "unique": true, "index": true }, + { "name": "name", "type": { "kind": "varchar", "length": 100 } }, + { + "name": "status", + "type": { "kind": "enum", "name": "user_status", + "values": ["active", "inactive", "banned"] }, + "default": "'active'" + }, + { "name": "created_at", "type": "timestamptz", "default": "NOW()" } + ] +}` + +const EXAMPLE_MODEL = `{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/main/schemas/model.schema.json", + "name": "post", + "columns": [ + { "name": "id", "type": "integer", "primary_key": true }, + { "name": "title", "type": { "kind": "varchar", "length": 200 } }, + { "name": "body", "type": "text" }, + { + "name": "author_id", + "type": "integer", + "foreign_key": { + "ref_table": "user", + "ref_columns": ["id"], + "on_delete": "cascade" + }, + "index": true + }, + { + "name": "status", + "type": { "kind": "enum", "name": "post_status", + "values": ["draft", "published", "archived"] }, + "default": "'draft'" + } + ] +}` + +const EXAMPLE_CLI = `# Initialize a new project +$ vespertide init + +# Scaffold a model +$ vespertide new post + +# Edit models/post.json, then preview the diff +$ vespertide diff ++ create_table post (id, title, body, author_id, status) ++ create_enum post_status [draft, published, archived] ++ create_index ix_post_author_id ON post (author_id) + +# Inspect dialect-specific SQL +$ vespertide sql --backend postgres + +# Persist as a migration file +$ vespertide revision -m "create post table"` + +const EXAMPLE_RUNTIME = `use sea_orm::Database; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let db = Database::connect("postgres://user:pass@localhost/mydb").await?; + + // Generated at compile time, run on startup. + vespertide::vespertide_migration!(db).await?; + + Ok(()) +}` + +const EXAMPLE_EXPORT = `# Generate Rust SeaORM entities +$ vespertide export --orm seaorm + +# Or Python — SQLAlchemy +$ vespertide export --orm sqlalchemy + +# Or FastAPI-flavoured SQLModel +$ vespertide export --orm sqlmodel + +# Or Go — GORM +$ vespertide export --orm gorm` + +type FeatureIconName = + | 'hamburger' + | 'arrow-up-right' + | 'chevron' + | 'logo-image' + | 'devfive' + | 'theme-dark' + | 'search' + | 'external-link' + | 'github' + +const FEATURES: { icon: FeatureIconName; title: string; desc: string }[] = [ + { + icon: 'hamburger', + title: 'Declarative schema', + desc: 'Describe your desired database state in JSON files. The current model is the source of truth.', + }, + { + icon: 'arrow-up-right', + title: 'Automatic diffing', + desc: 'Vespertide replays applied migrations and compares them to your models to compute changes.', + }, + { + icon: 'chevron', + title: 'Typed migration plans', + desc: 'Generates safe, portable MigrationAction enums — not raw SQL. Review before you commit.', + }, + { + icon: 'logo-image', + title: 'Multi-database', + desc: 'PostgreSQL, MySQL, and SQLite — same schema, identical semantics, backend-aware quoting.', + }, + { + icon: 'devfive', + title: 'Native enums', + desc: 'First-class string and integer enums. Add new integer values without ever touching the DB.', + }, + { + icon: 'theme-dark', + title: 'Zero-runtime macro', + desc: 'vespertide_migration!() generates database-specific SQL at compile time. Nothing to ship at runtime.', + }, + { + icon: 'search', + title: 'JSON Schema validation', + desc: 'Ships with JSON Schemas — autocomplete, hover docs, and instant errors in your editor.', + }, + { + icon: 'external-link', + title: 'ORM export', + desc: 'One command emits SeaORM, SQLAlchemy, SQLModel, or GORM — entities stay in lockstep with schema.', + }, + { + icon: 'github', + title: 'Built in Rust', + desc: "Single binary CLI, no Node, no Python, no JVM. cargo install vespertide-cli and you're done.", + }, +] + +const STEPS: { title: string; desc: string; mono: string }[] = [ + { + title: 'Define', + desc: 'Author JSON models in your editor with full IDE validation via JSON Schema.', + mono: 'models/user.json', + }, + { + title: 'Replay', + desc: 'Vespertide reconstructs the baseline schema by replaying applied migrations.', + mono: 'migrations/*.sql', + }, + { + title: 'Diff', + desc: 'Current models are compared to the baseline to find what changed.', + mono: 'vespertide diff', + }, + { + title: 'Plan', + desc: 'Differences are converted into typed MigrationAction enums.', + mono: 'MigrationAction', + }, { - id: '1', - title: 'How to Use', - description: - 'Lorem ipsum dolor sit amet. Etiam sit amet feugiat turpis. Proin nec ante a sem vestibulum sodales non ut ex.', - imageUrl: '/images/hero-figure.webp', + title: 'Emit', + desc: 'Actions translate to dialect-specific SQL — Postgres, MySQL, or SQLite.', + mono: 'vespertide sql', + }, +] + +const DBS = [ + { + key: 'PG', + name: 'PostgreSQL', + quote: '"identifier"', + note: 'Full feature support — native enums, JSONB, INET, CIDR, TSVECTOR.', }, { - id: '2', - title: 'How to Use', - description: - 'Lorem ipsum dolor sit amet. Etiam sit amet feugiat turpis. Proin nec ante a sem vestibulum sodales non ut ex.', - imageUrl: '/images/join-us-bg.webp', + key: 'MY', + name: 'MySQL', + quote: '`identifier`', + note: 'Full feature support with MySQL-aware identifier quoting and types.', }, { - id: '3', - title: 'How to Use', - description: - 'Lorem ipsum dolor sit amet. Etiam sit amet feugiat turpis. Proin nec ante a sem vestibulum sodales non ut ex.', - imageUrl: '/images/code.webp', + key: 'SL', + name: 'SQLite', + quote: '"identifier"', + note: 'Full feature support — perfect for tests, CLIs, and embedded apps.', }, ] -export default function HomePage() { +const ORMS = [ + { lang: 'Rust', name: 'SeaORM' }, + { lang: 'Python', name: 'SQLAlchemy' }, + { lang: 'Python', name: 'SQLModel · FastAPI' }, + { lang: 'Go', name: 'GORM' }, +] + +function MaskIcon({ + icon, + size = '24px', + color = '$vespertidePrimary', + ...props +}: { + icon: FeatureIconName | 'discord' | 'kakao' + size?: string + color?: string +} & ComponentProps>) { return ( - <> - -
+ ) +} + +function SectionHead({ + eyebrow, + title, + emphasis, + lede, +}: { + eyebrow: string + title: string + emphasis?: string + lede?: string +}) { + return ( + + + — {eyebrow} + + + {title} + {emphasis && ( + + {emphasis} + + )} + + {lede && ( + + {lede} + + )} + + ) +} + +function HeroSection({ codeHtml }: { codeHtml: string }) { + return ( + + + + + + + + + v{VERSION} · Apache-2.0 · Rust + + + + + Define schemas. +
+ + Forget migrations. + +
+ + + + Vespertide is a declarative database schema manager for Rust. Write + your tables in JSON, and let it diff, plan, and emit type-safe + migrations to Postgres, MySQL, and SQLite — automatically. + + + + + + + + + + + + View on GitHub + + + + + + + + + {[ + { num: `v${VERSION}`, lbl: 'Version' }, + { num: '3', lbl: 'Databases' }, + { num: '4', lbl: 'ORM exports' }, + { num: '0ms', lbl: 'Runtime cost' }, + ].map((s) => ( + + + {s.num} + + + {s.lbl} + + + ))} + +
+ + + + + + +
+
+
+ ) +} + +function FeaturesSection() { + return ( + + + + + + - + {FEATURES.map((f) => ( - - Lorem ipsum dolor sit amet,
- consectetur adipiscing elit. + + + {f.title} - - Etiam sit amet feugiat turpis. Proin nec ante a sem vestibulum - sodales non ut ex.
- Morbi diam turpis, fringilla vitae enim et, egestas consequat - nibh.
- Etiam auctor cursus urna sit amet elementum. + + {f.desc}
- - -
-
+ ))} +
+ + + ) +} -
+ + + + + - - - - Title + {STEPS.map((s, i) => ( + + + {String(i + 1).padStart(2, '0')} - - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam - venenatis, elit in hendrerit porta, augue ante scelerisque diam,{' '} -
- ac egestas lacus est nec urna. Cras commodo risus hendrerit, - suscipit nibh at, porttitor dui. + + {s.title} -
- - {[0, 1, 2, 3].map((i) => ( - + {s.desc} +
+ + - + +
+ ))} +
+
+ + ) +} + +function ExamplesSection({ examples }: { examples: CodeExampleQuad }) { + return ( + + + + + + — Examples + + + One source of truth, +
+ four ways to use it. +
+ + Your JSON models drive the diff, the SQL, the ORM entities, and the + runtime macro. Pick the workflow that fits your team — Vespertide + stays consistent. + + + {[ + { + k: 'Models.', + v: 'Inline foreign keys, enums, and constraints — no separate schema language.', + }, + { + k: 'CLI.', + v: 'diff, sql, revision, status, log — every step is a plain command.', + }, + { + k: 'Runtime.', + v: 'Compile-time macro, zero overhead, no migrations folder shipped to prod.', + }, + { + k: 'Export.', + v: 'SeaORM, SQLAlchemy, SQLModel, GORM — typed entities, generated.', + }, + ].map((it) => ( + + - - Feature title - - - Lorem ipsum dolor sit amet. Etiam sit amet feugiat turpis. - Proin nec ante a sem vestibulum sodales non ut ex.{' '} + → + + + + {it.k} - + {it.v} + ))}
-
- - -
- - - - Title - - - Lorem ipsum dolor sit amet, consectetur adipiscing elit. - Nullam venenatis ac egestas lacus est nec urna.{' '} - - - + + + + + + + ) +} + +function CompatibilitySection() { + return ( + + + + + + + + {DBS.map((db) => ( + + - - - - + {db.key} + - - {EXAMPLES.map(({ id, title, description }) => ( - - - - {title} - - - {description} - - - - ))} - - + + {db.name} + + + + {db.quote} + + + {db.note} + -
-
- - + + + + — ORM export + + + Generate typed entities for the runtime you use. + + + + vespertide export --orm <target> + {' '} + emits up-to-date entities from your current models. + + + {ORMS.map((orm) => ( + + + {orm.lang} + + {orm.name} + + ))} + + + + + ) +} + +function ChannelRow({ + href, + icon, + name, + meta, +}: { + href: string + icon: 'github' | 'discord' | 'kakao' + name: string + meta: string +}) { + return ( + + + + + + + {name} + + - + + + ) +} + +function CommunitySection() { + return ( + + + + + - - - - Join our community - - - Join our Discord and help build the future of frontend with - CSS-in-JS!{' '} + + + — Get started + + + Install once.{' '} + + Iterate forever. - - - - - - - - - - - + + + Vespertide is open source under Apache-2.0 and built in public. + Join the community, file an issue, or pair with us in Discord. + + + + + - - - + + + + Star on GitHub + + - join us background image - - + + + + + +
+ + + + + Apache-2.0 · v{VERSION} ·{' '} + + crates.io + + + + built with Rust · maintained in Seoul + + - + + ) +} + +export default async function HomePage() { + const [heroHtml, modelHtml, cliHtml, runtimeHtml, exportHtml] = + await Promise.all([ + highlight(HERO_MODEL_JSON, 'json'), + highlight(EXAMPLE_MODEL, 'json'), + highlight(EXAMPLE_CLI, 'shell'), + highlight(EXAMPLE_RUNTIME, 'rust'), + highlight(EXAMPLE_EXPORT, 'shell'), + ]) + + const examples: CodeExampleQuad = [ + { key: 'model', label: 'Model', file: 'models/post.json', html: modelHtml }, + { key: 'cli', label: 'CLI', file: '~/projects/blog', html: cliHtml }, + { key: 'runtime', label: 'Runtime', file: 'src/main.rs', html: runtimeHtml }, + { + key: 'export', + label: 'ORM export', + file: '$ vespertide export', + html: exportHtml, + }, + ] + + return ( + + + + + + + + ) } diff --git a/crates/vespertide-cli/AGENTS.md b/crates/vespertide-cli/AGENTS.md index 118ce130..6355f08d 100644 --- a/crates/vespertide-cli/AGENTS.md +++ b/crates/vespertide-cli/AGENTS.md @@ -21,7 +21,7 @@ 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) — + ├── export/ # Export to ORM code (SeaORM/SQLAlchemy/SQLModel/JPA/Prisma/Drizzle/GORM/Django) — │ # mod.rs + tests/ (mod.rs, prisma.rs, drizzle.rs) └── erd/ # ERD diagram export — mod.rs, mermaid.rs, dot.rs, svg/ (style, model, # layout, edges, render, util), tests/ @@ -55,7 +55,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; the non-Rust ORMs (Python/Java/Go) 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`) - 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/erd/mod.rs b/crates/vespertide-cli/src/commands/erd/mod.rs index d94309f7..d14770d4 100644 --- a/crates/vespertide-cli/src/commands/erd/mod.rs +++ b/crates/vespertide-cli/src/commands/erd/mod.rs @@ -146,14 +146,12 @@ pub(super) fn filter_tables_with_warnings( } fn normalize_tables(tables: Vec) -> Result> { - tables - .into_iter() - .map(|table| { - table - .normalize() - .with_context(|| format!("normalize table '{}'", table.name)) - }) - .collect() + let mut normalized = Vec::with_capacity(tables.len()); + for table in tables { + let context = format!("normalize table '{}'", table.name); + normalized.push(table.normalize().context(context)?); + } + Ok(normalized) } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] diff --git a/crates/vespertide-cli/src/commands/erd/tests/mod.rs b/crates/vespertide-cli/src/commands/erd/tests/mod.rs index 7c50005e..7b0f76fa 100644 --- a/crates/vespertide-cli/src/commands/erd/tests/mod.rs +++ b/crates/vespertide-cli/src/commands/erd/tests/mod.rs @@ -11,9 +11,10 @@ use super::dot::render_dot; use super::mermaid::render_mermaid; use super::svg::render_svg; -// SVG / junction mutation-coverage tests live in a sibling file to keep this +// SVG / junction mutation-coverage tests live in sibling files to keep this // module under the 1200-line budget. `use super::*;` there reaches the shared // fixtures defined below. +mod mod_helper_coverage; mod svg_coverage; fn integer() -> ColumnType { @@ -937,230 +938,3 @@ async fn cmd_erd_with_filters_propagates_filter_warnings() { .await .unwrap(); } - -// === Coverage closure for erd/mod.rs private helpers === - -/// `is_junction_table` returns false when fewer than 2 distinct FK column -/// groups are present even though the table has 2+ PK columns → covers -/// mod.rs:259 (`return false;` after `foreign_key_groups.len() < 2`). -#[test] -fn is_junction_table_with_fewer_than_two_fk_groups_returns_false() { - // 2 PK columns, only 1 inline FK → foreign_key_groups.len() == 1. - let tbl = table( - "link", - vec![ - primary_key("a_id", integer()).foreign_key(ForeignKeySyntax::String("other.id".into())), - primary_key("b_id", integer()), - ], - ); - assert!(!is_junction_table(&tbl)); -} - -/// `are_columns_unique` short-circuits to false when the FK column list is -/// empty → covers mod.rs:269 (`return false;`). -#[test] -fn are_columns_unique_empty_columns_returns_false() { - let tbl = table("foo", vec![primary_key("id", integer())]); - let empty: Vec = Vec::new(); - assert!(!are_columns_unique(&tbl, &empty)); -} - -/// `are_columns_unique` returns true when the queried columns match the -/// table's primary key set → covers mod.rs:274 (`return true;`). Driven via -/// `collect_foreign_key_relations` so the OneToOne classification proves the -/// path executed end-to-end. -#[test] -fn are_columns_unique_pk_match_drives_one_to_one_via_collect_relations() { - let users = normalize(&table("user", vec![primary_key("id", integer())])); - // `profile` has a single PK column `user_id` that is ALSO an inline FK - // to user.id → after normalize, primary_key_columns == FK columns → - // are_columns_unique returns true via the PK-match branch. - let profile = normalize(&table( - "profile", - vec![ - primary_key("user_id", integer()) - .foreign_key(ForeignKeySyntax::String("user.id".into())), - ], - )); - let relations = collect_foreign_key_relations(&[users, profile]); - let rel = relations - .iter() - .find(|r| r.child_table == "profile") - .expect("profile relation"); - assert_eq!(rel.cardinality, Cardinality::OneToOne); -} - -/// `foreign_key_column_groups` collects inline FK columns when not yet -/// normalized → covers mod.rs:305 (`if column.foreign_key.is_some()`) + -/// 308 (`groups.push(group)`). Drives through `is_junction_table` so the -/// branch executes on a real public path. -#[test] -fn foreign_key_column_groups_collects_inline_fk_for_unnormalized_junction() { - // NOT normalized — inline FKs remain inline so foreign_key_column_groups' - // inline-FK loop must process them. - let junction = table( - "user_tag", - vec![ - primary_key("user_id", integer()) - .foreign_key(ForeignKeySyntax::String("user.id".into())), - primary_key("tag_id", integer()).foreign_key(ForeignKeySyntax::String("tag.id".into())), - ], - ); - assert!( - is_junction_table(&junction), - "unnormalized junction should still classify via inline FK groups" - ); -} - -/// `inline_unique_column_groups` handles `StrOrBoolOrArray::Bool(true)` by -/// inserting an auto-named group → covers mod.rs:332 (arm header) + 333 -/// (`groups.insert(format!("__auto_{}", column.name), ...)`). -#[test] -fn inline_unique_column_groups_bool_true_creates_auto_group() { - let users = normalize(&table("user", vec![primary_key("id", integer())])); - // child has a single FK column declared `unique: true` (Bool variant). - // `unique_foreign_key` builds exactly that shape. - let child = table( - "child", - vec![ - primary_key("id", integer()), - unique_foreign_key("user_id", "user.id"), - ], - ); - let relations = collect_foreign_key_relations(&[users, child]); - let rel = relations - .iter() - .find(|r| r.child_table == "child") - .expect("child relation"); - // OneToOne proves are_columns_unique returned true via the inline-unique - // Bool(true) path (`__auto_{column}` group). - assert_eq!(rel.cardinality, Cardinality::OneToOne); -} - -/// Direct cover for `foreign_key_column_groups` line 305 -/// (`if column.foreign_key.is_some()`). Calls the private helper with a -/// table whose columns carry inline FK syntax (un-normalized) so the -/// `column.foreign_key.is_some()` predicate evaluates true for each -/// inline-FK column and the `groups.push(group)` body executes. -#[test] -fn foreign_key_column_groups_inline_fk_column_executes_is_some_branch() { - let tbl = table( - "posts", - vec![ - primary_key("id", integer()), - foreign_key("user_id", "users.id"), - foreign_key("author_id", "users.id"), - ], - ); - let groups = foreign_key_column_groups(&tbl); - assert!(groups.iter().any(|g| g == &vec!["user_id".to_string()])); - assert!(groups.iter().any(|g| g == &vec!["author_id".to_string()])); -} - -/// Companion: column without `foreign_key` does NOT push a group. Locks -/// the false-branch of line 305 so a future refactor that reverses the -/// predicate is caught. -#[test] -fn foreign_key_column_groups_skips_columns_without_inline_fk() { - let tbl = table( - "plain", - vec![primary_key("id", integer()), column("body", text())], - ); - let groups = foreign_key_column_groups(&tbl); - assert!( - groups.is_empty(), - "no inline FK → no groups; got {groups:?}" - ); -} - -#[test] -fn foreign_key_column_groups_single_inline_fk_returns_single_column_group() { - let tbl = table( - "posts", - vec![ - primary_key("id", integer()), - foreign_key("user_id", "users.id"), - ], - ); - let groups = foreign_key_column_groups(&tbl); - assert_eq!(groups, vec![vec!["user_id".to_string()]]); -} - -#[test] -fn foreign_key_column_groups_pushes_object_inline_fk_without_table_constraint() { - let inline_fk_column = ColumnDef::new("user_id", integer(), false).foreign_key( - ForeignKeySyntax::Object(ForeignKeyDef { - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - orphan_strategy: Default::default(), - }), - ); - let tbl = TableDef { - name: "posts".into(), - description: None, - columns: vec![primary_key("id", integer()), inline_fk_column], - constraints: vec![], - }; - - let groups = foreign_key_column_groups(&tbl); - - let expected_group = vec!["user_id".to_string()]; - assert!( - groups.iter().any(|group| group == &expected_group), - "inline FK column group was not pushed: {groups:?}" - ); - assert_eq!(groups, vec![expected_group]); -} - -#[test] -fn foreign_key_column_groups_pushes_new_inline_group_after_table_constraint() { - let tbl = TableDef { - name: "posts".into(), - description: None, - columns: vec![ - primary_key("id", integer()), - column("author_id", integer()), - foreign_key("reviewer_id", "users.id"), - ], - constraints: vec![TableConstraint::ForeignKey { - name: Some("fk_posts__author_id".into()), - columns: vec!["author_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - orphan_strategy: Default::default(), - }], - }; - - let groups = foreign_key_column_groups(&tbl); - - assert_eq!( - groups, - vec![ - vec!["author_id".to_string()], - vec!["reviewer_id".to_string()] - ] - ); -} - -/// `parse_reference` is only reached indirectly (through -/// `collect_foreign_key_relations`), which leaves its accept/reject arms -/// attributed to a region the workspace-wide and single-package tarpaulin runs -/// disagree about. Calling it directly pins every branch to its own region. -#[rstest::rstest] -#[case::table_and_column("users.id", Some(("users", "id")))] -#[case::three_parts("a.b.c", None)] -#[case::empty_table(".id", None)] -#[case::empty_column("users.", None)] -#[case::no_separator("users", None)] -#[case::empty_input("", None)] -fn parse_reference_accepts_only_table_dot_column( - #[case] input: &str, - #[case] expected: Option<(&str, &str)>, -) { - let expected = expected.map(|(table, column)| (table.to_string(), vec![column.to_string()])); - assert_eq!(parse_reference(input), expected); -} diff --git a/crates/vespertide-cli/src/commands/erd/tests/mod_helper_coverage.rs b/crates/vespertide-cli/src/commands/erd/tests/mod_helper_coverage.rs new file mode 100644 index 00000000..a02381da --- /dev/null +++ b/crates/vespertide-cli/src/commands/erd/tests/mod_helper_coverage.rs @@ -0,0 +1,273 @@ +//! `erd/mod.rs` private-helper mutation-coverage tests, split out of +//! `tests/mod.rs` to keep that file under the 1200-line budget. `use +//! super::*;` reaches the shared fixtures (`table`, `primary_key`, `integer`, +//! `text`, `column`, `foreign_key`, `unique_foreign_key`, `normalize`, +//! `is_junction_table`, `are_columns_unique`, `foreign_key_column_groups`, +//! `parse_reference`, `ForeignKeySyntax`, …) defined in `tests/mod.rs`. +use super::*; + +/// `is_junction_table` returns false when fewer than 2 distinct FK column +/// groups are present even though the table has 2+ PK columns → covers +/// mod.rs:259 (`return false;` after `foreign_key_groups.len() < 2`). +#[test] +fn is_junction_table_with_fewer_than_two_fk_groups_returns_false() { + // 2 PK columns, only 1 inline FK → foreign_key_groups.len() == 1. + let tbl = table( + "link", + vec![ + primary_key("a_id", integer()).foreign_key(ForeignKeySyntax::String("other.id".into())), + primary_key("b_id", integer()), + ], + ); + assert!(!is_junction_table(&tbl)); +} + +/// `are_columns_unique` short-circuits to false when the FK column list is +/// empty → covers mod.rs:269 (`return false;`). +#[test] +fn are_columns_unique_empty_columns_returns_false() { + let tbl = table("foo", vec![primary_key("id", integer())]); + let empty: Vec = Vec::new(); + assert!(!are_columns_unique(&tbl, &empty)); +} + +/// `are_columns_unique` returns true when the queried columns match the +/// table's primary key set → covers mod.rs:274 (`return true;`). Driven via +/// `collect_foreign_key_relations` so the OneToOne classification proves the +/// path executed end-to-end. +#[test] +fn are_columns_unique_pk_match_drives_one_to_one_via_collect_relations() { + let users = normalize(&table("user", vec![primary_key("id", integer())])); + // `profile` has a single PK column `user_id` that is ALSO an inline FK + // to user.id → after normalize, primary_key_columns == FK columns → + // are_columns_unique returns true via the PK-match branch. + let profile = normalize(&table( + "profile", + vec![ + primary_key("user_id", integer()) + .foreign_key(ForeignKeySyntax::String("user.id".into())), + ], + )); + let relations = collect_foreign_key_relations(&[users, profile]); + let rel = relations + .iter() + .find(|r| r.child_table == "profile") + .expect("profile relation"); + assert_eq!(rel.cardinality, Cardinality::OneToOne); +} + +/// `foreign_key_column_groups` collects inline FK columns when not yet +/// normalized → covers mod.rs:305 (`if column.foreign_key.is_some()`) + +/// 308 (`groups.push(group)`). Drives through `is_junction_table` so the +/// branch executes on a real public path. +#[test] +fn foreign_key_column_groups_collects_inline_fk_for_unnormalized_junction() { + // NOT normalized — inline FKs remain inline so foreign_key_column_groups' + // inline-FK loop must process them. + let junction = table( + "user_tag", + vec![ + primary_key("user_id", integer()) + .foreign_key(ForeignKeySyntax::String("user.id".into())), + primary_key("tag_id", integer()).foreign_key(ForeignKeySyntax::String("tag.id".into())), + ], + ); + assert!( + is_junction_table(&junction), + "unnormalized junction should still classify via inline FK groups" + ); +} + +/// `inline_unique_column_groups` handles `StrOrBoolOrArray::Bool(true)` by +/// inserting an auto-named group → covers mod.rs:332 (arm header) + 333 +/// (`groups.insert(format!("__auto_{}", column.name), ...)`). +#[test] +fn inline_unique_column_groups_bool_true_creates_auto_group() { + let users = normalize(&table("user", vec![primary_key("id", integer())])); + // child has a single FK column declared `unique: true` (Bool variant). + // `unique_foreign_key` builds exactly that shape. + let child = table( + "child", + vec![ + primary_key("id", integer()), + unique_foreign_key("user_id", "user.id"), + ], + ); + let relations = collect_foreign_key_relations(&[users, child]); + let rel = relations + .iter() + .find(|r| r.child_table == "child") + .expect("child relation"); + // OneToOne proves are_columns_unique returned true via the inline-unique + // Bool(true) path (`__auto_{column}` group). + assert_eq!(rel.cardinality, Cardinality::OneToOne); +} + +/// Direct cover for `foreign_key_column_groups` line 305 +/// (`if column.foreign_key.is_some()`). Calls the private helper with a +/// table whose columns carry inline FK syntax (un-normalized) so the +/// `column.foreign_key.is_some()` predicate evaluates true for each +/// inline-FK column and the `groups.push(group)` body executes. +#[test] +fn foreign_key_column_groups_inline_fk_column_executes_is_some_branch() { + let tbl = table( + "posts", + vec![ + primary_key("id", integer()), + foreign_key("user_id", "users.id"), + foreign_key("author_id", "users.id"), + ], + ); + let groups = foreign_key_column_groups(&tbl); + assert!(groups.iter().any(|g| g == &vec!["user_id".to_string()])); + assert!(groups.iter().any(|g| g == &vec!["author_id".to_string()])); +} + +/// Companion: column without `foreign_key` does NOT push a group. Locks +/// the false-branch of line 305 so a future refactor that reverses the +/// predicate is caught. +#[test] +fn foreign_key_column_groups_skips_columns_without_inline_fk() { + let tbl = table( + "plain", + vec![primary_key("id", integer()), column("body", text())], + ); + let groups = foreign_key_column_groups(&tbl); + assert!( + groups.is_empty(), + "no inline FK → no groups; got {groups:?}" + ); +} + +#[test] +fn foreign_key_column_groups_single_inline_fk_returns_single_column_group() { + let tbl = table( + "posts", + vec![ + primary_key("id", integer()), + foreign_key("user_id", "users.id"), + ], + ); + let groups = foreign_key_column_groups(&tbl); + assert_eq!(groups, vec![vec!["user_id".to_string()]]); +} + +#[test] +fn foreign_key_column_groups_pushes_object_inline_fk_without_table_constraint() { + let inline_fk_column = ColumnDef::new("user_id", integer(), false).foreign_key( + ForeignKeySyntax::Object(ForeignKeyDef { + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + orphan_strategy: Default::default(), + }), + ); + let tbl = TableDef { + name: "posts".into(), + description: None, + columns: vec![primary_key("id", integer()), inline_fk_column], + constraints: vec![], + }; + + let groups = foreign_key_column_groups(&tbl); + + let expected_group = vec!["user_id".to_string()]; + assert!( + groups.iter().any(|group| group == &expected_group), + "inline FK column group was not pushed: {groups:?}" + ); + assert_eq!(groups, vec![expected_group]); +} + +/// Inline FK whose parent table is not in the schema is silently ignored. +/// Covers the `table_lookup.get(parent_table)?` `None` branch inside +/// `inline_foreign_key_relation` (the early-return path when the referenced +/// table is absent from the provided schema). +#[test] +fn inline_fk_to_absent_table_is_ignored() { + let article = table( + "article", + vec![ + primary_key("id", integer()), + foreign_key("author_id", "user.id"), + ], + ); + // "user" table is deliberately absent — FK reference cannot resolve. + assert!(collect_foreign_key_relations(&[article]).is_empty()); +} + +/// Mirrors `inline_fk_to_absent_table_is_ignored`, but for a table-level +/// `TableConstraint::ForeignKey` (the `let ... else { continue }` early-exit +/// path in `collect_foreign_key_relations` when the referenced table is +/// absent from the provided schema). +#[test] +fn table_level_fk_to_absent_table_is_ignored() { + let article = TableDef { + name: "article".into(), + description: None, + columns: vec![primary_key("id", integer()), column("author_id", integer())], + constraints: vec![TableConstraint::ForeignKey { + name: Some("fk_article__author_id".into()), + columns: vec!["author_id".into()], + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + orphan_strategy: Default::default(), + }], + }; + // "user" table is deliberately absent — FK reference cannot resolve. + assert!(collect_foreign_key_relations(&[article]).is_empty()); +} + +#[test] +fn foreign_key_column_groups_pushes_new_inline_group_after_table_constraint() { + let tbl = TableDef { + name: "posts".into(), + description: None, + columns: vec![ + primary_key("id", integer()), + column("author_id", integer()), + foreign_key("reviewer_id", "users.id"), + ], + constraints: vec![TableConstraint::ForeignKey { + name: Some("fk_posts__author_id".into()), + columns: vec!["author_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + orphan_strategy: Default::default(), + }], + }; + + let groups = foreign_key_column_groups(&tbl); + + assert_eq!( + groups, + vec![ + vec!["author_id".to_string()], + vec!["reviewer_id".to_string()] + ] + ); +} + +/// `parse_reference` is only reached indirectly (through +/// `collect_foreign_key_relations`), which leaves its accept/reject arms +/// attributed to a region the workspace-wide and single-package tarpaulin runs +/// disagree about. Calling it directly pins every branch to its own region. +#[rstest::rstest] +#[case::table_and_column("users.id", Some(("users", "id")))] +#[case::three_parts("a.b.c", None)] +#[case::empty_table(".id", None)] +#[case::empty_column("users.", None)] +#[case::no_separator("users", None)] +#[case::empty_input("", None)] +fn parse_reference_accepts_only_table_dot_column( + #[case] input: &str, + #[case] expected: Option<(&str, &str)>, +) { + let expected = expected.map(|(table, column)| (table.to_string(), vec![column.to_string()])); + assert_eq!(parse_reference(input), expected); +} diff --git a/crates/vespertide-cli/src/commands/erd/tests/svg_coverage.rs b/crates/vespertide-cli/src/commands/erd/tests/svg_coverage.rs index 4f2a9747..382efcb7 100644 --- a/crates/vespertide-cli/src/commands/erd/tests/svg_coverage.rs +++ b/crates/vespertide-cli/src/commands/erd/tests/svg_coverage.rs @@ -676,3 +676,23 @@ fn normalize_tables_preserves_tables_and_normalizes() { post.constraints ); } + +// `normalize_tables` propagates each table's `.normalize()` error via `?`, +// wrapped with a "normalize table ''" context. An inline FK reference +// missing the required "table.column" dot format fails normalize's FK +// parsing, exercising that error path. +#[test] +fn normalize_tables_propagates_normalize_error_with_context() { + let raw = vec![table( + "dup", + vec![ + primary_key("id", integer()), + foreign_key("owner_id", "noformat"), + ], + )]; + let err = normalize_tables(raw).expect_err("malformed inline FK reference must fail normalize"); + assert!( + format!("{err:#}").contains("normalize table 'dup'"), + "error must be wrapped with table context: {err:#}" + ); +} diff --git a/crates/vespertide-cli/src/commands/export/mod.rs b/crates/vespertide-cli/src/commands/export/mod.rs index 3d5e4f9b..f6acb4ce 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}; @@ -62,14 +62,19 @@ pub async fn cmd_export(orm: Orm, export_dir: Option) -> Result<()> { // Derive crate:: prefix from export directory (e.g., "src/models" -> "crate::models") let crate_prefix = export_dir_to_crate_prefix(&target_root); - // Create SeaORM exporter with config if needed + // Create per-ORM exporters that honor their `vespertide.json` config section let seaorm_exporter = SeaOrmExporterWithConfig::new(config.seaorm(), config.prefix()); + let django_exporter = DjangoExporterWithConfig::new(config.django()); + let gorm_package_name = config.gorm_package_name(&target_root); + let gorm_exporter = GormExporterWithConfig::new(&gorm_package_name); let render_context = ExportRenderContext { target_root: &target_root, all_tables: &all_tables, module_paths: &module_paths, crate_prefix: &crate_prefix, seaorm_exporter: &seaorm_exporter, + django_exporter: &django_exporter, + gorm_exporter: &gorm_exporter, orm_kind: orm, }; @@ -131,6 +136,8 @@ struct ExportRenderContext<'a> { module_paths: &'a HashMap>, crate_prefix: &'a str, seaorm_exporter: &'a SeaOrmExporterWithConfig<'a>, + django_exporter: &'a DjangoExporterWithConfig<'a>, + gorm_exporter: &'a GormExporterWithConfig<'a>, orm_kind: Orm, } @@ -148,6 +155,14 @@ fn render_export_entity( context.crate_prefix, ) .map_err(|e| anyhow::anyhow!(e)), + Orm::Django => context + .django_exporter + .render_entity_with_schema(table, context.all_tables) + .map_err(|e| anyhow::anyhow!(e)), + Orm::Gorm => context + .gorm_exporter + .render_entity_with_schema(table, context.all_tables) + .map_err(|e| anyhow::anyhow!(e)), _ => render_entity_with_schema(context.orm_kind, table, context.all_tables) .map_err(|e| anyhow::anyhow!(e)), }?; diff --git a/crates/vespertide-cli/src/commands/export/tests/django.rs b/crates/vespertide-cli/src/commands/export/tests/django.rs new file mode 100644 index 00000000..94b95d72 --- /dev/null +++ b/crates/vespertide-cli/src/commands/export/tests/django.rs @@ -0,0 +1,40 @@ +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/gadgets.py")).unwrap(); + assert_snapshot!(written); +} + +#[tokio::test] +async fn clean_export_dir_removes_py_files_for_django() { + let tmp = tempdir().unwrap(); + let root = tmp.path().join("export_dir"); + std_fs::create_dir_all(&root).unwrap(); + + std_fs::write(root.join("old_model.py"), "# python file").unwrap(); + std_fs::write(root.join("keep.rs"), "// keep this").unwrap(); + + clean_export_dir(&root, Orm::Django).await.unwrap(); + + assert!(!root.join("old_model.py").exists()); + assert!(root.join("keep.rs").exists()); +} 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..01c67454 --- /dev/null +++ b/crates/vespertide-cli/src/commands/export/tests/gorm.rs @@ -0,0 +1,44 @@ +use super::*; +use insta::assert_snapshot; + +/// Go requires the `package` clause to match the directory the files live 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/widgets.go")).unwrap(); + assert_snapshot!(written); +} + +#[test] +fn build_output_path_gorm_go_extension() { + let root = Path::new("src/models"); + let out = build_output_path(root, Path::new("user.json"), Orm::Gorm); + assert_eq!(out, Path::new("src/models/user.go")); +} + +#[tokio::test] +async fn clean_export_dir_removes_go_files_for_gorm() { + let tmp = tempdir().unwrap(); + let root = tmp.path().join("export_dir"); + std_fs::create_dir_all(&root).unwrap(); + + std_fs::write(root.join("model.go"), "// go file").unwrap(); + std_fs::write(root.join("keep.rs"), "// keep this").unwrap(); + + clean_export_dir(&root, Orm::Gorm).await.unwrap(); + + assert!(!root.join("model.go").exists()); + assert!(root.join("keep.rs").exists()); +} diff --git a/crates/vespertide-cli/src/commands/export/tests/mod.rs b/crates/vespertide-cli/src/commands/export/tests/mod.rs index 5dc489fa..7e1dd79f 100644 --- a/crates/vespertide-cli/src/commands/export/tests/mod.rs +++ b/crates/vespertide-cli/src/commands/export/tests/mod.rs @@ -6,7 +6,9 @@ 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 prisma; fn write_config() { 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..70a1cb8a --- /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,15 @@ +--- +source: crates/vespertide-cli/src/commands/export/tests/django.rs +expression: written +--- +from __future__ import annotations + +from django.db import models + + +class Gadgets(models.Model): + id = models.IntegerField(primary_key=True) + + class Meta: + 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..106b45d6 --- /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,11 @@ +--- +source: crates/vespertide-cli/src/commands/export/tests/gorm.rs +expression: written +--- +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/tests/integration_test.rs b/crates/vespertide-cli/tests/integration_test.rs index a694292c..67dbac86 100644 --- a/crates/vespertide-cli/tests/integration_test.rs +++ b/crates/vespertide-cli/tests/integration_test.rs @@ -109,6 +109,79 @@ fn test_main_with_export_command() { let _ = cmd.assert(); } +fn write_minimal_export_project(root: &std::path::Path) { + std::fs::write( + root.join("vespertide.json"), + r#"{ + "modelsDir": "models", + "migrationsDir": "migrations", + "tableNamingCase": "snake", + "columnNamingCase": "snake", + "modelFormat": "json", + "migrationFormat": "json", + "modelExportDir": "generated" +}"#, + ) + .expect("write config"); + + let models_dir = root.join("models"); + std::fs::create_dir(&models_dir).expect("create models dir"); + std::fs::write( + models_dir.join("users.json"), + r#"{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/model.schema.json", + "name": "users", + "columns": [ + { "name": "id", "type": "integer", "nullable": false, "primary_key": { "auto_increment": true } }, + { "name": "name", "type": { "kind": "varchar", "length": 100 }, "nullable": false } + ] +}"#, + ) + .expect("write model"); +} + +#[test] +fn test_export_django_writes_files_end_to_end() { + let temp_dir = tempfile::TempDir::new().expect("create temp dir"); + write_minimal_export_project(temp_dir.path()); + + vespertide() + .current_dir(temp_dir.path()) + .args(["export", "--orm", "django", "--export-dir", "generated"]) + .assert() + .success(); + + let output_file = temp_dir.path().join("generated").join("users.py"); + assert!( + output_file.exists(), + "expected {} to exist", + output_file.display() + ); + let content = std::fs::read_to_string(&output_file).expect("read generated file"); + assert!(content.contains("class Users(models.Model):")); +} + +#[test] +fn test_export_gorm_writes_files_end_to_end() { + let temp_dir = tempfile::TempDir::new().expect("create temp dir"); + write_minimal_export_project(temp_dir.path()); + + vespertide() + .current_dir(temp_dir.path()) + .args(["export", "--orm", "gorm", "--export-dir", "generated"]) + .assert() + .success(); + + let output_file = temp_dir.path().join("generated").join("users.go"); + assert!( + output_file.exists(), + "expected {} to exist", + output_file.display() + ); + let content = std::fs::read_to_string(&output_file).expect("read generated file"); + assert!(content.contains("type Users struct {")); +} + #[test] fn test_unknown_subcommand_exits_two() { vespertide() diff --git a/crates/vespertide-config/Cargo.toml b/crates/vespertide-config/Cargo.toml index 092f76e2..ea62010e 100644 --- a/crates/vespertide-config/Cargo.toml +++ b/crates/vespertide-config/Cargo.toml @@ -23,6 +23,7 @@ schema = ["dep:schemars"] [dev-dependencies] serde_json = "1" +rstest = "0.26" [lints] workspace = true diff --git a/crates/vespertide-config/src/config.rs b/crates/vespertide-config/src/config.rs index 126c28c3..a9138bfc 100644 --- a/crates/vespertide-config/src/config.rs +++ b/crates/vespertide-config/src/config.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; @@ -78,6 +79,105 @@ impl SeaOrmConfig { } } +/// Django-specific export configuration. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct DjangoConfig { + /// Explicit `app_label` written into every generated model's `Meta` + /// class. Needed when generated models don't live inside a standard + /// Django app package layout, where Django would otherwise infer the + /// label from the containing package. `None` (default) omits + /// `app_label` and leaves Django's normal inference in place. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub app_label: Option, +} + +impl DjangoConfig { + /// Explicit `app_label` to emit in every model's `Meta` class, if set. + pub fn app_label(&self) -> Option<&str> { + self.app_label.as_deref() + } +} + +/// GORM-specific export configuration. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct GormConfig { + /// Go package name emitted at the top of every generated file + /// (`package `). `None` (default) infers the name from the + /// export directory's final path segment (sanitized to a valid Go + /// identifier), falling back to `"models"` when that segment isn't + /// usable. See [`VespertideConfig::gorm_package_name`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub package_name: Option, +} + +impl GormConfig { + /// Explicit Go package name from config, if set. Prefer + /// [`VespertideConfig::gorm_package_name`] to resolve the effective + /// name (this accessor doesn't apply the folder-based inference). + pub fn package_name(&self) -> Option<&str> { + self.package_name.as_deref() + } +} + +/// Fallback Go package name used when neither an explicit `gorm.package_name` +/// nor a usable export directory name is available. +pub const DEFAULT_GORM_PACKAGE_NAME: &str = "models"; + +/// Go reserved words, which can't be used as a package name. +const GO_RESERVED_WORDS: &[&str] = &[ + "break", + "default", + "func", + "interface", + "select", + "case", + "defer", + "go", + "map", + "struct", + "chan", + "else", + "goto", + "package", + "switch", + "const", + "fallthrough", + "if", + "range", + "type", + "continue", + "for", + "import", + "return", + "var", +]; + +/// Sanitize a candidate string into a valid, idiomatic Go package identifier: +/// lowercase ASCII letters/digits only, must not start with a digit, must +/// not collide with a Go reserved word. Returns `None` when nothing usable +/// remains (e.g. an all-Unicode or empty candidate). +fn sanitize_go_package_name(candidate: &str) -> Option { + let cleaned: String = candidate + .chars() + .filter(char::is_ascii_alphanumeric) + .map(|c| c.to_ascii_lowercase()) + .collect(); + + if cleaned.is_empty() || cleaned.starts_with(|c: char| c.is_ascii_digit()) { + return None; + } + if GO_RESERVED_WORDS.contains(&cleaned.as_str()) { + return None; + } + Some(cleaned) +} + /// Top-level vespertide configuration. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] @@ -100,6 +200,12 @@ pub struct VespertideConfig { /// SeaORM-specific export configuration. #[serde(default)] pub seaorm: SeaOrmConfig, + /// Django-specific export configuration. + #[serde(default)] + pub django: DjangoConfig, + /// GORM-specific export configuration. + #[serde(default)] + pub gorm: GormConfig, /// Prefix to add to all table names (including migration version table). /// Default: "" (no prefix) #[serde(default)] @@ -138,6 +244,8 @@ impl Default for VespertideConfig { migration_filename_pattern: default_migration_filename_pattern(), model_export_dir: default_model_export_dir(), seaorm: SeaOrmConfig::default(), + django: DjangoConfig::default(), + gorm: GormConfig::default(), prefix: String::new(), lock_timeout_ms: None, statement_timeout_ms: None, @@ -191,6 +299,38 @@ impl VespertideConfig { &self.seaorm } + /// Django-specific export configuration. + pub fn django(&self) -> &DjangoConfig { + &self.django + } + + /// GORM-specific export configuration. + pub fn gorm(&self) -> &GormConfig { + &self.gorm + } + + /// Effective Go package name for GORM export: an explicit + /// `gorm.package_name` always wins; otherwise it's inferred from + /// `export_dir`'s final path segment (sanitized to a valid Go + /// identifier), falling back to [`DEFAULT_GORM_PACKAGE_NAME`] when that + /// segment isn't usable (e.g. empty, digit-led, or non-ASCII). + /// + /// `export_dir` is the *actual* directory the `.go` files will be + /// written to — normally `model_export_dir`, but callers must pass + /// whatever directory wins after resolving CLI overrides (e.g. + /// `vespertide export --export-dir `), since Go requires the + /// `package` declaration to match the directory the files live in. + pub fn gorm_package_name(&self, export_dir: &Path) -> Cow<'_, str> { + if let Some(name) = &self.gorm.package_name { + return Cow::Borrowed(name); + } + let inferred = export_dir + .file_name() + .and_then(|s| s.to_str()) + .and_then(sanitize_go_package_name); + Cow::Owned(inferred.unwrap_or_else(|| DEFAULT_GORM_PACKAGE_NAME.to_string())) + } + /// Prefix to add to all table names. pub fn prefix(&self) -> &str { &self.prefix diff --git a/crates/vespertide-config/src/lib.rs b/crates/vespertide-config/src/lib.rs index 57ebace6..cd5cbf63 100644 --- a/crates/vespertide-config/src/lib.rs +++ b/crates/vespertide-config/src/lib.rs @@ -7,7 +7,10 @@ pub mod config; pub mod file_format; pub mod name_case; -pub use config::{SeaOrmConfig, VespertideConfig, default_migration_filename_pattern}; +pub use config::{ + DEFAULT_GORM_PACKAGE_NAME, DjangoConfig, GormConfig, SeaOrmConfig, VespertideConfig, + default_migration_filename_pattern, +}; pub use file_format::FileFormat; pub use name_case::NameCase; @@ -15,6 +18,8 @@ pub use name_case::NameCase; mod tests { use std::path::{Path, PathBuf}; + use rstest::rstest; + use super::*; #[test] @@ -100,4 +105,137 @@ mod tests { let cfg: VespertideConfig = serde_json::from_str(json).unwrap(); assert_eq!(cfg.seaorm().extra_enum_derives(), &["MyDerive"]); } + + #[test] + fn django_config_default_has_no_app_label() { + let cfg = DjangoConfig::default(); + assert_eq!(cfg.app_label(), None); + } + + #[test] + fn django_config_accessor() { + let cfg = DjangoConfig { + app_label: Some("myapp".to_string()), + }; + assert_eq!(cfg.app_label(), Some("myapp")); + } + + #[test] + fn django_config_deserialize_with_defaults() { + let json = r"{}"; + let cfg: DjangoConfig = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.app_label(), None); + } + + #[test] + fn django_config_deserialize_with_app_label() { + let json = r#"{"appLabel": "myapp"}"#; + let cfg: DjangoConfig = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.app_label(), Some("myapp")); + } + + #[test] + fn django_config_app_label_absent_from_json_when_none() { + let cfg = DjangoConfig::default(); + let json = serde_json::to_string(&cfg).unwrap(); + assert!( + !json.contains("appLabel"), + "None app_label must not serialize: {json}" + ); + } + + #[test] + fn gorm_config_default_package_name_is_none() { + let cfg = GormConfig::default(); + assert_eq!(cfg.package_name(), None); + } + + #[test] + fn gorm_config_accessor() { + let cfg = GormConfig { + package_name: Some("entities".to_string()), + }; + assert_eq!(cfg.package_name(), Some("entities")); + } + + #[test] + fn gorm_config_deserialize_with_defaults() { + let json = r"{}"; + let cfg: GormConfig = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.package_name(), None); + } + + #[test] + fn gorm_config_deserialize_with_custom_package_name() { + let json = r#"{"packageName": "entities"}"#; + let cfg: GormConfig = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.package_name(), Some("entities")); + } + + #[test] + fn vespertide_config_django_and_gorm_accessors() { + let cfg = VespertideConfig::default(); + assert_eq!(cfg.django().app_label(), None); + assert_eq!(cfg.gorm().package_name(), None); + // model_export_dir defaults to "src/models", so the inferred name matches + // the pre-existing fixed default. + assert_eq!(cfg.gorm_package_name(cfg.model_export_dir()), "models"); + } + + #[test] + fn vespertide_config_deserialize_with_django_and_gorm() { + let json = r#"{ + "modelsDir": "models", + "migrationsDir": "migrations", + "tableNamingCase": "snake", + "columnNamingCase": "snake", + "django": { + "appLabel": "myapp" + }, + "gorm": { + "packageName": "entities" + } + }"#; + let cfg: VespertideConfig = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.django().app_label(), Some("myapp")); + assert_eq!(cfg.gorm().package_name(), Some("entities")); + assert_eq!(cfg.gorm_package_name(cfg.model_export_dir()), "entities"); + } + + #[rstest] + #[case::default_dir_matches_folder("src/models", "models")] + #[case::infers_from_folder_name("src/entities", "entities")] + #[case::strips_invalid_chars("src/db-models", "dbmodels")] + #[case::falls_back_when_digit_led("src/2024-models", "models")] + #[case::falls_back_on_non_ascii("src/모델", "models")] + #[case::falls_back_on_reserved_word("src/type", "models")] + fn gorm_package_name_inferred_from_export_dir( + #[case] export_dir: &str, + #[case] expected: &str, + ) { + let cfg = VespertideConfig::default(); + assert_eq!(cfg.gorm_package_name(Path::new(export_dir)), expected); + } + + #[test] + fn gorm_package_name_explicit_override_wins_over_inference() { + let cfg = VespertideConfig { + gorm: GormConfig { + package_name: Some("custom".to_string()), + }, + ..Default::default() + }; + assert_eq!(cfg.gorm_package_name(Path::new("src/entities")), "custom"); + } + + #[test] + fn gorm_package_name_tracks_cli_export_dir_override_not_config_default() { + // The `--export-dir` CLI flag can point somewhere other than + // `model_export_dir`; the inferred package name must follow the + // actual write target, since Go requires `package` to match the + // directory the files live in. + let cfg = VespertideConfig::default(); + assert_eq!(cfg.model_export_dir(), Path::new("src/models")); + assert_eq!(cfg.gorm_package_name(Path::new("generated")), "generated"); + } } diff --git a/crates/vespertide-core/src/lib.rs b/crates/vespertide-core/src/lib.rs index 85d56f1e..790eff68 100644 --- a/crates/vespertide-core/src/lib.rs +++ b/crates/vespertide-core/src/lib.rs @@ -18,7 +18,8 @@ pub use migration::{MigrationError, MigrationOptions}; pub use schema::{ CheckViolationStrategy, ColumnDef, ColumnName, ColumnType, ComplexColumnType, ConstraintKind, DefaultValue, EnumValues, ForeignKeyOrphanStrategy, IndexDef, IndexName, KeepPolicy, NumValue, - PrimaryKeyAdditionStrategy, ReferenceAction, SimpleColumnType, StrOrBoolOrArray, StringOrBool, - TableConstraint, TableDef, TableName, TableValidationError, UniqueConstraintStrategy, + PrimaryKeyAdditionStrategy, ReferenceAction, ReferenceActionKind, SimpleColumnKind, + SimpleColumnType, StrOrBoolOrArray, StringOrBool, TableConstraint, TableDef, TableName, + TableValidationError, UniqueConstraintStrategy, }; pub use sql_escape::escape_sql_string_literal; diff --git a/crates/vespertide-core/src/schema/column.rs b/crates/vespertide-core/src/schema/column.rs index 9bdcb6e5..464c5bac 100644 --- a/crates/vespertide-core/src/schema/column.rs +++ b/crates/vespertide-core/src/schema/column.rs @@ -444,6 +444,68 @@ impl SimpleColumnType { } } +/// Closed, exhaustive mirror of [`SimpleColumnType`] for downstream crates that need to +/// `match` on it without a wildcard arm. +/// +/// [`SimpleColumnType`] is `#[non_exhaustive]`, so every match on it made *outside* +/// `vespertide-core` must carry a `_` arm even when every current variant is already +/// handled — that arm is genuinely unreachable and shows up as a permanent 0-hit line +/// under coverage instrumentation. This type is deliberately **not** `#[non_exhaustive]`: +/// the conversion below is written inside the crate that owns `SimpleColumnType`, where +/// the non-exhaustiveness restriction doesn't apply, so it can be matched exhaustively +/// with no wildcard, here and in every downstream crate. If `SimpleColumnType` ever gains +/// a variant, `From for SimpleColumnKind` below fails to compile until +/// a matching variant is added here — a compile-time forcing function that replaces the +/// old pattern of a runtime `unreachable!()` guard that only a test could catch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SimpleColumnKind { + SmallInt, + Integer, + BigInt, + Real, + DoublePrecision, + Text, + Boolean, + Date, + Time, + Timestamp, + Timestamptz, + Interval, + Bytea, + Uuid, + Json, + Inet, + Cidr, + Macaddr, + Xml, +} + +impl From for SimpleColumnKind { + fn from(ty: SimpleColumnType) -> Self { + match ty { + SimpleColumnType::SmallInt => Self::SmallInt, + SimpleColumnType::Integer => Self::Integer, + SimpleColumnType::BigInt => Self::BigInt, + SimpleColumnType::Real => Self::Real, + SimpleColumnType::DoublePrecision => Self::DoublePrecision, + SimpleColumnType::Text => Self::Text, + SimpleColumnType::Boolean => Self::Boolean, + SimpleColumnType::Date => Self::Date, + SimpleColumnType::Time => Self::Time, + SimpleColumnType::Timestamp => Self::Timestamp, + SimpleColumnType::Timestamptz => Self::Timestamptz, + SimpleColumnType::Interval => Self::Interval, + SimpleColumnType::Bytea => Self::Bytea, + SimpleColumnType::Uuid => Self::Uuid, + SimpleColumnType::Json => Self::Json, + SimpleColumnType::Inet => Self::Inet, + SimpleColumnType::Cidr => Self::Cidr, + SimpleColumnType::Macaddr => Self::Macaddr, + SimpleColumnType::Xml => Self::Xml, + } + } +} + /// A single variant of an integer-backed enum, pairing a Rust-friendly name with its stored value. /// /// Used inside [`EnumValues::Integer`] to define enums that are stored as `INTEGER` in the diff --git a/crates/vespertide-core/src/schema/mod.rs b/crates/vespertide-core/src/schema/mod.rs index 3e4ca6b3..2fb5699f 100644 --- a/crates/vespertide-core/src/schema/mod.rs +++ b/crates/vespertide-core/src/schema/mod.rs @@ -14,7 +14,8 @@ pub mod unique_strategy; pub use check_violation_strategy::CheckViolationStrategy; pub use column::{ - ColumnDef, ColumnType, ComplexColumnType, EnumValues, NumValue, SimpleColumnType, + ColumnDef, ColumnType, ComplexColumnType, EnumValues, NumValue, SimpleColumnKind, + SimpleColumnType, }; pub use constraint::{ConstraintKind, TableConstraint}; pub use fk_orphan_strategy::ForeignKeyOrphanStrategy; @@ -22,7 +23,7 @@ pub use index::IndexDef; pub use names::{ColumnName, IndexName, TableName}; pub use pk_addition_strategy::PrimaryKeyAdditionStrategy; pub use primary_key::PrimaryKeyDef; -pub use reference::ReferenceAction; +pub use reference::{ReferenceAction, ReferenceActionKind}; pub use str_or_bool::{DefaultValue, StrOrBoolOrArray, StringOrBool}; pub use table::{TableDef, TableValidationError}; pub use unique_strategy::{KeepPolicy, UniqueConstraintStrategy}; @@ -871,5 +872,32 @@ mod tests { }); assert_eq!(ty.enum_variant_names(), Some(vec![])); } + + #[rstest] + #[case(SimpleColumnType::SmallInt, SimpleColumnKind::SmallInt)] + #[case(SimpleColumnType::Integer, SimpleColumnKind::Integer)] + #[case(SimpleColumnType::BigInt, SimpleColumnKind::BigInt)] + #[case(SimpleColumnType::Real, SimpleColumnKind::Real)] + #[case(SimpleColumnType::DoublePrecision, SimpleColumnKind::DoublePrecision)] + #[case(SimpleColumnType::Text, SimpleColumnKind::Text)] + #[case(SimpleColumnType::Boolean, SimpleColumnKind::Boolean)] + #[case(SimpleColumnType::Date, SimpleColumnKind::Date)] + #[case(SimpleColumnType::Time, SimpleColumnKind::Time)] + #[case(SimpleColumnType::Timestamp, SimpleColumnKind::Timestamp)] + #[case(SimpleColumnType::Timestamptz, SimpleColumnKind::Timestamptz)] + #[case(SimpleColumnType::Interval, SimpleColumnKind::Interval)] + #[case(SimpleColumnType::Bytea, SimpleColumnKind::Bytea)] + #[case(SimpleColumnType::Uuid, SimpleColumnKind::Uuid)] + #[case(SimpleColumnType::Json, SimpleColumnKind::Json)] + #[case(SimpleColumnType::Inet, SimpleColumnKind::Inet)] + #[case(SimpleColumnType::Cidr, SimpleColumnKind::Cidr)] + #[case(SimpleColumnType::Macaddr, SimpleColumnKind::Macaddr)] + #[case(SimpleColumnType::Xml, SimpleColumnKind::Xml)] + fn test_simple_column_kind_from_matches_variant( + #[case] ty: SimpleColumnType, + #[case] expected: SimpleColumnKind, + ) { + assert_eq!(SimpleColumnKind::from(ty), expected); + } } } diff --git a/crates/vespertide-core/src/schema/reference.rs b/crates/vespertide-core/src/schema/reference.rs index d4d246ef..3ddaa551 100644 --- a/crates/vespertide-core/src/schema/reference.rs +++ b/crates/vespertide-core/src/schema/reference.rs @@ -44,6 +44,41 @@ impl ReferenceAction { } } +/// Closed, exhaustive mirror of [`ReferenceAction`] for downstream crates that need to +/// `match` on it without a wildcard arm. +/// +/// [`ReferenceAction`] is `#[non_exhaustive]`, so every match on it made *outside* +/// `vespertide-core` must carry a `_` arm even when every current variant is already +/// handled — that arm is genuinely unreachable and shows up as a permanent 0-hit line +/// under coverage instrumentation. This type is deliberately **not** `#[non_exhaustive]`: +/// the conversion below is written inside the crate that owns `ReferenceAction`, where +/// the non-exhaustiveness restriction doesn't apply, so it can be matched exhaustively +/// with no wildcard, here and in every downstream crate. If `ReferenceAction` ever gains +/// a variant, `From<&ReferenceAction> for ReferenceActionKind` below fails to compile +/// until a matching variant is added here — a compile-time forcing function that +/// replaces the old pattern of a runtime `unreachable!()` guard that only a test could +/// catch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReferenceActionKind { + Cascade, + Restrict, + SetNull, + SetDefault, + NoAction, +} + +impl From<&ReferenceAction> for ReferenceActionKind { + fn from(action: &ReferenceAction) -> Self { + match action { + ReferenceAction::Cascade => Self::Cascade, + ReferenceAction::Restrict => Self::Restrict, + ReferenceAction::SetNull => Self::SetNull, + ReferenceAction::SetDefault => Self::SetDefault, + ReferenceAction::NoAction => Self::NoAction, + } + } +} + #[cfg(test)] mod tests { //! Coverage-closure tests for `ReferenceAction::to_sql_keyword`. @@ -67,4 +102,17 @@ mod tests { // lines 40, 41, 42. assert_eq!(action.to_sql_keyword(), expected); } + + #[rstest] + #[case::cascade(ReferenceAction::Cascade, ReferenceActionKind::Cascade)] + #[case::restrict(ReferenceAction::Restrict, ReferenceActionKind::Restrict)] + #[case::set_null(ReferenceAction::SetNull, ReferenceActionKind::SetNull)] + #[case::set_default(ReferenceAction::SetDefault, ReferenceActionKind::SetDefault)] + #[case::no_action(ReferenceAction::NoAction, ReferenceActionKind::NoAction)] + fn reference_action_kind_from_matches_variant( + #[case] action: ReferenceAction, + #[case] expected: ReferenceActionKind, + ) { + assert_eq!(ReferenceActionKind::from(&action), expected); + } } diff --git a/crates/vespertide-exporter/AGENTS.md b/crates/vespertide-exporter/AGENTS.md index 77d6275d..6f285bc7 100644 --- a/crates/vespertide-exporter/AGENTS.md +++ b/crates/vespertide-exporter/AGENTS.md @@ -1,19 +1,19 @@ # 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) ├── 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) ├── 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,6 +21,8 @@ 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 +├── gorm/ # mod.rs, render.rs, types.rs, enums.rs, tests/ — GORM structs +├── django/ # mod.rs, render.rs, types.rs, enums.rs — Django models.Model classes ├── utils/ # common.rs (join_quoted/unquote/claim_field_name), python.rs, │ # typescript.rs (ts_binding/ts_string) └── tests/ # Shared orm_cases! cross-ORM snapshot suite + fixtures/ + snapshots/ @@ -69,6 +71,41 @@ 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` +- **Reverse (has-many)**: `find_reverse_relations()` scans the full schema for FKs pointing back at + the table, including **self-referencing FKs** (a table referencing itself, e.g. + `categories.parent_id -> categories.id`) — the self-ref case is named `Children` rather than a + pluralized table name to avoid colliding with the struct's own name +- **No M2M/junction detection**: a junction table (composite-PK, 2+ FKs) is rendered as a plain + has-many to the junction struct itself, not a dedicated M2M relation +- **Config**: `GormExporterWithConfig` takes the *resolved* package name (a `&str`), not a `GormConfig` — callers get it from `VespertideConfig::gorm_package_name(export_dir)`, which uses an explicit `gorm.package_name` if set, otherwise infers one from the actual export directory's final path segment (sanitized to a valid Go identifier), falling back to `"models"`. The CLI passes the real write target (`--export-dir` override or `model_export_dir`), not the config's static default, since Go requires `package` to match the directory the files live in. +- **Tests**: rendered output is pinned by the shared `orm_cases!` suite; `gorm/tests/` holds only + non-snapshot unit tests — `tests/mod.rs` (type mapping, naming, tag/relation + regressions, package-name config) and `tests/relations.rs` + (composite-FK + self-ref regressions) + +### Django (Python) +- Renders `models.Model` classes with a `class Meta` (`db_table`, `indexes`, `constraints`) +- **M2M junction detection**: `find_many_to_many_fields()` recognizes composite-PK, 2+ FK junction + tables and emits `ManyToManyField(..., through=..., related_name="+")` on both sides; purely + self-referential junctions are skipped rather than guessed at +- **Composite (multi-column) FK**: Django has no native multi-column FK field, so + `collect_composite_fks` (from `utils/python.rs`, shared with SQLAlchemy) is used to emit a + `# composite foreign key: (...) -> ref_table(...)` comment instead of silently dropping the + relationship +- **`build_default()`**: only emits a bare (unquoted) SQL default when it parses as a numeric + literal — an unrecognized bare constant (e.g. a named SQL constant) is omitted rather than + emitted as an undefined Python name +- **PK kwarg**: `primary_key=True` is always emitted for the (non-composite) PK column, regardless + of field type — `models.AutoField`/`SmallAutoField`/`BigAutoField` do **not** imply + `primary_key=True` in real Django; omitting it fails Django's own `fields.E100` system check +- **Config**: `DjangoExporterWithConfig` for `app_label` (omitted from `Meta` when unset) +- **Tests**: rendered output is pinned by the shared `orm_cases!` suite; the inline + `#[cfg(test)] mod tests` in `django/mod.rs` holds only non-snapshot unit tests + ### 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 +144,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 +- 574 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/src/django/enums.rs b/crates/vespertide-exporter/src/django/enums.rs new file mode 100644 index 00000000..85dae123 --- /dev/null +++ b/crates/vespertide-exporter/src/django/enums.rs @@ -0,0 +1,25 @@ +use vespertide_core::schema::column::EnumValues; + +use super::render::to_upper_snake_case; + +pub(super) fn render_enum(lines: &mut Vec, class_name: &str, values: &EnumValues) { + match values { + EnumValues::String(vals) => { + lines.push(format!("class {class_name}(models.TextChoices):")); + for val in vals { + let const_name = to_upper_snake_case(val); + lines.push(format!(" {const_name} = \"{val}\", \"{val}\"")); + } + } + EnumValues::Integer(vals) => { + lines.push(format!("class {class_name}(models.IntegerChoices):")); + for val in vals { + let const_name = to_upper_snake_case(&val.name); + lines.push(format!( + " {const_name} = {}, \"{}\"", + val.value, val.name + )); + } + } + } +} diff --git a/crates/vespertide-exporter/src/django/mod.rs b/crates/vespertide-exporter/src/django/mod.rs new file mode 100644 index 00000000..9bcea8a0 --- /dev/null +++ b/crates/vespertide-exporter/src/django/mod.rs @@ -0,0 +1,673 @@ +mod enums; +mod render; +mod types; + +use crate::orm::OrmExporter; +use vespertide_config::DjangoConfig; +use vespertide_core::TableDef; + +pub use render::{ + export, export_with_config, render_entity, render_entity_with_schema, + render_entity_with_schema_and_config, +}; + +pub struct DjangoExporter; + +impl OrmExporter for DjangoExporter { + fn render_entity(&self, table: &TableDef) -> Result { + render_entity(table) + } + + fn render_entity_with_schema( + &self, + table: &TableDef, + schema: &[TableDef], + ) -> Result { + render_entity_with_schema(table, schema) + } +} + +/// Django exporter that honors `vespertide.json`'s `django` config section +/// (currently an optional `app_label` written into every model's `Meta` +/// class). Mirrors `seaorm::SeaOrmExporterWithConfig`. +pub struct DjangoExporterWithConfig<'a> { + pub config: &'a DjangoConfig, +} + +impl<'a> DjangoExporterWithConfig<'a> { + pub fn new(config: &'a DjangoConfig) -> Self { + Self { config } + } + + pub fn render_entity_with_schema( + &self, + table: &TableDef, + schema: &[TableDef], + ) -> Result { + render_entity_with_schema_and_config(table, schema, self.config.app_label()) + } +} + +#[cfg(test)] +pub(crate) fn to_pascal_case_for_tests(s: &str) -> String { + render::to_pascal_case(s) +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + use vespertide_core::schema::column::SimpleColumnType; + use vespertide_core::schema::constraint::TableConstraint; + use vespertide_core::{ColumnType, ComplexColumnType, DefaultValue, ReferenceAction, TableDef}; + + fn col(name: &str, ty: ColumnType) -> vespertide_core::ColumnDef { + vespertide_core::ColumnDef::new(name, ty, false) + } + + fn nullable_col(name: &str, ty: ColumnType) -> vespertide_core::ColumnDef { + vespertide_core::ColumnDef::new(name, ty, true) + } + + fn auto_pk(columns: &[&str]) -> TableConstraint { + TableConstraint::PrimaryKey { + auto_increment: true, + columns: columns.iter().copied().map(Into::into).collect(), + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + } + } + + fn pk(columns: &[&str]) -> TableConstraint { + TableConstraint::PrimaryKey { + auto_increment: false, + columns: columns.iter().copied().map(Into::into).collect(), + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + } + } + + fn fk(col: &str, ref_table: &str, on_delete: Option) -> TableConstraint { + TableConstraint::ForeignKey { + name: None, + columns: vec![col.into()], + ref_table: ref_table.into(), + ref_columns: vec!["id".into()], + on_delete, + on_update: None, + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + } + } + + #[test] + fn test_composite_pk_of_fk_columns_uses_attname_not_field_name() { + // Composite PK made of FK columns: CompositePrimaryKey must reference + // the Django attname ("{field}_id"), not the stripped field name + // ("article"/"user") used for the ForeignKey attribute itself. + let table = TableDef { + name: "article_user".into(), + description: None, + columns: vec![ + col("article_id", ColumnType::Simple(SimpleColumnType::Integer)), + col("user_id", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![ + pk(&["article_id", "user_id"]), + fk("article_id", "articles", Some(ReferenceAction::Cascade)), + fk("user_id", "users", Some(ReferenceAction::Cascade)), + ], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains("pk = models.CompositePrimaryKey(\"article_id\", \"user_id\")"), + "expected attname-based CompositePrimaryKey args, got:\n{result}" + ); + } + + // ----------------------------------------------------------------------- + // Type coverage — all simple types + // ----------------------------------------------------------------------- + + #[rstest] + #[case::small_int(SimpleColumnType::SmallInt, "models.SmallIntegerField")] + #[case::bigint(SimpleColumnType::BigInt, "models.BigIntegerField")] + #[case::real(SimpleColumnType::Real, "models.FloatField")] + #[case::text(SimpleColumnType::Text, "models.TextField")] + #[case::boolean(SimpleColumnType::Boolean, "models.BooleanField")] + #[case::date(SimpleColumnType::Date, "models.DateField")] + #[case::time(SimpleColumnType::Time, "models.TimeField")] + #[case::timestamp(SimpleColumnType::Timestamp, "models.DateTimeField")] + #[case::uuid(SimpleColumnType::Uuid, "models.UUIDField")] + #[case::json(SimpleColumnType::Json, "models.JSONField")] + #[case::bytea(SimpleColumnType::Bytea, "models.BinaryField")] + #[case::inet(SimpleColumnType::Inet, "models.GenericIPAddressField")] + #[case::interval(SimpleColumnType::Interval, "models.DurationField")] + #[case::macaddr(SimpleColumnType::Macaddr, "models.CharField")] + fn test_simple_type_mapping(#[case] ty: SimpleColumnType, #[case] expected: &str) { + let table = TableDef { + name: "t".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("val", ColumnType::Simple(ty)), + ], + constraints: vec![pk(&["id"])], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains(expected), + "expected {expected} in:\n{result}" + ); + } + + #[rstest] + #[case::small_auto(SimpleColumnType::SmallInt, "models.SmallAutoField")] + #[case::big_auto(SimpleColumnType::BigInt, "models.BigAutoField")] + fn test_auto_pk_field_types(#[case] ty: SimpleColumnType, #[case] expected: &str) { + let table = TableDef { + name: "t".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(ty))], + constraints: vec![auto_pk(&["id"])], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains(expected), + "expected {expected} in:\n{result}" + ); + } + + #[test] + fn test_numeric_field() { + let table = TableDef { + name: "prices".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "amount", + ColumnType::Complex(ComplexColumnType::Numeric { + precision: 10, + scale: 2, + }), + ), + ], + constraints: vec![auto_pk(&["id"])], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains("models.DecimalField"), + "expected DecimalField" + ); + assert!(result.contains("max_digits=10"), "expected max_digits=10"); + assert!( + result.contains("decimal_places=2"), + "expected decimal_places=2" + ); + } + + #[test] + fn test_custom_type_field() { + let table = TableDef { + name: "docs".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "data", + ColumnType::Complex(ComplexColumnType::Custom { + custom_type: "JSONB".into(), + }), + ), + ], + constraints: vec![auto_pk(&["id"])], + }; + let result = render_entity(&table).unwrap(); + // Custom type → models.TextField (Django has no native JSONB) + assert!( + result.contains("data = models.TextField()"), + "expected Custom→TextField in:\n{result}" + ); + } + + #[test] + fn test_uuid_default() { + let mut id_col = col("id", ColumnType::Simple(SimpleColumnType::Uuid)); + id_col.default = Some(DefaultValue::String("gen_random_uuid()".into())); + let table = TableDef { + name: "sessions".into(), + description: None, + columns: vec![id_col], + constraints: vec![pk(&["id"])], + }; + let result = render_entity(&table).unwrap(); + assert!(result.contains("import uuid"), "expected uuid import"); + assert!( + result.contains("default=uuid.uuid4"), + "expected uuid4 callable" + ); + } + + #[test] + fn test_export_multi_table() { + let users = TableDef { + name: "users".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], + constraints: vec![auto_pk(&["id"])], + }; + let posts = TableDef { + name: "posts".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("author_id", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![ + auto_pk(&["id"]), + fk("author_id", "users", Some(ReferenceAction::Cascade)), + ], + }; + let result = export(&[users, posts]).unwrap(); + assert!(result.contains("class Users(models.Model):")); + assert!(result.contains("class Posts(models.Model):")); + } + + #[test] + fn test_nullable_fk_with_db_column() { + // FK column without `_id` suffix → emits db_column kwarg + // Nullable FK → emits null=True, blank=True + let table = TableDef { + name: "comments".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + nullable_col("parent", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![ + auto_pk(&["id"]), + fk("parent", "comments", Some(ReferenceAction::SetNull)), + ], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains(r#"db_column="parent""#), + "expected db_column kwarg" + ); + assert!( + result.contains("null=True"), + "expected null=True for nullable FK" + ); + assert!( + result.contains("blank=True"), + "expected blank=True for nullable FK" + ); + } + + // ----------------------------------------------------------------------- + // build_default: Boolean false → "False" + // ----------------------------------------------------------------------- + + #[test] + fn test_bool_false_default() { + let mut flag = col("enabled", ColumnType::Simple(SimpleColumnType::Boolean)); + flag.default = Some(DefaultValue::Bool(false)); + let table = TableDef { + name: "settings".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + flag, + ], + constraints: vec![auto_pk(&["id"])], + }; + let result = render_entity(&table).unwrap(); + assert!(result.contains("default=False"), "expected default=False"); + } + + // ----------------------------------------------------------------------- + // build_default: Boolean true → "True" + // ----------------------------------------------------------------------- + + #[test] + fn test_bool_true_default() { + let mut flag = col("enabled", ColumnType::Simple(SimpleColumnType::Boolean)); + flag.default = Some(DefaultValue::Bool(true)); + let table = TableDef { + name: "settings".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + flag, + ], + constraints: vec![auto_pk(&["id"])], + }; + let result = render_entity(&table).unwrap(); + assert!(result.contains("default=True"), "expected default=True"); + } + + // ----------------------------------------------------------------------- + // build_default: functional default on non-Timestamp/UUID type → None (omitted) + // ----------------------------------------------------------------------- + + #[test] + fn test_functional_default_non_special() { + let mut seq_id = col("seq_id", ColumnType::Simple(SimpleColumnType::Integer)); + seq_id.default = Some(DefaultValue::String("nextval('my_seq')".into())); + let table = TableDef { + name: "items".into(), + description: None, + columns: vec![seq_id], + constraints: vec![pk(&["seq_id"])], + }; + let result = render_entity(&table).unwrap(); + assert!( + !result.contains("default="), + "functional default should be omitted" + ); + } + + // ----------------------------------------------------------------------- + // reference_action_str: Restrict, SetDefault, NoAction + // ----------------------------------------------------------------------- + + #[rstest] + #[case(ReferenceAction::Restrict, "models.RESTRICT")] + #[case(ReferenceAction::SetDefault, "models.SET_DEFAULT")] + #[case(ReferenceAction::NoAction, "models.DO_NOTHING")] + fn test_fk_on_delete_actions(#[case] action: ReferenceAction, #[case] expected: &str) { + let table = TableDef { + name: "comments".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("post_id", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![auto_pk(&["id"]), fk("post_id", "posts", Some(action))], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains(expected), + "expected {expected} in:\n{result}" + ); + } + + // ----------------------------------------------------------------------- + // Column comment → emits "# ..." line before the field + // ----------------------------------------------------------------------- + + #[test] + fn test_column_comment() { + let mut c = col("name", ColumnType::Simple(SimpleColumnType::Text)); + c.comment = Some("The user's full name".into()); + let table = TableDef { + name: "users".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), c], + constraints: vec![auto_pk(&["id"])], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains(" # The user's full name"), + "expected column comment in output" + ); + } + + // ----------------------------------------------------------------------- + // Unnamed index stays unnamed; unnamed unique constraint gets a name + // ----------------------------------------------------------------------- + + #[test] + fn test_unnamed_index_stays_unnamed_but_unnamed_unique_is_named() { + let table = TableDef { + name: "entries".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "slug", + ColumnType::Complex(ComplexColumnType::Varchar { length: 100 }), + ), + col( + "tag", + ColumnType::Complex(ComplexColumnType::Varchar { length: 50 }), + ), + ], + constraints: vec![ + auto_pk(&["id"]), + TableConstraint::Index { + name: None, + columns: vec!["slug".into()], + }, + TableConstraint::Unique { + name: None, + columns: vec!["slug".into(), "tag".into()], + strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates { + keep: vespertide_core::KeepPolicy::First, + }, + }, + ], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains("models.Index(fields=[\"slug\"]),"), + "expected unnamed Index" + ); + // Django generates an `Index` name itself but rejects a constraint + // without one, so the unnamed unique takes the SQL layer's name. + assert!( + result.contains( + "models.UniqueConstraint(fields=[\"slug\", \"tag\"], name=\"uq_entries__slug_tag\")," + ), + "expected the SQL-layer name on the unnamed UniqueConstraint" + ); + } + + // ----------------------------------------------------------------------- + // Many-to-many junction table recognition (render_entity_with_schema) + // ----------------------------------------------------------------------- + + fn users_table() -> TableDef { + TableDef { + name: "users".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], + constraints: vec![auto_pk(&["id"])], + } + } + + fn tags_table() -> TableDef { + TableDef { + name: "tags".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], + constraints: vec![auto_pk(&["id"])], + } + } + + fn junction_table( + name: &str, + left_col: &str, + left_ref: &str, + right_col: &str, + right_ref: &str, + ) -> TableDef { + TableDef { + name: name.into(), + description: None, + columns: vec![ + col(left_col, ColumnType::Simple(SimpleColumnType::Integer)), + col(right_col, ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![ + pk(&[left_col, right_col]), + fk(left_col, left_ref, None), + fk(right_col, right_ref, None), + ], + } + } + + #[test] + fn test_many_to_many_junction_table() { + let users = users_table(); + let tags = tags_table(); + let user_tags = junction_table("user_tags", "user_id", "users", "tag_id", "tags"); + let schema = vec![users.clone(), tags.clone(), user_tags.clone()]; + + let result = render_entity_with_schema(&users, &schema).unwrap(); + assert!( + result.contains( + "tags = models.ManyToManyField(\"Tags\", through=\"UserTags\", related_name=\"+\")" + ), + "expected ManyToManyField on users side, got:\n{result}" + ); + + let result = render_entity_with_schema(&tags, &schema).unwrap(); + assert!( + result.contains( + "users = models.ManyToManyField(\"Users\", through=\"UserTags\", related_name=\"+\")" + ), + "expected ManyToManyField on tags side, got:\n{result}" + ); + } + + #[test] + fn test_many_to_many_disambiguates_multiple_junctions_to_same_target() { + let users = users_table(); + let tags = tags_table(); + let user_tags = junction_table("user_tags", "user_id", "users", "tag_id", "tags"); + let user_favorite_tags = + junction_table("user_favorite_tags", "user_id", "users", "tag_id", "tags"); + let schema = vec![users.clone(), tags, user_tags, user_favorite_tags]; + + let result = render_entity_with_schema(&users, &schema).unwrap(); + assert!( + result.contains( + "tags_via_user_tags = models.ManyToManyField(\"Tags\", through=\"UserTags\"" + ), + "expected disambiguated field for user_tags junction, got:\n{result}" + ); + assert!( + result.contains( + "tags_via_user_favorite_tags = models.ManyToManyField(\"Tags\", through=\"UserFavoriteTags\"" + ), + "expected disambiguated field for user_favorite_tags junction, got:\n{result}" + ); + } + + #[test] + fn test_purely_self_referential_junction_is_skipped() { + // "friends" links users to users on both sides — not a two-sided M2M + // we can safely name, so no ManyToManyField should be emitted. + let users = users_table(); + let friends = junction_table("friends", "user_id", "users", "friend_id", "users"); + let schema = vec![users.clone(), friends]; + + let result = render_entity_with_schema(&users, &schema).unwrap(); + assert!( + !result.contains("ManyToManyField"), + "self-referential junction must not produce a guessed M2M field, got:\n{result}" + ); + } + + #[test] + fn test_junction_table_unrelated_to_current_table_is_ignored() { + // "order_tags" is a genuine junction (composite PK, 2 FKs both in the + // PK), but neither side references `users` at all — it links + // "orders" and "tags" together, so it must not produce any + // ManyToManyField on `users`. + let users = users_table(); + let tags = tags_table(); + let orders = TableDef { + name: "orders".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], + constraints: vec![auto_pk(&["id"])], + }; + let order_tags = junction_table("order_tags", "order_id", "orders", "tag_id", "tags"); + let schema = vec![users.clone(), tags, orders, order_tags]; + + let result = render_entity_with_schema(&users, &schema).unwrap(); + assert!( + !result.contains("ManyToManyField"), + "junction table unrelated to `users` must not produce a M2M field, got:\n{result}" + ); + } + + #[test] + fn test_export_multi_table_includes_many_to_many() { + let users = users_table(); + let tags = tags_table(); + let user_tags = junction_table("user_tags", "user_id", "users", "tag_id", "tags"); + let result = export(&[users, tags, user_tags]).unwrap(); + assert!( + result.contains("models.ManyToManyField(\"Tags\", through=\"UserTags\""), + "expected ManyToManyField in multi-table export, got:\n{result}" + ); + } + + // ----------------------------------------------------------------------- + // Composite FK: Django has no native multi-column FK field, so it must + // be surfaced as a comment instead of silently dropped. + // ----------------------------------------------------------------------- + + #[test] + fn test_composite_fk_emits_comment() { + let table = TableDef { + name: "order_items".into(), + description: None, + columns: vec![ + col("order_id", ColumnType::Simple(SimpleColumnType::Integer)), + col("region_id", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![ + pk(&["order_id", "region_id"]), + TableConstraint::ForeignKey { + name: None, + columns: vec!["order_id".into(), "region_id".into()], + ref_table: "order_regions".into(), + ref_columns: vec!["order_id".into(), "region_id".into()], + on_delete: None, + on_update: None, + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + ], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains( + "# composite foreign key: (order_id, region_id) -> order_regions(order_id, region_id)" + ), + "expected composite FK comment, got:\n{result}" + ); + } + + // ----------------------------------------------------------------------- + // DjangoExporterWithConfig: app_label reaches the Meta class + // ----------------------------------------------------------------------- + + #[test] + fn test_app_label_omitted_by_default() { + let table = users_table(); + let schema = vec![table.clone()]; + let config = DjangoConfig::default(); + let exporter = DjangoExporterWithConfig::new(&config); + let result = exporter.render_entity_with_schema(&table, &schema).unwrap(); + assert!( + !result.contains("app_label"), + "expected no app_label with default config, got:\n{result}" + ); + } + + #[test] + fn test_app_label_from_config_reaches_meta_class() { + let table = users_table(); + let schema = vec![table.clone()]; + let mut config = DjangoConfig::default(); + config.app_label = Some("myapp".to_string()); + let exporter = DjangoExporterWithConfig::new(&config); + let result = exporter.render_entity_with_schema(&table, &schema).unwrap(); + assert!( + result.contains(" app_label = \"myapp\""), + "expected app_label in Meta class, got:\n{result}" + ); + } +} diff --git a/crates/vespertide-exporter/src/django/render.rs b/crates/vespertide-exporter/src/django/render.rs new file mode 100644 index 00000000..92f0a54d --- /dev/null +++ b/crates/vespertide-exporter/src/django/render.rs @@ -0,0 +1,673 @@ +use std::collections::{HashMap, HashSet}; + +use super::enums::render_enum; +use super::types::{UsedImports, build_field_kwargs, django_field_type, reference_action_str}; +use crate::utils::python::collect_composite_fks; +use vespertide_core::schema::column::{ColumnType, ComplexColumnType}; +use vespertide_core::schema::constraint::TableConstraint; +use vespertide_core::{ReferenceAction, TableDef}; +use vespertide_naming::{IdentifierStart, build_unique_constraint_name, sanitize_identifier}; + +pub fn render_entity(table: &TableDef) -> Result { + let mut used = UsedImports::default(); + let body = render_entity_part(table, &mut used, &[], None); + Ok(assemble_with_imports(&used, &[body])) +} + +/// Render a single table with full schema context so many-to-many junction +/// tables can be recognized and exposed as `ManyToManyField(..., through=...)`. +pub fn render_entity_with_schema(table: &TableDef, schema: &[TableDef]) -> Result { + render_entity_with_schema_and_config(table, schema, None) +} + +/// Same as [`render_entity_with_schema`], but with an optional `app_label` +/// (from `vespertide.json`'s `django` config) written into every model's +/// `Meta` class. +pub fn render_entity_with_schema_and_config( + table: &TableDef, + schema: &[TableDef], + app_label: Option<&str>, +) -> Result { + let mut used = UsedImports::default(); + let m2m_fields = find_many_to_many_fields(table, schema); + let body = render_entity_part(table, &mut used, &m2m_fields, app_label); + Ok(assemble_with_imports(&used, &[body])) +} + +pub fn export(schema: &[TableDef]) -> Result { + export_with_config(schema, None) +} + +/// Same as [`export`], but with an optional `app_label` written into every +/// model's `Meta` class. +pub fn export_with_config(schema: &[TableDef], app_label: Option<&str>) -> Result { + let mut used = UsedImports::default(); + let parts: Vec = schema + .iter() + .map(|t| { + let m2m_fields = find_many_to_many_fields(t, schema); + render_entity_part(t, &mut used, &m2m_fields, app_label) + }) + .collect(); + Ok(assemble_with_imports(&used, &parts)) +} + +/// Recognize many-to-many junction tables (composite PK, 2+ FKs, all FK +/// columns part of the PK) that reference `table`, and render the +/// corresponding `ManyToManyField` lines for the *other* side of each +/// junction. Purely self-referential junctions (every FK pointing back at +/// `table`) are skipped rather than guessed at. +fn find_many_to_many_fields(table: &TableDef, schema: &[TableDef]) -> Vec { + let mut matches: Vec<(String, String)> = Vec::new(); // (target_table, junction_table) + + for other in schema { + if other.name == table.name { + continue; + } + + let other_pk: HashSet = other + .constraints + .iter() + .filter_map(|c| { + if let TableConstraint::PrimaryKey { columns, .. } = c { + Some( + columns + .iter() + .map(|c| c.as_str().to_owned()) + .collect::>(), + ) + } else { + None + } + }) + .flatten() + .collect(); + if other_pk.len() < 2 { + continue; + } + + let fks: Vec<(Vec, String)> = other + .constraints + .iter() + .filter_map(|c| { + if let TableConstraint::ForeignKey { + columns, ref_table, .. + } = c + { + Some(( + columns.iter().map(|c| c.as_str().to_owned()).collect(), + ref_table.as_str().to_owned(), + )) + } else { + None + } + }) + .collect(); + if fks.len() < 2 { + continue; + } + + let all_fk_cols_in_pk = fks + .iter() + .all(|(cols, _)| cols.iter().all(|c| other_pk.contains(c.as_str()))); + if !all_fk_cols_in_pk { + continue; + } + + if !fks + .iter() + .any(|(_, ref_table)| ref_table.as_str() == table.name.as_str()) + { + continue; + } + if fks + .iter() + .all(|(_, ref_table)| ref_table.as_str() == table.name.as_str()) + { + continue; + } + + for (_, ref_table) in &fks { + if ref_table.as_str() == table.name.as_str() { + continue; + } + if schema.iter().any(|t| t.name.as_str() == ref_table.as_str()) { + matches.push((ref_table.clone(), other.name.as_str().to_owned())); + } + } + } + + let mut target_counts: HashMap = HashMap::new(); + for (target, _) in &matches { + *target_counts.entry(target.clone()).or_default() += 1; + } + + let mut used_names: HashSet = HashSet::new(); + matches + .iter() + .map(|(target, junction)| { + let base = pluralize(target); + let field_name = if target_counts.get(target).copied().unwrap_or(0) > 1 { + unique_name(&format!("{base}_via_{junction}"), &mut used_names) + } else { + unique_name(&base, &mut used_names) + }; + let target_class = sanitize_identifier(&to_pascal_case(target), IdentifierStart::Underscore); + let junction_class = + sanitize_identifier(&to_pascal_case(junction), IdentifierStart::Underscore); + format!( + " {field_name} = models.ManyToManyField(\"{target_class}\", through=\"{junction_class}\", related_name=\"+\")" + ) + }) + .collect() +} + +fn pluralize(name: &str) -> String { + if name.ends_with('s') { + name.to_string() + } else { + format!("{name}s") + } +} + +fn unique_name(base: &str, used: &mut HashSet) -> String { + if used.insert(base.to_string()) { + return base.to_string(); + } + let mut n = 2; + loop { + let candidate = format!("{base}_{n}"); + if used.insert(candidate.clone()) { + return candidate; + } + n += 1; + } +} + +fn render_entity_part( + table: &TableDef, + used: &mut UsedImports, + extra_fields: &[String], + app_label: Option<&str>, +) -> String { + let mut lines: Vec = Vec::new(); + + // --- Constraint lookups --- + let pk_columns: HashSet = table + .constraints + .iter() + .filter_map(|c| { + if let TableConstraint::PrimaryKey { columns, .. } = c { + Some( + columns + .iter() + .map(|c| c.as_str().to_owned()) + .collect::>(), + ) + } else { + None + } + }) + .flatten() + .collect(); + + let auto_increment = table.constraints.iter().any(|c| { + matches!( + c, + TableConstraint::PrimaryKey { + auto_increment: true, + .. + } + ) + }); + + let is_composite_pk = pk_columns.len() > 1; + + // Column order (not just membership) matters for CompositePrimaryKey's + // positional args, so capture it separately from the `pk_columns` set. + let pk_columns_ordered: Vec = table + .constraints + .iter() + .find_map(|c| { + if let TableConstraint::PrimaryKey { columns, .. } = c { + Some(columns.iter().map(|c| c.as_str().to_owned()).collect()) + } else { + None + } + }) + .unwrap_or_default(); + + let single_unique_cols: HashSet = table + .constraints + .iter() + .filter_map(|c| { + if let TableConstraint::Unique { columns, .. } = c { + if columns.len() == 1 { + Some(columns[0].as_str().to_owned()) + } else { + None + } + } else { + None + } + }) + .collect(); + + // single-column FK info: col_name → (ref_table, on_delete, on_update) + let fk_map: HashMap, Option<&ReferenceAction>)> = table + .constraints + .iter() + .filter_map(|c| { + if let TableConstraint::ForeignKey { + columns, + ref_table, + ref_columns, + on_delete, + on_update, + .. + } = c + && columns.len() == 1 + && ref_columns.len() == 1 + { + return Some(( + columns[0].as_str().to_owned(), + (ref_table.as_str(), on_delete.as_ref(), on_update.as_ref()), + )); + } + None + }) + .collect(); + + // Enum class names for this table's columns + let enum_class_map: HashMap<&str, String> = table + .columns + .iter() + .filter_map(|col| { + if let ColumnType::Complex(ComplexColumnType::Enum { name, .. }) = &col.r#type { + Some(( + col.name.as_str(), + sanitize_identifier(&to_pascal_case(name), IdentifierStart::Underscore), + )) + } else { + None + } + }) + .collect(); + + // --- Enum class definitions --- + let mut seen_enums: HashSet = HashSet::new(); + for col in &table.columns { + if let ColumnType::Complex(ComplexColumnType::Enum { name, values }) = &col.r#type { + let class_name = + sanitize_identifier(&to_pascal_case(name), IdentifierStart::Underscore); + if seen_enums.insert(class_name.clone()) { + render_enum(&mut lines, &class_name, values); + lines.push(String::new()); + } + } + } + + // --- Class declaration --- + let class_name = sanitize_identifier(&to_pascal_case(&table.name), IdentifierStart::Underscore); + if let Some(ref desc) = table.description { + lines.push(format!("class {class_name}(models.Model):")); + lines.push(format!(" \"\"\"{}\"\"\"", desc.replace('\n', " "))); + lines.push(String::new()); + } else { + lines.push(format!("class {class_name}(models.Model):")); + } + + // Composite PK: Django (5.2+) represents this natively via + // `pk = models.CompositePrimaryKey(...)`, referencing each column by its + // attname (a ForeignKey's attname is always `{field_name}_id`, regardless + // of any `db_column` override). Without this, Django would fall back to + // adding its own implicit auto `id` PK, which doesn't correspond to any + // real uniqueness constraint on the actual table. + // 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 mut used_field_names: HashSet = HashSet::new(); + let mut attnames: HashMap<&str, String> = HashMap::new(); + for col in &table.columns { + let is_pk = pk_columns.contains(col.name.as_str()); + let is_unique = single_unique_cols.contains(col.name.as_str()); + + if let Some(ref comment) = col.comment { + lines.push(format!(" # {}", comment.replace('\n', " "))); + } + + let attname = + if let Some(&(ref_table, on_delete, on_update)) = fk_map.get(col.name.as_str()) { + let field_name = render_fk_field( + &mut lines, + &col.name, + ref_table, + on_delete, + on_update, + col.nullable, + &mut used_field_names, + ); + // A ForeignKey's attname is `{field}_id` whatever `db_column` says. + format!("{field_name}_id") + } else { + let effective_pk = is_pk && !is_composite_pk; + let field_type = django_field_type( + &col.r#type, + effective_pk, + auto_increment && !is_composite_pk, + ); + let field_name = unique_name( + &sanitize_identifier(col.name.as_str(), IdentifierStart::Underscore), + &mut used_field_names, + ); + let db_column = if field_name == col.name.as_str() { + None + } else { + Some(col.name.as_str()) + }; + let kwargs = build_field_kwargs( + &col.r#type, + effective_pk, + is_unique, + col.nullable, + col.default.as_ref(), + enum_class_map.get(col.name.as_str()).map(String::as_str), + db_column, + used, + ); + let kwargs_str = kwargs.join(", "); + if kwargs_str.is_empty() { + lines.push(format!(" {field_name} = {field_type}()")); + } else { + lines.push(format!(" {field_name} = {field_type}({kwargs_str})")); + } + field_name + }; + attnames.insert(col.name.as_str(), attname); + } + + if is_composite_pk { + let args = pk_columns_ordered + .iter() + .map(|col| format!("\"{}\"", attname_of(&attnames, col))) + .collect::>() + .join(", "); + lines.insert( + composite_pk_at, + format!(" pk = models.CompositePrimaryKey({args})"), + ); + } + + for line in extra_fields { + lines.push(line.clone()); + } + + // Composite (multi-column) FKs have no native Django ORM field — surface + // them as a comment rather than silently dropping the relationship info. + // The individual columns still render above as plain scalar fields, and + // referential integrity is enforced by the generated database schema. + for fk in collect_composite_fks(table) { + let local = fk.local_cols.join(", "); + let refs = fk.ref_cols.join(", "); + lines.push(format!( + " # composite foreign key: ({local}) -> {}({refs})", + fk.ref_table + )); + } + + // --- Meta class --- + let indexes: Vec<_> = table + .constraints + .iter() + .filter_map(|c| { + if let TableConstraint::Index { name, columns } = c { + Some((name.as_deref(), columns.as_slice())) + } else { + None + } + }) + .collect(); + + let composite_uniques: Vec<_> = table + .constraints + .iter() + .filter_map(|c| { + if let TableConstraint::Unique { name, columns, .. } = c { + if columns.len() > 1 { + Some((name.as_deref(), columns.as_slice())) + } else { + None + } + } else { + None + } + }) + .collect(); + + lines.push(String::new()); + lines.push(" class Meta:".into()); + lines.push(format!(" db_table = \"{}\"", table.name)); + if let Some(label) = app_label { + lines.push(format!(" app_label = \"{label}\"")); + } + + if !indexes.is_empty() { + lines.push(" indexes = [".into()); + for (name, cols) in &indexes { + let fields = cols + .iter() + .map(|c| format!("\"{}\"", attname_of(&attnames, c))) + .collect::>() + .join(", "); + if let Some(n) = name { + lines.push(format!( + " models.Index(fields=[{fields}], name=\"{n}\")," + )); + } else { + lines.push(format!(" models.Index(fields=[{fields}]),")); + } + } + lines.push(" ]".into()); + } + + if !composite_uniques.is_empty() { + lines.push(" constraints = [".into()); + for (name, cols) in &composite_uniques { + let fields = cols + .iter() + .map(|c| format!("\"{}\"", attname_of(&attnames, c))) + .collect::>() + .join(", "); + // `name` is required on every Django constraint, so an unnamed + // source constraint takes the name the SQL layer gives it. + let n = name.map_or_else( + || build_unique_constraint_name(&table.name, cols, None), + str::to_string, + ); + lines.push(format!( + " models.UniqueConstraint(fields=[{fields}], name=\"{n}\")," + )); + } + lines.push(" ]".into()); + } + + lines.push(String::new()); + lines.join("\n") +} + +fn render_fk_field( + lines: &mut Vec, + col_name: &str, + ref_table: &str, + on_delete: Option<&ReferenceAction>, + on_update: Option<&ReferenceAction>, + nullable: bool, + used_field_names: &mut HashSet, +) -> String { + let (field_name, db_column) = fk_field_name(col_name); + // The `_id` strip can collapse two distinct columns onto the same + // attribute name (e.g. `a_id` -> `a` colliding with a real column `a`). + let deduped_field_name = unique_name(&field_name, used_field_names); + let db_column = + db_column.or_else(|| (deduped_field_name != field_name).then(|| col_name.to_string())); + let field_name = deduped_field_name; + let ref_class = sanitize_identifier(&to_pascal_case(ref_table), IdentifierStart::Underscore); + let on_delete_str = on_delete.map_or("models.RESTRICT", reference_action_str); + + let _ = on_update; // Django ForeignKey has no on_update param; silently ignored + + let mut kwargs = vec![ + format!("\"{ref_class}\""), + format!("on_delete={on_delete_str}"), + ]; + if let Some(db_col) = db_column { + kwargs.push(format!("db_column=\"{db_col}\"")); + } + kwargs.push("related_name=\"+\"".into()); + if nullable { + kwargs.push("null=True".into()); + kwargs.push("blank=True".into()); + } + + let kwargs_str = kwargs.join(", "); + lines.push(format!( + " {field_name} = models.ForeignKey({kwargs_str})" + )); + field_name +} + +/// 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) +} + +/// Returns (field_name, Option). +/// If col_name ends with `_id`, strip it — Django automatically appends `_id`. +/// Otherwise, emit db_column explicitly so Django uses the raw column name. +/// Either way, `field_name` is sanitized into a valid Python identifier; if +/// that sanitization (or the `_id` strip) changes anything, `db_column` is +/// set to the original column name so the DB mapping isn't lost. +fn fk_field_name(col_name: &str) -> (String, Option) { + if let Some(base) = col_name.strip_suffix("_id") { + let sanitized = sanitize_identifier(base, IdentifierStart::Underscore); + if sanitized == base { + (sanitized, None) + } else { + (sanitized, Some(col_name.to_string())) + } + } else { + ( + sanitize_identifier(col_name, IdentifierStart::Underscore), + Some(col_name.to_string()), + ) + } +} + +fn assemble_with_imports(used: &UsedImports, parts: &[String]) -> String { + let mut lines: Vec = Vec::new(); + + lines.push("from __future__ import annotations".into()); + lines.push(String::new()); + + if used.needs_timezone { + lines.push("from django.utils import timezone".into()); + } + if used.needs_uuid_default { + lines.push("import uuid".into()); + } + + lines.push("from django.db import models".into()); + lines.push(String::new()); + lines.push(String::new()); + + lines.push(parts.join("\n")); + lines.join("\n") +} + +pub(super) use crate::python_naming::to_pascal_case; + +pub(super) fn to_upper_snake_case(s: &str) -> String { + let mut result = String::new(); + let chars: Vec = s.chars().collect(); + for (i, &c) in chars.iter().enumerate() { + if c == '-' || c == ' ' { + if !result.ends_with('_') { + result.push('_'); + } + } else if c == '_' { + result.push('_'); + } else if c.is_uppercase() && i > 0 && !result.ends_with('_') { + // Only split on camelCase transitions (lowercase/digit → uppercase). + // Adjacent uppercase letters (e.g. "ERROR") are not split. + let prev = chars[i - 1]; + if prev.is_lowercase() || prev.is_ascii_digit() { + result.push('_'); + } + result.push(c); + } else { + result.push(c.to_ascii_uppercase()); + } + } + // Python identifiers cannot start with a digit + if result.starts_with(|c: char| c.is_ascii_digit()) { + result.insert(0, '_'); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[rstest::rstest] + #[case("pending", "PENDING")] + #[case("in_progress", "IN_PROGRESS")] + #[case("inProgress", "IN_PROGRESS")] + #[case("ERROR_LEVEL", "ERROR_LEVEL")] + #[case("info-level", "INFO_LEVEL")] + #[case("1critical", "_1CRITICAL")] + fn test_to_upper_snake_case(#[case] input: &str, #[case] expected: &str) { + assert_eq!(to_upper_snake_case(input), expected); + } + + #[rstest::rstest] + #[case("author_id", "author", None)] + #[case("user_id", "user", None)] + #[case("parent", "parent", Some("parent"))] + #[case("ref", "ref", Some("ref"))] + fn test_fk_field_name( + #[case] col: &str, + #[case] expected_field: &str, + #[case] expected_db_col: Option<&str>, + ) { + let (field, db_col) = fk_field_name(col); + assert_eq!(field, expected_field); + assert_eq!(db_col.as_deref(), expected_db_col); + } + + #[test] + fn test_to_pascal_case_double_underscore() { + // An empty segment between two underscores contributes nothing + assert_eq!(to_pascal_case("order__item"), "OrderItem"); + assert_eq!(to_pascal_case("_leading"), "Leading"); + assert_eq!(to_pascal_case("trailing_"), "Trailing"); + } + + #[test] + fn test_unique_name_double_collision_appends_incrementing_suffix() { + let mut used = HashSet::new(); + used.insert("tag".to_string()); + used.insert("tag_2".to_string()); + assert_eq!(unique_name("tag", &mut used), "tag_3"); + } +} diff --git a/crates/vespertide-exporter/src/django/types.rs b/crates/vespertide-exporter/src/django/types.rs new file mode 100644 index 00000000..913e97ab --- /dev/null +++ b/crates/vespertide-exporter/src/django/types.rs @@ -0,0 +1,200 @@ +use vespertide_core::DefaultValue; +use vespertide_core::schema::column::{ + ColumnType, ComplexColumnType, EnumValues, SimpleColumnKind, SimpleColumnType, +}; + +#[derive(Default)] +pub(super) struct UsedImports { + pub(super) needs_timezone: bool, + pub(super) needs_uuid_default: bool, +} + +pub(super) fn django_field_type( + col_type: &ColumnType, + is_pk: bool, + auto_increment: bool, +) -> &'static str { + match col_type { + ColumnType::Simple(ty) => match SimpleColumnKind::from(*ty) { + SimpleColumnKind::SmallInt => { + if is_pk && auto_increment { + "models.SmallAutoField" + } else { + "models.SmallIntegerField" + } + } + SimpleColumnKind::Integer => { + if is_pk && auto_increment { + "models.AutoField" + } else { + "models.IntegerField" + } + } + SimpleColumnKind::BigInt => { + if is_pk && auto_increment { + "models.BigAutoField" + } else { + "models.BigIntegerField" + } + } + SimpleColumnKind::Real | SimpleColumnKind::DoublePrecision => "models.FloatField", + SimpleColumnKind::Text | SimpleColumnKind::Xml => "models.TextField", + SimpleColumnKind::Boolean => "models.BooleanField", + SimpleColumnKind::Date => "models.DateField", + SimpleColumnKind::Time => "models.TimeField", + SimpleColumnKind::Timestamp | SimpleColumnKind::Timestamptz => "models.DateTimeField", + SimpleColumnKind::Interval => "models.DurationField", + SimpleColumnKind::Bytea => "models.BinaryField", + SimpleColumnKind::Uuid => "models.UUIDField", + SimpleColumnKind::Json => "models.JSONField", + SimpleColumnKind::Inet | SimpleColumnKind::Cidr => "models.GenericIPAddressField", + SimpleColumnKind::Macaddr => "models.CharField", + }, + ColumnType::Complex(ty) => match ty { + ComplexColumnType::Varchar { .. } | ComplexColumnType::Char { .. } => { + "models.CharField" + } + ComplexColumnType::Numeric { .. } => "models.DecimalField", + ComplexColumnType::Custom { .. } => "models.TextField", + ComplexColumnType::Enum { values, .. } => match values { + EnumValues::String(_) => "models.CharField", + EnumValues::Integer(_) => "models.IntegerField", + }, + // `#[non_exhaustive]` future-variant guard; unreachable today. + #[cfg(not(tarpaulin_include))] + _ => { + unreachable!("ComplexColumnType is #[non_exhaustive]; all variants matched") + } + }, + } +} + +#[expect( + clippy::too_many_arguments, + reason = "all params are independent field-kwarg inputs; a context struct would add noise without reducing coupling" +)] +pub(super) fn build_field_kwargs( + col_type: &ColumnType, + is_pk: bool, + is_unique: bool, + nullable: bool, + default: Option<&DefaultValue>, + enum_class_name: Option<&str>, + db_column: Option<&str>, + used: &mut UsedImports, +) -> Vec { + let mut kwargs: Vec = Vec::new(); + + if let Some(db_col) = db_column { + kwargs.push(format!("db_column=\"{db_col}\"")); + } + + // Size / precision kwargs + match col_type { + ColumnType::Complex( + ComplexColumnType::Varchar { length } | ComplexColumnType::Char { length }, + ) => { + kwargs.push(format!("max_length={length}")); + } + ColumnType::Simple(SimpleColumnType::Macaddr) => { + kwargs.push("max_length=17".into()); + } + ColumnType::Complex(ComplexColumnType::Numeric { precision, scale }) => { + kwargs.push(format!("max_digits={precision}")); + kwargs.push(format!("decimal_places={scale}")); + } + ColumnType::Complex(ComplexColumnType::Enum { values, .. }) => { + if let Some(class) = enum_class_name { + if let EnumValues::String(vals) = values { + let mut max_len = 1; + for v in vals { + if v.len() > max_len { + max_len = v.len(); + } + } + kwargs.push(format!("max_length={max_len}")); + } + kwargs.push(format!("choices={class}.choices")); + } + } + _ => {} + } + + for (cond, kwarg) in [ + (is_pk, "primary_key=True"), + (is_unique && !is_pk, "unique=True"), + ] { + if cond { + kwargs.push(kwarg.into()); + } + } + if nullable && !is_pk { + kwargs.push("null=True".into()); + kwargs.push("blank=True".into()); + } + if let Some(dv) = default + && let Some(expr) = build_default(col_type, &dv.to_sql(), used) + { + kwargs.push(format!("default={expr}")); + } + + kwargs +} + +pub(super) fn build_default( + col_type: &ColumnType, + sql: &str, + used: &mut UsedImports, +) -> Option { + if sql.contains('(') { + let up = sql.to_uppercase(); + let is_timestamp_col = matches!( + col_type, + ColumnType::Simple(SimpleColumnType::Timestamp | SimpleColumnType::Timestamptz) + ); + if is_timestamp_col && (up.contains("NOW") || up.contains("CURRENT_TIMESTAMP")) { + used.needs_timezone = true; + return Some("timezone.now".into()); + } + if matches!(col_type, ColumnType::Simple(SimpleColumnType::Uuid)) { + used.needs_uuid_default = true; + return Some("uuid.uuid4".into()); + } + return None; + } + + let up = sql.to_uppercase(); + if up == "TRUE" { + return Some("True".into()); + } + if up == "FALSE" { + return Some("False".into()); + } + + if sql.starts_with('\'') && sql.ends_with('\'') && sql.len() >= 2 { + let inner = &sql[1..sql.len() - 1]; + return Some(format!("\"{}\"", inner.replace('"', "\\\""))); + } + + // A bare numeric literal (e.g. "0", "-1.5") is valid Python as-is. Any + // other bare, unquoted token is an unresolvable DB-level constant/ + // expression (e.g. a named SQL constant) — emitting it verbatim would + // produce an undefined-name reference in the generated Python, so omit + // the default entirely rather than guess. + if sql.parse::().is_ok() { + return Some(sql.into()); + } + + None +} + +pub(super) fn reference_action_str(action: &vespertide_core::ReferenceAction) -> &'static str { + use vespertide_core::ReferenceActionKind; + match ReferenceActionKind::from(action) { + ReferenceActionKind::Cascade => "models.CASCADE", + ReferenceActionKind::Restrict => "models.RESTRICT", + ReferenceActionKind::SetNull => "models.SET_NULL", + ReferenceActionKind::SetDefault => "models.SET_DEFAULT", + ReferenceActionKind::NoAction => "models.DO_NOTHING", + } +} diff --git a/crates/vespertide-exporter/src/gorm/enums.rs b/crates/vespertide-exporter/src/gorm/enums.rs new file mode 100644 index 00000000..1529e699 --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/enums.rs @@ -0,0 +1,58 @@ +use vespertide_core::schema::column::EnumValues; +use vespertide_naming::{IdentifierStart, sanitize_identifier}; + +use super::render::to_pascal_case; + +pub(super) fn render_enum(lines: &mut Vec, name: &str, values: &EnumValues) { + // `name` is already the sanitized, PascalCased (and possibly struct-qualified) + // identifier built by the caller — re-running `to_pascal_case` here would + // split on the `_` a leading-digit escape (e.g. `_1users`) introduces and + // silently drop it. + let type_name = name; + + let mut rendered = match values { + EnumValues::String(_) => { + vec![ + format!("type {type_name} string"), + String::new(), + "const (".into(), + ] + } + EnumValues::Integer(_) => { + vec![ + format!("type {type_name} int"), + String::new(), + "const (".into(), + ] + } + }; + + match values { + EnumValues::String(vals) => { + for val in vals { + let const_name = const_name(type_name, val); + rendered.push(format!(" {const_name} {type_name} = \"{val}\"")); + } + } + EnumValues::Integer(vals) => { + for val in vals { + let const_name = const_name(type_name, &val.name); + rendered.push(format!(" {const_name} {type_name} = {}", val.value)); + } + } + } + + rendered.push(")".into()); + lines.extend(rendered); +} + +/// A Go constant name for one enum member. 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. +fn const_name(type_name: &str, value: &str) -> String { + sanitize_identifier( + &format!("{type_name}{}", to_pascal_case(value)), + IdentifierStart::Underscore, + ) +} diff --git a/crates/vespertide-exporter/src/gorm/mod.rs b/crates/vespertide-exporter/src/gorm/mod.rs new file mode 100644 index 00000000..6a236d60 --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/mod.rs @@ -0,0 +1,110 @@ +mod enums; +mod render; +mod types; + +use crate::orm::OrmExporter; +use render::{imports_for, render_header, render_table_body}; +use vespertide_config::DEFAULT_GORM_PACKAGE_NAME; +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) + } +} + +/// GORM exporter that honors `vespertide.json`'s `gorm` config section +/// (currently the effective Go package name — see +/// `VespertideConfig::gorm_package_name`, which resolves an explicit +/// `gorm.package_name` or infers one from the actual export directory — +/// emitted at the top of every file). Mirrors `seaorm::SeaOrmExporterWithConfig`. +pub struct GormExporterWithConfig<'a> { + package_name: &'a str, +} + +impl<'a> GormExporterWithConfig<'a> { + /// `package_name` is the already-resolved effective package name (see + /// `VespertideConfig::gorm_package_name`), not the raw `GormConfig` + /// field — resolving requires the actual export directory, which the + /// `GormConfig` alone doesn't know. + pub fn new(package_name: &'a str) -> Self { + Self { package_name } + } + + pub fn render_entity(&self, table: &TableDef) -> Result { + Ok(render_entity_inner_with_package( + table, + &[], + self.package_name, + )) + } + + pub fn render_entity_with_schema( + &self, + table: &TableDef, + schema: &[TableDef], + ) -> Result { + Ok(render_entity_inner_with_package( + table, + schema, + self.package_name, + )) + } +} + +/// Render a GORM entity for the given table definition. +pub fn render_entity(table: &TableDef) -> Result { + Ok(render_entity_inner(table, &[])) +} + +/// Render a GORM entity with full schema context for reverse-relation (HasMany) generation. +pub fn render_entity_with_schema(table: &TableDef, schema: &[TableDef]) -> Result { + Ok(render_entity_inner(table, schema)) +} + +#[cfg(test)] +pub(crate) fn to_pascal_case_for_tests(s: &str) -> String { + render::to_pascal_case(s) +} + +fn render_entity_inner(table: &TableDef, schema: &[TableDef]) -> String { + render_entity_inner_with_package(table, schema, DEFAULT_GORM_PACKAGE_NAME) +} + +fn render_entity_inner_with_package( + table: &TableDef, + schema: &[TableDef], + package_name: &str, +) -> String { + let mut lines = render_header(package_name, &imports_for(std::slice::from_ref(table))); + lines.extend(render_table_body(table, schema)); + lines.join("\n") +} + +/// 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 { + let mut lines = render_header(DEFAULT_GORM_PACKAGE_NAME, &imports_for(schema)); + for (i, table) in schema.iter().enumerate() { + if i > 0 { + lines.push(String::new()); + } + lines.extend(render_table_body(table, schema)); + } + Ok(lines.join("\n")) +} + +#[cfg(test)] +mod tests; diff --git a/crates/vespertide-exporter/src/gorm/render.rs b/crates/vespertide-exporter/src/gorm/render.rs new file mode 100644 index 00000000..cb8a52a6 --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/render.rs @@ -0,0 +1,724 @@ +use std::collections::{HashMap, HashSet}; + +use super::enums::render_enum; +use super::types::{UsedImports, go_type_for_column_mapped}; +use crate::utils::common::claim_binding; +use vespertide_core::schema::column::{ + ColumnType, ComplexColumnType, EnumValues, SimpleColumnType, +}; +use vespertide_core::schema::constraint::TableConstraint; +use vespertide_core::schema::names::ColumnName; +use vespertide_core::{ColumnDef, DefaultValue, ReferenceAction, TableDef}; +use vespertide_naming::{IdentifierStart, sanitize_identifier}; + +/// The Go imports the columns of `tables` need. +pub(super) fn imports_for<'a>(tables: impl IntoIterator) -> UsedImports { + let mut used = UsedImports::default(); + for col in tables.into_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_datatypes { + lines.push(" \"gorm.io/datatypes\"".into()); + } + if used_imports.needs_uuid { + lines.push(" \"github.com/google/uuid\"".into()); + } + if used_imports.needs_decimal { + lines.push(" \"github.com/shopspring/decimal\"".into()); + } + lines.push(")".into()); + lines.push(String::new()); + } + lines +} + +/// Everything below the header for one table: enum types, the struct, and +/// its methods. +pub(super) fn render_table_body(table: &TableDef, schema: &[TableDef]) -> Vec { + let mut lines: Vec = Vec::new(); + + let struct_name = + sanitize_identifier(&to_pascal_case(&table.name), IdentifierStart::Underscore); + + // Find enum names that appear in multiple schema tables (need qualified Go type names) + let conflicting_enums: HashSet = { + let mut counts: HashMap = HashMap::new(); + for col in &table.columns { + if let ColumnType::Complex(ComplexColumnType::Enum { name, .. }) = &col.r#type { + counts + .entry(sanitize_identifier( + &to_pascal_case(name), + IdentifierStart::Underscore, + )) + .or_insert(1); + } + } + for other in schema { + if other.name == table.name { + continue; + } + let mut seen = HashSet::new(); + for col in &other.columns { + if let ColumnType::Complex(ComplexColumnType::Enum { name, .. }) = &col.r#type { + let pascal = + sanitize_identifier(&to_pascal_case(name), IdentifierStart::Underscore); + if seen.insert(pascal.clone()) { + *counts.entry(pascal).or_default() += 1; + } + } + } + } + counts + .into_iter() + .filter(|(_, c)| *c > 1) + .map(|(n, _)| n) + .collect() + }; + + // Collect enums defined in this table's columns, with qualified names where needed + let enums: Vec<(&str, &EnumValues, String)> = table + .columns + .iter() + .filter_map(|col| { + if let ColumnType::Complex(ComplexColumnType::Enum { name, values }) = &col.r#type { + let pascal = + sanitize_identifier(&to_pascal_case(name), IdentifierStart::Underscore); + let qualified = if conflicting_enums.contains(&pascal) { + format!("{struct_name}{pascal}") + } else { + pascal + }; + Some((name.as_str(), values, qualified)) + } else { + None + } + }) + .collect(); + let enum_name_map: HashMap<&str, String> = enums + .iter() + .map(|(name, _, qualified)| (*name, qualified.clone())) + .collect(); + + let fk_by_column = collect_fk_info(&table.constraints); + + let pk_columns: HashSet = table + .constraints + .iter() + .filter_map(|c| { + if let TableConstraint::PrimaryKey { columns, .. } = c { + Some(columns.clone()) + } else { + None + } + }) + .flatten() + .map(|c| c.as_str().to_owned()) + .collect(); + + let auto_increment = table.constraints.iter().any(|c| { + matches!( + c, + TableConstraint::PrimaryKey { + auto_increment: true, + .. + } + ) + }); + + let is_composite_pk = pk_columns.len() > 1; + + let single_unique_columns: HashSet = table + .constraints + .iter() + .filter_map(|c| { + if let TableConstraint::Unique { columns, .. } = c { + if columns.len() == 1 { + Some(columns[0].as_str().to_owned()) + } else { + None + } + } else { + None + } + }) + .collect(); + + let index_map = collect_index_info(&table.constraints); + let composite_unique_map = collect_composite_unique_info(&table.constraints); + + let reverse_relations = find_reverse_relations(&table.name, schema); + + // --- Enum type declarations --- + // Two columns of one table may share an enum; Go rejects the second + // declaration of the same type. + let mut declared_enums: HashSet<&str> = HashSet::new(); + for (_, values, qualified_name) in &enums { + if !declared_enums.insert(qualified_name.as_str()) { + continue; + } + render_enum(&mut lines, qualified_name, values); + lines.push(String::new()); + } + + // --- Struct definition --- + if let Some(ref desc) = table.description { + lines.push(format!("// {}", desc.replace('\n', " "))); + } + + lines.push(format!("type {struct_name} struct {{")); + + // Every real column's field name is reserved up front so belongs-to + // relation fields (single-column and composite) can detect a collision + // regardless of which column — FK or plain — happens to come first in + // the table definition. + let used_field_names: HashSet = table + .columns + .iter() + .map(|c| to_go_field_name(&c.name)) + .collect(); + let mut used_relation_names = used_field_names.clone(); + + for col in &table.columns { + let is_pk = pk_columns.contains(col.name.as_str()); + let is_unique = single_unique_columns.contains(col.name.as_str()); + let indexes = index_map + .get(col.name.as_str()) + .map_or(&[][..], Vec::as_slice); + let composite_unique_name = composite_unique_map.get(col.name.as_str()); + + if let Some(ref comment) = col.comment { + lines.push(format!(" // {}", comment.replace('\n', " "))); + } + + render_column_field( + &mut lines, + col, + is_pk, + auto_increment && !is_composite_pk, + is_unique, + indexes, + composite_unique_name, + &enum_name_map, + ); + + if let Some(fk) = fk_by_column.get(col.name.as_str()) { + render_fk_relation_field(&mut lines, col, fk, &mut used_relation_names); + } + } + + // Composite (multi-column) FK relation fields. GORM supports composite + // associations via comma-separated `foreignKey`/`references` tags, unlike + // Django which has no native equivalent. + for fk in collect_composite_fk_info(&table.constraints) { + render_composite_fk_relation_field(&mut lines, &fk, &mut used_relation_names); + } + + // Reverse relation fields (HasMany) derived from schema context + for rel in &reverse_relations { + let mut constraint_parts: Vec = Vec::new(); + if let Some(ref action) = rel.on_delete { + constraint_parts.push(format!("OnDelete:{}", action.to_sql_keyword())); + } + if let Some(ref action) = rel.on_update { + constraint_parts.push(format!("OnUpdate:{}", action.to_sql_keyword())); + } + let fk_field = to_go_field_name(&rel.fk_column); + let gorm_tag = if constraint_parts.is_empty() { + format!("foreignKey:{fk_field}") + } else { + format!( + "foreignKey:{fk_field};constraint:{}", + constraint_parts.join(",") + ) + }; + lines.push(format!( + " {field_name} []{ref_struct} `gorm:\"{gorm_tag}\" json:\"-\"`", + field_name = rel.field_name, + ref_struct = + sanitize_identifier(&to_pascal_case(&rel.ref_table), IdentifierStart::Underscore), + )); + } + + lines.push("}".into()); + lines.push(String::new()); + + // --- TableName() method --- + if needs_table_name_method(&table.name, &struct_name) { + lines.push(format!( + "func ({struct_name}) TableName() string {{ return \"{name}\" }}", + name = table.name, + )); + lines.push(String::new()); + } + + lines +} + +// --------------------------------------------------------------------------- +// FK info collection +// --------------------------------------------------------------------------- + +struct FkInfo { + ref_table: String, + on_delete: Option, + on_update: Option, +} + +struct CompositeFkInfo { + local_cols: Vec, + ref_table: String, + ref_cols: Vec, + on_delete: Option, + on_update: Option, +} + +fn collect_composite_fk_info(constraints: &[TableConstraint]) -> Vec { + constraints + .iter() + .filter_map(|c| { + if let TableConstraint::ForeignKey { + columns, + ref_table, + ref_columns, + on_delete, + on_update, + .. + } = c + && columns.len() > 1 + && columns.len() == ref_columns.len() + { + return Some(CompositeFkInfo { + local_cols: columns.iter().map(|c| c.as_str().to_owned()).collect(), + ref_table: ref_table.as_str().to_owned(), + ref_cols: ref_columns.iter().map(|c| c.as_str().to_owned()).collect(), + on_delete: on_delete.clone(), + on_update: on_update.clone(), + }); + } + None + }) + .collect() +} + +fn collect_fk_info(constraints: &[TableConstraint]) -> HashMap { + constraints + .iter() + .filter_map(|c| { + if let TableConstraint::ForeignKey { + columns, + ref_table, + ref_columns, + on_delete, + on_update, + .. + } = c + { + if columns.len() == 1 && ref_columns.len() == 1 { + Some(( + columns[0].as_str().to_owned(), + FkInfo { + ref_table: ref_table.as_str().to_owned(), + on_delete: on_delete.clone(), + on_update: on_update.clone(), + }, + )) + } else { + None + } + } else { + None + } + }) + .collect() +} + +// --------------------------------------------------------------------------- +// Index info collection +// --------------------------------------------------------------------------- + +struct IndexInfo { + name: Option, +} + +fn collect_index_info(constraints: &[TableConstraint]) -> HashMap> { + let mut map: HashMap> = HashMap::new(); + for c in constraints { + if let TableConstraint::Index { name, columns } = c { + for col in columns { + map.entry(col.as_str().to_owned()) + .or_default() + .push(IndexInfo { + name: name.as_ref().map(|n| n.as_str().to_owned()), + }); + } + } + } + map +} + +fn collect_composite_unique_info(constraints: &[TableConstraint]) -> HashMap { + let mut map = HashMap::new(); + for c in constraints { + if let TableConstraint::Unique { name, columns, .. } = c + && columns.len() > 1 + { + let uq_name = name.as_ref().map_or_else( + || { + let parts: Vec<&str> = columns.iter().map(ColumnName::as_str).collect(); + format!("uq_{}", parts.join("_")) + }, + |n| n.as_str().to_owned(), + ); + for col in columns { + map.insert(col.as_str().to_owned(), uq_name.clone()); + } + } + } + map +} + +// --------------------------------------------------------------------------- +// Reverse relation discovery +// --------------------------------------------------------------------------- + +struct ReverseRelation { + field_name: String, + ref_table: String, + fk_column: String, + on_delete: Option, + on_update: Option, +} + +fn find_reverse_relations(table_name: &str, schema: &[TableDef]) -> Vec { + type RawRelation = ( + String, + String, + String, + Option, + Option, + ); + let mut raw: Vec = Vec::new(); + for other in schema { + // Note: self-referencing tables (other.name == table_name) are NOT + // skipped here — a table's own FK column pointing back at itself + // (e.g. categories.parent_id -> categories.id) must still produce a + // reverse has-many ("Children") relation on the same struct. + for c in &other.constraints { + if let TableConstraint::ForeignKey { + columns, + ref_table, + on_delete, + on_update, + .. + } = c + && ref_table.as_str() == table_name + && columns.len() == 1 + { + let fk_col = columns[0].as_str().to_owned(); + let is_self_ref = other.name.as_str() == table_name; + let base_name = if is_self_ref { + "Children".to_string() + } else { + let pascal = sanitize_identifier( + &to_pascal_case(other.name.as_str()), + IdentifierStart::Underscore, + ); + if pascal.ends_with('s') { + pascal + } else { + format!("{pascal}s") + } + }; + raw.push(( + other.name.as_str().to_owned(), + fk_col, + base_name, + on_delete.clone(), + on_update.clone(), + )); + } + } + } + + let mut name_count: HashMap = HashMap::new(); + for (_, _, base_name, _, _) in &raw { + *name_count.entry(base_name.clone()).or_default() += 1; + } + + raw.into_iter() + .map(|(ref_table, fk_col, base_name, on_delete, on_update)| { + let field_name = if *name_count.get(&base_name).unwrap_or(&0) > 1 { + format!("{}By{}", base_name, to_go_field_name(&fk_col)) + } else { + base_name + }; + ReverseRelation { + field_name, + ref_table, + fk_column: fk_col, + on_delete, + on_update, + } + }) + .collect() +} + +// --------------------------------------------------------------------------- +// Field rendering +// --------------------------------------------------------------------------- + +#[expect( + clippy::too_many_arguments, + reason = "all params are independent field-rendering inputs; a context struct would add noise without reducing coupling" +)] +fn render_column_field( + lines: &mut Vec, + col: &ColumnDef, + is_pk: bool, + auto_increment: bool, + is_unique: bool, + indexes: &[IndexInfo], + composite_unique_name: Option<&String>, + enum_name_map: &HashMap<&str, String>, +) { + let go_type = go_type_for_column_mapped(&col.r#type, col.nullable, enum_name_map); + let field_name = to_go_field_name(&col.name); + let gorm_tag = build_gorm_tag( + col, + is_pk, + auto_increment, + is_unique, + indexes, + composite_unique_name, + ); + + lines.push(format!( + " {field_name} {go_type} `gorm:\"{gorm_tag}\" json:\"{json_name}\"`", + json_name = col.name, + )); +} + +fn render_fk_relation_field( + lines: &mut Vec, + col: &ColumnDef, + fk: &FkInfo, + used_relation_names: &mut HashSet, +) { + let ref_struct = + sanitize_identifier(&to_pascal_case(&fk.ref_table), IdentifierStart::Underscore); + let fk_field_name = to_go_field_name(&col.name); + let mut relation_field_name = infer_relation_field_name(&col.name); + if relation_field_name == fk_field_name { + relation_field_name = format!("{relation_field_name}{ref_struct}"); + } + // The name above only rules out colliding with this FK's own scalar + // field; it can still collide with an unrelated real column (or another + // relation) elsewhere in the table. + let relation_field_name = claim_binding(relation_field_name, used_relation_names); + + let mut constraint_parts: Vec = Vec::new(); + if let Some(ref action) = fk.on_delete { + constraint_parts.push(format!("OnDelete:{}", action.to_sql_keyword())); + } + if let Some(ref action) = fk.on_update { + constraint_parts.push(format!("OnUpdate:{}", action.to_sql_keyword())); + } + + let gorm_tag = if constraint_parts.is_empty() { + format!("foreignKey:{fk_field_name}") + } else { + format!( + "foreignKey:{fk_field_name};constraint:{}", + constraint_parts.join(",") + ) + }; + + let type_expr = if col.nullable { + format!("*{ref_struct}") + } else { + ref_struct + }; + + lines.push(format!( + " {relation_field_name} {type_expr} `gorm:\"{gorm_tag}\" json:\"-\"`" + )); +} + +/// Render a belongs-to relation field for a composite (multi-column) FK, +/// using GORM's comma-separated `foreignKey`/`references` tag syntax. +fn render_composite_fk_relation_field( + lines: &mut Vec, + fk: &CompositeFkInfo, + used_relation_names: &mut HashSet, +) { + let ref_struct = + sanitize_identifier(&to_pascal_case(&fk.ref_table), IdentifierStart::Underscore); + + let relation_field_name = claim_binding(ref_struct.clone(), used_relation_names); + + let fk_fields: Vec = fk.local_cols.iter().map(|c| to_go_field_name(c)).collect(); + let ref_fields: Vec = fk.ref_cols.iter().map(|c| to_go_field_name(c)).collect(); + + let mut constraint_parts: Vec = Vec::new(); + if let Some(ref action) = fk.on_delete { + constraint_parts.push(format!("OnDelete:{}", action.to_sql_keyword())); + } + if let Some(ref action) = fk.on_update { + constraint_parts.push(format!("OnUpdate:{}", action.to_sql_keyword())); + } + + let gorm_tag = if constraint_parts.is_empty() { + format!( + "foreignKey:{};references:{}", + fk_fields.join(","), + ref_fields.join(",") + ) + } else { + format!( + "foreignKey:{};references:{};constraint:{}", + fk_fields.join(","), + ref_fields.join(","), + constraint_parts.join(",") + ) + }; + + lines.push(format!( + " {relation_field_name} {ref_struct} `gorm:\"{gorm_tag}\" json:\"-\"`" + )); +} + +// --------------------------------------------------------------------------- +// GORM tag building +// --------------------------------------------------------------------------- + +fn build_gorm_tag( + col: &ColumnDef, + is_pk: bool, + auto_increment: bool, + is_unique: bool, + indexes: &[IndexInfo], + composite_unique_name: Option<&String>, +) -> String { + let mut parts: Vec = vec![format!("column:{}", col.name)]; + + if is_pk { + parts.push("primaryKey".into()); + } + if is_pk && auto_increment { + parts.push("autoIncrement".into()); + } + if !col.nullable && !is_pk { + parts.push("not null".into()); + } + if is_unique && !is_pk { + parts.push("unique".into()); + } + + match &col.r#type { + ColumnType::Simple(SimpleColumnType::Text) => parts.push("type:text".into()), + ColumnType::Simple(SimpleColumnType::Xml) => parts.push("type:xml".into()), + ColumnType::Simple(SimpleColumnType::Interval) => parts.push("type:interval".into()), + ColumnType::Simple(SimpleColumnType::Date) => parts.push("type:date".into()), + ColumnType::Simple(SimpleColumnType::Time) => parts.push("type:time".into()), + ColumnType::Simple(SimpleColumnType::Uuid) => parts.push("type:uuid".into()), + ColumnType::Complex(ComplexColumnType::Varchar { length }) => { + parts.push(format!("size:{length}")); + } + ColumnType::Complex(ComplexColumnType::Char { length }) => { + parts.push(format!("size:{length}")); + parts.push("type:char".into()); + } + ColumnType::Complex(ComplexColumnType::Numeric { precision, scale }) => { + parts.push(format!("type:numeric({precision},{scale})")); + } + ColumnType::Complex(ComplexColumnType::Custom { custom_type }) => { + parts.push(format!("type:{custom_type}")); + } + _ => {} + } + + if let Some(ref default) = col.default + && let Some(tag) = build_default_tag(default) + { + parts.push(tag); + } + + for idx in indexes { + if let Some(ref name) = idx.name { + parts.push(format!("index:{name}")); + } else { + parts.push("index".into()); + } + } + + if let Some(uq_name) = composite_unique_name { + parts.push(format!("uniqueIndex:{uq_name}")); + } + + parts.join(";") +} + +fn build_default_tag(default: &DefaultValue) -> Option { + let sql = default.to_sql(); + if sql.contains('(') { + return None; // Skip server-side function calls like NOW() + } + Some(format!("default:{sql}")) +} + +// --------------------------------------------------------------------------- +// Naming utilities +// --------------------------------------------------------------------------- + +pub(super) use crate::python_naming::to_pascal_case; + +pub(super) fn to_go_field_name(s: &str) -> String { + let pascal = to_pascal_case(s); + // Apply Go conventions for common abbreviations + let pascal = pascal.replace("Id", "ID"); + // Go identifiers can't start with a digit or contain non-alphanumeric + // characters; a leading `_` is legal (matches Rust module / Java field + // escaping elsewhere in the exporter). + sanitize_identifier(&pascal, IdentifierStart::Underscore) +} + +pub(super) fn infer_relation_field_name(fk_column: &str) -> String { + let base = fk_column.strip_suffix("_id").unwrap_or(fk_column); + sanitize_identifier(&to_pascal_case(base), IdentifierStart::Underscore) +} + +fn pascal_to_snake(s: &str) -> String { + let mut result = String::new(); + for c in s.chars() { + if c.is_uppercase() && !result.is_empty() { + result.push('_'); + } + result.extend(c.to_lowercase()); + } + result +} + +pub(super) fn needs_table_name_method(table_name: &str, struct_name: &str) -> bool { + let snake = pascal_to_snake(struct_name); + let gorm_default = format!("{snake}s"); + gorm_default != table_name +} diff --git a/crates/vespertide-exporter/src/gorm/tests/mod.rs b/crates/vespertide-exporter/src/gorm/tests/mod.rs new file mode 100644 index 00000000..3202b8c6 --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/tests/mod.rs @@ -0,0 +1,676 @@ +use std::collections::HashMap; + +use rstest::rstest; +use vespertide_core::schema::column::{ + ColumnType, ComplexColumnType, EnumValues, SimpleColumnType, +}; +use vespertide_core::schema::constraint::TableConstraint; +use vespertide_core::{ColumnDef, ReferenceAction, TableDef}; + +use super::render::{infer_relation_field_name, needs_table_name_method, to_go_field_name}; +use super::types::go_type_for_column_mapped; +use super::{GormExporterWithConfig, render_entity, render_entity_with_schema}; + +mod relations; + +fn col(name: &str, ty: ColumnType) -> ColumnDef { + ColumnDef { + name: name.into(), + r#type: ty, + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + } +} + +// ----------------------------------------------------------------------- +// Type mapping unit tests +// ----------------------------------------------------------------------- + +#[rstest] +#[case(ColumnType::Simple(SimpleColumnType::SmallInt), false, "int16")] +#[case(ColumnType::Simple(SimpleColumnType::Integer), false, "int32")] +#[case(ColumnType::Simple(SimpleColumnType::BigInt), false, "int64")] +#[case(ColumnType::Simple(SimpleColumnType::Real), false, "float32")] +#[case( + ColumnType::Simple(SimpleColumnType::DoublePrecision), + false, + "float64" +)] +#[case(ColumnType::Simple(SimpleColumnType::Text), false, "string")] +#[case(ColumnType::Simple(SimpleColumnType::Boolean), false, "bool")] +#[case(ColumnType::Simple(SimpleColumnType::Timestamp), false, "time.Time")] +#[case(ColumnType::Simple(SimpleColumnType::Timestamptz), false, "time.Time")] +#[case(ColumnType::Simple(SimpleColumnType::Date), false, "time.Time")] +#[case(ColumnType::Simple(SimpleColumnType::Time), false, "time.Time")] +#[case(ColumnType::Simple(SimpleColumnType::Uuid), false, "uuid.UUID")] +#[case(ColumnType::Simple(SimpleColumnType::Json), false, "datatypes.JSON")] +#[case(ColumnType::Simple(SimpleColumnType::Bytea), false, "[]byte")] +#[case(ColumnType::Simple(SimpleColumnType::Inet), false, "string")] +#[case(ColumnType::Complex(ComplexColumnType::Varchar { length: 255 }), false, "string")] +#[case(ColumnType::Complex(ComplexColumnType::Numeric { precision: 10, scale: 2 }), false, "decimal.Decimal")] +#[case(ColumnType::Complex(ComplexColumnType::Custom { custom_type: "JSONB".into() }), false, "datatypes.JSON")] +#[case(ColumnType::Complex(ComplexColumnType::Custom { custom_type: "jsonb".into() }), false, "datatypes.JSON")] +#[case(ColumnType::Complex(ComplexColumnType::Custom { custom_type: "TEXT".into() }), false, "string")] +#[case(ColumnType::Simple(SimpleColumnType::Integer), true, "*int32")] +#[case(ColumnType::Simple(SimpleColumnType::Text), true, "*string")] +#[case(ColumnType::Simple(SimpleColumnType::Timestamp), true, "*time.Time")] +fn test_go_type_mapping( + #[case] col_type: ColumnType, + #[case] nullable: bool, + #[case] expected: &str, +) { + assert_eq!( + go_type_for_column_mapped(&col_type, nullable, &HashMap::new()), + expected + ); +} + +#[rstest] +#[case("user_id", "UserID")] +#[case("id", "ID")] +#[case("created_at", "CreatedAt")] +#[case("profile_image", "ProfileImage")] +#[case("media_id", "MediaID")] +fn test_to_go_field_name(#[case] input: &str, #[case] expected: &str) { + assert_eq!(to_go_field_name(input), expected); +} + +#[rstest] +#[case("user_id", "User")] +#[case("author_id", "Author")] +#[case("parent_id", "Parent")] +#[case("node", "Node")] +fn test_infer_relation_field_name(#[case] input: &str, #[case] expected: &str) { + assert_eq!(infer_relation_field_name(input), expected); +} + +#[rstest] +#[case("User", "user", true)] +#[case("User", "users", false)] +#[case("OrderItem", "order_items", false)] +#[case("OrderItem", "order_item", true)] +fn test_needs_table_name_method( + #[case] struct_name: &str, + #[case] table_name: &str, + #[case] expected: bool, +) { + assert_eq!(needs_table_name_method(table_name, struct_name), expected); +} + +// ----------------------------------------------------------------------- +// Conflicting enum names across tables → qualified Go type name +// ----------------------------------------------------------------------- + +#[test] +fn test_conflicting_enum_names_qualified() { + let orders = TableDef { + name: "orders".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "status", + ColumnType::Complex(ComplexColumnType::Enum { + name: "status".into(), + values: EnumValues::String(vec!["pending".into(), "done".into()]), + }), + ), + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + }; + let tasks = TableDef { + name: "tasks".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "status", + ColumnType::Complex(ComplexColumnType::Enum { + name: "status".into(), + values: EnumValues::String(vec!["open".into(), "closed".into()]), + }), + ), + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + }; + let schema = vec![orders.clone(), tasks]; + let result = render_entity_with_schema(&orders, &schema).unwrap(); + assert!( + result.contains("OrdersStatus"), + "Expected qualified enum name 'OrdersStatus' in:\n{result}" + ); +} + +// ----------------------------------------------------------------------- +// Char column → type:char + size in GORM tag +// ----------------------------------------------------------------------- + +#[test] +fn test_char_type_column() { + let table = TableDef { + name: "codes".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "code", + ColumnType::Complex(ComplexColumnType::Char { length: 3 }), + ), + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains("type:char"), + "Expected type:char in GORM tag" + ); + assert!(result.contains("size:3"), "Expected size:3 in GORM tag"); +} + +// ----------------------------------------------------------------------- +// FK field name collision: infer == go_field → disambiguate with ref struct +// ----------------------------------------------------------------------- + +#[test] +fn test_fk_relation_field_name_collision() { + // Column "user" (no _id suffix): infer→"User", go_field→"User" → same → "UserUsers" + let table = TableDef { + name: "posts".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("user", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["user".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + ], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains("UserUsers"), + "Expected disambiguated relation name 'UserUsers' in:\n{result}" + ); +} + +// ----------------------------------------------------------------------- +// Reverse relation disambiguation: two FKs to same target → ByField suffix +// ----------------------------------------------------------------------- + +#[test] +fn test_reverse_relation_disambiguation() { + let users = TableDef { + name: "users".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + }; + let events = TableDef { + name: "events".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("creator_id", ColumnType::Simple(SimpleColumnType::Integer)), + col("attendee_id", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["creator_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["attendee_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + ], + }; + let schema = vec![users.clone(), events]; + let result = render_entity_with_schema(&users, &schema).unwrap(); + assert!( + result.contains("EventsByCreatorID"), + "Expected 'EventsByCreatorID' in:\n{result}" + ); + assert!( + result.contains("EventsByAttendeeID"), + "Expected 'EventsByAttendeeID' in:\n{result}" + ); +} + +// ----------------------------------------------------------------------- +// Numeric column: add_column_type needs_decimal, build_gorm_tag Numeric, decimal import +// ----------------------------------------------------------------------- + +#[test] +fn test_numeric_column_gorm() { + let table = TableDef { + name: "prices".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "amount", + ColumnType::Complex(ComplexColumnType::Numeric { + precision: 10, + scale: 2, + }), + ), + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains("type:numeric(10,2)"), + "expected numeric GORM tag" + ); + assert!( + result.contains("decimal.Decimal"), + "expected decimal.Decimal type" + ); + assert!( + result.contains("github.com/shopspring/decimal"), + "expected decimal import" + ); +} + +// ----------------------------------------------------------------------- +// Unnamed Index: build_gorm_tag unnamed index tag + collect_index_info inner body +// ----------------------------------------------------------------------- + +#[test] +fn test_unnamed_index_gorm() { + let table = TableDef { + name: "searches".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("query", ColumnType::Simple(SimpleColumnType::Text)), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::Index { + name: None, + columns: vec!["query".into()], + }, + ], + }; + let result = render_entity(&table).unwrap(); + assert!(result.contains(";index\""), "expected unnamed index tag"); +} + +// ----------------------------------------------------------------------- +// Named Index: build_gorm_tag named index tag + collect_index_info name closure +// ----------------------------------------------------------------------- + +#[test] +fn test_named_index_gorm() { + let table = TableDef { + name: "searches".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("query", ColumnType::Simple(SimpleColumnType::Text)), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::Index { + name: Some("ix_searches__query".into()), + columns: vec!["query".into()], + }, + ], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains("index:ix_searches__query"), + "expected named index tag" + ); +} + +// ----------------------------------------------------------------------- +// Unnamed composite unique: collect_composite_unique_info auto-name (uq_{cols}) +// ----------------------------------------------------------------------- + +#[test] +fn test_unnamed_composite_unique_gorm() { + let table = TableDef { + name: "order_lines".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("order_id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "sku", + ColumnType::Complex(ComplexColumnType::Varchar { length: 50 }), + ), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::Unique { + name: None, + columns: vec!["order_id".into(), "sku".into()], + strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates { + keep: vespertide_core::KeepPolicy::First, + }, + }, + ], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains("uniqueIndex:uq_order_id_sku"), + "expected auto-generated uniqueIndex name" + ); +} + +// ----------------------------------------------------------------------- +// Singular source table name: find_reverse_relations appends 's' for non-plural +// ----------------------------------------------------------------------- + +#[test] +fn test_singular_source_table_name() { + let user = TableDef { + name: "user".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + }; + let comment = TableDef { + name: "comment".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("user_id", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + ], + }; + let schema = vec![user.clone(), comment]; + let result = render_entity_with_schema(&user, &schema).unwrap(); + // "comment" → pascal "Comment" → doesn't end with 's' → appended 's' → "Comments" + assert!( + result.contains("Comments"), + "expected 'Comments' plural for singular 'comment' table" + ); +} + +// ----------------------------------------------------------------------- +// FK with on_update + nullable column: render_fk_relation_field lines 547, 559-560 +// ----------------------------------------------------------------------- + +#[test] +fn test_fk_with_on_update_and_nullable() { + let posts = TableDef { + name: "posts".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + ColumnDef { + name: "author_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: true, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["author_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: Some(ReferenceAction::Cascade), + on_update: Some(ReferenceAction::Restrict), + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + ], + }; + let result = render_entity(&posts).unwrap(); + assert!( + result.contains("OnUpdate:RESTRICT"), + "expected OnUpdate constraint" + ); + assert!( + result.contains("*Users"), + "expected nullable FK pointer type" + ); +} + +// ----------------------------------------------------------------------- +// FK on_delete keywords: SetNull, SetDefault, NoAction +// ----------------------------------------------------------------------- + +#[rstest] +#[case(ReferenceAction::SetNull, "SET NULL")] +#[case(ReferenceAction::SetDefault, "SET DEFAULT")] +#[case(ReferenceAction::NoAction, "NO ACTION")] +fn test_gorm_fk_on_delete_actions(#[case] action: ReferenceAction, #[case] expected: &str) { + let table = TableDef { + name: "posts".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("author_id", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["author_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: Some(action), + on_update: None, + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + ], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains(&format!("OnDelete:{expected}")), + "expected OnDelete:{expected} in:\n{result}" + ); +} + +// ----------------------------------------------------------------------- +// Double-underscore table name +// ----------------------------------------------------------------------- + +#[test] +fn test_gorm_double_underscore_table_name() { + // "order__item" splits into ["order", "", "item"]; the empty segment adds nothing + let table = TableDef { + name: "order__item".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains("type OrderItem struct"), + "expected pascal-cased struct with double underscore" + ); +} + +// ----------------------------------------------------------------------- +// collect_composite_unique_info: named branch (|n| n.as_str().to_owned()) +// ----------------------------------------------------------------------- + +#[test] +fn test_named_composite_unique_gorm() { + let table = TableDef { + name: "tenants".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)), + col("name", ColumnType::Simple(SimpleColumnType::Text)), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::Unique { + name: Some("uq_tenant_name".into()), + columns: vec!["tenant_id".into(), "name".into()], + strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates { + keep: vespertide_core::KeepPolicy::First, + }, + }, + ], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains("uniqueIndex:uq_tenant_name"), + "expected named uniqueIndex tag in GORM output" + ); +} + +// ----------------------------------------------------------------------- +// GormExporterWithConfig: package_name reaches the `package` declaration +// ----------------------------------------------------------------------- + +fn simple_table() -> TableDef { + TableDef { + name: "users".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + } +} + +#[test] +fn test_default_package_name_is_models() { + let table = simple_table(); + let exporter = GormExporterWithConfig::new("models"); + let result = exporter.render_entity(&table).unwrap(); + assert!( + result.starts_with("package models\n"), + "expected default 'package models', got:\n{result}" + ); +} + +#[test] +fn test_custom_package_name_from_config() { + let table = simple_table(); + let exporter = GormExporterWithConfig::new("entities"); + let result = exporter.render_entity(&table).unwrap(); + assert!( + result.starts_with("package entities\n"), + "expected 'package entities', got:\n{result}" + ); +} + +#[test] +fn test_custom_package_name_with_schema_context() { + let table = simple_table(); + let schema = vec![table.clone()]; + let exporter = GormExporterWithConfig::new("entities"); + let result = exporter.render_entity_with_schema(&table, &schema).unwrap(); + assert!( + result.starts_with("package entities\n"), + "expected 'package entities', got:\n{result}" + ); +} diff --git a/crates/vespertide-exporter/src/gorm/tests/relations.rs b/crates/vespertide-exporter/src/gorm/tests/relations.rs new file mode 100644 index 00000000..61b2405c --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/tests/relations.rs @@ -0,0 +1,143 @@ +use super::*; + +// ----------------------------------------------------------------------- +// Composite (multi-column) FK relation field +// ----------------------------------------------------------------------- + +fn composite_fk_table() -> TableDef { + TableDef { + name: "order_items".into(), + description: None, + columns: vec![ + col("order_id", ColumnType::Simple(SimpleColumnType::Integer)), + col("region_id", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: false, + columns: vec!["order_id".into(), "region_id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["order_id".into(), "region_id".into()], + ref_table: "order_regions".into(), + ref_columns: vec!["order_id".into(), "region_id".into()], + on_delete: Some(ReferenceAction::Cascade), + on_update: Some(ReferenceAction::Restrict), + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + ], + } +} + +#[test] +fn test_composite_fk_relation_field() { + let result = render_entity(&composite_fk_table()).unwrap(); + assert!( + result.contains( + "OrderRegions OrderRegions `gorm:\"foreignKey:OrderID,RegionID;references:OrderID,RegionID;constraint:OnDelete:CASCADE,OnUpdate:RESTRICT\" json:\"-\"`" + ), + "expected composite FK relation field in GORM output, got:\n{result}" + ); +} + +#[test] +fn test_composite_fk_relation_field_name_collision_suffixed() { + // A column already named "OrderRegions" (Go field name) collides with the + // natural composite-FK relation field name, forcing a numeric suffix. + let mut table = composite_fk_table(); + table.columns.push(col( + "order_regions", + ColumnType::Simple(SimpleColumnType::Text), + )); + let result = render_entity(&table).unwrap(); + assert!( + result.contains("OrderRegions2 OrderRegions `gorm:\"foreignKey:OrderID,RegionID"), + "expected suffixed relation field name on collision, got:\n{result}" + ); +} + +#[test] +fn test_composite_fk_relation_field_name_double_collision_increments_suffix() { + // Both "OrderRegions" and "OrderRegions2" are already taken by columns, + // so the collision loop must advance past its first candidate too. + let mut table = composite_fk_table(); + table.columns.push(col( + "order_regions", + ColumnType::Simple(SimpleColumnType::Text), + )); + table.columns.push(col( + "order_regions2", + ColumnType::Simple(SimpleColumnType::Text), + )); + let result = render_entity(&table).unwrap(); + assert!( + result.contains("OrderRegions3 OrderRegions `gorm:\"foreignKey:OrderID,RegionID"), + "expected double-suffixed relation field name on double collision, got:\n{result}" + ); +} + +// ----------------------------------------------------------------------- +// Self-referencing FK (single table referencing itself, e.g. a tree/ +// hierarchy structure: categories.parent_id -> categories.id) +// ----------------------------------------------------------------------- + +fn self_referencing_table() -> TableDef { + TableDef { + name: "categories".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + ColumnDef { + name: "parent_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: true, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["parent_id".into()], + ref_table: "categories".into(), + ref_columns: vec!["id".into()], + on_delete: Some(ReferenceAction::SetNull), + on_update: None, + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + ], + } +} + +#[test] +fn test_self_referencing_fk_forward_relation() { + let table = self_referencing_table(); + let schema = vec![table.clone()]; + let result = render_entity_with_schema(&table, &schema).unwrap(); + assert!( + result.contains("Parent *Categories `gorm:\"foreignKey:ParentID"), + "expected forward self-ref relation field, got:\n{result}" + ); +} + +#[test] +fn test_self_referencing_fk_reverse_relation() { + let table = self_referencing_table(); + let schema = vec![table.clone()]; + let result = render_entity_with_schema(&table, &schema).unwrap(); + assert!( + result.contains("Children []Categories `gorm:\"foreignKey:ParentID"), + "expected reverse (has-many) self-ref relation field, got:\n{result}" + ); +} diff --git a/crates/vespertide-exporter/src/gorm/types.rs b/crates/vespertide-exporter/src/gorm/types.rs new file mode 100644 index 00000000..2b984812 --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/types.rs @@ -0,0 +1,112 @@ +use std::collections::HashMap; + +use super::render::to_pascal_case; +use vespertide_core::schema::column::{ + ColumnType, ComplexColumnType, SimpleColumnKind, SimpleColumnType, +}; +use vespertide_naming::{IdentifierStart, sanitize_identifier}; + +/// Track which Go imports are actually used to generate minimal import statements. +#[expect( + clippy::struct_excessive_bools, + reason = "four independent import-presence flags; enum would add verbosity without clarity" +)] +#[derive(Default)] +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 + && custom_type.to_uppercase() == "JSONB" + { + 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(|| { + sanitize_identifier(&to_pascal_case(name), IdentifierStart::Underscore) + }) + } + _ => go_base_type(col_type), + }; + if nullable { format!("*{base}") } else { base } +} + +fn go_base_type(col_type: &ColumnType) -> String { + match col_type { + ColumnType::Simple(ty) => match SimpleColumnKind::from(*ty) { + SimpleColumnKind::SmallInt => "int16".to_string(), + SimpleColumnKind::Integer => "int32".to_string(), + SimpleColumnKind::BigInt => "int64".to_string(), + SimpleColumnKind::Real => "float32".to_string(), + SimpleColumnKind::DoublePrecision => "float64".to_string(), + SimpleColumnKind::Text + | SimpleColumnKind::Xml + | SimpleColumnKind::Inet + | SimpleColumnKind::Cidr + | SimpleColumnKind::Macaddr + | SimpleColumnKind::Interval => "string".to_string(), + SimpleColumnKind::Boolean => "bool".to_string(), + SimpleColumnKind::Date + | SimpleColumnKind::Time + | SimpleColumnKind::Timestamp + | SimpleColumnKind::Timestamptz => "time.Time".to_string(), + SimpleColumnKind::Bytea => "[]byte".to_string(), + SimpleColumnKind::Uuid => "uuid.UUID".to_string(), + SimpleColumnKind::Json => "datatypes.JSON".to_string(), + }, + ColumnType::Complex(ty) => match ty { + ComplexColumnType::Varchar { .. } | ComplexColumnType::Char { .. } => { + "string".to_string() + } + ComplexColumnType::Custom { custom_type } => { + if custom_type.to_uppercase() == "JSONB" { + "datatypes.JSON".to_string() + } else { + "string".to_string() + } + } + ComplexColumnType::Numeric { .. } => "decimal.Decimal".to_string(), + // `#[non_exhaustive]` future-variant guard; unreachable today. + #[cfg(not(tarpaulin_include))] + _ => { + unreachable!("ComplexColumnType is #[non_exhaustive]; all variants matched") + } + }, + } +} diff --git a/crates/vespertide-exporter/src/lib.rs b/crates/vespertide-exporter/src/lib.rs index c860a920..18c3b41c 100644 --- a/crates/vespertide-exporter/src/lib.rs +++ b/crates/vespertide-exporter/src/lib.rs @@ -1,9 +1,11 @@ //! 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; @@ -16,7 +18,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/python_naming.rs b/crates/vespertide-exporter/src/python_naming.rs index 6a5b4b29..36f2354a 100644 --- a/crates/vespertide-exporter/src/python_naming.rs +++ b/crates/vespertide-exporter/src/python_naming.rs @@ -1,6 +1,8 @@ -//! 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, diff --git a/crates/vespertide-exporter/src/tests/fixtures/mod.rs b/crates/vespertide-exporter/src/tests/fixtures/mod.rs index 7f3797ab..b829a40d 100644 --- a/crates/vespertide-exporter/src/tests/fixtures/mod.rs +++ b/crates/vespertide-exporter/src/tests/fixtures/mod.rs @@ -11,6 +11,9 @@ use vespertide_core::{ mod collisions; pub(crate) use collisions::binding_collisions; +mod reference_actions; +pub(crate) use reference_actions::reference_actions; + pub(crate) fn col(name: &str, ty: ColumnType) -> ColumnDef { ColumnDef::new(name, ty, false) } 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..1afe37f8 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/fixtures/reference_actions.rs @@ -0,0 +1,45 @@ +//! Foreign key carrying both referential actions. + +use vespertide_core::schema::column::SimpleColumnType; +use vespertide_core::schema::constraint::TableConstraint; +use vespertide_core::{ReferenceAction, TableDef}; + +use super::{pk, simple}; + +/// The only fixture whose foreign key sets `ON UPDATE` as well as `ON DELETE`. +/// 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 only this fixture pins. The two actions differ so a backend +/// that emits one of them in the other's place is visible. +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"]), + TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: Some(ReferenceAction::Cascade), + on_update: Some(ReferenceAction::Restrict), + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + ], + }; + [users, posts] + .into_iter() + .map(|t| t.normalize().expect("reference_actions normalizes")) + .collect() +} diff --git a/crates/vespertide-exporter/src/tests/mod.rs b/crates/vespertide-exporter/src/tests/mod.rs index 1c25fe0e..9eceb31f 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(); @@ -292,7 +298,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 +326,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,6 +385,12 @@ orm_cases!( "binding_collisions", fixtures::binding_collisions ); +// The only fixture that sets `ON UPDATE` as well as `ON DELETE`. +orm_cases!( + multi reference_actions_snapshot, + "reference_actions", + fixtures::reference_actions +); /// Dispatch the per-ORM `to_pascal_case` helper from a single entry point so /// the cross-ORM consolidation test can exercise every implementation without @@ -389,20 +404,22 @@ 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 => crate::gorm::to_pascal_case_for_tests(s), + Orm::Django => crate::django::to_pascal_case_for_tests(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 +428,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( ("", ""), @@ -439,6 +458,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", @@ -466,3 +487,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..300ba466 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_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 AllTypes(models.Model): + id = models.IntegerField(primary_key=True) + small = models.SmallIntegerField() + big = models.BigIntegerField() + real_num = models.FloatField() + double_num = models.FloatField() + text_col = models.TextField() + bool_col = models.BooleanField() + date_col = models.DateField() + time_col = models.TimeField() + ts_col = models.DateTimeField() + tstz_col = models.DateTimeField() + interval_col = models.DurationField() + bytea_col = models.BinaryField() + uuid_col = models.UUIDField() + json_col = models.JSONField() + inet_col = models.GenericIPAddressField() + cidr_col = models.GenericIPAddressField() + macaddr_col = models.CharField(max_length=17) + xml_col = models.TextField() + + class Meta: + db_table = "all_types" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Gorm.snap new file mode 100644 index 00000000..6e2f44d8 --- /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" + + "gorm.io/datatypes" + "github.com/google/uuid" +) + +type AllTypes struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Small int16 `gorm:"column:small;not null" json:"small"` + Big int64 `gorm:"column:big;not null" json:"big"` + RealNum float32 `gorm:"column:real_num;not null" json:"real_num"` + DoubleNum float64 `gorm:"column:double_num;not null" json:"double_num"` + TextCol string `gorm:"column:text_col;not null;type:text" json:"text_col"` + BoolCol bool `gorm:"column:bool_col;not null" json:"bool_col"` + DateCol time.Time `gorm:"column:date_col;not null;type:date" json:"date_col"` + TimeCol time.Time `gorm:"column:time_col;not null;type:time" json:"time_col"` + TsCol time.Time `gorm:"column:ts_col;not null" json:"ts_col"` + TstzCol time.Time `gorm:"column:tstz_col;not null" json:"tstz_col"` + IntervalCol string `gorm:"column:interval_col;not null;type:interval" json:"interval_col"` + ByteaCol []byte `gorm:"column:bytea_col;not null" json:"bytea_col"` + UuidCol uuid.UUID `gorm:"column:uuid_col;not null;type:uuid" json:"uuid_col"` + JsonCol datatypes.JSON `gorm:"column:json_col;not null" json:"json_col"` + InetCol string `gorm:"column:inet_col;not null" json:"inet_col"` + CidrCol string `gorm:"column:cidr_col;not null" json:"cidr_col"` + MacaddrCol string `gorm:"column:macaddr_col;not null" json:"macaddr_col"` + XmlCol string `gorm:"column:xml_col;not null;type:xml" json:"xml_col"` +} + +func (AllTypes) TableName() string { return "all_types" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_single_pk_snapshot@basic_single_pk_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_single_pk_snapshot@basic_single_pk_Django.snap new file mode 100644 index 00000000..38ca05ed --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_single_pk_snapshot@basic_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 Users(models.Model): + id = models.IntegerField(primary_key=True) + display_name = models.TextField(null=True, blank=True) + + class Meta: + db_table = "users" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_single_pk_snapshot@basic_single_pk_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_single_pk_snapshot@basic_single_pk_Gorm.snap new file mode 100644 index 00000000..d8c41deb --- /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..64df0790 --- /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,20 @@ +--- +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: + 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..53351d21 --- /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..2c95297e --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_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 UserRelations(models.Model): + id = models.IntegerField(primary_key=True) + kind = models.TextField() + + class Meta: + db_table = "user_relations" + +class User(models.Model): + id = models.IntegerField(primary_key=True) + + class Meta: + db_table = "user" + +class Sql(models.Model): + id = models.IntegerField(primary_key=True) + amount = models.IntegerField() + + class Meta: + 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: + 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..e067790d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_Gorm.snap @@ -0,0 +1,37 @@ +--- +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..8b28aa7d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_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 ComplexTypes(models.Model): + id = models.IntegerField(primary_key=True) + varchar_col = models.CharField(max_length=100) + char_col = models.CharField(max_length=10) + numeric_col = models.DecimalField(max_digits=10, decimal_places=2) + custom_col = models.TextField() + + class Meta: + db_table = "complex_types" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_Gorm.snap new file mode 100644 index 00000000..a84808ff --- /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;size:10;type:char" json:"char_col"` + NumericCol decimal.Decimal `gorm:"column:numeric_col;not null;type:numeric(10,2)" json:"numeric_col"` + CustomCol string `gorm:"column:custom_col;not null;type:CUSTOM_TYPE" json:"custom_col"` +} + +func (ComplexTypes) TableName() string { return "complex_types" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Django.snap new file mode 100644 index 00000000..363c753c --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_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 OrderItems(models.Model): + pk = models.CompositePrimaryKey("order_id", "product_id") + order = models.ForeignKey("Orders", on_delete=models.RESTRICT, related_name="+") + product = models.ForeignKey("Products", on_delete=models.RESTRICT, related_name="+") + quantity = models.IntegerField() + + class Meta: + db_table = "order_items" + indexes = [ + models.Index(fields=["order_id"], name="ix_order_items__order_id"), + ] + constraints = [ + models.UniqueConstraint(fields=["order_id", "product_id"], name="uq_order_items__order_product"), + ] diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Gorm.snap new file mode 100644 index 00000000..3037b2e6 --- /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__order_id;uniqueIndex:uq_order_items__order_product" json:"order_id"` + Order Orders `gorm:"foreignKey:OrderID" json:"-"` + ProductID int32 `gorm:"column:product_id;primaryKey;uniqueIndex:uq_order_items__order_product" json:"product_id"` + Product Products `gorm:"foreignKey:ProductID" json:"-"` + Quantity int32 `gorm:"column:quantity;not null" json:"quantity"` +} + +func (OrderItems) TableName() string { return "order_items" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_fk_relation_snapshot@composite_fk_relation_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_fk_relation_snapshot@composite_fk_relation_Django.snap new file mode 100644 index 00000000..94a22e2d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_fk_relation_snapshot@composite_fk_relation_Django.snap @@ -0,0 +1,26 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Orders(models.Model): + pk = models.CompositePrimaryKey("id", "version") + id = models.IntegerField() + version = models.IntegerField() + + class Meta: + db_table = "orders" + +class LineItems(models.Model): + id = models.IntegerField(primary_key=True) + order_id = models.IntegerField() + order_version = models.IntegerField() + sku = models.TextField() + # composite foreign key: (order_id, order_version) -> orders(id, version) + + class Meta: + db_table = "line_items" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_fk_relation_snapshot@composite_fk_relation_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_fk_relation_snapshot@composite_fk_relation_Gorm.snap new file mode 100644 index 00000000..26243cd6 --- /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"` +} + +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..9f0d7d12 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_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 CompositeIndex(models.Model): + id = models.IntegerField(primary_key=True) + tenant_id = models.IntegerField() + name = models.TextField() + + class Meta: + db_table = "composite_index" + indexes = [ + models.Index(fields=["tenant_id", "name"], name="idx_tenant_name"), + ] diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_Gorm.snap new file mode 100644 index 00000000..0d8ff156 --- /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:idx_tenant_name" json:"tenant_id"` + Name string `gorm:"column:name;not null;type:text;index:idx_tenant_name" json:"name"` +} + +func (CompositeIndex) TableName() string { return "composite_index" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_pk_snapshot@composite_pk_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_pk_snapshot@composite_pk_Django.snap new file mode 100644 index 00000000..06067796 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_pk_snapshot@composite_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 Accounts(models.Model): + pk = models.CompositePrimaryKey("id", "tenant_id") + id = models.IntegerField() + tenant_id = models.BigIntegerField() + + class Meta: + db_table = "accounts" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_pk_snapshot@composite_pk_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_pk_snapshot@composite_pk_Gorm.snap new file mode 100644 index 00000000..bbda781c --- /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..6330e81b --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_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 Membership(models.Model): + pk = models.CompositePrimaryKey("tenant_id", "user_id") + tenant_id = models.IntegerField() + user_id = models.IntegerField() + role = models.TextField() + + class Meta: + db_table = "membership" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_Gorm.snap new file mode 100644 index 00000000..612a9124 --- /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..83c46a6f --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_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 AccountAliases(models.Model): + id = models.IntegerField(primary_key=True) + tenant_id = models.IntegerField() + slug = models.TextField() + + class Meta: + db_table = "account_aliases" + constraints = [ + models.UniqueConstraint(fields=["tenant_id", "slug"], name="uq_account_aliases__tenant_slug"), + ] diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Gorm.snap new file mode 100644 index 00000000..890d9845 --- /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__tenant_slug" json:"tenant_id"` + Slug string `gorm:"column:slug;not null;type:text;uniqueIndex:uq_account_aliases__tenant_slug" json:"slug"` +} + +func (AccountAliases) TableName() string { return "account_aliases" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Django.snap new file mode 100644 index 00000000..8a5931dd --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_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 CompositeUnique(models.Model): + id = models.IntegerField(primary_key=True) + tenant_id = models.IntegerField() + name = models.TextField() + + class Meta: + db_table = "composite_unique" + constraints = [ + models.UniqueConstraint(fields=["tenant_id", "name"], name="uq_tenant_name"), + ] diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Gorm.snap new file mode 100644 index 00000000..4a62a230 --- /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_tenant_name" json:"tenant_id"` + Name string `gorm:"column:name;not null;type:text;uniqueIndex:uq_tenant_name" json:"name"` +} + +func (CompositeUnique) TableName() string { return "composite_unique" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Django.snap new file mode 100644 index 00000000..a057d241 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_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 Articles(models.Model): + id = models.AutoField(primary_key=True) + published = models.BooleanField(default=False) + view_count = models.IntegerField(default=0) + status = models.TextField(default="draft") + + class Meta: + db_table = "articles" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Gorm.snap new file mode 100644 index 00000000..4707fc9e --- /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..30ff167d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_multiple_columns_snapshot@enum_multiple_columns_Django.snap @@ -0,0 +1,26 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class ProductCategory(models.TextChoices): + ELECTRONICS = "electronics", "electronics" + CLOTHING = "clothing", "clothing" + FOOD = "food", "food" + +class AvailabilityStatus(models.TextChoices): + IN_STOCK = "in_stock", "in_stock" + OUT_OF_STOCK = "out_of_stock", "out_of_stock" + PRE_ORDER = "pre_order", "pre_order" + +class Products(models.Model): + id = models.IntegerField() + category = models.CharField(max_length=11, choices=ProductCategory.choices) + availability = models.CharField(max_length=12, choices=AvailabilityStatus.choices) + + class Meta: + db_table = "products" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_multiple_columns_snapshot@enum_multiple_columns_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_multiple_columns_snapshot@enum_multiple_columns_Gorm.snap new file mode 100644 index 00000000..33bcbcac --- /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_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..dcdecbcb --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_shared_snapshot@enum_shared_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 DocStatus(models.TextChoices): + DRAFT = "draft", "draft" + PUBLISHED = "published", "published" + ARCHIVED = "archived", "archived" + +class Documents(models.Model): + id = models.IntegerField() + status = models.CharField(max_length=9, choices=DocStatus.choices) + review_status = models.CharField(max_length=9, choices=DocStatus.choices, null=True, blank=True) + + class Meta: + db_table = "documents" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_shared_snapshot@enum_shared_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_shared_snapshot@enum_shared_Gorm.snap new file mode 100644 index 00000000..36056366 --- /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..183e0e88 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_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 EventSeverity(models.TextChoices): + INFO_LEVEL = "info-level", "info-level" + WARNING_LEVEL = "warning_level", "warning_level" + ERROR_LEVEL = "ERROR_LEVEL", "ERROR_LEVEL" + _1CRITICAL = "1critical", "1critical" + +class Events(models.Model): + id = models.IntegerField() + severity = models.CharField(max_length=13, choices=EventSeverity.choices) + + class Meta: + db_table = "events" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_Gorm.snap new file mode 100644 index 00000000..d95d90d2 --- /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..2ec2192d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_with_default_snapshot@enum_with_default_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 TaskStatus(models.TextChoices): + PENDING = "pending", "pending" + IN_PROGRESS = "in_progress", "in_progress" + COMPLETED = "completed", "completed" + +class Tasks(models.Model): + id = models.IntegerField() + status = models.CharField(max_length=11, choices=TaskStatus.choices, default="pending") + priority = models.IntegerField(default=0) + is_archived = models.BooleanField(default=False) + + class Meta: + db_table = "tasks" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_with_default_snapshot@enum_with_default_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_with_default_snapshot@enum_with_default_Gorm.snap new file mode 100644 index 00000000..5320ae21 --- /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..b5ee9a03 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__false_boolean_default_snapshot@false_boolean_default_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 BoolDefaults(models.Model): + id = models.IntegerField(primary_key=True) + is_deleted = models.BooleanField(default=False) + + class Meta: + db_table = "bool_defaults" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__false_boolean_default_snapshot@false_boolean_default_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__false_boolean_default_snapshot@false_boolean_default_Gorm.snap new file mode 100644 index 00000000..63ea6c32 --- /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..949d8d51 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_names_collide_after_id_strip_snapshot@fk_names_collide_after_id_strip_Django.snap @@ -0,0 +1,23 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Target(models.Model): + id = models.IntegerField(primary_key=True) + alt = models.IntegerField(unique=True) + + class Meta: + db_table = "target" + +class Src(models.Model): + pk = models.IntegerField(primary_key=True) + a = models.ForeignKey("Target", on_delete=models.RESTRICT, related_name="+", null=True, blank=True) + a_2 = models.ForeignKey("Target", on_delete=models.RESTRICT, db_column="a", related_name="+", null=True, blank=True) + + class Meta: + db_table = "src" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_names_collide_after_id_strip_snapshot@fk_names_collide_after_id_strip_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_names_collide_after_id_strip_snapshot@fk_names_collide_after_id_strip_Gorm.snap new file mode 100644 index 00000000..766bc301 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_names_collide_after_id_strip_snapshot@fk_names_collide_after_id_strip_Gorm.snap @@ -0,0 +1,25 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Target struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Alt int32 `gorm:"column:alt;not null;unique" json:"alt"` + SrcsByAID []Src `gorm:"foreignKey:AID" json:"-"` + SrcsByA []Src `gorm:"foreignKey:A" 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" 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..7e405bed --- /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,16 @@ +--- +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.ForeignKey("Parent", on_delete=models.RESTRICT, related_name="+") + value = models.TextField() + + class Meta: + db_table = "child" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_with_comment_and_auto_increment_snapshot@fk_with_comment_and_auto_increment_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_with_comment_and_auto_increment_snapshot@fk_with_comment_and_auto_increment_Gorm.snap new file mode 100644 index 00000000..60af1909 --- /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..828b9976 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__inline_pk_snapshot@inline_pk_Django.snap @@ -0,0 +1,16 @@ +--- +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: + 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..62c26f53 --- /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..e3c7ff8d --- /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,21 @@ +--- +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, "unknown" + NOT_STARTED = 0, "not_started" + IN_PROGRESS = 10, "InProgress" + HTTP_500 = 500, "HTTP_500" + +class WorkflowRuns(models.Model): + id = models.IntegerField(primary_key=True) + state = models.IntegerField(choices=EdgeState.choices) + + class Meta: + db_table = "workflow_runs" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_all_variant_types_snapshot@integer_enum_all_variant_types_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_all_variant_types_snapshot@integer_enum_all_variant_types_Gorm.snap new file mode 100644 index 00000000..d7abe2c5 --- /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..85e9949b --- /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,19 @@ +--- +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, "Pending" + COMPLETED = 100, "Completed" + +class Tasks(models.Model): + id = models.IntegerField() + status = models.IntegerField(choices=TaskStatus.choices, default=1) + + class Meta: + db_table = "tasks" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_default_snapshot@integer_enum_with_default_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_default_snapshot@integer_enum_with_default_Gorm.snap new file mode 100644 index 00000000..5ea01f11 --- /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..3045a247 --- /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,19 @@ +--- +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, "Pending" + COMPLETED = 100, "Completed" + +class TaskRuns(models.Model): + id = models.IntegerField() + status = models.IntegerField(choices=TaskRunStatus.choices) + + class Meta: + db_table = "task_runs" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_variant_default_snapshot@integer_enum_with_variant_default_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_variant_default_snapshot@integer_enum_with_variant_default_Gorm.snap new file mode 100644 index 00000000..f9c3d364 --- /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:Completed" json:"status"` +} + +func (TaskRuns) TableName() string { return "task_runs" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__json_default_snapshot@json_default_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__json_default_snapshot@json_default_Django.snap new file mode 100644 index 00000000..f49c7214 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__json_default_snapshot@json_default_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 Configs(models.Model): + id = models.IntegerField(primary_key=True) + data = models.JSONField() + + class Meta: + db_table = "configs" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__json_default_snapshot@json_default_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__json_default_snapshot@json_default_Gorm.snap new file mode 100644 index 00000000..14f27409 --- /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..4d1e0011 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__jsonb_custom_type_snapshot@jsonb_custom_type_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 JsonStruct(models.Model): + id = models.IntegerField() + json_data = models.JSONField() + jsonb_data = models.TextField() + jsonb_nullable = models.TextField(null=True, blank=True) + + class Meta: + db_table = "json_struct" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__jsonb_custom_type_snapshot@jsonb_custom_type_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__jsonb_custom_type_snapshot@jsonb_custom_type_Gorm.snap new file mode 100644 index 00000000..ce0b5be8 --- /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__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..89da2d7a --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__no_description_snapshot@no_description_Django.snap @@ -0,0 +1,14 @@ +--- +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: + 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..261f3752 --- /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..c6ec0e4c --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_in_constraints_snapshot@non_identifier_names_in_constraints_Django.snap @@ -0,0 +1,24 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Membership(models.Model): + pk = models.CompositePrimaryKey("_1tenant_id", "_2user_id") + _1tenant_id = models.IntegerField(db_column="1tenant_id") + _2user_id = models.IntegerField(db_column="2user_id") + user_email = models.TextField(db_column="user-email") + _3created = models.TextField(db_column="3created") + + class Meta: + db_table = "membership" + indexes = [ + models.Index(fields=["_3created"]), + ] + constraints = [ + models.UniqueConstraint(fields=["user_email", "_1tenant_id"], 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..8c03e4d4 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_in_constraints_snapshot@non_identifier_names_in_constraints_Gorm.snap @@ -0,0 +1,14 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Membership struct { + _1tenantID int32 `gorm:"column:1tenant_id;primaryKey;uniqueIndex:uq_user-email_1tenant_id" json:"1tenant_id"` + _2userID int32 `gorm:"column:2user_id;primaryKey" json:"2user_id"` + User_email string `gorm:"column:user-email;not null;type:text;uniqueIndex:uq_user-email_1tenant_id" json:"user-email"` + _3created string `gorm:"column:3created;not null;type:text;index" json:"3created"` +} + +func (Membership) TableName() string { return "membership" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_snapshot@non_identifier_names_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_snapshot@non_identifier_names_Django.snap new file mode 100644 index 00000000..7f87cf12 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_snapshot@non_identifier_names_Django.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class _1users(models.Model): + id = models.IntegerField(primary_key=True) + _1st_place = models.IntegerField(db_column="1st_place", null=True, blank=True) + user_id = models.TextField(db_column="user-id", null=True, blank=True) + _1st_owner = models.ForeignKey("_1users", on_delete=models.RESTRICT, db_column="1st_owner_id", related_name="+", null=True, blank=True) + + class Meta: + db_table = "1users" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_snapshot@non_identifier_names_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_snapshot@non_identifier_names_Gorm.snap new file mode 100644 index 00000000..aefde068 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_snapshot@non_identifier_names_Gorm.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type _1users struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + _1stPlace *int32 `gorm:"column:1st_place" json:"1st_place"` + User_id *string `gorm:"column:user-id;type:text" json:"user-id"` + _1stOwnerID *int32 `gorm:"column:1st_owner_id" json:"1st_owner_id"` + _1stOwner *_1users `gorm:"foreignKey:_1stOwnerID" json:"-"` +} + +func (_1users) TableName() string { return "1users" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_relation_names_snapshot@non_identifier_relation_names_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_relation_names_snapshot@non_identifier_relation_names_Django.snap new file mode 100644 index 00000000..cf6dcf3e --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_relation_names_snapshot@non_identifier_relation_names_Django.snap @@ -0,0 +1,23 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class _1users(models.Model): + _1id = models.IntegerField(db_column="1id", primary_key=True) + email = models.TextField() + + class Meta: + db_table = "1users" + +class Posts(models.Model): + id = models.IntegerField(primary_key=True) + _1st_owner = models.ForeignKey("_1users", on_delete=models.RESTRICT, db_column="1st_owner_id", related_name="+", null=True, blank=True) + _2nd_owner = models.ForeignKey("_1users", on_delete=models.RESTRICT, db_column="2nd_owner_id", related_name="+", null=True, blank=True) + + class Meta: + db_table = "posts" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_relation_names_snapshot@non_identifier_relation_names_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_relation_names_snapshot@non_identifier_relation_names_Gorm.snap new file mode 100644 index 00000000..8cb79dd9 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_relation_names_snapshot@non_identifier_relation_names_Gorm.snap @@ -0,0 +1,25 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type _1users struct { + _1id int32 `gorm:"column:1id;primaryKey" json:"1id"` + Email string `gorm:"column:email;not null;type:text" json:"email"` + PostsBy_1stOwnerID []Posts `gorm:"foreignKey:_1stOwnerID" json:"-"` + PostsBy_2ndOwnerID []Posts `gorm:"foreignKey:_2ndOwnerID" json:"-"` +} + +func (_1users) TableName() string { return "1users" } + + +type Posts struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + _1stOwnerID *int32 `gorm:"column:1st_owner_id" json:"1st_owner_id"` + _1stOwner *_1users `gorm:"foreignKey:_1stOwnerID" json:"-"` + _2ndOwnerID *int32 `gorm:"column:2nd_owner_id" json:"2nd_owner_id"` + _2ndOwner *_1users `gorm:"foreignKey:_2ndOwnerID" json:"-"` +} + +func (Posts) TableName() string { return "posts" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Django.snap new file mode 100644 index 00000000..c3bda5f5 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_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 Profiles(models.Model): + id = models.AutoField(primary_key=True) + bio = models.TextField(null=True, blank=True) + avatar_url = models.CharField(max_length=500, null=True, blank=True) + + class Meta: + db_table = "profiles" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Gorm.snap new file mode 100644 index 00000000..fbff031c --- /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..f6cecb30 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_enum_snapshot@nullable_enum_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 StatusType(models.TextChoices): + ACTIVE = "active", "active" + INACTIVE = "inactive", "inactive" + +class NullableEnum(models.Model): + id = models.IntegerField(primary_key=True) + status = models.CharField(max_length=8, choices=StatusType.choices, null=True, blank=True) + + class Meta: + db_table = "nullable_enum" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_enum_snapshot@nullable_enum_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_enum_snapshot@nullable_enum_Gorm.snap new file mode 100644 index 00000000..1d6a5b56 --- /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..06f667e2 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__numeric_default_value_snapshot@numeric_default_value_Django.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Products(models.Model): + id = models.IntegerField() + price = models.DecimalField(max_digits=10, decimal_places=2, default=0) + + class Meta: + db_table = "products" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__numeric_default_value_snapshot@numeric_default_value_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__numeric_default_value_snapshot@numeric_default_value_Gorm.snap new file mode 100644 index 00000000..c972d90e --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__numeric_default_value_snapshot@numeric_default_value_Gorm.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +import ( + "github.com/shopspring/decimal" +) + +type Products struct { + ID int32 `gorm:"column:id;not null" json:"id"` + Price decimal.Decimal `gorm:"column:price;not null;type:numeric(10,2);default:0" json:"price"` +} + +func (Products) TableName() string { return "products" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Django.snap new file mode 100644 index 00000000..b8193a0f --- /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,25 @@ +--- +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: + db_table = "article_user" + indexes = [ + models.Index(fields=["article_id"]), + models.Index(fields=["user_id"]), + ] diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Gorm.snap new file mode 100644 index 00000000..a28ec07f --- /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" json:"article_id"` + Article Article `gorm:"foreignKey:ArticleID;constraint:OnDelete:CASCADE" json:"-"` + UserID uuid.UUID `gorm:"column:user_id;primaryKey;type:uuid;index" json:"user_id"` + User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"-"` + AuthorOrder int32 `gorm:"column:author_order;not null;default:1" json:"author_order"` + Role string `gorm:"column:role;not null;size:20;default:'contributor'" json:"role"` + IsLead bool `gorm:"column:is_lead;not null;default:false" json:"is_lead"` + CreatedAt time.Time `gorm:"column:created_at;not null" json:"created_at"` +} + +func (ArticleUser) TableName() string { return "article_user" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__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..19073751 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Django.snap @@ -0,0 +1,21 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Users(models.Model): + id = models.IntegerField(primary_key=True) + + class Meta: + db_table = "users" + +class Posts(models.Model): + id = models.IntegerField(primary_key=True) + user = models.ForeignKey("Users", on_delete=models.CASCADE, related_name="+") + + class Meta: + db_table = "posts" 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..3f311630 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Drizzle_pg.snap @@ -0,0 +1,22 @@ +--- +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), +})); + +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] }), +})); 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..041bc520 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Gorm.snap @@ -0,0 +1,21 @@ +--- +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:"-"` +} + +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:"-"` +} + +func (Posts) TableName() string { return "posts" } 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..860a55ea --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Jpa.snap @@ -0,0 +1,35 @@ +--- +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() { + } +} 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..c5229a39 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Prisma.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Users { + id Int @id + posts Posts[] + + @@map("users") +} + +model Posts { + id Int @id + user_id Int + user Users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: Restrict) + + @@map("posts") +} 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..af941995 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_SeaOrm.snap @@ -0,0 +1,35 @@ +--- +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, +} + +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, +} + +vespera::schema_type!(Schema from Model, name = "PostsSchema"); +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..91368671 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_SqlAlchemy.snap @@ -0,0 +1,21 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + + +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) 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..8124070a --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_SqlModel.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + + +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") diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_name_taken_by_column_snapshot@relation_name_taken_by_column_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_name_taken_by_column_snapshot@relation_name_taken_by_column_Django.snap new file mode 100644 index 00000000..e2678a70 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_name_taken_by_column_snapshot@relation_name_taken_by_column_Django.snap @@ -0,0 +1,21 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Users(models.Model): + id = models.IntegerField(primary_key=True) + + class Meta: + db_table = "users" + +class Items(models.Model): + id = models.IntegerField(primary_key=True) + owner = models.ForeignKey("Users", on_delete=models.RESTRICT, db_column="owner", related_name="+", null=True, blank=True) + + class Meta: + db_table = "items" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_name_taken_by_column_snapshot@relation_name_taken_by_column_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_name_taken_by_column_snapshot@relation_name_taken_by_column_Gorm.snap new file mode 100644 index 00000000..eee88d46 --- /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,21 @@ +--- +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..6fdb2b20 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_and_single_fk_same_target_Django.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Src(models.Model): + id = models.IntegerField(primary_key=True) + a_id = models.IntegerField() + b_id = models.IntegerField() + solo = models.ForeignKey("Target", on_delete=models.RESTRICT, db_column="solo", related_name="+") + # composite foreign key: (a_id, b_id) -> target(a, b) + + class Meta: + db_table = "src" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_and_single_fk_same_target_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_and_single_fk_same_target_Gorm.snap new file mode 100644 index 00000000..f57baa34 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_and_single_fk_same_target_Gorm.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Src struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + AID int32 `gorm:"column:a_id;not null" json:"a_id"` + BID int32 `gorm:"column:b_id;not null" json:"b_id"` + Solo int32 `gorm:"column:solo;not null" json:"solo"` + SoloTarget Target `gorm:"foreignKey:Solo" json:"-"` + Target Target `gorm:"foreignKey:AID,BID;references:A,B" json:"-"` +} + +func (Src) TableName() string { return "src" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_fk_parent_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_fk_parent_Django.snap new file mode 100644 index 00000000..1f9f3542 --- /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,16 @@ +--- +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: + 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..3f2126c5 --- /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,12 @@ +--- +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"` +} + +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..8cf659b2 --- /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,14 @@ +--- +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: + 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..df8f0f4a --- /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..8efa9c99 --- /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,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) + users = models.ManyToManyField("User", through="ArticleUser", related_name="+") + + class Meta: + db_table = "article" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_article_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_article_Gorm.snap new file mode 100644 index 00000000..4a3be4a8 --- /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..81fa485d --- /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,14 @@ +--- +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: + 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..4a3be4a8 --- /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..b5f3a049 --- /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,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) + medias_via_user_media_role = models.ManyToManyField("Media", through="UserMediaRole", related_name="+") + medias_via_user_media_favorite = models.ManyToManyField("Media", through="UserMediaFavorite", related_name="+") + + class Meta: + db_table = "user" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_multiple_junctions_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_multiple_junctions_Gorm.snap new file mode 100644 index 00000000..f8d93567 --- /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_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..d772fd79 --- /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,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) + articles = models.ManyToManyField("Article", through="ArticleUser", related_name="+") + + class Meta: + db_table = "user" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_user_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_user_Gorm.snap new file mode 100644 index 00000000..b9ea04e5 --- /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..39f31656 --- /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,16 @@ +--- +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: + 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..afa80595 --- /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..68f592ca --- /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,14 @@ +--- +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: + 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..c838319f --- /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..68f592ca --- /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,14 @@ +--- +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: + 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..3024287c --- /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..f4c67f72 --- /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,14 @@ +--- +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: + 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..ed4f66e5 --- /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..760f96e0 --- /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,14 @@ +--- +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: + 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..06491708 --- /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..760f96e0 --- /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,14 @@ +--- +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: + 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..8547a1ed --- /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@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..8cf659b2 --- /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,14 @@ +--- +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: + 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..f1fba222 --- /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..3e2b2117 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_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 Session(models.Model): + id = models.UUIDField(primary_key=True) + username = models.ForeignKey("User", on_delete=models.RESTRICT, db_column="username", related_name="+") + + class Meta: + db_table = "session" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Gorm.snap new file mode 100644 index 00000000..51563f6d --- /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..ec6bd68d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reserved_word_identifiers_snapshot@reserved_word_identifiers_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 Order(models.Model): + id = models.IntegerField(primary_key=True) + user = models.TextField() + select = models.IntegerField() + + class Meta: + db_table = "order" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reserved_word_identifiers_snapshot@reserved_word_identifiers_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reserved_word_identifiers_snapshot@reserved_word_identifiers_Gorm.snap new file mode 100644 index 00000000..6ae986f4 --- /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..4e074a0e --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__self_referencing_fk_snapshot@self_referencing_fk_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 Employees(models.Model): + id = models.IntegerField(primary_key=True) + manager = models.ForeignKey("Employees", on_delete=models.SET_NULL, related_name="+", null=True, blank=True) + + class Meta: + db_table = "employees" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__self_referencing_fk_snapshot@self_referencing_fk_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__self_referencing_fk_snapshot@self_referencing_fk_Gorm.snap new file mode 100644 index 00000000..ae3e2cbc --- /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__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..da2b8e26 --- /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,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 Logs(models.Model): + id = models.AutoField(primary_key=True) + active = models.BooleanField(default=True) + created_at = models.DateTimeField(default=timezone.now) + score = models.FloatField(default=1.5) + tag = models.TextField() + + class Meta: + db_table = "logs" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_default_and_true_boolean_snapshot@server_default_and_true_boolean_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_default_and_true_boolean_snapshot@server_default_and_true_boolean_Gorm.snap new file mode 100644 index 00000000..995291ea --- /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..78974d94 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_defaults_snapshot@server_defaults_Django.snap @@ -0,0 +1,18 @@ +--- +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: + 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..18446914 --- /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..edd68842 --- /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,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) + display_name = models.TextField(null=True, blank=True) + + class Meta: + db_table = "users" + +class Posts(models.Model): + id = models.IntegerField(primary_key=True) + user = models.ForeignKey("Users", on_delete=models.RESTRICT, related_name="+") + title = models.TextField() + + class Meta: + db_table = "posts" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__small_multi_schema_sequential_snapshot@small_multi_schema_sequential_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__small_multi_schema_sequential_snapshot@small_multi_schema_sequential_Gorm.snap new file mode 100644 index 00000000..7fe1f9e1 --- /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,23 @@ +--- +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..0a4aaba1 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__string_default_snapshot@string_default_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 StringDefaults(models.Model): + id = models.IntegerField(primary_key=True) + status = models.TextField(default="active") + + class Meta: + db_table = "string_defaults" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__string_default_snapshot@string_default_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__string_default_snapshot@string_default_Gorm.snap new file mode 100644 index 00000000..f0da2a9a --- /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..4c828c8c --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_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 Orders(models.Model): + id = models.UUIDField(primary_key=True) + customer_id = models.UUIDField() + total = models.FloatField() + + class Meta: + db_table = "orders" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_pk_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_pk_Gorm.snap new file mode 100644 index 00000000..1f6ab81f --- /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..ed5bc350 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_Django.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Products(models.Model): + id = models.IntegerField(primary_key=True) + price = models.IntegerField() + + class Meta: + 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..ed0f12a8 --- /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..e5a5f2fe --- /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,18 @@ +--- +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: + 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..8c2e0178 --- /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..8ba6eb4b --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_enum_snapshot@table_with_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 OrderStatus(models.TextChoices): + PENDING = "pending", "pending" + SHIPPED = "shipped", "shipped" + DELIVERED = "delivered", "delivered" + +class Orders(models.Model): + id = models.IntegerField() + status = models.CharField(max_length=9, choices=OrderStatus.choices) + + class Meta: + db_table = "orders" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_enum_snapshot@table_with_enum_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_enum_snapshot@table_with_enum_Gorm.snap new file mode 100644 index 00000000..237569a2 --- /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..e28c58db --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_fk_snapshot@table_with_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 Posts(models.Model): + id = models.IntegerField(primary_key=True) + user = models.ForeignKey("Users", on_delete=models.RESTRICT, related_name="+") + title = models.TextField() + + class Meta: + db_table = "posts" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_fk_snapshot@table_with_fk_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_fk_snapshot@table_with_fk_Gorm.snap new file mode 100644 index 00000000..755416f0 --- /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..002e6ff5 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_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 Articles(models.Model): + id = models.IntegerField(primary_key=True) + title = models.TextField() + created_at = models.DateTimeField() + + class Meta: + db_table = "articles" + indexes = [ + models.Index(fields=["created_at"], name="idx_articles_created_at"), + models.Index(fields=["title"]), + ] diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_Gorm.snap new file mode 100644 index 00000000..0108f08c --- /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" json:"title"` + CreatedAt time.Time `gorm:"column:created_at;not null;index:idx_articles_created_at" json:"created_at"` +} + +func (Articles) TableName() string { return "articles" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_integer_enum_snapshot@table_with_integer_enum_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_integer_enum_snapshot@table_with_integer_enum_Django.snap new file mode 100644 index 00000000..be6f0b51 --- /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,20 @@ +--- +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, "low" + MEDIUM = 10, "medium" + HIGH = 20, "high" + +class Tasks(models.Model): + id = models.IntegerField(primary_key=True) + priority = models.IntegerField(choices=PriorityLevel.choices) + + class Meta: + db_table = "tasks" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_integer_enum_snapshot@table_with_integer_enum_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_integer_enum_snapshot@table_with_integer_enum_Gorm.snap new file mode 100644 index 00000000..2449fc9b --- /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..755dfa71 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Django.snap @@ -0,0 +1,21 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Users(models.Model): + id = models.IntegerField() + email = models.TextField(unique=True) + username = models.TextField(unique=True) + department = models.TextField(null=True, blank=True) + status = models.TextField(default="active") + + class Meta: + db_table = "users" + indexes = [ + models.Index(fields=["department"], name="idx_department"), + ] diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Gorm.snap new file mode 100644 index 00000000..7b13630e --- /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: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..2ff8fe21 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_constant_default_snapshot@unknown_constant_default_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 UnknownDefault(models.Model): + id = models.IntegerField(primary_key=True) + value = models.TextField() + + class Meta: + db_table = "unknown_default" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_constant_default_snapshot@unknown_constant_default_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_constant_default_snapshot@unknown_constant_default_Gorm.snap new file mode 100644 index 00000000..832495de --- /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..61d84a9e --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_function_default_snapshot@unknown_function_default_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 UnknownDefaults(models.Model): + id = models.IntegerField(primary_key=True) + code = models.TextField() + + class Meta: + db_table = "unknown_defaults" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_function_default_snapshot@unknown_function_default_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_function_default_snapshot@unknown_function_default_Gorm.snap new file mode 100644 index 00000000..1b133d58 --- /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..8ef4656b --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_index_snapshot@unnamed_composite_index_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 UnnamedIndex(models.Model): + id = models.IntegerField(primary_key=True) + col_a = models.IntegerField() + col_b = models.IntegerField() + + class Meta: + db_table = "unnamed_index" + indexes = [ + models.Index(fields=["col_a", "col_b"]), + ] diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_index_snapshot@unnamed_composite_index_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_index_snapshot@unnamed_composite_index_Gorm.snap new file mode 100644 index 00000000..558c9032 --- /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" json:"col_a"` + ColB int32 `gorm:"column:col_b;not null;index" json:"col_b"` +} + +func (UnnamedIndex) TableName() string { return "unnamed_index" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_unique_snapshot@unnamed_composite_unique_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_unique_snapshot@unnamed_composite_unique_Django.snap new file mode 100644 index 00000000..341b2b18 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_unique_snapshot@unnamed_composite_unique_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 UnnamedUnique(models.Model): + id = models.IntegerField(primary_key=True) + col_a = models.IntegerField() + col_b = models.IntegerField() + + class Meta: + db_table = "unnamed_unique" + constraints = [ + models.UniqueConstraint(fields=["col_a", "col_b"], 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..187fdfe1 --- /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_col_a_col_b" json:"col_a"` + ColB int32 `gorm:"column:col_b;not null;uniqueIndex:uq_col_a_col_b" json:"col_b"` +} + +func (UnnamedUnique) TableName() string { return "unnamed_unique" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_index_and_unique_snapshot@unnamed_index_and_unique_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_index_and_unique_snapshot@unnamed_index_and_unique_Django.snap new file mode 100644 index 00000000..affbe90e --- /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,22 @@ +--- +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: + db_table = "events" + indexes = [ + models.Index(fields=["venue_id", "date"]), + ] + 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..623e0ecb --- /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;uniqueIndex:uq_venue_id_date" json:"venue_id"` + Date time.Time `gorm:"column:date;not null;type:date;index;uniqueIndex:uq_venue_id_date" json:"date"` +} + +func (Events) TableName() string { return "events" } diff --git a/crates/vespertide-exporter/tests/parallel_consolidated.rs b/crates/vespertide-exporter/tests/parallel_consolidated.rs index 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/schemas/config.schema.json b/schemas/config.schema.json index fa5f2852..b5452120 100644 --- a/schemas/config.schema.json +++ b/schemas/config.schema.json @@ -7,6 +7,16 @@ "columnNamingCase": { "$ref": "#/$defs/NameCase" }, + "django": { + "description": "Django-specific export configuration.", + "$ref": "#/$defs/DjangoConfig", + "default": {} + }, + "gorm": { + "description": "GORM-specific export configuration.", + "$ref": "#/$defs/GormConfig", + "default": {} + }, "lockTimeoutMs": { "description": "Maximum time (milliseconds) to wait acquiring a lock during a runtime\nmigration before failing. When set, the `vespertide_migration!` macro\nemits a backend-appropriate session/connection timeout at the start of\nthe migration (`PostgreSQL` `lock_timeout`, `MySQL`\n`innodb_lock_wait_timeout`, `SQLite` `PRAGMA busy_timeout`). `None`\n(default) leaves backend defaults untouched. Absent from serialized\nJSON when `None` (wire-compatible).", "type": [ @@ -76,6 +86,19 @@ "columnNamingCase" ], "$defs": { + "DjangoConfig": { + "description": "Django-specific export configuration.", + "type": "object", + "properties": { + "appLabel": { + "description": "Explicit `app_label` written into every generated model's `Meta`\nclass. Needed when generated models don't live inside a standard\nDjango app package layout, where Django would otherwise infer the\nlabel from the containing package. `None` (default) omits\n`app_label` and leaves Django's normal inference in place.", + "type": [ + "string", + "null" + ] + } + } + }, "FileFormat": { "description": "Supported file formats for generated artifacts.", "type": "string", @@ -85,6 +108,19 @@ "yml" ] }, + "GormConfig": { + "description": "GORM-specific export configuration.", + "type": "object", + "properties": { + "packageName": { + "description": "Go package name emitted at the top of every generated file\n(`package `). `None` (default) infers the name from the\nexport directory's final path segment (sanitized to a valid Go\nidentifier), falling back to `\"models\"` when that segment isn't\nusable. See [`VespertideConfig::gorm_package_name`].", + "type": [ + "string", + "null" + ] + } + } + }, "NameCase": { "description": "Supported naming cases.", "type": "string",