Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changepacks/changepack_log_5O2QrCyrfNoD1wBL403qE.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"changes": {"crates/vespertide-cli/Cargo.toml": "Minor", "crates/vespertide-config/Cargo.toml": "Minor", "crates/vespertide-core/Cargo.toml": "Minor", "crates/vespertide-exporter/Cargo.toml": "Minor", "crates/vespertide-loader/Cargo.toml": "Minor", "crates/vespertide-lsp/Cargo.toml": "Minor", "crates/vespertide-macro/Cargo.toml": "Minor", "crates/vespertide-naming/Cargo.toml": "Minor", "crates/vespertide-planner/Cargo.toml": "Minor", "crates/vespertide-query/Cargo.toml": "Minor", "crates/vespertide/Cargo.toml": "Minor"}, "note": "GORM(Go) 익스포터를 7번째 ORM 백엔드로 추가. `Orm`이 exhaustive pub enum이라 `Orm::Gorm` 추가가 0.x 기준 breaking이고, vespertide-cli에 `export --orm gorm` 경로가 함께 들어간다(스키마 전체를 `models.go` 한 파일로 쓴다). vespertide-core의 `SimpleColumnType`·`ReferenceAction`에서 `#[non_exhaustive]`를 제거해 downstream이 exhaustive match를 쓸 수 있게 한다(기존 `_` arm은 `unreachable_patterns` 경고가 된다). published 크레이트를 전부 같은 Minor로 올리는 이유는 #185·#186과 동일하다: [workspace.dependencies]의 `=` 핀으로 물려 있어 일부만 올리면 핀과 크레이트 버전이 어긋나 resolve가 깨진다.", "date": "2026-09-15T07:40:25.0000000Z"}
1 change: 1 addition & 0 deletions .changepacks/changepack_log_XRCvp4xmrYU0QgPi9kMB.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"changes": {"crates/vespertide-cli/Cargo.toml": "Minor", "crates/vespertide-config/Cargo.toml": "Minor", "crates/vespertide-core/Cargo.toml": "Minor", "crates/vespertide-exporter/Cargo.toml": "Minor", "crates/vespertide-loader/Cargo.toml": "Minor", "crates/vespertide-lsp/Cargo.toml": "Minor", "crates/vespertide-macro/Cargo.toml": "Minor", "crates/vespertide-naming/Cargo.toml": "Minor", "crates/vespertide-planner/Cargo.toml": "Minor", "crates/vespertide-query/Cargo.toml": "Minor", "crates/vespertide/Cargo.toml": "Minor"}, "note": "Django(Python) 익스포터를 8번째 ORM 백엔드로 추가. `Orm`이 exhaustive pub enum이라 `Orm::Django` 추가가 0.x 기준 breaking이고, vespertide-config에 `django` 설정 섹션(`appLabel`)이, vespertide-cli에 `export --orm django` 경로가 함께 들어간다(스키마 전체를 `models.py` 한 파일로 쓰고, 모델은 `managed = False`로 나간다). 기존 파이썬 백엔드(SQLAlchemy·SQLModel)도 파이썬 키워드 컬럼명을 이스케이프한다. published 크레이트를 전부 같은 Minor로 올리는 이유는 #185·#186과 동일하다: [workspace.dependencies]의 `=` 핀으로 물려 있어 일부만 올리면 핀과 크레이트 버전이 어긋나 resolve가 깨진다.", "date": "2026-09-20T09:00:00.0000000Z"}
18 changes: 9 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ vespertide/
│ ├── vespertide-planner/ # Schema diffing, baseline reconstruction, validation
│ ├── vespertide-query/ # SQL generation (Postgres/MySQL/SQLite)
│ ├── vespertide-cli/ # CLI commands: init, diff, sql, revision, export
│ ├── vespertide-exporter/ # ORM codegen: SeaORM, SQLAlchemy, SQLModel, JPA, Prisma, Drizzle
│ ├── vespertide-exporter/ # ORM codegen: SeaORM, SQLAlchemy, SQLModel, JPA, Prisma, Drizzle, GORM, Django
│ ├── vespertide-loader/ # Filesystem loading of models/migrations
│ ├── vespertide-config/ # vespertide.json configuration
│ ├── vespertide-lsp/ # Language server: 13 LSP capabilities + HS-7~11 caching
Expand Down Expand Up @@ -48,7 +48,7 @@ vespertide/
| Schema diffing | `vespertide-planner/src/diff/` | topological sort for FK deps |
| SQL generation | `vespertide-query/src/sql/` | One file per action type |
| CLI commands | `vespertide-cli/src/commands/` | `cmd_*` functions |
| ORM export | `vespertide-exporter/src/{seaorm,sqlalchemy,sqlmodel,jpa,prisma,drizzle}/` | Backend-specific generators |
| ORM export | `vespertide-exporter/src/{seaorm,sqlalchemy,sqlmodel,jpa,prisma,drizzle,gorm,django}/` | Backend-specific generators |
| Compile-time macro | `vespertide-macro/src/lib.rs` | `vespertide_migration!` proc macro |
| **LSP RingCache (HS-7~11)** | `vespertide-lsp/src/cache.rs` | Generic ring-buffer LRU shared across symbols/diagnostics/drift/semantic-token caches |
| **LSP drift cache** | `vespertide-lsp/src/drift/cache.rs` | HS-10 drift cache implementation |
Expand Down Expand Up @@ -103,7 +103,7 @@ When constructing struct literals (e.g. `TableDef { name: ... }`), prefer `.into
from string literals over the explicit constructor for terseness.

### `#[non_exhaustive]` Structs (0.2.0+)
`VespertideConfig`, `SeaOrmConfig`, `MigrationOptions` are `#[non_exhaustive]`:
`VespertideConfig`, `SeaOrmConfig`, `DjangoConfig`, `MigrationOptions` are `#[non_exhaustive]`:
external callers MUST construct via `..Default::default()` or the provided
constructor.

