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
12 changes: 6 additions & 6 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 @@ -373,9 +373,9 @@ 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**.

### 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/`, 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 => ...)`.

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.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ vespertide-macro = { path = "crates/vespertide-macro", version = "=0.3.0" }
vespertide-naming = { path = "crates/vespertide-naming", version = "=0.3.0" }
vespertide-planner = { path = "crates/vespertide-planner", version = "=0.3.0" }
vespertide-query = { path = "crates/vespertide-query", version = "=0.3.0" }
vespertide-exporter = { path = "crates/vespertide-exporter", version = "=0.3.0" }
vespertide-exporter = { path = "crates/vespertide-exporter", version = "=0.4.0" }
vespertide-lsp = { path = "crates/vespertide-lsp", version = "=0.3.0" }

[profile.dev]
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
73 changes: 57 additions & 16 deletions crates/vespertide-cli/src/commands/export/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use tokio::fs;
use vespertide_config::VespertideConfig;
use vespertide_core::TableDef;
use vespertide_exporter::{
Orm, prisma, python_naming::to_pascal_case, render_entity_with_schema,
Orm, drizzle, prisma, python_naming::to_pascal_case, render_entity_with_schema,
seaorm::SeaOrmExporterWithConfig,
};
use vespertide_naming::{IdentifierStart, sanitize_identifier, seaorm_module_name};
Expand All @@ -35,19 +35,16 @@ pub async fn cmd_export(orm: Orm, export_dir: Option<PathBuf>) -> Result<()> {

let target_root = resolve_export_dir(export_dir, &config);

// Prisma uses a single-file output strategy
// Prisma and Drizzle 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;
}

// Clean the export directory before regenerating
clean_export_dir(&target_root, orm).await?;

if !target_root.exists() {
fs::create_dir_all(&target_root)
.await
.with_context(|| format!("create export dir {}", target_root.display()))?;
}
prepare_export_dir(&target_root, orm).await?;

// Extract all tables for schema context (used for FK chain resolution)
let all_tables: Vec<TableDef> = normalized_models.iter().map(|(t, _)| t.clone()).collect();
Expand Down Expand Up @@ -215,6 +212,19 @@ fn resolve_export_dir(export_dir: Option<PathBuf>, config: &VespertideConfig) ->
config.model_export_dir().to_path_buf()
}

/// Clean stale output for `orm` and make sure the directory exists — the
/// shared preamble of every export path.
async fn prepare_export_dir(root: &Path, orm: Orm) -> Result<()> {
clean_export_dir(root, orm).await?;

if !root.exists() {
fs::create_dir_all(root)
.await
.with_context(|| format!("create export dir {}", root.display()))?;
}
Ok(())
}

