Skip to content

Commit b5a17d5

Browse files
authored
Merge pull request #166 from yyuneu/feat/prisma-exporter
feat(exporter): Prisma ORM exporter 추가 (PR #151 이어받음)
2 parents f8396be + c91cdd6 commit b5a17d5

150 files changed

Lines changed: 5106 additions & 1224 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"changes":{"crates/vespertide-naming/Cargo.toml":"Minor","crates/vespertide-exporter/Cargo.toml":"Minor","crates/vespertide-cli/Cargo.toml":"Minor"},"note":"Prisma ORM exporter 추가","date":"2026-07-09T14:25:30.6412773Z"}

‎AGENTS.md‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ vespertide/
1818
│ ├── vespertide-planner/ # Schema diffing, baseline reconstruction, validation
1919
│ ├── vespertide-query/ # SQL generation (Postgres/MySQL/SQLite)
2020
│ ├── vespertide-cli/ # CLI commands: init, diff, sql, revision, export
21-
│ ├── vespertide-exporter/ # ORM codegen: SeaORM, SQLAlchemy, SQLModel
21+
│ ├── vespertide-exporter/ # ORM codegen: SeaORM, SQLAlchemy, SQLModel, JPA, Prisma
2222
│ ├── vespertide-loader/ # Filesystem loading of models/migrations
2323
│ ├── vespertide-config/ # vespertide.json configuration
2424
│ ├── vespertide-lsp/ # Language server: 13 LSP capabilities + HS-7~11 caching
@@ -47,7 +47,7 @@ vespertide/
4747
| Schema diffing | `vespertide-planner/src/diff/` | topological sort for FK deps |
4848
| SQL generation | `vespertide-query/src/sql/` | One file per action type |
4949
| CLI commands | `vespertide-cli/src/commands/` | `cmd_*` functions |
50-
| ORM export | `vespertide-exporter/src/{seaorm,sqlalchemy,sqlmodel,jpa}/` | Backend-specific generators |
50+
| ORM export | `vespertide-exporter/src/{seaorm,sqlalchemy,sqlmodel,jpa,prisma}/` | Backend-specific generators |
5151
| Compile-time macro | `vespertide-macro/src/lib.rs` | `vespertide_migration!` proc macro |
5252
| **LSP RingCache (HS-7~11)** | `vespertide-lsp/src/cache.rs` | Generic ring-buffer LRU shared across symbols/diagnostics/drift/semantic-token caches |
5353
| **LSP drift cache** | `vespertide-lsp/src/drift/cache.rs` | HS-10 drift cache implementation |
@@ -169,7 +169,7 @@ See `docs/clippy-allow-audit.md` for the full audit history.
169169
| `QueryError::Other(...)` in new code | Emits deprecation warning. Use `SchemaError` / `InvalidColumnType` / `BackendError` / `UnsupportedAction` |
170170
| Exhaustive struct literal for `MigrationOptions` / `VespertideConfig` | `#[non_exhaustive]` — use `..Default::default()` |
171171
| Comparing newtype with `String::eq(&name.to_string(), "user")` | `TableName: PartialEq<&str>` — use `name == "user"` directly |
172-
| Per-ORM exporter snapshot test (single ORM) | Use the 4-ORM `orm_cases!` macro; snapshots must cross-compare all ORMs |
172+
| Per-ORM exporter snapshot test (single ORM) | Use the 5-ORM `orm_cases!` macro; snapshots must cross-compare all ORMs |
173173

174174
## COMMANDS
175175

@@ -368,12 +368,14 @@ fn create_table_snapshot(#[case] backend: DatabaseBackend) {
368368
}
369369
```
370370

371-
This is the same pattern used by `vespertide-query` (3 backends, 357 snapshots) and `vespertide-exporter` (4 ORMs via `Orm` enum, 232 cross-ORM snapshots). When adding a new backend / ORM / format, the change is **one `#[case::name(Value)]` line**.
371+
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**.
372372

373373
### Exporter snapshots MUST cover ALL ORMs (no per-ORM snapshots)
374-
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 four ORMs** (`Orm::SeaOrm`, `Orm::SqlAlchemy`, `Orm::SqlModel`, `Orm::Jpa`). A new export scenario = ONE fixture + ONE `orm_cases!(...)` line, producing exactly four snapshots (one per ORM) in the single shared `crates/vespertide-exporter/src/tests/snapshots/` directory.
374+
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.
375375