Expand Down Expand Up @@ -170,7 +170,7 @@ See `docs/clippy-allow-audit.md` for the full audit history.
| `QueryError::Other(...)` in new code | Emits deprecation warning. Use `SchemaError` / `InvalidColumnType` / `BackendError` / `UnsupportedAction` |
| Exhaustive struct literal for `MigrationOptions` / `VespertideConfig` | `#[non_exhaustive]` — use `..Default::default()` |
| Comparing newtype with `String::eq(&name.to_string(), "user")` | `TableName: PartialEq<&str>` — use `name == "user"` directly |
| Per-ORM exporter snapshot test (single ORM) | Use the 6-ORM `orm_cases!` macro; snapshots must cross-compare all ORMs |
| Per-ORM exporter snapshot test (single ORM) | Use the 8-ORM `orm_cases!` macro; snapshots must cross-compare all ORMs |

## COMMANDS

Expand Down Expand Up @@ -235,7 +235,7 @@ Files near the ceiling (next split candidates — line counts as of the
| `query/src/sql/delete_column/mod.rs` | 1138 | prod+inline-tests (≤1200) | DROP COLUMN with SQLite rebuild |
| `query/src/sql/add_constraint/mod.rs` | 1138 | prod+inline-tests (≤1200) | ADD CONSTRAINT |
| `core/src/schema/table/tests/mod.rs` | 1137 | test-file (≤1200) | Table normalization tests |
| `exporter/src/tests/fixtures/mod.rs` | 1146 | test-file (≤1200) | Shared 6-ORM fixture schemas |
| `exporter/src/tests/fixtures/mod.rs` | 1173 | test-file (≤1200) | Shared 8-ORM fixture schemas |
| `planner/src/validate/check_strengthening.rs` | 1121 | prod+inline-tests (≤1200) | CHECK strengthening analysis |
| `query/src/sql/helpers.rs` | 1109 | prod+inline-tests (≤1200) | Identifier quoting / type-cast helpers |
| `lsp/src/code_actions.rs` | 1107 | prod+inline-tests (≤1200) | LSP code actions (incl. CHECK BETWEEN-swap) |
Expand Down Expand Up @@ -369,7 +369,7 @@ alongside what the dialect emits *in place of* the missing construct.
**How many cases:**

Where the axis has a documented matrix — `vespertide-query`'s
`{PG, MySQL, SQLite}` triple and the exporter's six-ORM `orm_cases!` — fan out
`{PG, MySQL, SQLite}` triple and the exporter's eight-ORM `orm_cases!` — fan out
**always**, even when every case renders the same bytes: identity across the
matrix is itself the assertion (`uniform_sql_is_emitted_byte_for_byte`), and a
lone single-backend snapshot is a fault (`vespertide-query/AGENTS.md`).
Expand Down Expand Up @@ -397,14 +397,14 @@ fn create_table_snapshot(#[case] backend: DatabaseBackend) {
```

This is the same pattern used by `vespertide-query` (3 backends, 564 snapshots)
and `vespertide-exporter` (6 ORMs via `Orm` enum, 414 cross-ORM snapshots). When
and `vespertide-exporter` (8 ORMs via `Orm` enum, 632 cross-ORM snapshots). When
adding a new backend / ORM / format, the change is **one `#[case::name(Value)]`
line**.

### Exporter snapshots MUST cover ALL ORMs (no per-ORM snapshots)
Every `vespertide-exporter` snapshot test MUST be written through the shared `orm_cases!` rstest macro in `crates/vespertide-exporter/src/tests/mod.rs`, which renders each fixture for **all six ORMs** (`Orm::SeaOrm`, `Orm::SqlAlchemy`, `Orm::SqlModel`, `Orm::Jpa`, `Orm::Prisma`, `Orm::Drizzle`). A new export scenario = ONE fixture + ONE `orm_cases!(...)` line, producing exactly six snapshots (one per ORM) in the single shared `crates/vespertide-exporter/src/tests/snapshots/` directory.
Every `vespertide-exporter` snapshot test MUST be written through the shared `orm_cases!` rstest macro in `crates/vespertide-exporter/src/tests/mod.rs`, which renders each fixture for **all eight ORMs** (`Orm::SeaOrm`, `Orm::SqlAlchemy`, `Orm::SqlModel`, `Orm::Jpa`, `Orm::Prisma`, `Orm::Drizzle`, `Orm::Gorm`, `Orm::Django`). A new export scenario = ONE fixture + ONE `orm_cases!(...)` line, producing exactly eight snapshots (one per ORM) in the single shared `crates/vespertide-exporter/src/tests/snapshots/` directory.

FORBIDDEN: per-ORM `#[test]` snapshot functions inside `src/seaorm/`, `src/sqlalchemy/`, `src/sqlmodel/`, `src/jpa/`, `src/prisma/`, `src/drizzle/`, or any `snapshots/` directory other than `src/tests/snapshots/`. A scenario snapshotted for only one ORM is a defect — ORM output must always be cross-compared across all six. When adding a new ORM the change is a single `#[case::<orm>(Orm::<Variant>)]` line in the macro, never a new per-ORM test.
FORBIDDEN: per-ORM `#[test]` snapshot functions inside `src/seaorm/`, `src/sqlalchemy/`, `src/sqlmodel/`, `src/jpa/`, `src/prisma/`, `src/drizzle/`, `src/gorm/`, `src/django/`, or any `snapshots/` directory other than `src/tests/snapshots/`. A scenario snapshotted for only one ORM is a defect — ORM output must always be cross-compared across all eight. When adding a new ORM the change is a single `#[case::<orm>(Orm::<Variant>)]` line in the macro, never a new per-ORM test.

Exception: an entry point that exists in only one backend (Prisma's single-file `render_schema`, which deduplicates enums globally; Drizzle's dialect-aware `render_schema`, whose axis is the SQL dialect rather than the ORM) is not a cross-ORM scenario, so its snapshot tests live as inline tests of that module — with the snapshot files still written to the shared `src/tests/snapshots/` via `with_settings!(snapshot_path => ...)`.

Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@ Declarative database schema management. Define your schemas in JSON, and Vespert
- **Enum Types**: Native string enums and integer enums (no migration needed for new values)
- **Zero-Runtime Migrations**: Compile-time macro generates database-specific SQL
- **JSON Schema Validation**: Ships with JSON Schemas for IDE autocompletion and validation
- **ORM Export**: Export schemas to SeaORM, SQLAlchemy, SQLModel, JPA, Prisma, Drizzle
- **ORM Export**: Export schemas to SeaORM, SQLAlchemy, SQLModel, JPA, Prisma, Drizzle, GORM, Django
- **Language Server**: First-class editor support via the bundled `vespertide-lsp` — see [LSP Features](#lsp-features) below

## What's new in 0.2.0

API stability pass with a byte-identical JSON wire format — existing models and migration files load unchanged.

- **Newtype identifiers**: `TableName`, `ColumnName`, `IndexName` in `vespertide-core` (`crates/vespertide-core/src/schema/names.rs`). `#[serde(transparent)]` keeps JSON identical; `Deref<Target = str>` means most call sites need no edit.
- **`#[non_exhaustive]` configs**: `VespertideConfig`, `SeaOrmConfig`, and `MigrationOptions` must be built with `..Default::default()` (or `MigrationOptions::new()`), so future fields don't break semver.
- **`#[non_exhaustive]` configs**: `VespertideConfig`, `SeaOrmConfig`, `DjangoConfig`, and `MigrationOptions` must be built with `..Default::default()` (or `MigrationOptions::new()`), so future fields don't break semver.
- **Decomposed `QueryError`**: new `InvalidColumnType`, `SchemaError`, `BackendError`, and `UnsupportedAction` variants. `QueryError::Other(String)` is `#[deprecated]` but still compiles.
- **Cloneable `MigrationError`**: backed by `Arc<dyn Error>`, so retry loops can re-emit errors without re-running the planner.
- **Faster LSP**: every editor hot path (diagnostics, symbols, drift) is now `RingCache`-backed in `vespertide-lsp`. No API change; -99% latency on the synthetic `tools/lsp-profile/` workload.
Expand Down Expand Up @@ -245,6 +245,8 @@ vespertide export --orm sqlmodel # Python - SQLModel (FastAPI)
vespertide export --orm jpa # Java - JPA/Hibernate entities
vespertide export --orm prisma # Prisma - schema.prisma models
vespertide export --orm drizzle # TypeScript - Drizzle ORM (pg/mysql/sqlite files)
vespertide export --orm gorm # Go - GORM models (models.go)
vespertide export --orm django # Python - Django models (models.py)
```

## Runtime Migrations (Macro)
Expand Down
2 changes: 1 addition & 1 deletion bridge/node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Declarative database schema management: define tables in JSON or YAML, diff
them against the migration history, and generate SQL for PostgreSQL, MySQL
and SQLite plus ORM code (SeaORM, SQLAlchemy, SQLModel, JPA, Prisma, Drizzle).
and SQLite plus ORM code (SeaORM, SQLAlchemy, SQLModel, JPA, Prisma, Drizzle, GORM, Django).

This package is the `vespertide` command-line tool as a native Node addon,
so no Rust toolchain is needed.
Expand Down
9 changes: 5 additions & 4 deletions crates/vespertide-cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ src/
│ # choices_and_apply/), tests/
├── status.rs # Show config and sync status
├── log.rs # List applied migrations with SQL
├── export/ # Export to ORM code (SeaORM/SQLAlchemy/SQLModel/JPA/Prisma/Drizzle) —
│ # mod.rs + tests/ (mod.rs, prisma.rs, drizzle.rs)
├── export/ # Export to ORM code (SeaORM/SQLAlchemy/SQLModel/JPA/Prisma/Drizzle/GORM/Django) —
│ # mod.rs + tests/ (mod.rs, prisma.rs, drizzle.rs, gorm.rs, django.rs,
│ # models_file.rs)
└── erd/ # ERD diagram export — mod.rs, mermaid.rs, dot.rs, svg/ (style, model,
# layout, edges, render, util), tests/
```
Expand All @@ -38,7 +39,7 @@ src/
| `revision -m` | `cmd_revision(msg, fill_with)` | Interactive prompts via `dialoguer::Input` |
| `status` | `cmd_status()` | Display config paths and migration count |
| `log` | `cmd_log(backend)` | Iterate applied migrations, print SQL |
| `export --orm` | `cmd_export(orm, dir)` | `render_entity_with_schema()` + mod.rs wiring |
| `export --orm` | `cmd_export(orm, dir)` | per-table `render_entity_with_schema()` + mod.rs wiring; Prisma/Drizzle/GORM/Django render the whole schema into fixed file names |
| `erd -f svg\|mermaid\|dot` | `cmd_erd_with_filters(format, output, include, exclude, depth)` | FK-graph filtered ERD rendering |

## WHERE TO LOOK
Expand All @@ -55,7 +56,7 @@ src/
## NOTES

- **revision/**: Most complex command — handles interactive `--fill-with` prompts for NOT NULL columns without defaults; long ago split from a single 3064-line file into `revision/{mod,parse,emit,write,timezones}.rs` + `prompts/` + `tests/`
- **export/**: Generates the `mod.rs` chain for SeaORM exports; Python/Java ORMs skip it. Prisma and Drizzle take separate single-file paths rather than one file per model — Prisma writes one `models.prisma`, Drizzle one file per dialect (`models.pg.ts` / `models.mysql.ts` / `models.sqlite.ts`)
- **export/**: Generates the `mod.rs` chain for SeaORM exports; Python/Java ORMs skip it. Prisma, Drizzle, GORM and Django take separate single-file paths rather than one file per model — Prisma writes one `models.prisma`, Drizzle one file per dialect (`models.pg.ts` / `models.mysql.ts` / `models.sqlite.ts`), GORM `models.go` and Django `models.py`. The last two skip the extension sweep like Drizzle, and refuse to overwrite a `models.*` that does not open with the `Code generated by vespertide. DO NOT EDIT.` line
- All commands use `load_config()`, `load_models()`, `load_migrations()` from `vespertide_loader`
- YAML and JSON are both fully supported for models and migrations; `new <name> -f yaml` creates YAML templates.
- Prefer typed `MigrationAction` enums; `RawSql` exists as a documented emergency escape hatch, but is not recommended for normal use.
Expand Down
65 changes: 62 additions & 3 deletions crates/vespertide-cli/src/commands/export/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ use tokio::fs;
use vespertide_config::VespertideConfig;
use vespertide_core::TableDef;
use vespertide_exporter::{
Orm, drizzle, prisma, python_naming::to_pascal_case, render_entity_with_schema,
seaorm::SeaOrmExporterWithConfig,
Orm, django::DjangoExporterWithConfig, drizzle, gorm::GormExporterWithConfig, prisma,
python_naming::to_pascal_case, render_entity_with_schema, seaorm::SeaOrmExporterWithConfig,
};
use vespertide_naming::{IdentifierStart, sanitize_identifier, seaorm_module_name};

Expand All @@ -35,13 +35,16 @@ pub async fn cmd_export(orm: Orm, export_dir: Option<PathBuf>) -> Result<()> {

let target_root = resolve_export_dir(export_dir, &config);

// Prisma and Drizzle use a single-file output strategy
// Prisma, Drizzle, GORM and Django use a single-file output strategy
if matches!(orm, Orm::Prisma) {
return cmd_export_prisma(normalized_models, target_root).await;
}
if matches!(orm, Orm::Drizzle) {
return cmd_export_drizzle(normalized_models, target_root).await;
}
if matches!(orm, Orm::Gorm | Orm::Django) {
return cmd_export_models_file(orm, &config, normalized_models, target_root).await;
}

// Clean the export directory before regenerating
prepare_export_dir(&target_root, orm).await?;
Expand Down Expand Up @@ -465,6 +468,62 @@ async fn cmd_export_drizzle(
Ok(())
}

/// First line of the file [`cmd_export_models_file`] writes, after the
/// language's comment token.
const GENERATED_MARKER: &str = "Code generated by vespertide. DO NOT EDIT.";

/// GORM and Django get the whole schema as one `models.go` / `models.py`. Go
/// reads a directory as one package and a relation is rendered from both of
/// its ends, so models spread over directories would import each other in a
/// cycle; Django loads an app's models from its one `models` module.
///
/// A fixed file name also leaves nothing to sweep, so the user's own `.go` and
/// `.py` files are never touched. The file itself is only overwritten when it
/// starts with [`GENERATED_MARKER`]: `models.py` is the name `startapp` gives
/// the user's own module.
async fn cmd_export_models_file(
orm: Orm,
config: &VespertideConfig,
normalized_models: Vec<(TableDef, PathBuf)>,
target_root: PathBuf,
) -> Result<()> {
let all_tables: Vec<TableDef> = normalized_models.iter().map(|(t, _)| t.clone()).collect();
let (comment, code) = if matches!(orm, Orm::Gorm) {
let exporter = GormExporterWithConfig::for_export_dir(&target_root);
("//", exporter.export(&all_tables))
} else {
let exporter = DjangoExporterWithConfig::new(config.django());
("#", exporter.export(&all_tables))
};
let code = code.map_err(|e| anyhow::anyhow!(e))?;
let marker = format!("{comment} {GENERATED_MARKER}");

let out_path = target_root.join(format!("models.{}", orm.file_extension()));
if let Ok(existing) = fs::read(&out_path).await
&& !existing.starts_with(marker.as_bytes())
{
anyhow::bail!(
"{} was not generated by vespertide; move it or choose another --export-dir",
out_path.display()
);
}

fs::create_dir_all(&target_root)
.await
.with_context(|| format!("create export dir {}", target_root.display()))?;
fs::write(&out_path, format!("{marker}\n\n{code}"))
.await
.with_context(|| format!("write {}", out_path.display()))?;

println!(
"Exported {} model(s) -> {}",
normalized_models.len(),
out_path.display()
);

Ok(())
}

#[async_recursion::async_recursion]
async fn walk_models(
root: &Path,
Expand Down
Loading
Loading