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 (
-
- )
-}
-
-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 (
+
+ )
+}
+
+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" }