376-
FORBIDDEN: per-ORM `#[test]` snapshot functions inside `src/seaorm/`, `src/sqlalchemy/`, `src/sqlmodel/`, `src/jpa/`, 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 four. When adding a new ORM the change is a single `#[case::<orm>(Orm::<Variant>)]` line in the macro, never a new per-ORM test.
376+
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.
377+
378+
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 => ...)`.
377379

378380
### `#[cfg(test)]` test-oracle pattern
379381
When a function exists solely as an oracle for a regression test (e.g. comparing

‎Cargo.lock‎

Lines changed: 18 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Declarative database schema management. Define your schemas in JSON, and Vespert
1616
- **Enum Types**: Native string enums and integer enums (no migration needed for new values)
1717
- **Zero-Runtime Migrations**: Compile-time macro generates database-specific SQL
1818
- **JSON Schema Validation**: Ships with JSON Schemas for IDE autocompletion and validation
19-
- **ORM Export**: Export schemas to SeaORM, SQLAlchemy, SQLModel
19+
- **ORM Export**: Export schemas to SeaORM, SQLAlchemy, SQLModel, JPA, Prisma
2020
- **Language Server**: First-class editor support via the bundled `vespertide-lsp` — see [LSP Features](#lsp-features) below
2121

2222
## What's new in 0.2.0

‎crates/vespertide-cli/Cargo.toml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,10 @@ dot-writer = "0.1.4"
3030
vespertide-config = { workspace = true, features = ["cli", "schema"] }
3131
vespertide-core = { workspace = true, features = ["schema"] }
3232
vespertide-loader = { workspace = true }
33+
vespertide-naming = { workspace = true }
3334
vespertide-planner = { workspace = true }
3435
vespertide-query = { workspace = true }
35-
vespertide-exporter = { workspace = true }
36+
vespertide-exporter = { workspace = true, features = ["cli"] }
3637

3738
[dev-dependencies]
3839
tempfile = "3"

‎crates/vespertide-cli/src/commands/erd/dot.rs‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
use dot_writer::{Attributes, DotWriter, RankDirection, Shape};
22
use vespertide_core::{ColumnDef, TableDef};
3+
use vespertide_naming::{IdentifierStart, sanitize_identifier};
34

4-
use super::{
5-
ForeignKeyRelation, collect_foreign_key_relations, column_markers, sanitize_identifier,
6-
};
5+
use super::{ForeignKeyRelation, collect_foreign_key_relations, column_markers};
76

87
pub fn render_dot(tables: &[TableDef]) -> String {
98
DotWriter::write_string(|writer| {
@@ -25,16 +24,19 @@ pub fn render_dot(tables: &[TableDef]) -> String {
2524
}
2625

2726
for table in tables {
28-
let mut node = digraph.node_named(sanitize_identifier(&table.name));
27+
let mut node = digraph.node_named(sanitize_identifier(
28+
&table.name,
29+
IdentifierStart::Underscore,
30+
));
2931
node.set_shape(Shape::Record);
3032
node.set_label(&record_label(table));
3133
}
3234

3335
for relation in collect_foreign_key_relations(tables) {
3436
let mut edge_attributes = digraph
3537
.edge(
36-
sanitize_identifier(&relation.child_table),
37-
sanitize_identifier(&relation.parent_table),
38+
sanitize_identifier(&relation.child_table, IdentifierStart::Underscore),
39+
sanitize_identifier(&relation.parent_table, IdentifierStart::Underscore),
3840
)
3941
.attributes();
4042
edge_attributes.set_label(&relationship_label(&relation));

‎crates/vespertide-cli/src/commands/erd/mermaid.rs‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,23 @@
11
use std::fmt::Write as _;
22

33
use vespertide_core::{ColumnType, ComplexColumnType, EnumValues, SimpleColumnType, TableDef};
4+
use vespertide_naming::{IdentifierStart, sanitize_identifier};
45

56
use super::{
67
Cardinality, ForeignKeyRelation, collect_foreign_key_relations, is_foreign_key_column,
7-
is_primary_key_column, sanitize_identifier,
8+
is_primary_key_column,
89
};
910

1011
pub fn render_mermaid(tables: &[TableDef]) -> String {
1112
let mut output = String::from("erDiagram\n");
1213

1314
for table in tables {
14-
writeln!(output, " {} {{", sanitize_identifier(&table.name))
15-
.expect("write Mermaid table header");
15+
writeln!(
16+
output,
17+
" {} {{",
18+
sanitize_identifier(&table.name, IdentifierStart::Underscore)
19+
)
20+
.expect("write Mermaid table header");
1621

1722
for column in &table.columns {
1823
let primary_key = if is_primary_key_column(table, &column.name) {
@@ -30,7 +35,7 @@ pub fn render_mermaid(tables: &[TableDef]) -> String {
3035
output,
3136
" {} {}{}{}",
3237
column_type_to_mermaid(&column.r#type),
33-
sanitize_identifier(&column.name),
38+
sanitize_identifier(&column.name, IdentifierStart::Underscore),
3439
primary_key,
3540
foreign_key
3641
)
@@ -45,9 +50,9 @@ pub fn render_mermaid(tables: &[TableDef]) -> String {
4550
writeln!(
4651
output,
4752
" {} {} {} : \"{}\"",
48-
sanitize_identifier(left_table),
53+
sanitize_identifier(left_table, IdentifierStart::Underscore),
4954
connector,
50-
sanitize_identifier(right_table),
55+
sanitize_identifier(right_table, IdentifierStart::Underscore),
5156
escape_mermaid_label(&relation.child_columns.join(", "))
5257
)
5358
.expect("write Mermaid relationship");

‎crates/vespertide-cli/src/commands/erd/mod.rs‎

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -262,27 +262,6 @@ pub(super) fn column_markers(table: &TableDef, column: &ColumnDef) -> String {
262262
}
263263
}
264264

265-
pub(super) fn sanitize_identifier(input: &str) -> String {
266-
let mut identifier = String::new();
267-
268-
for (index, ch) in input.chars().enumerate() {
269-
if ch == '_' || ch.is_ascii_alphanumeric() {
270-
if index == 0 && ch.is_ascii_digit() {
271-
identifier.push('_');
272-
}
273-
identifier.push(ch);
274-
} else {
275-
identifier.push('_');
276-
}
277-
}
278-
279-
if identifier.is_empty() {
280-
"_".to_string()
281-
} else {
282-
identifier
283-
}
284-
}
285-
286265
fn inline_foreign_key_relation(
287266
table: &TableDef,
288267
column: &ColumnDef,

‎crates/vespertide-cli/src/commands/erd/tests/mod.rs‎

Lines changed: 18 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -539,28 +539,6 @@ fn svg_cardinality_labels(schema: &[TableDef]) -> String {
539539
.join("\n")
540540
}
541541

542-
// === sanitize_identifier edge cases ===
543-
544-
#[test]
545-
fn sanitize_identifier_digit_first_prefixes_underscore() {
546-
assert_eq!(sanitize_identifier("9lives"), "_9lives");
547-
}
548-
549-
#[test]
550-
fn sanitize_identifier_empty_returns_underscore() {
551-
assert_eq!(sanitize_identifier(""), "_");
552-
}
553-
554-
#[test]
555-
fn sanitize_identifier_non_ascii_becomes_underscore() {
556-
assert_eq!(sanitize_identifier("a b-c.d"), "a_b_c_d");
557-
}
558-
559-
#[test]
560-
fn sanitize_identifier_preserves_underscores_and_alphanumerics() {
561-
assert_eq!(sanitize_identifier("table_99_ok"), "table_99_ok");
562-
}
563-
564542
// === parallel FK edges between same (child, parent) pair ===
565543

566544
#[test]
@@ -664,6 +642,24 @@ fn malformed_inline_fk_reference_is_ignored() {
664642
assert!(collect_foreign_key_relations(&[users, bad]).is_empty());
665643
}
666644

645+
#[test]
646+
fn table_level_fk_to_missing_table_is_ignored() {
647+
let mut orphan = table(
648+
"orphan",
649+
vec![primary_key("id", integer()), column("ghost_id", integer())],
650+
);
651+
orphan.constraints.push(TableConstraint::ForeignKey {
652+
name: Some("fk_orphan_ghost".into()),
653+
columns: vec!["ghost_id".into()],
654+
ref_table: "ghost".into(),
655+
ref_columns: vec!["id".into()],
656+
on_delete: None,
657+
on_update: None,
658+
orphan_strategy: Default::default(),
659+
});
660+
assert!(collect_foreign_key_relations(&[orphan]).is_empty());
661+
}
662+
667663
#[test]
668664
fn inline_unique_group_variants_drive_one_to_one_detection() {
669665
let users = normalize(&table("users", vec![primary_key("id", integer())]));

0 commit comments

Comments
 (0)