/// Clean the export directory by removing all generated files.
/// This ensures no stale files remain from previous exports.
async fn clean_export_dir(root: &Path, orm: Orm) -> Result<()> {
Expand Down Expand Up @@ -400,13 +410,7 @@ async fn cmd_export_prisma(
let all_tables: Vec<TableDef> = normalized_models.iter().map(|(t, _)| t.clone()).collect();
let content = prisma::render_schema(&all_tables);

clean_export_dir(&target_root, Orm::Prisma).await?;

if !target_root.exists() {
fs::create_dir_all(&target_root)
.await
.with_context(|| format!("create export dir {}", target_root.display()))?;
}
prepare_export_dir(&target_root, Orm::Prisma).await?;

// Not `schema.prisma`: that name belongs to the user's own file holding the
// datasource/generator blocks.
Expand All @@ -424,6 +428,43 @@ async fn cmd_export_prisma(
Ok(())
}

/// Drizzle has no backend-neutral output — the table constructors fork at the
/// `import` line — so one export writes one file per dialect:
/// `models.pg.ts` / `models.mysql.ts` / `models.sqlite.ts`. Not `schema.ts`:
/// that name belongs to the user's own schema entry file.
async fn cmd_export_drizzle(
normalized_models: Vec<(TableDef, PathBuf)>,
target_root: PathBuf,
) -> Result<()> {
let all_tables: Vec<TableDef> = normalized_models.iter().map(|(t, _)| t.clone()).collect();

// No `prepare_export_dir`: its extension sweep would take every `.ts`
// under the root — the user's own source files included — and Drizzle
// writes exactly three fixed names, so `fs::write` overwriting them below
// is all the cleaning a re-export needs.
if !target_root.exists() {
fs::create_dir_all(&target_root)
.await
.with_context(|| format!("create export dir {}", target_root.display()))?;
}

for dialect in drizzle::DrizzleDialect::ALL {
let content = drizzle::render_schema(&all_tables, dialect);
let out_path = target_root.join(format!("models.{}.ts", dialect.file_suffix()));
fs::write(&out_path, &content)
.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
66 changes: 66 additions & 0 deletions crates/vespertide-cli/src/commands/export/tests/drizzle.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
use super::*;

/// One export writes one file per dialect — Drizzle's table constructors fork
/// at the `import` line, so there is no backend-neutral single file.
#[tokio::test]
#[serial]
async fn export_drizzle_writes_one_file_per_dialect() {
let tmp = tempdir().unwrap();
let _guard = CwdGuard::new(&tmp.path().to_path_buf());
write_config();
write_model(Path::new("models/events.json"), &sample_table("events"));

cmd_export(Orm::Drizzle, None).await.unwrap();

let root = PathBuf::from("src/models");
let pg = std_fs::read_to_string(root.join("models.pg.ts")).unwrap();
let mysql = std_fs::read_to_string(root.join("models.mysql.ts")).unwrap();
let sqlite = std_fs::read_to_string(root.join("models.sqlite.ts")).unwrap();

assert!(pg.contains("pgTable(\"events\""));
assert!(pg.contains("from \"drizzle-orm/pg-core\""));
assert!(mysql.contains("mysqlTable(\"events\""));
assert!(mysql.contains("from \"drizzle-orm/mysql-core\""));
assert!(sqlite.contains("sqliteTable(\"events\""));
assert!(sqlite.contains("from \"drizzle-orm/sqlite-core\""));
}

#[test]
fn build_output_path_drizzle_uses_ts_extension() {
use std::path::Path;
let root = Path::new("src/models");
let out = build_output_path(root, Path::new("user.json"), Orm::Drizzle);
assert_eq!(out, Path::new("src/models/user.ts"));
}

/// The Drizzle path deliberately skips the `.ts` extension sweep the other
/// ORMs run: the export root doubles as a source directory, so the user's own
/// files must survive an export, and the three fixed outputs are simply
/// overwritten in place.
#[tokio::test]
#[serial]
async fn export_drizzle_preserves_user_ts_files() {
let tmp = tempdir().unwrap();
let _guard = CwdGuard::new(&tmp.path().to_path_buf());
write_config();
write_model(Path::new("models/events.json"), &sample_table("events"));

let root = PathBuf::from("src/models");
std_fs::create_dir_all(root.join("helpers")).unwrap();
std_fs::write(root.join("index.ts"), "export {};").unwrap();
std_fs::write(root.join("helpers/util.ts"), "export {};").unwrap();
std_fs::write(root.join("models.pg.ts"), "stale").unwrap();

cmd_export(Orm::Drizzle, None).await.unwrap();

assert_eq!(
std_fs::read_to_string(root.join("index.ts")).unwrap(),
"export {};"
);
assert_eq!(
std_fs::read_to_string(root.join("helpers/util.ts")).unwrap(),
"export {};"
);
let pg = std_fs::read_to_string(root.join("models.pg.ts")).unwrap();
assert!(pg.contains("pgTable(\"events\""));
}
1 change: 1 addition & 0 deletions crates/vespertide-cli/src/commands/export/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ pub(super) use std::fs as std_fs;
pub(super) use tempfile::tempdir;
pub(super) use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType, TableConstraint};

mod drizzle;
mod prisma;

fn write_config() {
Expand Down
Loading
Loading