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_8YDBi8wcZVtIZyqBe-tuk.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": "Drizzle(TypeScript) 익스포터를 6번째 ORM 백엔드로 추가. `Orm`이 exhaustive pub enum이라 `Orm::Drizzle` 추가가 0.x 기준 breaking이고, vespertide-naming에 to_camel_case/infer_relation_field_name가, vespertide-cli에 `export --orm drizzle` 경로가 함께 들어간다. published 크레이트를 전부 같은 Minor로 올리는 이유는 #185와 동일하다: vespertide-naming 하나만 해도 cli/core/exporter/planner/query가 [workspace.dependencies]의 `=` 핀으로 물려 있어, 일부만 올리면 핀과 크레이트 버전이 어긋나 resolve가 깨진다.", "date": "2026-08-30T12:00:00.0000000Z"}
54 changes: 41 additions & 13 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
│ ├── vespertide-exporter/ # ORM codegen: SeaORM, SQLAlchemy, SQLModel, JPA, Prisma, Drizzle
│ ├── 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 @@ -47,7 +47,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}/` | Backend-specific generators |
| ORM export | `vespertide-exporter/src/{seaorm,sqlalchemy,sqlmodel,jpa,prisma,drizzle}/` | 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 @@ -169,7 +169,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 5-ORM `orm_cases!` macro; snapshots must cross-compare all ORMs |
| Per-ORM exporter snapshot test (single ORM) | Use the 6-ORM `orm_cases!` macro; snapshots must cross-compare all ORMs |

## COMMANDS

Expand Down Expand Up @@ -234,7 +234,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` | 1126 | test-file (≤1200) | Shared 5-ORM fixture schemas |
| `exporter/src/tests/fixtures/mod.rs` | 1146 | test-file (≤1200) | Shared 6-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 @@ -280,10 +280,9 @@ Verify line policy (canonical, same as CI): `sh scripts/check-line-budget.sh`

## TESTING

- `rstest` for parameterized tests — **default choice for any test with ≥ 2 input variants** (multi-backend, multi-ORM, multi-format). Plain `#[test]` is reserved for single-case unit tests.
- When writing new tests or improving existing ones, PREFER `rstest` parametric `#[case::name(...)]` cases over duplicated `#[test]` functions. Plain `#[test]` is reserved for genuinely single-input unit tests. Multi-variant logic (multi-backend, multi-ORM, multi-format, multiple inputs) MUST use `rstest`.
- `rstest` for parameterized tests — **default choice for any test with ≥ 2 input variants** (multi-backend, multi-ORM, multi-format). Plain `#[test]` is reserved for single-case unit tests. See **Snapshot vs assertion, one case vs many** below for which form a given test takes.
- `serial_test::serial` for filesystem tests
- `insta` for snapshot testing (exporter crate)
- `insta` for snapshot testing (exporter, query, planner, lsp, CLI)
- `proptest` for property-based testing (`vespertide-planner` diff + `vespertide-query` SQL)
- Helper functions: `col()`, `table()` reduce boilerplate
- **4234 tests across ~383 `.rs` files, 0 failed, 3 documented `#[ignore]`** (offline trybuild + 2 `///` doctest blocks)
Expand Down Expand Up @@ -350,8 +349,34 @@ both **forbidden** — owner directive: "no magic test wiring."
modules`, `rustdoc`, and any tooling that walks `mod` declarations. The
`pub(super)` + explicit `use` pattern is fully transparent and self-documenting.

### `rstest` is the default for parametric tests
For backend / ORM / format / configuration matrices, use `rstest` with explicit case names so each case appears as its own `cargo test` row and produces its own snapshot.
### Snapshot vs assertion, one case vs many

Two independent decisions. Getting them right is what keeps a test readable
*and* regression-proof.

**What the test asserts:**

| The subject is | Use | Why |
|---|---|---|
| Generated text — rendered ORM code, SQL, a file the command wrote | `insta` snapshot | The whole output is the contract; `contains("…")` pins a fragment and lets the rest rot silently |
| A behavioural or structural fact — a file survived / was deleted, a derived path, a count invariant, "every emitted symbol is in this set" | `assert!` / `assert_eq!` | There is no text to pin; the fact *is* the assertion |

A negative check on generated text (`assert!(!out.contains("pgEnum"))`) is a
snapshot in disguise — snapshot the case instead, where the absence is visible
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
**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`).

On an axis with no such mandate — the Drizzle dialect inside a module test —
split when the variant actually changes the output, each case owning its own
snapshot; use one canonical case (PostgreSQL) when the fixture carries nothing
that can fork, where three identical snapshots would only be noise.

```rust
use rstest::rstest;
Expand All @@ -370,14 +395,17 @@ fn create_table_snapshot(#[case] backend: DatabaseBackend) {
}
```

This is the same pattern used by `vespertide-query` (3 backends, 357 snapshots) and `vespertide-exporter` (5 ORMs via `Orm` enum, 335 cross-ORM snapshots). When adding a new backend / ORM / format, the change is **one `#[case::name(Value)]` line**.
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
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 five ORMs** (`Orm::SeaOrm`, `Orm::SqlAlchemy`, `Orm::SqlModel`, `Orm::Jpa`, `Orm::Prisma`). A new export scenario = ONE fixture + ONE `orm_cases!(...)` line, producing exactly five 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 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.

FORBIDDEN: per-ORM `#[test]` snapshot functions inside `src/seaorm/`, `src/sqlalchemy/`, `src/sqlmodel/`, `src/jpa/`, `src/prisma/`, 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 five. 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/`, 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.

Exception: an entry point that exists in only one backend (e.g. Prisma's single-file `render_schema`, which deduplicates enums globally) 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 => ...)`.
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 => ...)`.

### `#[cfg(test)]` test-oracle pattern
When a function exists solely as an oracle for a regression test (e.g. comparing
Expand Down
22 changes: 11 additions & 11 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ too_many_lines = { level = "allow", priority = 1 } # large scenario t
useless_vec = { level = "allow", priority = 1 } # tests sometimes use Vec shape to mirror production APIs
float_cmp = { level = "allow", priority = 1 } # SVG geometry tests assert exact constants where produced deterministically
default_trait_access = { level = "allow", priority = 1 } # test fixtures often rely on contextual Default for brevity
unused_async_trait_impl = { level = "allow", priority = 1 } # LSP handlers keep the trait's `async` signature even when they delegate synchronously

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

## What's new in 0.2.0
Expand Down Expand Up @@ -238,6 +238,7 @@ vespertide export --orm sqlalchemy # Python - SQLAlchemy models
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)
```

## Runtime Migrations (Macro)
Expand Down
6 changes: 3 additions & 3 deletions crates/vespertide-cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ 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) —
│ # mod.rs + tests/ (mod.rs, prisma.rs)
├── export/ # Export to ORM code (SeaORM/SQLAlchemy/SQLModel/JPA/Prisma/Drizzle) —
│ # mod.rs + tests/ (mod.rs, prisma.rs, drizzle.rs)
└── erd/ # ERD diagram export — mod.rs, mermaid.rs, dot.rs, svg/ (style, model,
# layout, edges, render, util), tests/
```
Expand Down Expand Up @@ -53,7 +53,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 takes a separate single-file path (`prisma::render_schema` → one `schema.prisma`) rather than one file per model
- **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`)
- 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
Loading
Loading