diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..3d21e815 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [Unreleased] + +### Added + +- GORM exporter (`--orm gorm`): generates Go structs with GORM tags from schema definitions + - Full type mapping: all SQL types to Go equivalents (`int32`, `int64`, `float32`, `float64`, `string`, `bool`, `time.Time`, `uuid.UUID`, `datatypes.JSON`, `decimal.Decimal`, etc.) + - String and integer enum support with Go type aliases and `const` blocks + - Foreign key relations with `foreignKey` and `constraint` tags + - Reverse (HasMany) relations when schema context is provided + - Composite primary keys, unique indexes, and named indexes + - `TableName()` method generated when table name differs from GORM convention + - Smart import generation (only includes `time`, `gorm.io/datatypes`, `github.com/google/uuid`, `github.com/shopspring/decimal` when needed) diff --git a/CLAUDE.md b/CLAUDE.md index 43c994c2..eef4bd20 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -@AGENTS.md +@AGENTS.md \ No newline at end of file diff --git a/README.md b/README.md index 8fcb17d7..95e38bae 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 +- **ORM Export**: Export schemas to SeaORM, SQLAlchemy, SQLModel, GORM ## Installation @@ -199,6 +199,7 @@ The only exception is adding `fill_with` values when prompted (for NOT NULL colu vespertide export --orm seaorm # Rust - SeaORM entities vespertide export --orm sqlalchemy # Python - SQLAlchemy models vespertide export --orm sqlmodel # Python - SQLModel (FastAPI) +vespertide export --orm gorm # Go - GORM models ``` ## Runtime Migrations (Macro) 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..3774c8ba --- /dev/null +++ b/apps/landing/src/app/_components/code-tabs.tsx @@ -0,0 +1,72 @@ +'use client' + +import { Box, Flex, Text } from '@devup-ui/react' +import { useState } from 'react' + +import { CodeWindow, HighlightedCode } from './code-window' + +export interface CodeExample { + key: string + label: string + file: string + html: string +} + +export function CodeTabs({ examples }: { examples: CodeExample[] }) { + const [active, setActive] = useState(examples[0]?.key ?? '') + const current = examples.find((e) => e.key === active) ?? examples[0] + + if (!current) return null + + return ( + ( + setActive(ex.key)} + px="12px" + py="5px" + transition="color .15s, background .15s" + > + + {ex.label} + + + ))} + title={current.file} + > + + + ) +} + +export function StaticCodeBlock({ + title, + html, +}: { + title: string + html: string +}) { + return ( + + + + ) +} + +export function HeroCodeWrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/apps/landing/src/app/_components/code-theme.ts b/apps/landing/src/app/_components/code-theme.ts new file mode 100644 index 00000000..9db3dfef --- /dev/null +++ b/apps/landing/src/app/_components/code-theme.ts @@ -0,0 +1,19 @@ +import { globalCss } from '@devup-ui/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', + }, +}) 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..63a71e2f --- /dev/null +++ b/apps/landing/src/app/_components/code-window.tsx @@ -0,0 +1,71 @@ +import { Box, Flex, Text } from '@devup-ui/react' +import type { ComponentProps, ReactNode } from 'react' + +import './code-theme' + +export function CodeWindow({ + title, + tabs, + children, + ...props +}: { + title: string + tabs?: ReactNode + children: ReactNode +} & ComponentProps>) { + return ( + + + + {[0, 1, 2].map((i) => ( + + ))} + + + {title} + + {tabs && ( + + {tabs} + + )} + + + {children} + + + ) +} + +export function HighlightedCode({ html }: { html: string }) { + return
+} 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..f867ce08 --- /dev/null +++ b/apps/landing/src/app/_components/copy-install.tsx @@ -0,0 +1,61 @@ +'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" + > + {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..bd9468a1 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 CodeExample } 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,964 @@ 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.', + }, { - 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', + icon: 'logo-image', + title: 'Multi-database', + desc: 'PostgreSQL, MySQL, and SQLite — same schema, identical semantics, backend-aware quoting.', }, { - 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', + icon: 'devfive', + title: 'Native enums', + desc: 'First-class string and integer enums. Add new integer values without ever touching the DB.', }, { - 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', + 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.", }, ] -export default function HomePage() { +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', + }, + { + 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.', + }, + { + key: 'MY', + name: 'MySQL', + quote: '`identifier`', + note: 'Full feature support with MySQL-aware identifier quoting and types.', + }, + { + key: 'SL', + name: 'SQLite', + quote: '"identifier"', + note: 'Full feature support — perfect for tests, CLIs, and embedded apps.', + }, +] + +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: CodeExample[] }) { + 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: CodeExample[] = [ + { key: 'model', label: 'Model', file: 'models/post.json', html: modelHtml }, + { key: 'cli', label: 'CLI', file: '~/projects/blog', html: cliHtml }, + { key: 'runtime', label: 'Runtime', file: 'src/main.rs', html: runtimeHtml }, + { + key: 'export', + label: 'ORM export', + file: '$ vespertide export', + html: exportHtml, + }, + ] + + return ( + + + + + + + + ) } diff --git a/crates/vespertide-cli/src/commands/export.rs b/crates/vespertide-cli/src/commands/export.rs index da274037..f8247662 100644 --- a/crates/vespertide-cli/src/commands/export.rs +++ b/crates/vespertide-cli/src/commands/export.rs @@ -17,6 +17,7 @@ pub enum OrmArg { Sqlalchemy, Sqlmodel, Jpa, + Gorm, } impl From for Orm { @@ -26,6 +27,7 @@ impl From for Orm { OrmArg::Sqlalchemy => Orm::SqlAlchemy, OrmArg::Sqlmodel => Orm::SqlModel, OrmArg::Jpa => Orm::Jpa, + OrmArg::Gorm => Orm::Gorm, } } } @@ -203,6 +205,7 @@ async fn clean_export_dir(root: &Path, orm: Orm) -> Result<()> { Orm::SeaOrm => "rs", Orm::SqlAlchemy | Orm::SqlModel => "py", Orm::Jpa => "java", + Orm::Gorm => "go", }; clean_dir_recursive(root, ext).await?; @@ -295,6 +298,7 @@ fn build_output_path(root: &Path, rel_path: &Path, orm: Orm) -> PathBuf { Orm::SeaOrm => "rs", Orm::SqlAlchemy | Orm::SqlModel => "py", Orm::Jpa => "java", + Orm::Gorm => "go", }; // Java requires filename to match PascalCase class name let file_stem = if matches!(orm, Orm::Jpa) { @@ -673,6 +677,7 @@ mod tests { #[case(OrmArg::Sqlalchemy, Orm::SqlAlchemy)] #[case(OrmArg::Sqlmodel, Orm::SqlModel)] #[case(OrmArg::Jpa, Orm::Jpa)] + #[case(OrmArg::Gorm, Orm::Gorm)] fn orm_arg_maps_to_enum(#[case] arg: OrmArg, #[case] expected: Orm) { assert_eq!(Orm::from(arg), expected); } diff --git a/crates/vespertide-exporter/src/gorm/mod.rs b/crates/vespertide-exporter/src/gorm/mod.rs new file mode 100644 index 00000000..47eec638 --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/mod.rs @@ -0,0 +1,1203 @@ +use std::collections::{HashMap, HashSet}; + +use crate::orm::OrmExporter; +use vespertide_core::schema::column::{ + ColumnType, ComplexColumnType, EnumValues, SimpleColumnType, +}; +use vespertide_core::schema::constraint::TableConstraint; +use vespertide_core::{ColumnDef, DefaultValue, ReferenceAction, TableDef}; + +/// Track which Go imports are actually used to generate minimal import statements. +#[derive(Default)] +struct UsedImports { + needs_time: bool, + needs_uuid: bool, + needs_datatypes: bool, + needs_decimal: bool, +} + +impl UsedImports { + fn add_column_type(&mut self, col_type: &ColumnType) { + match col_type { + ColumnType::Simple(ty) => match ty { + SimpleColumnType::Date + | SimpleColumnType::Time + | SimpleColumnType::Timestamp + | SimpleColumnType::Timestamptz => { + self.needs_time = true; + } + SimpleColumnType::Uuid => { + self.needs_uuid = true; + } + SimpleColumnType::Json => { + self.needs_datatypes = true; + } + _ => {} + }, + ColumnType::Complex(ty) => { + if let ComplexColumnType::Numeric { .. } = ty { + self.needs_decimal = true; + } + if let ComplexColumnType::Custom { custom_type } = ty { + if custom_type.to_uppercase() == "JSONB" { + self.needs_datatypes = true; + } + } + } + } + } +} + +pub struct GormExporter; + +impl OrmExporter for GormExporter { + fn render_entity(&self, table: &TableDef) -> Result { + render_entity(table) + } + + fn render_entity_with_schema( + &self, + table: &TableDef, + schema: &[TableDef], + ) -> Result { + render_entity_with_schema(table, schema) + } +} + +/// Render a GORM entity for the given table definition. +pub fn render_entity(table: &TableDef) -> Result { + 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 { + render_entity_inner(table, schema) +} + +fn render_entity_inner(table: &TableDef, schema: &[TableDef]) -> Result { + let mut lines: Vec = Vec::new(); + + let struct_name = to_pascal_case(&table.name); + + // 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(to_pascal_case(name)).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 = to_pascal_case(name); + 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 = to_pascal_case(name); + 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() + .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].clone()) } else { None } + } else { + None + } + }) + .collect(); + + let index_map = collect_index_info(&table.constraints); + let composite_unique_map = collect_composite_unique_info(&table.constraints); + + let mut used_imports = UsedImports::default(); + for col in &table.columns { + used_imports.add_column_type(&col.r#type); + } + + let reverse_relations = find_reverse_relations(&table.name, schema); + + // --- Package declaration --- + lines.push("package models".into()); + lines.push(String::new()); + + // --- Imports --- + let has_stdlib = used_imports.needs_time; + let has_external = + used_imports.needs_uuid || used_imports.needs_datatypes || used_imports.needs_decimal; + + if has_stdlib || has_external { + lines.push("import (".into()); + if has_stdlib { + lines.push(" \"time\"".into()); + } + if has_stdlib && has_external { + lines.push(String::new()); + } + if used_imports.needs_datatypes { + lines.push(" \"gorm.io/datatypes\"".into()); + } + if used_imports.needs_uuid { + lines.push(" \"github.com/google/uuid\"".into()); + } + if used_imports.needs_decimal { + lines.push(" \"github.com/shopspring/decimal\"".into()); + } + lines.push(")".into()); + lines.push(String::new()); + } + + // --- Enum type declarations --- + for (_, values, qualified_name) in &enums { + render_enum(&mut lines, qualified_name, values); + lines.push(String::new()); + } + + // --- Struct definition --- + if let Some(ref desc) = table.description { + lines.push(format!("// {}", desc.replace('\n', " "))); + } + + lines.push(format!("type {struct_name} struct {{")); + + for col in &table.columns { + let is_pk = pk_columns.contains(&col.name); + let is_unique = single_unique_columns.contains(&col.name); + let indexes = index_map.get(&col.name).map(|v| v.as_slice()).unwrap_or(&[]); + let composite_unique_name = composite_unique_map.get(&col.name); + + 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) { + render_fk_relation_field(&mut lines, col, fk); + } + } + + // Reverse relation fields (HasMany) derived from schema context + for rel in &reverse_relations { + let mut constraint_parts: Vec = Vec::new(); + if let Some(ref action) = rel.on_delete { + constraint_parts.push(format!("OnDelete:{}", reference_action_str(action))); + } + if let Some(ref action) = rel.on_update { + constraint_parts.push(format!("OnUpdate:{}", reference_action_str(action))); + } + let fk_field = to_go_field_name(&rel.fk_column); + let gorm_tag = if constraint_parts.is_empty() { + format!("foreignKey:{fk_field}") + } else { + format!("foreignKey:{fk_field};constraint:{}", constraint_parts.join(",")) + }; + lines.push(format!( + " {field_name} []{ref_struct} `gorm:\"{gorm_tag}\" json:\"-\"`", + field_name = rel.field_name, + ref_struct = to_pascal_case(&rel.ref_table), + )); + } + + 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()); + } + + Ok(lines.join("\n")) +} + +// --------------------------------------------------------------------------- +// FK info collection +// --------------------------------------------------------------------------- + +struct FkInfo { + ref_table: String, + on_delete: Option, + on_update: Option, +} + +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].clone(), + FkInfo { + ref_table: ref_table.clone(), + 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.clone()) + .or_default() + .push(IndexInfo { name: name.clone() }); + } + } + } + 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 { + if columns.len() > 1 { + let uq_name = name + .clone() + .unwrap_or_else(|| format!("uq_{}", columns.join("_"))); + for col in columns { + map.insert(col.clone(), 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 { + let mut raw: Vec<(String, String, String, Option, Option)> = + Vec::new(); + for other in schema { + if other.name == table_name { + continue; + } + for c in &other.constraints { + if let TableConstraint::ForeignKey { + columns, + ref_table, + on_delete, + on_update, + .. + } = c + { + if ref_table.as_str() == table_name && columns.len() == 1 { + let fk_col = columns[0].clone(); + let pascal = to_pascal_case(&other.name); + let base_name = + if pascal.ends_with('s') { pascal } else { format!("{pascal}s") }; + raw.push(( + other.name.clone(), + fk_col, + base_name, + on_delete.clone(), + on_update.clone(), + )); + } + } + } + } + + let mut name_count: HashMap = HashMap::new(); + for (_, _, base_name, _, _) in &raw { + *name_count.entry(base_name.clone()).or_default() += 1; + } + + raw.into_iter() + .map(|(ref_table, fk_col, base_name, on_delete, on_update)| { + let field_name = if *name_count.get(&base_name).unwrap_or(&0) > 1 { + format!("{}By{}", base_name, to_go_field_name(&fk_col)) + } else { + base_name + }; + ReverseRelation { field_name, ref_table, fk_column: fk_col, on_delete, on_update } + }) + .collect() +} + +// --------------------------------------------------------------------------- +// Enum rendering +// --------------------------------------------------------------------------- + +fn render_enum(lines: &mut Vec, name: &str, values: &EnumValues) { + let type_name = to_pascal_case(name); + + match values { + EnumValues::String(vals) => { + lines.push(format!("type {type_name} string")); + lines.push(String::new()); + lines.push("const (".into()); + for val in vals { + let const_name = format!("{type_name}{}", to_pascal_case(val)); + lines.push(format!(" {const_name} {type_name} = \"{val}\"")); + } + lines.push(")".into()); + } + EnumValues::Integer(vals) => { + lines.push(format!("type {type_name} int")); + lines.push(String::new()); + lines.push("const (".into()); + for val in vals { + let const_name = format!("{type_name}{}", to_pascal_case(&val.name)); + lines.push(format!(" {const_name} {type_name} = {}", val.value)); + } + lines.push(")".into()); + } + } +} + +// --------------------------------------------------------------------------- +// Field rendering +// --------------------------------------------------------------------------- + +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) { + let ref_struct = to_pascal_case(&fk.ref_table); + 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}"); + } + + let mut constraint_parts: Vec = Vec::new(); + if let Some(ref action) = fk.on_delete { + constraint_parts.push(format!("OnDelete:{}", reference_action_str(action))); + } + if let Some(ref action) = fk.on_update { + constraint_parts.push(format!("OnUpdate:{}", reference_action_str(action))); + } + + let gorm_tag = if constraint_parts.is_empty() { + format!("foreignKey:{fk_field_name}") + } else { + format!("foreignKey:{fk_field_name};constraint:{}", constraint_parts.join(",")) + }; + + let type_expr = if col.nullable { + format!("*{ref_struct}") + } else { + ref_struct + }; + + lines.push(format!( + " {relation_field_name} {type_expr} `gorm:\"{gorm_tag}\" json:\"-\"`" + )); +} + +// --------------------------------------------------------------------------- +// 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 { + if let Some(tag) = build_default_tag(default) { + parts.push(tag); + } + } + + for idx in indexes { + if let Some(ref name) = idx.name { + parts.push(format!("index:{name}")); + } else { + parts.push("index".into()); + } + } + + if let Some(uq_name) = composite_unique_name { + parts.push(format!("uniqueIndex:{uq_name}")); + } + + parts.join(";") +} + +fn build_default_tag(default: &DefaultValue) -> Option { + let sql = default.to_sql(); + if sql.contains('(') { + return None; // Skip server-side function calls like NOW() + } + Some(format!("default:{sql}")) +} + +// --------------------------------------------------------------------------- +// Type mapping +// --------------------------------------------------------------------------- + +fn go_type_for_column(col_type: &ColumnType, nullable: bool) -> String { + go_type_for_column_mapped(col_type, nullable, &HashMap::new()) +} + +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(|| to_pascal_case(name)) + } + _ => 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 ty { + SimpleColumnType::SmallInt => "int16".to_string(), + SimpleColumnType::Integer => "int32".to_string(), + SimpleColumnType::BigInt => "int64".to_string(), + SimpleColumnType::Real => "float32".to_string(), + SimpleColumnType::DoublePrecision => "float64".to_string(), + SimpleColumnType::Text + | SimpleColumnType::Xml + | SimpleColumnType::Inet + | SimpleColumnType::Cidr + | SimpleColumnType::Macaddr + | SimpleColumnType::Interval => "string".to_string(), + SimpleColumnType::Boolean => "bool".to_string(), + SimpleColumnType::Date + | SimpleColumnType::Time + | SimpleColumnType::Timestamp + | SimpleColumnType::Timestamptz => "time.Time".to_string(), + SimpleColumnType::Bytea => "[]byte".to_string(), + SimpleColumnType::Uuid => "uuid.UUID".to_string(), + SimpleColumnType::Json => "datatypes.JSON".to_string(), + }, + ColumnType::Complex(ty) => match ty { + ComplexColumnType::Varchar { .. } | ComplexColumnType::Char { .. } => { + "string".to_string() + } + ComplexColumnType::Custom { custom_type } => { + if custom_type.to_uppercase() == "JSONB" { + "datatypes.JSON".to_string() + } else { + "string".to_string() + } + } + ComplexColumnType::Numeric { .. } => "decimal.Decimal".to_string(), + ComplexColumnType::Enum { name, .. } => to_pascal_case(name), + }, + } +} + +fn reference_action_str(action: &ReferenceAction) -> &'static str { + match action { + ReferenceAction::Cascade => "CASCADE", + ReferenceAction::Restrict => "RESTRICT", + ReferenceAction::SetNull => "SET NULL", + ReferenceAction::SetDefault => "SET DEFAULT", + ReferenceAction::NoAction => "NO ACTION", + } +} + +// --------------------------------------------------------------------------- +// Naming utilities +// --------------------------------------------------------------------------- + +fn to_pascal_case(s: &str) -> String { + s.split('_') + .map(|word| { + let mut chars = word.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().chain(chars).collect(), + } + }) + .collect() +} + +fn to_go_field_name(s: &str) -> String { + let pascal = to_pascal_case(s); + // Apply Go conventions for common abbreviations + pascal.replace("Id", "ID") +} + +fn infer_relation_field_name(fk_column: &str) -> String { + let base = fk_column.strip_suffix("_id").unwrap_or(fk_column); + to_pascal_case(base) +} + +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 +} + +fn needs_table_name_method(table_name: &str, struct_name: &str) -> bool { + let snake = pascal_to_snake(struct_name); + let gorm_default = format!("{snake}s"); + gorm_default != table_name +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use insta::assert_snapshot; + use rstest::rstest; + use vespertide_core::{DefaultValue, NumValue}; + + fn col(name: &str, ty: ColumnType) -> ColumnDef { + ColumnDef { + name: name.to_string(), + 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(&col_type, nullable), expected); + } + + #[rstest] + #[case("user_id", "UserID")] + #[case("id", "ID")] + #[case("created_at", "CreatedAt")] + #[case("profile_image", "ProfileImage")] + #[case("media_id", "MediaID")] + fn test_to_go_field_name(#[case] input: &str, #[case] expected: &str) { + assert_eq!(to_go_field_name(input), expected); + } + + #[rstest] + #[case("user_id", "User")] + #[case("author_id", "Author")] + #[case("parent_id", "Parent")] + #[case("node", "Node")] + fn test_infer_relation_field_name(#[case] input: &str, #[case] expected: &str) { + assert_eq!(infer_relation_field_name(input), expected); + } + + #[rstest] + #[case("User", "user", true)] + #[case("User", "users", false)] + #[case("OrderItem", "order_items", false)] + #[case("OrderItem", "order_item", true)] + fn test_needs_table_name_method( + #[case] struct_name: &str, + #[case] table_name: &str, + #[case] expected: bool, + ) { + assert_eq!(needs_table_name_method(table_name, struct_name), expected); + } + + // ----------------------------------------------------------------------- + // Snapshot tests (a) simple table - columns only + // ----------------------------------------------------------------------- + + #[test] + fn test_basic_table() { + let table = TableDef { + name: "users".into(), + description: Some("User accounts".into()), + columns: vec![ + ColumnDef { + name: "id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: Some("Primary key".into()), + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "email".into(), + r#type: ColumnType::Complex(ComplexColumnType::Varchar { length: 255 }), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "name".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: true, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "active".into(), + r#type: ColumnType::Simple(SimpleColumnType::Boolean), + nullable: false, + default: Some(DefaultValue::Bool(true)), + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + }, + TableConstraint::Unique { + name: None, + columns: vec!["email".into()], + }, + ], + }; + let result = render_entity(&table).unwrap(); + assert_snapshot!(result); + } + + // ----------------------------------------------------------------------- + // Snapshot tests (b) FK + // ----------------------------------------------------------------------- + + #[test] + fn test_table_with_foreign_key() { + let table = TableDef { + name: "posts".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + ColumnDef { + name: "author_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + col("title", ColumnType::Simple(SimpleColumnType::Text)), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["author_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: Some(ReferenceAction::Cascade), + on_update: None, + }, + TableConstraint::Index { + name: Some("ix_posts__author_id".into()), + columns: vec!["author_id".into()], + }, + ], + }; + let result = render_entity(&table).unwrap(); + assert_snapshot!(result); + } + + // ----------------------------------------------------------------------- + // Snapshot tests (c) enums + // ----------------------------------------------------------------------- + + #[test] + fn test_table_with_string_enum() { + let table = TableDef { + name: "orders".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + ColumnDef { + name: "status".into(), + r#type: ColumnType::Complex(ComplexColumnType::Enum { + name: "order_status".into(), + values: EnumValues::String(vec![ + "pending".into(), + "shipped".into(), + "delivered".into(), + ]), + }), + nullable: false, + default: Some(DefaultValue::String("'pending'".into())), + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + }], + }; + let result = render_entity(&table).unwrap(); + assert_snapshot!(result); + } + + #[test] + fn test_table_with_integer_enum() { + let table = TableDef { + name: "tasks".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + ColumnDef { + name: "priority".into(), + r#type: ColumnType::Complex(ComplexColumnType::Enum { + name: "priority_level".into(), + values: EnumValues::Integer(vec![ + NumValue { name: "low".into(), value: 0 }, + NumValue { name: "medium".into(), value: 10 }, + NumValue { name: "high".into(), value: 20 }, + ]), + }), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: false, + columns: vec!["id".into()], + }], + }; + let result = render_entity(&table).unwrap(); + assert_snapshot!(result); + } + + // ----------------------------------------------------------------------- + // Snapshot tests (d) composite PK + nullable + // ----------------------------------------------------------------------- + + #[test] + fn test_composite_pk_nullable() { + let table = TableDef { + name: "order_items".into(), + description: None, + columns: vec![ + col("order_id", ColumnType::Simple(SimpleColumnType::Integer)), + col("product_id", ColumnType::Simple(SimpleColumnType::Integer)), + ColumnDef { + name: "quantity".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "note".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: true, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: false, + columns: vec!["order_id".into(), "product_id".into()], + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["order_id".into()], + ref_table: "orders".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["product_id".into()], + ref_table: "products".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + TableConstraint::Unique { + name: Some("uq_order_items__order_product".into()), + columns: vec!["order_id".into(), "product_id".into()], + }, + ], + }; + let result = render_entity(&table).unwrap(); + assert_snapshot!(result); + } + + // ----------------------------------------------------------------------- + // All simple types + // ----------------------------------------------------------------------- + + #[test] + fn test_all_simple_types() { + let table = TableDef { + name: "type_test".into(), + description: None, + columns: vec![ + col("col_smallint", ColumnType::Simple(SimpleColumnType::SmallInt)), + col("col_integer", ColumnType::Simple(SimpleColumnType::Integer)), + col("col_bigint", ColumnType::Simple(SimpleColumnType::BigInt)), + col("col_real", ColumnType::Simple(SimpleColumnType::Real)), + col("col_double", ColumnType::Simple(SimpleColumnType::DoublePrecision)), + col("col_text", ColumnType::Simple(SimpleColumnType::Text)), + col("col_boolean", ColumnType::Simple(SimpleColumnType::Boolean)), + col("col_date", ColumnType::Simple(SimpleColumnType::Date)), + col("col_time", ColumnType::Simple(SimpleColumnType::Time)), + col("col_timestamp", ColumnType::Simple(SimpleColumnType::Timestamp)), + col("col_timestamptz", ColumnType::Simple(SimpleColumnType::Timestamptz)), + col("col_interval", ColumnType::Simple(SimpleColumnType::Interval)), + col("col_bytea", ColumnType::Simple(SimpleColumnType::Bytea)), + col("col_uuid", ColumnType::Simple(SimpleColumnType::Uuid)), + col("col_json", ColumnType::Simple(SimpleColumnType::Json)), + col("col_inet", ColumnType::Simple(SimpleColumnType::Inet)), + col("col_cidr", ColumnType::Simple(SimpleColumnType::Cidr)), + col("col_macaddr", ColumnType::Simple(SimpleColumnType::Macaddr)), + col("col_xml", ColumnType::Simple(SimpleColumnType::Xml)), + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: false, + columns: vec!["col_integer".into()], + }], + }; + let result = render_entity(&table).unwrap(); + assert_snapshot!(result); + } + + // ----------------------------------------------------------------------- + // Snapshot tests (e) JSONB custom type + // ----------------------------------------------------------------------- + + #[test] + fn test_table_with_jsonb_column() { + let table = TableDef { + name: "documents".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("data", ColumnType::Complex(ComplexColumnType::Custom { custom_type: "JSONB".into() })), + col("meta", ColumnType::Simple(SimpleColumnType::Json)), + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + }], + }; + let result = render_entity(&table).unwrap(); + assert_snapshot!(result); + } + + // ----------------------------------------------------------------------- + // Snapshot tests (f) server-side default skipped + // ----------------------------------------------------------------------- + + #[test] + fn test_server_default_skipped() { + let table = TableDef { + name: "events".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + ColumnDef { + name: "created_at".into(), + r#type: ColumnType::Simple(SimpleColumnType::Timestamptz), + nullable: false, + default: Some(DefaultValue::String("NOW()".into())), + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "count".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: Some(DefaultValue::Integer(0)), + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + }], + }; + let result = render_entity(&table).unwrap(); + // Server-side function calls must not appear as GORM default tags + assert!(!result.contains("default:NOW()")); + // Literal integer defaults are still included + assert!(result.contains("default:0")); + assert_snapshot!(result); + } + + // ----------------------------------------------------------------------- + // Snapshot tests (g) HasMany with on_delete/on_update constraint + // ----------------------------------------------------------------------- + + #[test] + fn test_has_many_with_constraint() { + let users = TableDef { + name: "users".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + }], + }; + let posts = TableDef { + name: "posts".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("user_id", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + }, + 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), + }, + ], + }; + let schema = vec![users.clone(), posts.clone()]; + let result = render_entity_with_schema(&users, &schema).unwrap(); + assert!(result.contains("OnDelete:CASCADE")); + assert!(result.contains("OnUpdate:RESTRICT")); + assert_snapshot!(result); + } +} diff --git a/crates/vespertide-exporter/src/gorm/snapshots/vespertide_exporter__gorm__tests__all_simple_types.snap b/crates/vespertide-exporter/src/gorm/snapshots/vespertide_exporter__gorm__tests__all_simple_types.snap new file mode 100644 index 00000000..c7c36956 --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/snapshots/vespertide_exporter__gorm__tests__all_simple_types.snap @@ -0,0 +1,36 @@ +--- +source: crates/vespertide-exporter/src/gorm/mod.rs +expression: result +--- +package models + +import ( + "time" + + "gorm.io/datatypes" + "github.com/google/uuid" +) + +type TypeTest struct { + ColSmallint int16 `gorm:"column:col_smallint;not null" json:"col_smallint"` + ColInteger int32 `gorm:"column:col_integer;primaryKey" json:"col_integer"` + ColBigint int64 `gorm:"column:col_bigint;not null" json:"col_bigint"` + ColReal float32 `gorm:"column:col_real;not null" json:"col_real"` + ColDouble float64 `gorm:"column:col_double;not null" json:"col_double"` + ColText string `gorm:"column:col_text;not null;type:text" json:"col_text"` + ColBoolean bool `gorm:"column:col_boolean;not null" json:"col_boolean"` + ColDate time.Time `gorm:"column:col_date;not null;type:date" json:"col_date"` + ColTime time.Time `gorm:"column:col_time;not null;type:time" json:"col_time"` + ColTimestamp time.Time `gorm:"column:col_timestamp;not null" json:"col_timestamp"` + ColTimestamptz time.Time `gorm:"column:col_timestamptz;not null" json:"col_timestamptz"` + ColInterval string `gorm:"column:col_interval;not null;type:interval" json:"col_interval"` + ColBytea []byte `gorm:"column:col_bytea;not null" json:"col_bytea"` + ColUuid uuid.UUID `gorm:"column:col_uuid;not null;type:uuid" json:"col_uuid"` + ColJson datatypes.JSON `gorm:"column:col_json;not null" json:"col_json"` + ColInet string `gorm:"column:col_inet;not null" json:"col_inet"` + ColCidr string `gorm:"column:col_cidr;not null" json:"col_cidr"` + ColMacaddr string `gorm:"column:col_macaddr;not null" json:"col_macaddr"` + ColXml string `gorm:"column:col_xml;not null;type:xml" json:"col_xml"` +} + +func (TypeTest) TableName() string { return "type_test" } diff --git a/crates/vespertide-exporter/src/gorm/snapshots/vespertide_exporter__gorm__tests__basic_table.snap b/crates/vespertide-exporter/src/gorm/snapshots/vespertide_exporter__gorm__tests__basic_table.snap new file mode 100644 index 00000000..fa115d76 --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/snapshots/vespertide_exporter__gorm__tests__basic_table.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/gorm/mod.rs +expression: result +--- +package models + +// User accounts +type Users struct { + // Primary key + ID int32 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + Email string `gorm:"column:email;not null;unique;size:255" json:"email"` + Name *string `gorm:"column:name;type:text" json:"name"` + Active bool `gorm:"column:active;not null;default:true" json:"active"` +} + +func (Users) TableName() string { return "users" } diff --git a/crates/vespertide-exporter/src/gorm/snapshots/vespertide_exporter__gorm__tests__composite_pk_nullable.snap b/crates/vespertide-exporter/src/gorm/snapshots/vespertide_exporter__gorm__tests__composite_pk_nullable.snap new file mode 100644 index 00000000..a70f0b44 --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/snapshots/vespertide_exporter__gorm__tests__composite_pk_nullable.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/gorm/mod.rs +expression: result +--- +package models + +type OrderItems struct { + OrderID int32 `gorm:"column:order_id;primaryKey;uniqueIndex:uq_order_items__order_product" json:"order_id"` + Order Orders `gorm:"foreignKey:OrderID" json:"-"` + ProductID int32 `gorm:"column:product_id;primaryKey;uniqueIndex:uq_order_items__order_product" json:"product_id"` + Product Products `gorm:"foreignKey:ProductID" json:"-"` + Quantity int32 `gorm:"column:quantity;not null" json:"quantity"` + Note *string `gorm:"column:note;type:text" json:"note"` +} + +func (OrderItems) TableName() string { return "order_items" } diff --git a/crates/vespertide-exporter/src/gorm/snapshots/vespertide_exporter__gorm__tests__has_many_with_constraint.snap b/crates/vespertide-exporter/src/gorm/snapshots/vespertide_exporter__gorm__tests__has_many_with_constraint.snap new file mode 100644 index 00000000..fe836c1f --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/snapshots/vespertide_exporter__gorm__tests__has_many_with_constraint.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/gorm/mod.rs +expression: result +--- +package models + +type Users struct { + ID int32 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + Posts []Posts `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE,OnUpdate:RESTRICT" json:"-"` +} + +func (Users) TableName() string { return "users" } diff --git a/crates/vespertide-exporter/src/gorm/snapshots/vespertide_exporter__gorm__tests__server_default_skipped.snap b/crates/vespertide-exporter/src/gorm/snapshots/vespertide_exporter__gorm__tests__server_default_skipped.snap new file mode 100644 index 00000000..cd226a79 --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/snapshots/vespertide_exporter__gorm__tests__server_default_skipped.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/gorm/mod.rs +expression: result +--- +package models + +import ( + "time" +) + +type Events struct { + ID int32 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + CreatedAt time.Time `gorm:"column:created_at;not null" json:"created_at"` + Count int32 `gorm:"column:count;not null;default:0" json:"count"` +} + +func (Events) TableName() string { return "events" } diff --git a/crates/vespertide-exporter/src/gorm/snapshots/vespertide_exporter__gorm__tests__table_with_foreign_key.snap b/crates/vespertide-exporter/src/gorm/snapshots/vespertide_exporter__gorm__tests__table_with_foreign_key.snap new file mode 100644 index 00000000..37b7ce58 --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/snapshots/vespertide_exporter__gorm__tests__table_with_foreign_key.snap @@ -0,0 +1,14 @@ +--- +source: crates/vespertide-exporter/src/gorm/mod.rs +expression: result +--- +package models + +type Posts struct { + ID int32 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + AuthorID int32 `gorm:"column:author_id;not null;index:ix_posts__author_id" json:"author_id"` + Author Users `gorm:"foreignKey:AuthorID;constraint:OnDelete:CASCADE" json:"-"` + Title string `gorm:"column:title;not null;type:text" json:"title"` +} + +func (Posts) TableName() string { return "posts" } diff --git a/crates/vespertide-exporter/src/gorm/snapshots/vespertide_exporter__gorm__tests__table_with_integer_enum.snap b/crates/vespertide-exporter/src/gorm/snapshots/vespertide_exporter__gorm__tests__table_with_integer_enum.snap new file mode 100644 index 00000000..f1fae1b2 --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/snapshots/vespertide_exporter__gorm__tests__table_with_integer_enum.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/gorm/mod.rs +expression: result +--- +package models + +type PriorityLevel int + +const ( + PriorityLevelLow PriorityLevel = 0 + PriorityLevelMedium PriorityLevel = 10 + PriorityLevelHigh PriorityLevel = 20 +) + +type Tasks struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Priority PriorityLevel `gorm:"column:priority;not null" json:"priority"` +} + +func (Tasks) TableName() string { return "tasks" } diff --git a/crates/vespertide-exporter/src/gorm/snapshots/vespertide_exporter__gorm__tests__table_with_jsonb_column.snap b/crates/vespertide-exporter/src/gorm/snapshots/vespertide_exporter__gorm__tests__table_with_jsonb_column.snap new file mode 100644 index 00000000..73699dda --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/snapshots/vespertide_exporter__gorm__tests__table_with_jsonb_column.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/gorm/mod.rs +expression: result +--- +package models + +import ( + "gorm.io/datatypes" +) + +type Documents struct { + ID int32 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + Data datatypes.JSON `gorm:"column:data;not null;type:JSONB" json:"data"` + Meta datatypes.JSON `gorm:"column:meta;not null" json:"meta"` +} + +func (Documents) TableName() string { return "documents" } diff --git a/crates/vespertide-exporter/src/gorm/snapshots/vespertide_exporter__gorm__tests__table_with_string_enum.snap b/crates/vespertide-exporter/src/gorm/snapshots/vespertide_exporter__gorm__tests__table_with_string_enum.snap new file mode 100644 index 00000000..1fbaeb7c --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/snapshots/vespertide_exporter__gorm__tests__table_with_string_enum.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/gorm/mod.rs +expression: result +--- +package models + +type OrderStatus string + +const ( + OrderStatusPending OrderStatus = "pending" + OrderStatusShipped OrderStatus = "shipped" + OrderStatusDelivered OrderStatus = "delivered" +) + +type Orders struct { + ID int32 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + Status OrderStatus `gorm:"column:status;not null;default:'pending'" json:"status"` +} + +func (Orders) TableName() string { return "orders" } diff --git a/crates/vespertide-exporter/src/lib.rs b/crates/vespertide-exporter/src/lib.rs index 22da1da2..7a013df9 100644 --- a/crates/vespertide-exporter/src/lib.rs +++ b/crates/vespertide-exporter/src/lib.rs @@ -1,12 +1,14 @@ //! Helpers to convert `TableDef` models into ORM-specific representations -//! such as SeaORM, SQLAlchemy, SQLModel, and JPA. +//! such as SeaORM, SQLAlchemy, SQLModel, JPA, and GORM. +pub mod gorm; pub mod jpa; pub mod orm; pub mod seaorm; pub mod sqlalchemy; pub mod sqlmodel; +pub use gorm::GormExporter; pub use jpa::JpaExporter; pub use orm::{Orm, OrmExporter, render_entity, render_entity_with_schema}; pub use seaorm::{SeaOrmExporter, render_entity as render_seaorm_entity}; diff --git a/crates/vespertide-exporter/src/orm.rs b/crates/vespertide-exporter/src/orm.rs index cf16fd98..586227c5 100644 --- a/crates/vespertide-exporter/src/orm.rs +++ b/crates/vespertide-exporter/src/orm.rs @@ -1,8 +1,8 @@ use vespertide_core::TableDef; use crate::{ - jpa::JpaExporter, seaorm::SeaOrmExporter, sqlalchemy::SqlAlchemyExporter, - sqlmodel::SqlModelExporter, + gorm::GormExporter, jpa::JpaExporter, seaorm::SeaOrmExporter, + sqlalchemy::SqlAlchemyExporter, sqlmodel::SqlModelExporter, }; /// Supported ORM targets. @@ -12,6 +12,7 @@ pub enum Orm { SqlAlchemy, SqlModel, Jpa, + Gorm, } /// Standardized exporter interface for all supported ORMs. @@ -36,6 +37,7 @@ pub fn render_entity(orm: Orm, table: &TableDef) -> Result { Orm::SqlAlchemy => SqlAlchemyExporter.render_entity(table), Orm::SqlModel => SqlModelExporter.render_entity(table), Orm::Jpa => JpaExporter.render_entity(table), + Orm::Gorm => GormExporter.render_entity(table), } } @@ -50,6 +52,7 @@ pub fn render_entity_with_schema( Orm::SqlAlchemy => SqlAlchemyExporter.render_entity_with_schema(table, schema), Orm::SqlModel => SqlModelExporter.render_entity_with_schema(table, schema), Orm::Jpa => JpaExporter.render_entity_with_schema(table, schema), + Orm::Gorm => GormExporter.render_entity_with_schema(table, schema), } } @@ -87,6 +90,7 @@ mod tests { #[case("sqlalchemy", Orm::SqlAlchemy)] #[case("sqlmodel", Orm::SqlModel)] #[case("jpa", Orm::Jpa)] + #[case("gorm", Orm::Gorm)] fn test_render_entity_snapshots(#[case] name: &str, #[case] orm: Orm) { let table = simple_table(); let result = render_entity(orm, &table); @@ -101,6 +105,7 @@ mod tests { #[case("sqlalchemy", Orm::SqlAlchemy)] #[case("sqlmodel", Orm::SqlModel)] #[case("jpa", Orm::Jpa)] + #[case("gorm", Orm::Gorm)] fn test_render_entity_with_schema_snapshots(#[case] name: &str, #[case] orm: Orm) { let table = simple_table(); let schema = vec![table.clone()]; diff --git a/crates/vespertide-exporter/src/snapshots/vespertide_exporter__orm__tests__render_entity_snapshots@gorm.snap b/crates/vespertide-exporter/src/snapshots/vespertide_exporter__orm__tests__render_entity_snapshots@gorm.snap new file mode 100644 index 00000000..4d791bb6 --- /dev/null +++ b/crates/vespertide-exporter/src/snapshots/vespertide_exporter__orm__tests__render_entity_snapshots@gorm.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespertide-exporter/src/orm.rs +expression: result.unwrap() +--- +package models + +type Test struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` +} + +func (Test) TableName() string { return "test" } diff --git a/crates/vespertide-exporter/src/snapshots/vespertide_exporter__orm__tests__render_entity_with_schema_snapshots@gorm.snap b/crates/vespertide-exporter/src/snapshots/vespertide_exporter__orm__tests__render_entity_with_schema_snapshots@gorm.snap new file mode 100644 index 00000000..4d791bb6 --- /dev/null +++ b/crates/vespertide-exporter/src/snapshots/vespertide_exporter__orm__tests__render_entity_with_schema_snapshots@gorm.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespertide-exporter/src/orm.rs +expression: result.unwrap() +--- +package models + +type Test struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` +} + +func (Test) TableName() string { return "test" }