From 2892b7829cd59991a80f69db02a0299c78738e8a Mon Sep 17 00:00:00 2001 From: L33gn21 Date: Sun, 21 Jun 2026 04:02:07 +0900 Subject: [PATCH 01/25] feat(exporter): add Prisma ORM exporter Add a Prisma schema generator as a fourth ORM backend alongside SeaORM, SQLAlchemy, and SQLModel. Renders enum + model blocks with full schema context (relations, indexes, unique/composite constraints, native types, default attributes, referential actions) and wires Prisma into the cross-ORM test harness and CLI export command. Model names use plural PascalCase; string default values escape backslashes correctly. Snapshots cover the full column-type matrix plus relation, enum, default, and reserved-identifier scenarios. Co-Authored-By: Claude Opus 4.8 --- crates/vespertide-cli/src/commands/export.rs | 44 +- crates/vespertide-config/src/config.rs | 54 ++ crates/vespertide-config/src/lib.rs | 2 +- crates/vespertide-exporter/src/lib.rs | 4 +- crates/vespertide-exporter/src/orm.rs | 9 +- crates/vespertide-exporter/src/prisma/mod.rs | 607 ++++++++++++++++++ crates/vespertide-exporter/src/tests/mod.rs | 6 + .../tests/parallel_consolidated.rs | 1 + schemas/config.schema.json | 32 + 9 files changed, 754 insertions(+), 5 deletions(-) create mode 100644 crates/vespertide-exporter/src/prisma/mod.rs diff --git a/crates/vespertide-cli/src/commands/export.rs b/crates/vespertide-cli/src/commands/export.rs index 7eec2f53..76b0db79 100644 --- a/crates/vespertide-cli/src/commands/export.rs +++ b/crates/vespertide-cli/src/commands/export.rs @@ -8,7 +8,10 @@ use rayon::prelude::*; use tokio::fs; use vespertide_config::VespertideConfig; use vespertide_core::TableDef; -use vespertide_exporter::{Orm, render_entity_with_schema, seaorm::SeaOrmExporterWithConfig}; +use vespertide_exporter::{ + Orm, render_entity_with_schema, prisma::PrismaExporterWithConfig, + seaorm::SeaOrmExporterWithConfig, +}; use crate::parallel_config::{EXPORT_RENDER_PAR_MIN_LEN, EXPORT_RENDER_PAR_THRESHOLD}; use crate::utils::load_config; @@ -19,6 +22,7 @@ pub enum OrmArg { Sqlalchemy, Sqlmodel, Jpa, + Prisma, } impl From for Orm { @@ -28,6 +32,7 @@ impl From for Orm { OrmArg::Sqlalchemy => Orm::SqlAlchemy, OrmArg::Sqlmodel => Orm::SqlModel, OrmArg::Jpa => Orm::Jpa, + OrmArg::Prisma => Orm::Prisma, } } } @@ -51,6 +56,11 @@ pub async fn cmd_export(orm: OrmArg, export_dir: Option) -> Result<()> let target_root = resolve_export_dir(export_dir, &config); + // Prisma uses a single-file output strategy + if matches!(orm, OrmArg::Prisma) { + return cmd_export_prisma(config, normalized_models, target_root).await; + } + // Clean the export directory before regenerating let orm_kind: Orm = orm.into(); clean_export_dir(&target_root, orm_kind).await?; @@ -238,6 +248,7 @@ async fn clean_export_dir(root: &Path, orm: Orm) -> Result<()> { Orm::SeaOrm => "rs", Orm::SqlAlchemy | Orm::SqlModel => "py", Orm::Jpa => "java", + Orm::Prisma => "prisma", }; clean_dir_recursive(root, ext).await?; @@ -330,6 +341,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::Prisma => "prisma", }; // Java requires filename to match PascalCase class name let file_stem = if matches!(orm, Orm::Jpa) { @@ -426,6 +438,36 @@ async fn ensure_mod_chain(root: &Path, rel_path: &Path) -> Result<()> { Ok(()) } +async fn cmd_export_prisma( + config: VespertideConfig, + normalized_models: Vec<(TableDef, PathBuf)>, + target_root: PathBuf, +) -> Result<()> { + let all_tables: Vec = normalized_models.iter().map(|(t, _)| t.clone()).collect(); + let content = PrismaExporterWithConfig::new(config.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()))?; + } + + let out_path = target_root.join("schema.prisma"); + 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, diff --git a/crates/vespertide-config/src/config.rs b/crates/vespertide-config/src/config.rs index 126c28c3..c4a7690e 100644 --- a/crates/vespertide-config/src/config.rs +++ b/crates/vespertide-config/src/config.rs @@ -78,6 +78,51 @@ impl SeaOrmConfig { } } +/// Prisma-specific export configuration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct PrismaConfig { + /// Database provider: postgresql, mysql, sqlite, sqlserver, mongodb, cockroachdb. + #[serde(default = "default_prisma_provider")] + pub provider: String, + /// Optional output path for the generated Prisma client. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_output: Option, + /// Optional relationMode override ("foreignKeys" or "prisma"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub relation_mode: Option, +} + +fn default_prisma_provider() -> String { + "postgresql".to_string() +} + +impl Default for PrismaConfig { + fn default() -> Self { + Self { + provider: default_prisma_provider(), + client_output: None, + relation_mode: None, + } + } +} + +impl PrismaConfig { + pub fn provider(&self) -> &str { + &self.provider + } + + pub fn client_output(&self) -> Option<&str> { + self.client_output.as_deref() + } + + pub fn relation_mode(&self) -> Option<&str> { + self.relation_mode.as_deref() + } +} + /// Top-level vespertide configuration. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] @@ -100,6 +145,9 @@ pub struct VespertideConfig { /// SeaORM-specific export configuration. #[serde(default)] pub seaorm: SeaOrmConfig, + /// Prisma-specific export configuration. + #[serde(default)] + pub prisma: PrismaConfig, /// Prefix to add to all table names (including migration version table). /// Default: "" (no prefix) #[serde(default)] @@ -138,6 +186,7 @@ impl Default for VespertideConfig { migration_filename_pattern: default_migration_filename_pattern(), model_export_dir: default_model_export_dir(), seaorm: SeaOrmConfig::default(), + prisma: PrismaConfig::default(), prefix: String::new(), lock_timeout_ms: None, statement_timeout_ms: None, @@ -191,6 +240,11 @@ impl VespertideConfig { &self.seaorm } + /// Prisma-specific export configuration. + pub fn prisma(&self) -> &PrismaConfig { + &self.prisma + } + /// Prefix to add to all table names. pub fn prefix(&self) -> &str { &self.prefix diff --git a/crates/vespertide-config/src/lib.rs b/crates/vespertide-config/src/lib.rs index e8f6640d..0e727655 100644 --- a/crates/vespertide-config/src/lib.rs +++ b/crates/vespertide-config/src/lib.rs @@ -7,7 +7,7 @@ pub mod config; pub mod file_format; pub mod name_case; -pub use config::{SeaOrmConfig, VespertideConfig, default_migration_filename_pattern}; +pub use config::{PrismaConfig, SeaOrmConfig, VespertideConfig, default_migration_filename_pattern}; pub use file_format::FileFormat; pub use name_case::NameCase; diff --git a/crates/vespertide-exporter/src/lib.rs b/crates/vespertide-exporter/src/lib.rs index 0d4732c5..c1d5069d 100644 --- a/crates/vespertide-exporter/src/lib.rs +++ b/crates/vespertide-exporter/src/lib.rs @@ -1,9 +1,10 @@ //! Helpers to convert `TableDef` models into ORM-specific representations -//! such as `SeaORM`, `SQLAlchemy`, `SQLModel`, and JPA. +//! such as `SeaORM`, `SQLAlchemy`, `SQLModel`, JPA, and Prisma. pub mod jpa; pub mod orm; mod parallel_config; +pub mod prisma; pub mod seaorm; pub mod sqlalchemy; pub mod sqlmodel; @@ -13,6 +14,7 @@ mod utils; pub use jpa::JpaExporter; pub use orm::{Orm, OrmExporter, render_entity, render_entity_with_schema}; +pub use prisma::PrismaExporter; pub use seaorm::{SeaOrmExporter, render_entity as render_seaorm_entity}; pub use sqlalchemy::SqlAlchemyExporter; pub use sqlmodel::SqlModelExporter; diff --git a/crates/vespertide-exporter/src/orm.rs b/crates/vespertide-exporter/src/orm.rs index ea18829f..d1d68d2a 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, + jpa::JpaExporter, prisma::PrismaExporter, seaorm::SeaOrmExporter, + sqlalchemy::SqlAlchemyExporter, sqlmodel::SqlModelExporter, }; /// Supported ORM targets. @@ -12,6 +12,7 @@ pub enum Orm { SqlAlchemy, SqlModel, Jpa, + Prisma, } /// 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::Prisma => PrismaExporter.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::Prisma => PrismaExporter.render_entity_with_schema(table, schema), } } @@ -64,6 +67,7 @@ mod tests { #[case::sqlalchemy(Orm::SqlAlchemy)] #[case::sqlmodel(Orm::SqlModel)] #[case::jpa(Orm::Jpa)] + #[case::prisma(Orm::Prisma)] fn dispatch_render_entity_succeeds(#[case] orm: Orm) { let table = basic_single_pk(); assert!(render_entity(orm, &table).is_ok()); @@ -74,6 +78,7 @@ mod tests { #[case::sqlalchemy(Orm::SqlAlchemy)] #[case::sqlmodel(Orm::SqlModel)] #[case::jpa(Orm::Jpa)] + #[case::prisma(Orm::Prisma)] fn dispatch_render_entity_with_schema_succeeds(#[case] orm: Orm) { let table = basic_single_pk(); let schema = vec![table.clone()]; diff --git a/crates/vespertide-exporter/src/prisma/mod.rs b/crates/vespertide-exporter/src/prisma/mod.rs new file mode 100644 index 00000000..c28629a0 --- /dev/null +++ b/crates/vespertide-exporter/src/prisma/mod.rs @@ -0,0 +1,607 @@ +use std::collections::{HashMap, HashSet}; + +use crate::orm::OrmExporter; +use vespertide_config::PrismaConfig; +use vespertide_core::schema::column::{ColumnType, ComplexColumnType, EnumValues, SimpleColumnType}; +use vespertide_core::schema::constraint::TableConstraint; +use vespertide_core::schema::names::ColumnName; +use vespertide_core::schema::reference::ReferenceAction; +use vespertide_core::TableDef; + +pub struct PrismaExporter; + +impl OrmExporter for PrismaExporter { + fn render_entity(&self, table: &TableDef) -> Result { + Ok(render_entity(table)) + } + + fn render_entity_with_schema( + &self, + table: &TableDef, + schema: &[TableDef], + ) -> Result { + Ok(render_entity_with_schema(table, schema)) + } +} + +/// Prisma exporter with configuration support. +/// +/// Assembles a complete `schema.prisma` file from a full table list. +pub struct PrismaExporterWithConfig<'a> { + pub config: &'a PrismaConfig, +} + +impl<'a> PrismaExporterWithConfig<'a> { + pub fn new(config: &'a PrismaConfig) -> Self { + Self { config } + } + + /// Render a complete `schema.prisma` file for all tables. + /// + /// Output order: datasource → generator → (globally deduped) enum blocks → model blocks. + pub fn render_schema(&self, tables: &[TableDef]) -> String { + let mut seen_enums: HashSet = HashSet::new(); + let mut enum_blocks: Vec = Vec::new(); + for table in tables { + for (name, values) in collect_table_enums(table) { + if seen_enums.insert(name.to_string()) { + enum_blocks.push(render_enum(name, values)); + } + } + } + + let mut parts: Vec = Vec::new(); + + let mut datasource = vec![ + "datasource db {".to_string(), + format!(" provider = \"{}\"", self.config.provider()), + " url = env(\"DATABASE_URL\")".to_string(), + ]; + if let Some(rm) = self.config.relation_mode() { + datasource.push(format!(" relationMode = \"{}\"", rm)); + } + datasource.push("}".to_string()); + parts.push(datasource.join("\n")); + + let mut generator = vec![ + "generator client {".to_string(), + " provider = \"prisma-client-js\"".to_string(), + ]; + if let Some(output) = self.config.client_output() { + generator.push(format!(" output = \"{}\"", output)); + } + generator.push("}".to_string()); + parts.push(generator.join("\n")); + + parts.extend(enum_blocks); + + for table in tables { + parts.push(render_model(table, tables)); + } + + parts.join("\n\n") + "\n" + } +} + +fn collect_table_enums<'a>(table: &'a TableDef) -> Vec<(&'a str, &'a EnumValues)> { + let mut seen = HashSet::new(); + let mut result = Vec::new(); + for col in &table.columns { + if let ColumnType::Complex(ComplexColumnType::Enum { name, values }) = &col.r#type { + if seen.insert(name.as_str()) { + result.push((name.as_str(), values)); + } + } + } + result +} + +/// Render enum blocks + model block without schema context (no back-relations). +pub fn render_entity(table: &TableDef) -> String { + render_entity_with_schema(table, &[]) +} + +/// Render enum blocks + model block with full schema context (includes back-relations). +pub fn render_entity_with_schema(table: &TableDef, schema: &[TableDef]) -> String { + let mut parts: Vec = Vec::new(); + for (name, values) in collect_table_enums(table) { + parts.push(render_enum(name, values)); + } + parts.push(render_model(table, schema)); + parts.join("\n\n") +} + +/// Multi-table entry point: render every table (enum + model blocks) with full +/// schema context and join them. Mirrors the other ORMs' `export` so the +/// cross-ORM test harness can dispatch Prisma through a single call. The +/// `datasource`/`generator` wrapper lives in [`PrismaExporterWithConfig`]. +pub fn export(schema: &[TableDef]) -> Result { + Ok(schema + .iter() + .map(|table| render_entity_with_schema(table, schema)) + .collect::>() + .join("\n\n")) +} + +/// Test-only accessor for the internal `to_pascal_case` helper, mirroring the +/// other ORM backends so the cross-ORM consolidation test can exercise it +/// without making the helper generally public. +#[cfg(test)] +pub fn to_pascal_case_for_tests(s: &str) -> String { + to_pascal_case(s) +} + +fn render_enum(name: &str, values: &EnumValues) -> String { + let enum_name = to_pascal_case(name); + let mut lines = Vec::new(); + lines.push(format!("enum {} {{", enum_name)); + match values { + EnumValues::String(vals) => { + for val in vals { + let variant = to_screaming_snake(val); + if variant == *val { + lines.push(format!(" {}", variant)); + } else { + lines.push(format!(" {} @map(\"{}\")", variant, val)); + } + } + } + EnumValues::Integer(vals) => { + // Prisma doesn't support integer enums natively; emit as string variants with comment + for val in vals { + let variant = to_screaming_snake(&val.name); + lines.push(format!(" {} // = {}", variant, val.value)); + } + } + } + lines.push("}".into()); + lines.join("\n") +} + +struct PkInfo { + columns: Vec, + auto_increment: bool, +} + +fn extract_pk_info(constraints: &[TableConstraint]) -> PkInfo { + for c in constraints { + if let TableConstraint::PrimaryKey { auto_increment, columns, .. } = c { + return PkInfo { + columns: columns.iter().map(|c| c.to_string()).collect(), + auto_increment: *auto_increment, + }; + } + } + PkInfo { columns: Vec::new(), auto_increment: false } +} + +struct FkInfo<'a> { + ref_table: &'a str, + ref_cols: &'a [ColumnName], + on_delete: Option<&'a ReferenceAction>, + on_update: Option<&'a ReferenceAction>, +} + +fn render_model(table: &TableDef, schema: &[TableDef]) -> String { + let mut lines: Vec = Vec::new(); + + if let Some(desc) = &table.description { + for line in desc.lines() { + lines.push(format!("/// {}", line)); + } + } + + let model_name = to_pascal_case(&table.name); + lines.push(format!("model {} {{", model_name)); + + let pk_info = extract_pk_info(&table.constraints); + let pk_columns: HashSet<&str> = pk_info.columns.iter().map(|s| s.as_str()).collect(); + let is_composite_pk = pk_info.columns.len() > 1; + + let unique_single: HashMap<&str, Option<&str>> = table.constraints.iter() + .filter_map(|c| { + if let TableConstraint::Unique { name, columns, .. } = c { + if columns.len() == 1 { Some((columns[0].as_str(), name.as_deref())) } else { None } + } else { None } + }) + .collect(); + + // FK lookup by column + let fk_by_col: HashMap<&str, FkInfo<'_>> = table.constraints.iter() + .filter_map(|c| { + if let TableConstraint::ForeignKey { columns, ref_table, ref_columns, on_delete, on_update, .. } = c { + if columns.len() == 1 { + Some(( + columns[0].as_str(), + FkInfo { + ref_table: ref_table.as_str(), + ref_cols: ref_columns.as_slice(), + on_delete: on_delete.as_ref(), + on_update: on_update.as_ref(), + }, + )) + } else { None } + } else { None } + }) + .collect(); + + // Count FKs per ref_table for disambiguation detection + let mut ref_table_fk_count: HashMap<&str, usize> = HashMap::new(); + for fk in fk_by_col.values() { + *ref_table_fk_count.entry(fk.ref_table).or_default() += 1; + } + + // Render scalar fields + inline relation fields + for col in &table.columns { + let col_name = col.name.as_str(); + let in_pk = pk_columns.contains(col_name); + let is_single_pk = in_pk && !is_composite_pk; + let auto_inc = is_single_pk && pk_info.auto_increment; + let is_unique = unique_single.get(col_name).copied(); + + if let Some(ref comment) = col.comment { + lines.push(format!(" /// {}", comment.replace('\n', " "))); + } + + let (type_str, native_attr) = column_type_to_prisma(&col.r#type, col.nullable); + let mut attrs: Vec = Vec::new(); + + if is_single_pk { + attrs.push("@id".into()); + if auto_inc { + attrs.push("@default(autoincrement())".into()); + } + } + + if !auto_inc { + if let Some(ref default) = col.default { + attrs.push(prisma_default_attr(default.to_sql(), &col.r#type)); + } + } + + if let Some(unique_name) = is_unique { + if !is_single_pk { + match unique_name { + Some(n) => attrs.push(format!("@unique(map: \"{}\")", n)), + None => attrs.push("@unique".into()), + } + } + } + + if let Some(ref native) = native_attr { + attrs.push(native.clone()); + } + + let attrs_str = if attrs.is_empty() { + String::new() + } else { + format!(" {}", attrs.join(" ")) + }; + + lines.push(format!(" {} {}{}", col_name, type_str, attrs_str)); + + // Emit inline relation field for FK columns + if let Some(fk) = fk_by_col.get(col_name) { + let rel_field_name = infer_relation_field_name(col_name); + let rel_model = to_pascal_case(fk.ref_table); + let rel_type = if col.nullable { + format!("{}?", rel_model) + } else { + rel_model.clone() + }; + + let multi_fk = ref_table_fk_count.get(fk.ref_table).copied().unwrap_or(0) > 1; + let is_self_ref = fk.ref_table == table.name.as_str(); + let needs_name = multi_fk || is_self_ref; + + let mut rel_args: Vec = Vec::new(); + if needs_name { + let rel_name = format!( + "{}{}", + to_pascal_case(&table.name), + to_pascal_case(&rel_field_name) + ); + rel_args.push(format!("\"{}\"", rel_name)); + } + rel_args.push(format!("fields: [{}]", col_name)); + rel_args.push(format!( + "references: [{}]", + fk.ref_cols.iter().map(|s| s.as_str()).collect::>().join(", ") + )); + if let Some(od) = fk.on_delete { + rel_args.push(format!("onDelete: {}", reference_action_to_prisma(od))); + } + if let Some(ou) = fk.on_update { + rel_args.push(format!("onUpdate: {}", reference_action_to_prisma(ou))); + } + + lines.push(format!( + " {} {} @relation({})", + rel_field_name, + rel_type, + rel_args.join(", ") + )); + } + } + + // Back-relations from schema context + if !schema.is_empty() { + let back_rels = collect_back_relations(&table.name, schema); + for br in &back_rels { + let (field_name, rel_type) = back_rel_field(br); + let rel_attr = match &br.relation_name { + Some(name) => format!(" @relation(\"{}\")", name), + None => String::new(), + }; + lines.push(format!(" {} {}{}", field_name, rel_type, rel_attr)); + } + } + + // Blank line before model-level attributes + lines.push(String::new()); + + // Composite PK + if is_composite_pk { + lines.push(format!(" @@id([{}])", pk_info.columns.join(", "))); + } + + // Composite unique constraints + for c in &table.constraints { + if let TableConstraint::Unique { name, columns, .. } = c { + if columns.len() > 1 { + let cols = columns.join(", "); + if let Some(n) = name { + lines.push(format!(" @@unique([{}], name: \"{}\")", cols, n)); + } else { + lines.push(format!(" @@unique([{}])", cols)); + } + } + } + } + + // All index constraints + for c in &table.constraints { + if let TableConstraint::Index { name, columns } = c { + let cols = columns.join(", "); + if let Some(n) = name { + lines.push(format!(" @@index([{}], name: \"{}\")", cols, n)); + } else { + lines.push(format!(" @@index([{}])", cols)); + } + } + } + + // @@map (always present since model is PascalCase but table is snake_case) + lines.push(format!(" @@map(\"{}\")", table.name)); + lines.push("}".into()); + + lines.join("\n") +} + +struct BackRelation { + source_table: String, + fk_col: String, + is_one_to_one: bool, + relation_name: Option, +} + +fn back_rel_field(br: &BackRelation) -> (String, String) { + let source_pascal = to_pascal_case(&br.source_table); + let rel_type = if br.is_one_to_one { + format!("{}?", source_pascal) + } else { + format!("{}[]", source_pascal) + }; + + // source_table is already the plural table name — use it directly + let field_name = if br.relation_name.is_some() { + let rel_field = infer_relation_field_name(&br.fk_col); + if br.is_one_to_one { + format!("{}_{}", rel_field, br.source_table) + } else { + format!("{}_{}", rel_field, &br.source_table) + } + } else if br.is_one_to_one { + br.source_table.clone() + } else { + br.source_table.clone() + }; + + (field_name, rel_type) +} + +fn collect_back_relations(target_table: &str, schema: &[TableDef]) -> Vec { + let mut result = Vec::new(); + + for source in schema { + let fks_to_target: Vec<(&str, &[ColumnName])> = source.constraints.iter() + .filter_map(|c| { + if let TableConstraint::ForeignKey { columns, ref_table, ref_columns, .. } = c { + if ref_table.as_str() == target_table && columns.len() == 1 { + Some((columns[0].as_str(), ref_columns.as_slice())) + } else { None } + } else { None } + }) + .collect(); + + if fks_to_target.is_empty() { continue; } + + let multi_fk = fks_to_target.len() > 1; + let is_self_ref = source.name.as_str() == target_table; + + for (fk_col, _) in &fks_to_target { + let is_unique = source.constraints.iter().any(|c| { + matches!(c, TableConstraint::Unique { columns, .. } + if columns.len() == 1 && columns[0].as_str() == *fk_col) + }); + + let needs_name = multi_fk || is_self_ref; + let relation_name = if needs_name { + let rel_field = infer_relation_field_name(fk_col); + Some(format!( + "{}{}", + to_pascal_case(&source.name), + to_pascal_case(&rel_field) + )) + } else { + None + }; + + result.push(BackRelation { + source_table: source.name.as_str().to_string(), + fk_col: fk_col.to_string(), + is_one_to_one: is_unique, + relation_name, + }); + } + } + + result +} + +fn column_type_to_prisma(ty: &ColumnType, nullable: bool) -> (String, Option) { + let q = if nullable { "?" } else { "" }; + + match ty { + ColumnType::Simple(simple) => { + let (base, native) = match simple { + SimpleColumnType::SmallInt => ("Int", Some("@db.SmallInt")), + SimpleColumnType::Integer => ("Int", None), + SimpleColumnType::BigInt => ("BigInt", None), + SimpleColumnType::Real => ("Float", Some("@db.Real")), + SimpleColumnType::DoublePrecision => ("Float", None), + SimpleColumnType::Text => ("String", Some("@db.Text")), + SimpleColumnType::Boolean => ("Boolean", None), + SimpleColumnType::Date => ("DateTime", Some("@db.Date")), + SimpleColumnType::Time => ("DateTime", Some("@db.Time")), + SimpleColumnType::Timestamp => ("DateTime", Some("@db.Timestamp")), + SimpleColumnType::Timestamptz => ("DateTime", Some("@db.Timestamptz")), + SimpleColumnType::Interval => ("String", Some("@db.Interval")), + SimpleColumnType::Bytea => ("Bytes", None), + SimpleColumnType::Uuid => ("String", Some("@db.Uuid")), + SimpleColumnType::Json => ("Json", None), + SimpleColumnType::Inet => ("String", Some("@db.Inet")), + SimpleColumnType::Cidr => ("String", Some("@db.Cidr")), + SimpleColumnType::Macaddr => ("String", Some("@db.Macaddr")), + SimpleColumnType::Xml => ("String", Some("@db.Xml")), + // Unknown/future simple types fall back to a plain String column. + _ => ("String", None), + }; + (format!("{}{}", base, q), native.map(str::to_string)) + } + ColumnType::Complex(complex) => match complex { + ComplexColumnType::Varchar { length } => { + (format!("String{}", q), Some(format!("@db.VarChar({})", length))) + } + ComplexColumnType::Char { length } => { + (format!("String{}", q), Some(format!("@db.Char({})", length))) + } + ComplexColumnType::Numeric { precision, scale } => { + (format!("Decimal{}", q), Some(format!("@db.Decimal({}, {})", precision, scale))) + } + ComplexColumnType::Custom { custom_type } => { + (format!("Unsupported(\"{}\"){}", custom_type, q), None) + } + ComplexColumnType::Enum { name, .. } => { + (format!("{}{}", to_pascal_case(name), q), None) + } + // Unknown/future complex types fall back to a plain String column. + _ => (format!("String{}", q), None), + }, + } +} + +fn prisma_default_attr(default_sql: String, col_type: &ColumnType) -> String { + if default_sql == "true" { + return "@default(true)".into(); + } + if default_sql == "false" { + return "@default(false)".into(); + } + + let lower = default_sql.to_lowercase(); + if lower.contains("now()") || lower.starts_with("current_timestamp") { + return "@default(now())".into(); + } + if lower.contains("gen_random_uuid()") + || lower.contains("uuid_generate_v4()") + || lower.contains("newid()") + { + return "@default(uuid())".into(); + } + + // Any remaining function call → dbgenerated + if default_sql.contains('(') { + let escaped = default_sql.replace('"', "\\\""); + return format!("@default(dbgenerated(\"{}\"))", escaped); + } + + // String literal with quotes — may be an enum value + if default_sql.starts_with('\'') || default_sql.starts_with('"') { + let stripped = default_sql.trim_matches(|c| c == '\'' || c == '"'); + if let ColumnType::Complex(ComplexColumnType::Enum { values, .. }) = col_type { + if let EnumValues::String(variants) = values { + if variants.iter().any(|v| v.as_str() == stripped) { + let variant = to_screaming_snake(stripped); + return format!("@default({})", variant); + } + } + } + return format!("@default(\"{}\")", stripped.replace('\\', "\\\\").replace('"', "\\\"")); + } + + // Numeric + if default_sql.parse::().is_ok() { + return format!("@default({})", default_sql); + } + + // Fallback + let escaped = default_sql.replace('"', "\\\""); + format!("@default(dbgenerated(\"{}\"))", escaped) +} + +fn reference_action_to_prisma(action: &ReferenceAction) -> &'static str { + match action { + ReferenceAction::Cascade => "Cascade", + ReferenceAction::Restrict => "Restrict", + ReferenceAction::SetNull => "SetNull", + ReferenceAction::SetDefault => "SetDefault", + ReferenceAction::NoAction => "NoAction", + // Unknown/future referential actions fall back to Prisma's default. + _ => "NoAction", + } +} + +fn infer_relation_field_name(fk_col: &str) -> String { + fk_col.strip_suffix("_id").unwrap_or(fk_col).to_string() +} + +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_screaming_snake(s: &str) -> String { + let mut result = String::new(); + let mut prev_lower = false; + for ch in s.chars() { + if ch.is_uppercase() && prev_lower { + result.push('_'); + } + if ch.is_alphanumeric() { + result.push(ch.to_ascii_uppercase()); + prev_lower = ch.is_lowercase(); + } else { + result.push('_'); + prev_lower = false; + } + } + result.trim_end_matches('_').to_string() +} diff --git a/crates/vespertide-exporter/src/tests/mod.rs b/crates/vespertide-exporter/src/tests/mod.rs index 5d99b907..fc6cc638 100644 --- a/crates/vespertide-exporter/src/tests/mod.rs +++ b/crates/vespertide-exporter/src/tests/mod.rs @@ -17,6 +17,7 @@ fn render_schema(orm: Orm, schema: &[TableDef]) -> Result { Orm::SqlAlchemy => crate::sqlalchemy::export(schema), Orm::SqlModel => crate::sqlmodel::render_entities(schema), Orm::Jpa => crate::jpa::render_entities(schema).map(|entities| entities.join("\n")), + Orm::Prisma => crate::prisma::export(schema), } } @@ -29,6 +30,7 @@ macro_rules! orm_cases { #[case::sqlalchemy(Orm::SqlAlchemy)] #[case::sqlmodel(Orm::SqlModel)] #[case::jpa(Orm::Jpa)] + #[case::prisma(Orm::Prisma)] fn $test_name(#[case] orm: Orm) { let table = $fixture(); let rendered = render_entity(orm, &table).unwrap(); @@ -45,6 +47,7 @@ macro_rules! orm_cases { #[case::sqlalchemy(Orm::SqlAlchemy)] #[case::sqlmodel(Orm::SqlModel)] #[case::jpa(Orm::Jpa)] + #[case::prisma(Orm::Prisma)] fn $test_name(#[case] orm: Orm) { let schema: Vec = $fixture(); let rendered = render_schema(orm, &schema).unwrap(); @@ -299,6 +302,7 @@ fn to_pascal_case_for(orm: Orm, s: &str) -> String { Orm::SqlAlchemy => crate::sqlalchemy::to_pascal_case_for_tests(s), Orm::SqlModel => crate::sqlmodel::to_pascal_case_for_tests(s), Orm::Jpa => crate::jpa::to_pascal_case_for_tests(s), + Orm::Prisma => crate::prisma::to_pascal_case_for_tests(s), } } @@ -318,6 +322,7 @@ fn to_pascal_case_for(orm: Orm, s: &str) -> String { #[case::sqlalchemy(Orm::SqlAlchemy)] #[case::sqlmodel(Orm::SqlModel)] #[case::jpa(Orm::Jpa)] +#[case::prisma(Orm::Prisma)] fn to_pascal_case_shared_semantics( #[values( ("", ""), @@ -344,6 +349,7 @@ fn to_pascal_case_shared_semantics( #[case::sqlalchemy(Orm::SqlAlchemy)] #[case::sqlmodel(Orm::SqlModel)] #[case::jpa(Orm::Jpa)] +#[case::prisma(Orm::Prisma)] fn render_entity_with_schema_snapshots( #[values( "many_to_many_article", diff --git a/crates/vespertide-exporter/tests/parallel_consolidated.rs b/crates/vespertide-exporter/tests/parallel_consolidated.rs index f93674b4..423b5486 100644 --- a/crates/vespertide-exporter/tests/parallel_consolidated.rs +++ b/crates/vespertide-exporter/tests/parallel_consolidated.rs @@ -37,6 +37,7 @@ fn render_schema(orm: Orm, schema: &[TableDef]) -> Result { Orm::Jpa => { vespertide_exporter::jpa::render_entities(schema).map(|entities| entities.join("\n")) } + Orm::Prisma => vespertide_exporter::prisma::export(schema), } } diff --git a/schemas/config.schema.json b/schemas/config.schema.json index fa5f2852..f310b779 100644 --- a/schemas/config.schema.json +++ b/schemas/config.schema.json @@ -44,6 +44,13 @@ "type": "string", "default": "" }, + "prisma": { + "description": "Prisma-specific export configuration.", + "$ref": "#/$defs/PrismaConfig", + "default": { + "provider": "postgresql" + } + }, "seaorm": { "description": "SeaORM-specific export configuration.", "$ref": "#/$defs/SeaOrmConfig", @@ -94,6 +101,31 @@ "pascal" ] }, + "PrismaConfig": { + "description": "Prisma-specific export configuration.", + "type": "object", + "properties": { + "clientOutput": { + "description": "Optional output path for the generated Prisma client.", + "type": [ + "string", + "null" + ] + }, + "provider": { + "description": "Database provider: postgresql, mysql, sqlite, sqlserver, mongodb, cockroachdb.", + "type": "string", + "default": "postgresql" + }, + "relationMode": { + "description": "Optional relationMode override (\"foreignKeys\" or \"prisma\").", + "type": [ + "string", + "null" + ] + } + } + }, "SeaOrmConfig": { "description": "SeaORM-specific export configuration.", "type": "object", From 88bde9e9dc4a9a525ba21c38e1b177e185b8aac2 Mon Sep 17 00:00:00 2001 From: L33gn21 Date: Sun, 21 Jun 2026 04:27:42 +0900 Subject: [PATCH 02/25] test(exporter): add Prisma ORM snapshots Generate the 60 missing Prisma insta snapshots so the Prisma backend is cross-compared with the other four ORMs through the shared orm_cases! matrix. The Orm::Prisma cases were already wired into every test; only the accepted snapshot files were absent. Co-Authored-By: Claude Opus 4.8 --- ...ypes_snapshot@all_simple_types_Prisma.snap | 27 +++++++++++++++++++ ...le_pk_snapshot@basic_single_pk_Prisma.snap | 10 +++++++ ...t@basic_table_with_description_Prisma.snap | 14 ++++++++++ ...x_types_snapshot@complex_types_Prisma.snap | 13 +++++++++ ...snapshot@composite_constraints_Prisma.snap | 16 +++++++++++ ...index_snapshot@composite_index_Prisma.snap | 12 +++++++++ ...osite_pk_snapshot@composite_pk_Prisma.snap | 11 ++++++++ ...snapshot@composite_primary_key_Prisma.snap | 12 +++++++++ ...ot@composite_unique_constraint_Prisma.snap | 12 +++++++++ ...ique_snapshot@composite_unique_Prisma.snap | 12 +++++++++ ...ts__defaults_snapshot@defaults_Prisma.snap | 12 +++++++++ ...snapshot@enum_multiple_columns_Prisma.snap | 23 ++++++++++++++++ ...um_shared_snapshot@enum_shared_Prisma.snap | 17 ++++++++++++ ...s_snapshot@enum_special_values_Prisma.snap | 17 ++++++++++++ ...ult_snapshot@enum_with_default_Prisma.snap | 18 +++++++++++++ ...snapshot@false_boolean_default_Prisma.snap | 10 +++++++ ...ith_comment_and_auto_increment_Prisma.snap | 12 +++++++++ ...__inline_pk_snapshot@inline_pk_Prisma.snap | 10 +++++++ ...integer_enum_all_variant_types_Prisma.snap | 17 ++++++++++++ ...shot@integer_enum_with_default_Prisma.snap | 15 +++++++++++ ...eger_enum_with_variant_default_Prisma.snap | 15 +++++++++++ ..._default_snapshot@json_default_Prisma.snap | 10 +++++++ ...ype_snapshot@jsonb_custom_type_Prisma.snap | 12 +++++++++ ...iption_snapshot@no_description_Prisma.snap | 9 +++++++ ...umns_snapshot@nullable_columns_Prisma.snap | 11 ++++++++ ...le_enum_snapshot@nullable_enum_Prisma.snap | 15 +++++++++++ ...snapshot@numeric_default_value_Prisma.snap | 10 +++++++ ...er_snapshot@pk_and_fk_together_Prisma.snap | 19 +++++++++++++ ..._snapshots@composite_fk_parent_Prisma.snap | 11 ++++++++ ...apshots@dual_reverse_relations_Prisma.snap | 11 ++++++++ ...snapshots@many_to_many_article_Prisma.snap | 10 +++++++ ...ts@many_to_many_missing_target_Prisma.snap | 10 +++++++ ...any_to_many_multiple_junctions_Prisma.snap | 11 ++++++++ ...ma_snapshots@many_to_many_user_Prisma.snap | 10 +++++++ ...apshots@multiple_fk_same_table_Prisma.snap | 13 +++++++++ ...ots@multiple_has_one_relations_Prisma.snap | 11 ++++++++ ...ots@multiple_reverse_relations_Prisma.snap | 11 ++++++++ ..._junction_fk_not_in_pk_another_Prisma.snap | 10 +++++++ ...ot_junction_fk_not_in_pk_other_Prisma.snap | 10 +++++++ ...apshots@not_junction_single_pk_Prisma.snap | 10 +++++++ ...shots@triple_reverse_relations_Prisma.snap | 12 +++++++++ ...h_schema_snapshots@username_fk_Prisma.snap | 11 ++++++++ ...shot@reserved_word_identifiers_Prisma.snap | 11 ++++++++ ...k_snapshot@self_referencing_fk_Prisma.snap | 11 ++++++++ ...erver_default_and_true_boolean_Prisma.snap | 13 +++++++++ ...aults_snapshot@server_defaults_Prisma.snap | 12 +++++++++ ...@small_multi_schema_sequential_Prisma.snap | 20 ++++++++++++++ ...efault_snapshot@string_default_Prisma.snap | 10 +++++++ ...vel_pk_snapshot@table_level_pk_Prisma.snap | 11 ++++++++ ...apshot@table_with_composite_fk_Prisma.snap | 12 +++++++++ ..._enum_snapshot@table_with_enum_Prisma.snap | 16 +++++++++++ ...with_fk_snapshot@table_with_fk_Prisma.snap | 12 +++++++++ ...es_snapshot@table_with_indexes_Prisma.snap | 13 +++++++++ ...apshot@table_with_integer_enum_Prisma.snap | 16 +++++++++++ ...ed_snapshot@unique_and_indexed_Prisma.snap | 14 ++++++++++ ...pshot@unknown_constant_default_Prisma.snap | 10 +++++++ ...pshot@unknown_function_default_Prisma.snap | 10 +++++++ ...apshot@unnamed_composite_index_Prisma.snap | 12 +++++++++ ...pshot@unnamed_composite_unique_Prisma.snap | 12 +++++++++ ...pshot@unnamed_index_and_unique_Prisma.snap | 13 +++++++++ 60 files changed, 770 insertions(+) create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_single_pk_snapshot@basic_single_pk_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_table_with_description_snapshot@basic_table_with_description_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_pk_snapshot@composite_pk_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_multiple_columns_snapshot@enum_multiple_columns_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_shared_snapshot@enum_shared_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_with_default_snapshot@enum_with_default_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__false_boolean_default_snapshot@false_boolean_default_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_with_comment_and_auto_increment_snapshot@fk_with_comment_and_auto_increment_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__inline_pk_snapshot@inline_pk_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_all_variant_types_snapshot@integer_enum_all_variant_types_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_default_snapshot@integer_enum_with_default_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_variant_default_snapshot@integer_enum_with_variant_default_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__json_default_snapshot@json_default_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__jsonb_custom_type_snapshot@jsonb_custom_type_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__no_description_snapshot@no_description_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_enum_snapshot@nullable_enum_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__numeric_default_value_snapshot@numeric_default_value_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_fk_parent_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@dual_reverse_relations_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_article_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_missing_target_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_multiple_junctions_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_user_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_fk_same_table_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_has_one_relations_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_reverse_relations_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_another_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_other_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_single_pk_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@triple_reverse_relations_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reserved_word_identifiers_snapshot@reserved_word_identifiers_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__self_referencing_fk_snapshot@self_referencing_fk_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_default_and_true_boolean_snapshot@server_default_and_true_boolean_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_defaults_snapshot@server_defaults_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__small_multi_schema_sequential_snapshot@small_multi_schema_sequential_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__string_default_snapshot@string_default_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_pk_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_composite_fk_snapshot@table_with_composite_fk_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_enum_snapshot@table_with_enum_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_fk_snapshot@table_with_fk_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_integer_enum_snapshot@table_with_integer_enum_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_constant_default_snapshot@unknown_constant_default_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_function_default_snapshot@unknown_function_default_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_index_snapshot@unnamed_composite_index_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_unique_snapshot@unnamed_composite_unique_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_index_and_unique_snapshot@unnamed_index_and_unique_Prisma.snap diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Prisma.snap new file mode 100644 index 00000000..d9449b4e --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Prisma.snap @@ -0,0 +1,27 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model AllTypes { + id Int @id + small Int @db.SmallInt + big BigInt + real_num Float @db.Real + double_num Float + text_col String @db.Text + bool_col Boolean + date_col DateTime @db.Date + time_col DateTime @db.Time + ts_col DateTime @db.Timestamp + tstz_col DateTime @db.Timestamptz + interval_col String @db.Interval + bytea_col Bytes + uuid_col String @db.Uuid + json_col Json + inet_col String @db.Inet + cidr_col String @db.Cidr + macaddr_col String @db.Macaddr + xml_col String @db.Xml + + @@map("all_types") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_single_pk_snapshot@basic_single_pk_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_single_pk_snapshot@basic_single_pk_Prisma.snap new file mode 100644 index 00000000..8e48a736 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_single_pk_snapshot@basic_single_pk_Prisma.snap @@ -0,0 +1,10 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Users { + id Int @id + display_name String? @db.Text + + @@map("users") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_table_with_description_snapshot@basic_table_with_description_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_table_with_description_snapshot@basic_table_with_description_Prisma.snap new file mode 100644 index 00000000..8e8c5a5d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_table_with_description_snapshot@basic_table_with_description_Prisma.snap @@ -0,0 +1,14 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +/// User accounts table +model Users { + /// Primary key + id Int @id @default(autoincrement()) + /// User email address + email String @unique @db.Text + name String? @db.Text + + @@map("users") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_Prisma.snap new file mode 100644 index 00000000..0db0f663 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_Prisma.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model ComplexTypes { + id Int @id + varchar_col String @db.VarChar(100) + char_col String @db.Char(10) + numeric_col Decimal @db.Decimal(10, 2) + custom_col Unsupported("CUSTOM_TYPE") + + @@map("complex_types") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Prisma.snap new file mode 100644 index 00000000..c439a591 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Prisma.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model OrderItems { + order_id Int + order Orders @relation(fields: [order_id], references: [id]) + product_id Int + product Products @relation(fields: [product_id], references: [id]) + quantity Int + + @@id([order_id, product_id]) + @@unique([order_id, product_id], name: "uq_order_items__order_product") + @@index([order_id], name: "ix_order_items__order_id") + @@map("order_items") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_Prisma.snap new file mode 100644 index 00000000..f4c1907c --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_Prisma.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model CompositeIndex { + id Int @id + tenant_id Int + name String @db.Text + + @@index([tenant_id, name], name: "idx_tenant_name") + @@map("composite_index") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_pk_snapshot@composite_pk_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_pk_snapshot@composite_pk_Prisma.snap new file mode 100644 index 00000000..94e0e483 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_pk_snapshot@composite_pk_Prisma.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Accounts { + id Int + tenant_id BigInt + + @@id([id, tenant_id]) + @@map("accounts") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_Prisma.snap new file mode 100644 index 00000000..698e9d36 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_Prisma.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Membership { + tenant_id Int + user_id Int + role String @db.Text + + @@id([tenant_id, user_id]) + @@map("membership") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Prisma.snap new file mode 100644 index 00000000..2ef045c3 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Prisma.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model AccountAliases { + id Int @id + tenant_id Int + slug String @db.Text + + @@unique([tenant_id, slug], name: "uq_account_aliases__tenant_slug") + @@map("account_aliases") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Prisma.snap new file mode 100644 index 00000000..c62e246a --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Prisma.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model CompositeUnique { + id Int @id + tenant_id Int + name String @db.Text + + @@unique([tenant_id, name], name: "uq_tenant_name") + @@map("composite_unique") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Prisma.snap new file mode 100644 index 00000000..a3cb11e2 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Prisma.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Articles { + id Int @id @default(autoincrement()) + published Boolean @default(false) + view_count Int @default(0) + status String @default("draft") @db.Text + + @@map("articles") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_multiple_columns_snapshot@enum_multiple_columns_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_multiple_columns_snapshot@enum_multiple_columns_Prisma.snap new file mode 100644 index 00000000..85a18060 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_multiple_columns_snapshot@enum_multiple_columns_Prisma.snap @@ -0,0 +1,23 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +enum ProductCategory { + ELECTRONICS @map("electronics") + CLOTHING @map("clothing") + FOOD @map("food") +} + +enum AvailabilityStatus { + IN_STOCK @map("in_stock") + OUT_OF_STOCK @map("out_of_stock") + PRE_ORDER @map("pre_order") +} + +model Products { + id Int + category ProductCategory + availability AvailabilityStatus + + @@map("products") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_shared_snapshot@enum_shared_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_shared_snapshot@enum_shared_Prisma.snap new file mode 100644 index 00000000..86f0234f --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_shared_snapshot@enum_shared_Prisma.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +enum DocStatus { + DRAFT @map("draft") + PUBLISHED @map("published") + ARCHIVED @map("archived") +} + +model Documents { + id Int + status DocStatus + review_status DocStatus? + + @@map("documents") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_Prisma.snap new file mode 100644 index 00000000..cb2b4c24 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_Prisma.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +enum EventSeverity { + INFO_LEVEL @map("info-level") + WARNING_LEVEL @map("warning_level") + ERROR_LEVEL + 1CRITICAL @map("1critical") +} + +model Events { + id Int + severity EventSeverity + + @@map("events") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_with_default_snapshot@enum_with_default_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_with_default_snapshot@enum_with_default_Prisma.snap new file mode 100644 index 00000000..29cd4ceb --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_with_default_snapshot@enum_with_default_Prisma.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +enum TaskStatus { + PENDING @map("pending") + IN_PROGRESS @map("in_progress") + COMPLETED @map("completed") +} + +model Tasks { + id Int + status TaskStatus @default(PENDING) + priority Int @default(0) + is_archived Boolean @default(false) + + @@map("tasks") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__false_boolean_default_snapshot@false_boolean_default_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__false_boolean_default_snapshot@false_boolean_default_Prisma.snap new file mode 100644 index 00000000..a738b4e9 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__false_boolean_default_snapshot@false_boolean_default_Prisma.snap @@ -0,0 +1,10 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model BoolDefaults { + id Int @id + is_deleted Boolean @default(false) + + @@map("bool_defaults") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_with_comment_and_auto_increment_snapshot@fk_with_comment_and_auto_increment_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_with_comment_and_auto_increment_snapshot@fk_with_comment_and_auto_increment_Prisma.snap new file mode 100644 index 00000000..36c10606 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_with_comment_and_auto_increment_snapshot@fk_with_comment_and_auto_increment_Prisma.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Child { + /// References parent table + parent_id Int @id @default(autoincrement()) + parent Parent @relation(fields: [parent_id], references: [id]) + value String @db.Text + + @@map("child") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__inline_pk_snapshot@inline_pk_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__inline_pk_snapshot@inline_pk_Prisma.snap new file mode 100644 index 00000000..a1a87358 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__inline_pk_snapshot@inline_pk_Prisma.snap @@ -0,0 +1,10 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Users { + id String @default(uuid()) @db.Uuid + email String @db.Text + + @@map("users") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_all_variant_types_snapshot@integer_enum_all_variant_types_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_all_variant_types_snapshot@integer_enum_all_variant_types_Prisma.snap new file mode 100644 index 00000000..ff867cfc --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_all_variant_types_snapshot@integer_enum_all_variant_types_Prisma.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +enum EdgeState { + UNKNOWN // = -1 + NOT_STARTED // = 0 + IN_PROGRESS // = 10 + HTTP_500 // = 500 +} + +model WorkflowRuns { + id Int @id + state EdgeState + + @@map("workflow_runs") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_default_snapshot@integer_enum_with_default_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_default_snapshot@integer_enum_with_default_Prisma.snap new file mode 100644 index 00000000..dec7034d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_default_snapshot@integer_enum_with_default_Prisma.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +enum TaskStatus { + PENDING // = 0 + COMPLETED // = 100 +} + +model Tasks { + id Int + status TaskStatus @default(1) + + @@map("tasks") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_variant_default_snapshot@integer_enum_with_variant_default_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_variant_default_snapshot@integer_enum_with_variant_default_Prisma.snap new file mode 100644 index 00000000..f34d556a --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_variant_default_snapshot@integer_enum_with_variant_default_Prisma.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +enum TaskRunStatus { + PENDING // = 0 + COMPLETED // = 100 +} + +model TaskRuns { + id Int + status TaskRunStatus @default(dbgenerated("Completed")) + + @@map("task_runs") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__json_default_snapshot@json_default_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__json_default_snapshot@json_default_Prisma.snap new file mode 100644 index 00000000..bb69f71f --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__json_default_snapshot@json_default_Prisma.snap @@ -0,0 +1,10 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Configs { + id Int @id + data Json @default(dbgenerated("{\"hello\": \"world\"}")) + + @@map("configs") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__jsonb_custom_type_snapshot@jsonb_custom_type_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__jsonb_custom_type_snapshot@jsonb_custom_type_Prisma.snap new file mode 100644 index 00000000..2b61c89d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__jsonb_custom_type_snapshot@jsonb_custom_type_Prisma.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model JsonStruct { + id Int + json_data Json + jsonb_data Unsupported("JSONB") + jsonb_nullable Unsupported("jsonb")? + + @@map("json_struct") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__no_description_snapshot@no_description_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__no_description_snapshot@no_description_Prisma.snap new file mode 100644 index 00000000..63eb7850 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__no_description_snapshot@no_description_Prisma.snap @@ -0,0 +1,9 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model NoDesc { + id Int @id + + @@map("no_desc") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Prisma.snap new file mode 100644 index 00000000..e820cb09 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Prisma.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Profiles { + id Int @id @default(autoincrement()) + bio String? @db.Text + avatar_url String? @db.VarChar(500) + + @@map("profiles") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_enum_snapshot@nullable_enum_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_enum_snapshot@nullable_enum_Prisma.snap new file mode 100644 index 00000000..6600f6ae --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_enum_snapshot@nullable_enum_Prisma.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +enum StatusType { + ACTIVE @map("active") + INACTIVE @map("inactive") +} + +model NullableEnum { + id Int @id + status StatusType? + + @@map("nullable_enum") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__numeric_default_value_snapshot@numeric_default_value_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__numeric_default_value_snapshot@numeric_default_value_Prisma.snap new file mode 100644 index 00000000..a6393f2d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__numeric_default_value_snapshot@numeric_default_value_Prisma.snap @@ -0,0 +1,10 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Products { + id Int + price Decimal @default(0.00) @db.Decimal(10, 2) + + @@map("products") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Prisma.snap new file mode 100644 index 00000000..4751d68c --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Prisma.snap @@ -0,0 +1,19 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model ArticleUser { + article_id String @db.Uuid + article Article @relation(fields: [article_id], references: [id], onDelete: Cascade) + user_id String @db.Uuid + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + author_order Int @default(1) + role String @default("contributor") @db.VarChar(20) + is_lead Boolean @default(false) + created_at DateTime @default(now()) @db.Timestamptz + + @@id([article_id, user_id]) + @@index([article_id]) + @@index([user_id]) + @@map("article_user") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_fk_parent_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_fk_parent_Prisma.snap new file mode 100644 index 00000000..d6e65fa3 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_fk_parent_Prisma.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Parent { + id1 Int + id2 Int + + @@id([id1, id2]) + @@map("parent") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@dual_reverse_relations_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@dual_reverse_relations_Prisma.snap new file mode 100644 index 00000000..0a6af1d7 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@dual_reverse_relations_Prisma.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Dual { + username String @id @db.Text + username_dual_rel DualRel[] @relation("DualRelUsername") + checker_username_dual_rel DualRel[] @relation("DualRelCheckerUsername") + + @@map("dual") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_article_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_article_Prisma.snap new file mode 100644 index 00000000..5fc9573f --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_article_Prisma.snap @@ -0,0 +1,10 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Article { + id BigInt @id + article_user ArticleUser[] + + @@map("article") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_missing_target_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_missing_target_Prisma.snap new file mode 100644 index 00000000..5fc9573f --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_missing_target_Prisma.snap @@ -0,0 +1,10 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Article { + id BigInt @id + article_user ArticleUser[] + + @@map("article") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_multiple_junctions_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_multiple_junctions_Prisma.snap new file mode 100644 index 00000000..ba3f7323 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_multiple_junctions_Prisma.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model User { + id String @id @db.Uuid + user_media_role UserMediaRole[] + user_media_favorite UserMediaFavorite[] + + @@map("user") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_user_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_user_Prisma.snap new file mode 100644 index 00000000..2ee63a6a --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_user_Prisma.snap @@ -0,0 +1,10 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model User { + id String @id @db.Uuid + article_user ArticleUser[] + + @@map("user") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_fk_same_table_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_fk_same_table_Prisma.snap new file mode 100644 index 00000000..e90b4fd8 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_fk_same_table_Prisma.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Post { + id String @id @db.Uuid + creator_user_id String @db.Uuid + creator_user User @relation("PostCreatorUser", fields: [creator_user_id], references: [id]) + used_by_user_id String @db.Uuid + used_by_user User @relation("PostUsedByUser", fields: [used_by_user_id], references: [id]) + + @@map("post") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_has_one_relations_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_has_one_relations_Prisma.snap new file mode 100644 index 00000000..824aa181 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_has_one_relations_Prisma.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model User { + id String @id @db.Uuid + created_by_user_settings Settings? @relation("SettingsCreatedByUser") + updated_by_user_settings Settings? @relation("SettingsUpdatedByUser") + + @@map("user") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_reverse_relations_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_reverse_relations_Prisma.snap new file mode 100644 index 00000000..2f8b8d4a --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_reverse_relations_Prisma.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model User { + id String @id @db.Uuid + preferred_user_profile Profile[] @relation("ProfilePreferredUser") + backup_user_profile Profile[] @relation("ProfileBackupUser") + + @@map("user") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_another_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_another_Prisma.snap new file mode 100644 index 00000000..849af190 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_another_Prisma.snap @@ -0,0 +1,10 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Another { + id Int @id + not_junction NotJunction[] + + @@map("another") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_other_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_other_Prisma.snap new file mode 100644 index 00000000..ae8747af --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_other_Prisma.snap @@ -0,0 +1,10 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Other { + id Int @id + not_junction NotJunction[] + + @@map("other") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_single_pk_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_single_pk_Prisma.snap new file mode 100644 index 00000000..68f8850b --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_single_pk_Prisma.snap @@ -0,0 +1,10 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Other { + id Int @id + regular Regular[] + + @@map("other") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@triple_reverse_relations_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@triple_reverse_relations_Prisma.snap new file mode 100644 index 00000000..1dbab1e2 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@triple_reverse_relations_Prisma.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Dual { + username String @id @db.Text + username_triple_rel TripleRel[] @relation("TripleRelUsername") + checker_username_triple_rel TripleRel[] @relation("TripleRelCheckerUsername") + other_username_triple_rel TripleRel[] @relation("TripleRelOtherUsername") + + @@map("dual") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Prisma.snap new file mode 100644 index 00000000..f20329df --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Prisma.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Session { + id String @id @db.Uuid + username String @db.Text + username User @relation(fields: [username], references: [username]) + + @@map("session") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reserved_word_identifiers_snapshot@reserved_word_identifiers_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reserved_word_identifiers_snapshot@reserved_word_identifiers_Prisma.snap new file mode 100644 index 00000000..dbd9361c --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reserved_word_identifiers_snapshot@reserved_word_identifiers_Prisma.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Order { + id Int @id + user String @db.Text + select Int + + @@map("order") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__self_referencing_fk_snapshot@self_referencing_fk_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__self_referencing_fk_snapshot@self_referencing_fk_Prisma.snap new file mode 100644 index 00000000..36a999c3 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__self_referencing_fk_snapshot@self_referencing_fk_Prisma.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Employees { + id Int @id + manager_id Int? + manager Employees? @relation("EmployeesManager", fields: [manager_id], references: [id], onDelete: SetNull) + + @@map("employees") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_default_and_true_boolean_snapshot@server_default_and_true_boolean_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_default_and_true_boolean_snapshot@server_default_and_true_boolean_Prisma.snap new file mode 100644 index 00000000..2838a1fa --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_default_and_true_boolean_snapshot@server_default_and_true_boolean_Prisma.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Logs { + id Int @id @default(autoincrement()) + active Boolean @default(true) + created_at DateTime @default(now()) @db.Timestamptz + score Float @default(1.5) @db.Real + tag String @default(dbgenerated("UNKNOWN_EXPR")) @db.Text + + @@map("logs") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_defaults_snapshot@server_defaults_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_defaults_snapshot@server_defaults_Prisma.snap new file mode 100644 index 00000000..8953edc9 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_defaults_snapshot@server_defaults_Prisma.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model WithDefaults { + id Int @id + created_at DateTime @default(now()) @db.Timestamptz + status String @default("active") @db.Text + count Int @default(0) + + @@map("with_defaults") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__small_multi_schema_sequential_snapshot@small_multi_schema_sequential_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__small_multi_schema_sequential_snapshot@small_multi_schema_sequential_Prisma.snap new file mode 100644 index 00000000..14772602 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__small_multi_schema_sequential_snapshot@small_multi_schema_sequential_Prisma.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Users { + id Int @id + display_name String? @db.Text + posts Posts[] + + @@map("users") +} + +model Posts { + id Int @id + user_id Int + user Users @relation(fields: [user_id], references: [id]) + title String @db.Text + + @@map("posts") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__string_default_snapshot@string_default_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__string_default_snapshot@string_default_Prisma.snap new file mode 100644 index 00000000..bd34221a --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__string_default_snapshot@string_default_Prisma.snap @@ -0,0 +1,10 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model StringDefaults { + id Int @id + status String @default("active") @db.Text + + @@map("string_defaults") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_pk_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_pk_Prisma.snap new file mode 100644 index 00000000..5d7872c2 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_pk_Prisma.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Orders { + id String @id @db.Uuid + customer_id String @db.Uuid + total Float @db.Real + + @@map("orders") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_composite_fk_snapshot@table_with_composite_fk_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_composite_fk_snapshot@table_with_composite_fk_Prisma.snap new file mode 100644 index 00000000..74988b55 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_composite_fk_snapshot@table_with_composite_fk_Prisma.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model LineItems { + id Int @id + order_id Int + order_version Int + sku String @db.Text + + @@map("line_items") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_enum_snapshot@table_with_enum_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_enum_snapshot@table_with_enum_Prisma.snap new file mode 100644 index 00000000..c069ed49 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_enum_snapshot@table_with_enum_Prisma.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +enum OrderStatus { + PENDING @map("pending") + SHIPPED @map("shipped") + DELIVERED @map("delivered") +} + +model Orders { + id Int + status OrderStatus + + @@map("orders") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_fk_snapshot@table_with_fk_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_fk_snapshot@table_with_fk_Prisma.snap new file mode 100644 index 00000000..2535a68e --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_fk_snapshot@table_with_fk_Prisma.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Posts { + id Int @id + user_id Int + user Users @relation(fields: [user_id], references: [id]) + title String @db.Text + + @@map("posts") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_Prisma.snap new file mode 100644 index 00000000..a69cbe60 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_Prisma.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Articles { + id Int @id + title String @db.Text + created_at DateTime @db.Timestamptz + + @@index([created_at], name: "idx_articles_created_at") + @@index([title]) + @@map("articles") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_integer_enum_snapshot@table_with_integer_enum_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_integer_enum_snapshot@table_with_integer_enum_Prisma.snap new file mode 100644 index 00000000..367e1ee1 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_integer_enum_snapshot@table_with_integer_enum_Prisma.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +enum PriorityLevel { + LOW // = 0 + MEDIUM // = 10 + HIGH // = 20 +} + +model Tasks { + id Int @id + priority PriorityLevel + + @@map("tasks") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Prisma.snap new file mode 100644 index 00000000..326c4d69 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Prisma.snap @@ -0,0 +1,14 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Users { + id Int + email String @unique @db.Text + username String @unique(map: "uq_username") @db.Text + department String? @db.Text + status String @default("active") @db.Text + + @@index([department], name: "idx_department") + @@map("users") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_constant_default_snapshot@unknown_constant_default_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_constant_default_snapshot@unknown_constant_default_Prisma.snap new file mode 100644 index 00000000..601d431e --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_constant_default_snapshot@unknown_constant_default_Prisma.snap @@ -0,0 +1,10 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model UnknownDefault { + id Int @id + value String @default(dbgenerated("SOME_CONSTANT")) @db.Text + + @@map("unknown_default") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_function_default_snapshot@unknown_function_default_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_function_default_snapshot@unknown_function_default_Prisma.snap new file mode 100644 index 00000000..8eeb4d72 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_function_default_snapshot@unknown_function_default_Prisma.snap @@ -0,0 +1,10 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model UnknownDefaults { + id Int @id + code String @default(dbgenerated("gen_code()")) @db.Text + + @@map("unknown_defaults") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_index_snapshot@unnamed_composite_index_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_index_snapshot@unnamed_composite_index_Prisma.snap new file mode 100644 index 00000000..31120619 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_index_snapshot@unnamed_composite_index_Prisma.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model UnnamedIndex { + id Int @id + col_a Int + col_b Int + + @@index([col_a, col_b]) + @@map("unnamed_index") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_unique_snapshot@unnamed_composite_unique_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_unique_snapshot@unnamed_composite_unique_Prisma.snap new file mode 100644 index 00000000..895a9617 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_unique_snapshot@unnamed_composite_unique_Prisma.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model UnnamedUnique { + id Int @id + col_a Int + col_b Int + + @@unique([col_a, col_b]) + @@map("unnamed_unique") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_index_and_unique_snapshot@unnamed_index_and_unique_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_index_and_unique_snapshot@unnamed_index_and_unique_Prisma.snap new file mode 100644 index 00000000..fd0d4664 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_index_and_unique_snapshot@unnamed_index_and_unique_Prisma.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Events { + id Int @id + venue_id Int + date DateTime @db.Date + + @@unique([venue_id, date]) + @@index([venue_id, date]) + @@map("events") +} From 0c6f27bb4c5c3f434a925c8590efedb574e97724 Mon Sep 17 00:00:00 2001 From: L33gn21 Date: Tue, 23 Jun 2026 00:15:30 +0900 Subject: [PATCH 03/25] refactor(exporter): split Prisma exporter into mod/render/types/enums Mirrors the sqlmodel/sqlalchemy 4-file convention. mod.rs is now a thin orchestrator; render.rs holds model rendering and relation logic; types.rs holds column-type mapping; enums.rs holds enum rendering and naming helpers. Public API surface (PrismaExporter, PrismaExporterWithConfig, export, render_entity, render_entity_with_schema, to_pascal_case_for_tests) unchanged. All 565 tests pass with byte-identical snapshot output. Co-Authored-By: Claude Opus 4.8 --- .../vespertide-exporter/src/prisma/enums.rs | 59 ++ crates/vespertide-exporter/src/prisma/mod.rs | 513 +----------------- .../vespertide-exporter/src/prisma/render.rs | 415 ++++++++++++++ .../vespertide-exporter/src/prisma/types.rs | 57 ++ 4 files changed, 551 insertions(+), 493 deletions(-) create mode 100644 crates/vespertide-exporter/src/prisma/enums.rs create mode 100644 crates/vespertide-exporter/src/prisma/render.rs create mode 100644 crates/vespertide-exporter/src/prisma/types.rs diff --git a/crates/vespertide-exporter/src/prisma/enums.rs b/crates/vespertide-exporter/src/prisma/enums.rs new file mode 100644 index 00000000..d7c025b3 --- /dev/null +++ b/crates/vespertide-exporter/src/prisma/enums.rs @@ -0,0 +1,59 @@ +use vespertide_core::schema::column::EnumValues; + +pub(super) fn render_enum(name: &str, values: &EnumValues) -> String { + let enum_name = to_pascal_case(name); + let mut lines = Vec::new(); + lines.push(format!("enum {enum_name} {{")); + match values { + EnumValues::String(vals) => { + for val in vals { + let variant = to_screaming_snake(val); + if variant == *val { + lines.push(format!(" {variant}")); + } else { + lines.push(format!(" {variant} @map(\"{val}\")")); + } + } + } + EnumValues::Integer(vals) => { + // Prisma doesn't support integer enums natively; emit as string variants with comment + for val in vals { + let variant = to_screaming_snake(&val.name); + let value = val.value; + lines.push(format!(" {variant} // = {value}")); + } + } + } + lines.push("}".into()); + lines.join("\n") +} + +pub(super) 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() +} + +pub(super) fn to_screaming_snake(s: &str) -> String { + let mut result = String::new(); + let mut prev_lower = false; + for ch in s.chars() { + if ch.is_uppercase() && prev_lower { + result.push('_'); + } + if ch.is_alphanumeric() { + result.push(ch.to_ascii_uppercase()); + prev_lower = ch.is_lowercase(); + } else { + result.push('_'); + prev_lower = false; + } + } + result.trim_end_matches('_').to_string() +} diff --git a/crates/vespertide-exporter/src/prisma/mod.rs b/crates/vespertide-exporter/src/prisma/mod.rs index c28629a0..b24d01a5 100644 --- a/crates/vespertide-exporter/src/prisma/mod.rs +++ b/crates/vespertide-exporter/src/prisma/mod.rs @@ -1,12 +1,13 @@ -use std::collections::{HashMap, HashSet}; +mod enums; +mod render; +mod types; + +use std::collections::HashSet; use crate::orm::OrmExporter; use vespertide_config::PrismaConfig; -use vespertide_core::schema::column::{ColumnType, ComplexColumnType, EnumValues, SimpleColumnType}; -use vespertide_core::schema::constraint::TableConstraint; -use vespertide_core::schema::names::ColumnName; -use vespertide_core::schema::reference::ReferenceAction; use vespertide_core::TableDef; +use vespertide_core::schema::column::{ColumnType, ComplexColumnType, EnumValues}; pub struct PrismaExporter; @@ -45,20 +46,21 @@ impl<'a> PrismaExporterWithConfig<'a> { for table in tables { for (name, values) in collect_table_enums(table) { if seen_enums.insert(name.to_string()) { - enum_blocks.push(render_enum(name, values)); + enum_blocks.push(enums::render_enum(name, values)); } } } let mut parts: Vec = Vec::new(); + let provider = self.config.provider(); let mut datasource = vec![ "datasource db {".to_string(), - format!(" provider = \"{}\"", self.config.provider()), + format!(" provider = \"{provider}\""), " url = env(\"DATABASE_URL\")".to_string(), ]; if let Some(rm) = self.config.relation_mode() { - datasource.push(format!(" relationMode = \"{}\"", rm)); + datasource.push(format!(" relationMode = \"{rm}\"")); } datasource.push("}".to_string()); parts.push(datasource.join("\n")); @@ -68,7 +70,7 @@ impl<'a> PrismaExporterWithConfig<'a> { " provider = \"prisma-client-js\"".to_string(), ]; if let Some(output) = self.config.client_output() { - generator.push(format!(" output = \"{}\"", output)); + generator.push(format!(" output = \"{output}\"")); } generator.push("}".to_string()); parts.push(generator.join("\n")); @@ -76,21 +78,21 @@ impl<'a> PrismaExporterWithConfig<'a> { parts.extend(enum_blocks); for table in tables { - parts.push(render_model(table, tables)); + parts.push(render::render_model(table, tables)); } parts.join("\n\n") + "\n" } } -fn collect_table_enums<'a>(table: &'a TableDef) -> Vec<(&'a str, &'a EnumValues)> { +fn collect_table_enums(table: &TableDef) -> Vec<(&str, &EnumValues)> { let mut seen = HashSet::new(); let mut result = Vec::new(); for col in &table.columns { - if let ColumnType::Complex(ComplexColumnType::Enum { name, values }) = &col.r#type { - if seen.insert(name.as_str()) { - result.push((name.as_str(), values)); - } + if let ColumnType::Complex(ComplexColumnType::Enum { name, values }) = &col.r#type + && seen.insert(name.as_str()) + { + result.push((name.as_str(), values)); } } result @@ -105,9 +107,9 @@ pub fn render_entity(table: &TableDef) -> String { pub fn render_entity_with_schema(table: &TableDef, schema: &[TableDef]) -> String { let mut parts: Vec = Vec::new(); for (name, values) in collect_table_enums(table) { - parts.push(render_enum(name, values)); + parts.push(enums::render_enum(name, values)); } - parts.push(render_model(table, schema)); + parts.push(render::render_model(table, schema)); parts.join("\n\n") } @@ -128,480 +130,5 @@ pub fn export(schema: &[TableDef]) -> Result { /// without making the helper generally public. #[cfg(test)] pub fn to_pascal_case_for_tests(s: &str) -> String { - to_pascal_case(s) -} - -fn render_enum(name: &str, values: &EnumValues) -> String { - let enum_name = to_pascal_case(name); - let mut lines = Vec::new(); - lines.push(format!("enum {} {{", enum_name)); - match values { - EnumValues::String(vals) => { - for val in vals { - let variant = to_screaming_snake(val); - if variant == *val { - lines.push(format!(" {}", variant)); - } else { - lines.push(format!(" {} @map(\"{}\")", variant, val)); - } - } - } - EnumValues::Integer(vals) => { - // Prisma doesn't support integer enums natively; emit as string variants with comment - for val in vals { - let variant = to_screaming_snake(&val.name); - lines.push(format!(" {} // = {}", variant, val.value)); - } - } - } - lines.push("}".into()); - lines.join("\n") -} - -struct PkInfo { - columns: Vec, - auto_increment: bool, -} - -fn extract_pk_info(constraints: &[TableConstraint]) -> PkInfo { - for c in constraints { - if let TableConstraint::PrimaryKey { auto_increment, columns, .. } = c { - return PkInfo { - columns: columns.iter().map(|c| c.to_string()).collect(), - auto_increment: *auto_increment, - }; - } - } - PkInfo { columns: Vec::new(), auto_increment: false } -} - -struct FkInfo<'a> { - ref_table: &'a str, - ref_cols: &'a [ColumnName], - on_delete: Option<&'a ReferenceAction>, - on_update: Option<&'a ReferenceAction>, -} - -fn render_model(table: &TableDef, schema: &[TableDef]) -> String { - let mut lines: Vec = Vec::new(); - - if let Some(desc) = &table.description { - for line in desc.lines() { - lines.push(format!("/// {}", line)); - } - } - - let model_name = to_pascal_case(&table.name); - lines.push(format!("model {} {{", model_name)); - - let pk_info = extract_pk_info(&table.constraints); - let pk_columns: HashSet<&str> = pk_info.columns.iter().map(|s| s.as_str()).collect(); - let is_composite_pk = pk_info.columns.len() > 1; - - let unique_single: HashMap<&str, Option<&str>> = table.constraints.iter() - .filter_map(|c| { - if let TableConstraint::Unique { name, columns, .. } = c { - if columns.len() == 1 { Some((columns[0].as_str(), name.as_deref())) } else { None } - } else { None } - }) - .collect(); - - // FK lookup by column - let fk_by_col: HashMap<&str, FkInfo<'_>> = table.constraints.iter() - .filter_map(|c| { - if let TableConstraint::ForeignKey { columns, ref_table, ref_columns, on_delete, on_update, .. } = c { - if columns.len() == 1 { - Some(( - columns[0].as_str(), - FkInfo { - ref_table: ref_table.as_str(), - ref_cols: ref_columns.as_slice(), - on_delete: on_delete.as_ref(), - on_update: on_update.as_ref(), - }, - )) - } else { None } - } else { None } - }) - .collect(); - - // Count FKs per ref_table for disambiguation detection - let mut ref_table_fk_count: HashMap<&str, usize> = HashMap::new(); - for fk in fk_by_col.values() { - *ref_table_fk_count.entry(fk.ref_table).or_default() += 1; - } - - // Render scalar fields + inline relation fields - for col in &table.columns { - let col_name = col.name.as_str(); - let in_pk = pk_columns.contains(col_name); - let is_single_pk = in_pk && !is_composite_pk; - let auto_inc = is_single_pk && pk_info.auto_increment; - let is_unique = unique_single.get(col_name).copied(); - - if let Some(ref comment) = col.comment { - lines.push(format!(" /// {}", comment.replace('\n', " "))); - } - - let (type_str, native_attr) = column_type_to_prisma(&col.r#type, col.nullable); - let mut attrs: Vec = Vec::new(); - - if is_single_pk { - attrs.push("@id".into()); - if auto_inc { - attrs.push("@default(autoincrement())".into()); - } - } - - if !auto_inc { - if let Some(ref default) = col.default { - attrs.push(prisma_default_attr(default.to_sql(), &col.r#type)); - } - } - - if let Some(unique_name) = is_unique { - if !is_single_pk { - match unique_name { - Some(n) => attrs.push(format!("@unique(map: \"{}\")", n)), - None => attrs.push("@unique".into()), - } - } - } - - if let Some(ref native) = native_attr { - attrs.push(native.clone()); - } - - let attrs_str = if attrs.is_empty() { - String::new() - } else { - format!(" {}", attrs.join(" ")) - }; - - lines.push(format!(" {} {}{}", col_name, type_str, attrs_str)); - - // Emit inline relation field for FK columns - if let Some(fk) = fk_by_col.get(col_name) { - let rel_field_name = infer_relation_field_name(col_name); - let rel_model = to_pascal_case(fk.ref_table); - let rel_type = if col.nullable { - format!("{}?", rel_model) - } else { - rel_model.clone() - }; - - let multi_fk = ref_table_fk_count.get(fk.ref_table).copied().unwrap_or(0) > 1; - let is_self_ref = fk.ref_table == table.name.as_str(); - let needs_name = multi_fk || is_self_ref; - - let mut rel_args: Vec = Vec::new(); - if needs_name { - let rel_name = format!( - "{}{}", - to_pascal_case(&table.name), - to_pascal_case(&rel_field_name) - ); - rel_args.push(format!("\"{}\"", rel_name)); - } - rel_args.push(format!("fields: [{}]", col_name)); - rel_args.push(format!( - "references: [{}]", - fk.ref_cols.iter().map(|s| s.as_str()).collect::>().join(", ") - )); - if let Some(od) = fk.on_delete { - rel_args.push(format!("onDelete: {}", reference_action_to_prisma(od))); - } - if let Some(ou) = fk.on_update { - rel_args.push(format!("onUpdate: {}", reference_action_to_prisma(ou))); - } - - lines.push(format!( - " {} {} @relation({})", - rel_field_name, - rel_type, - rel_args.join(", ") - )); - } - } - - // Back-relations from schema context - if !schema.is_empty() { - let back_rels = collect_back_relations(&table.name, schema); - for br in &back_rels { - let (field_name, rel_type) = back_rel_field(br); - let rel_attr = match &br.relation_name { - Some(name) => format!(" @relation(\"{}\")", name), - None => String::new(), - }; - lines.push(format!(" {} {}{}", field_name, rel_type, rel_attr)); - } - } - - // Blank line before model-level attributes - lines.push(String::new()); - - // Composite PK - if is_composite_pk { - lines.push(format!(" @@id([{}])", pk_info.columns.join(", "))); - } - - // Composite unique constraints - for c in &table.constraints { - if let TableConstraint::Unique { name, columns, .. } = c { - if columns.len() > 1 { - let cols = columns.join(", "); - if let Some(n) = name { - lines.push(format!(" @@unique([{}], name: \"{}\")", cols, n)); - } else { - lines.push(format!(" @@unique([{}])", cols)); - } - } - } - } - - // All index constraints - for c in &table.constraints { - if let TableConstraint::Index { name, columns } = c { - let cols = columns.join(", "); - if let Some(n) = name { - lines.push(format!(" @@index([{}], name: \"{}\")", cols, n)); - } else { - lines.push(format!(" @@index([{}])", cols)); - } - } - } - - // @@map (always present since model is PascalCase but table is snake_case) - lines.push(format!(" @@map(\"{}\")", table.name)); - lines.push("}".into()); - - lines.join("\n") -} - -struct BackRelation { - source_table: String, - fk_col: String, - is_one_to_one: bool, - relation_name: Option, -} - -fn back_rel_field(br: &BackRelation) -> (String, String) { - let source_pascal = to_pascal_case(&br.source_table); - let rel_type = if br.is_one_to_one { - format!("{}?", source_pascal) - } else { - format!("{}[]", source_pascal) - }; - - // source_table is already the plural table name — use it directly - let field_name = if br.relation_name.is_some() { - let rel_field = infer_relation_field_name(&br.fk_col); - if br.is_one_to_one { - format!("{}_{}", rel_field, br.source_table) - } else { - format!("{}_{}", rel_field, &br.source_table) - } - } else if br.is_one_to_one { - br.source_table.clone() - } else { - br.source_table.clone() - }; - - (field_name, rel_type) -} - -fn collect_back_relations(target_table: &str, schema: &[TableDef]) -> Vec { - let mut result = Vec::new(); - - for source in schema { - let fks_to_target: Vec<(&str, &[ColumnName])> = source.constraints.iter() - .filter_map(|c| { - if let TableConstraint::ForeignKey { columns, ref_table, ref_columns, .. } = c { - if ref_table.as_str() == target_table && columns.len() == 1 { - Some((columns[0].as_str(), ref_columns.as_slice())) - } else { None } - } else { None } - }) - .collect(); - - if fks_to_target.is_empty() { continue; } - - let multi_fk = fks_to_target.len() > 1; - let is_self_ref = source.name.as_str() == target_table; - - for (fk_col, _) in &fks_to_target { - let is_unique = source.constraints.iter().any(|c| { - matches!(c, TableConstraint::Unique { columns, .. } - if columns.len() == 1 && columns[0].as_str() == *fk_col) - }); - - let needs_name = multi_fk || is_self_ref; - let relation_name = if needs_name { - let rel_field = infer_relation_field_name(fk_col); - Some(format!( - "{}{}", - to_pascal_case(&source.name), - to_pascal_case(&rel_field) - )) - } else { - None - }; - - result.push(BackRelation { - source_table: source.name.as_str().to_string(), - fk_col: fk_col.to_string(), - is_one_to_one: is_unique, - relation_name, - }); - } - } - - result -} - -fn column_type_to_prisma(ty: &ColumnType, nullable: bool) -> (String, Option) { - let q = if nullable { "?" } else { "" }; - - match ty { - ColumnType::Simple(simple) => { - let (base, native) = match simple { - SimpleColumnType::SmallInt => ("Int", Some("@db.SmallInt")), - SimpleColumnType::Integer => ("Int", None), - SimpleColumnType::BigInt => ("BigInt", None), - SimpleColumnType::Real => ("Float", Some("@db.Real")), - SimpleColumnType::DoublePrecision => ("Float", None), - SimpleColumnType::Text => ("String", Some("@db.Text")), - SimpleColumnType::Boolean => ("Boolean", None), - SimpleColumnType::Date => ("DateTime", Some("@db.Date")), - SimpleColumnType::Time => ("DateTime", Some("@db.Time")), - SimpleColumnType::Timestamp => ("DateTime", Some("@db.Timestamp")), - SimpleColumnType::Timestamptz => ("DateTime", Some("@db.Timestamptz")), - SimpleColumnType::Interval => ("String", Some("@db.Interval")), - SimpleColumnType::Bytea => ("Bytes", None), - SimpleColumnType::Uuid => ("String", Some("@db.Uuid")), - SimpleColumnType::Json => ("Json", None), - SimpleColumnType::Inet => ("String", Some("@db.Inet")), - SimpleColumnType::Cidr => ("String", Some("@db.Cidr")), - SimpleColumnType::Macaddr => ("String", Some("@db.Macaddr")), - SimpleColumnType::Xml => ("String", Some("@db.Xml")), - // Unknown/future simple types fall back to a plain String column. - _ => ("String", None), - }; - (format!("{}{}", base, q), native.map(str::to_string)) - } - ColumnType::Complex(complex) => match complex { - ComplexColumnType::Varchar { length } => { - (format!("String{}", q), Some(format!("@db.VarChar({})", length))) - } - ComplexColumnType::Char { length } => { - (format!("String{}", q), Some(format!("@db.Char({})", length))) - } - ComplexColumnType::Numeric { precision, scale } => { - (format!("Decimal{}", q), Some(format!("@db.Decimal({}, {})", precision, scale))) - } - ComplexColumnType::Custom { custom_type } => { - (format!("Unsupported(\"{}\"){}", custom_type, q), None) - } - ComplexColumnType::Enum { name, .. } => { - (format!("{}{}", to_pascal_case(name), q), None) - } - // Unknown/future complex types fall back to a plain String column. - _ => (format!("String{}", q), None), - }, - } -} - -fn prisma_default_attr(default_sql: String, col_type: &ColumnType) -> String { - if default_sql == "true" { - return "@default(true)".into(); - } - if default_sql == "false" { - return "@default(false)".into(); - } - - let lower = default_sql.to_lowercase(); - if lower.contains("now()") || lower.starts_with("current_timestamp") { - return "@default(now())".into(); - } - if lower.contains("gen_random_uuid()") - || lower.contains("uuid_generate_v4()") - || lower.contains("newid()") - { - return "@default(uuid())".into(); - } - - // Any remaining function call → dbgenerated - if default_sql.contains('(') { - let escaped = default_sql.replace('"', "\\\""); - return format!("@default(dbgenerated(\"{}\"))", escaped); - } - - // String literal with quotes — may be an enum value - if default_sql.starts_with('\'') || default_sql.starts_with('"') { - let stripped = default_sql.trim_matches(|c| c == '\'' || c == '"'); - if let ColumnType::Complex(ComplexColumnType::Enum { values, .. }) = col_type { - if let EnumValues::String(variants) = values { - if variants.iter().any(|v| v.as_str() == stripped) { - let variant = to_screaming_snake(stripped); - return format!("@default({})", variant); - } - } - } - return format!("@default(\"{}\")", stripped.replace('\\', "\\\\").replace('"', "\\\"")); - } - - // Numeric - if default_sql.parse::().is_ok() { - return format!("@default({})", default_sql); - } - - // Fallback - let escaped = default_sql.replace('"', "\\\""); - format!("@default(dbgenerated(\"{}\"))", escaped) -} - -fn reference_action_to_prisma(action: &ReferenceAction) -> &'static str { - match action { - ReferenceAction::Cascade => "Cascade", - ReferenceAction::Restrict => "Restrict", - ReferenceAction::SetNull => "SetNull", - ReferenceAction::SetDefault => "SetDefault", - ReferenceAction::NoAction => "NoAction", - // Unknown/future referential actions fall back to Prisma's default. - _ => "NoAction", - } -} - -fn infer_relation_field_name(fk_col: &str) -> String { - fk_col.strip_suffix("_id").unwrap_or(fk_col).to_string() -} - -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_screaming_snake(s: &str) -> String { - let mut result = String::new(); - let mut prev_lower = false; - for ch in s.chars() { - if ch.is_uppercase() && prev_lower { - result.push('_'); - } - if ch.is_alphanumeric() { - result.push(ch.to_ascii_uppercase()); - prev_lower = ch.is_lowercase(); - } else { - result.push('_'); - prev_lower = false; - } - } - result.trim_end_matches('_').to_string() + enums::to_pascal_case(s) } diff --git a/crates/vespertide-exporter/src/prisma/render.rs b/crates/vespertide-exporter/src/prisma/render.rs new file mode 100644 index 00000000..5773d255 --- /dev/null +++ b/crates/vespertide-exporter/src/prisma/render.rs @@ -0,0 +1,415 @@ +use std::collections::HashMap; + +use vespertide_core::TableDef; +use vespertide_core::schema::column::{ColumnType, ComplexColumnType, EnumValues}; +use vespertide_core::schema::constraint::TableConstraint; +use vespertide_core::schema::names::ColumnName; +use vespertide_core::schema::reference::ReferenceAction; + +use super::enums::{to_pascal_case, to_screaming_snake}; +use super::types::column_type_to_prisma; + +struct PkInfo { + columns: Vec, + auto_increment: bool, +} + +fn extract_pk_info(constraints: &[TableConstraint]) -> PkInfo { + for c in constraints { + if let TableConstraint::PrimaryKey { + auto_increment, + columns, + .. + } = c + { + return PkInfo { + columns: columns.iter().map(ToString::to_string).collect(), + auto_increment: *auto_increment, + }; + } + } + PkInfo { + columns: Vec::new(), + auto_increment: false, + } +} + +struct FkInfo<'a> { + ref_table: &'a str, + ref_cols: &'a [ColumnName], + on_delete: Option<&'a ReferenceAction>, + on_update: Option<&'a ReferenceAction>, +} + +pub(super) struct BackRelation { + pub(super) source_table: String, + pub(super) fk_col: String, + pub(super) is_one_to_one: bool, + pub(super) relation_name: Option, +} + +pub(super) fn back_rel_field(br: &BackRelation) -> (String, String) { + let source_pascal = to_pascal_case(&br.source_table); + let rel_type = if br.is_one_to_one { + format!("{source_pascal}?") + } else { + format!("{source_pascal}[]") + }; + + let field_name = if br.relation_name.is_some() { + let rel_field = infer_relation_field_name(&br.fk_col); + format!("{rel_field}_{}", br.source_table) + } else { + br.source_table.clone() + }; + + (field_name, rel_type) +} + +pub(super) fn collect_back_relations(target_table: &str, schema: &[TableDef]) -> Vec { + let mut result = Vec::new(); + + for source in schema { + let fks_to_target: Vec<(&str, &[ColumnName])> = source + .constraints + .iter() + .filter_map(|c| { + if let TableConstraint::ForeignKey { + columns, + ref_table, + ref_columns, + .. + } = c + { + if ref_table.as_str() == target_table && columns.len() == 1 { + Some((columns[0].as_str(), ref_columns.as_slice())) + } else { + None + } + } else { + None + } + }) + .collect(); + + if fks_to_target.is_empty() { + continue; + } + + let multi_fk = fks_to_target.len() > 1; + let is_self_ref = source.name.as_str() == target_table; + + for (fk_col, _) in &fks_to_target { + let is_unique = source.constraints.iter().any(|c| { + matches!(c, TableConstraint::Unique { columns, .. } + if columns.len() == 1 && columns[0].as_str() == *fk_col) + }); + + let needs_name = multi_fk || is_self_ref; + let relation_name = if needs_name { + let rel_field = infer_relation_field_name(fk_col); + let source_pascal = to_pascal_case(&source.name); + let rel_pascal = to_pascal_case(&rel_field); + Some(format!("{source_pascal}{rel_pascal}")) + } else { + None + }; + + result.push(BackRelation { + source_table: source.name.as_str().to_string(), + fk_col: fk_col.to_string(), + is_one_to_one: is_unique, + relation_name, + }); + } + } + + result +} + +pub(super) fn render_model(table: &TableDef, schema: &[TableDef]) -> String { + let mut lines: Vec = Vec::new(); + + if let Some(desc) = &table.description { + for line in desc.lines() { + lines.push(format!("/// {line}")); + } + } + + let model_name = to_pascal_case(&table.name); + lines.push(format!("model {model_name} {{")); + + let pk_info = extract_pk_info(&table.constraints); + let pk_columns: std::collections::HashSet<&str> = + pk_info.columns.iter().map(String::as_str).collect(); + let is_composite_pk = pk_info.columns.len() > 1; + + let unique_single: HashMap<&str, Option<&str>> = table + .constraints + .iter() + .filter_map(|c| { + if let TableConstraint::Unique { name, columns, .. } = c { + if columns.len() == 1 { + Some((columns[0].as_str(), name.as_deref())) + } else { + None + } + } else { + None + } + }) + .collect(); + + // FK lookup by column + let fk_by_col: HashMap<&str, FkInfo<'_>> = table + .constraints + .iter() + .filter_map(|c| { + if let TableConstraint::ForeignKey { + columns, + ref_table, + ref_columns, + on_delete, + on_update, + .. + } = c + { + if columns.len() == 1 { + Some(( + columns[0].as_str(), + FkInfo { + ref_table: ref_table.as_str(), + ref_cols: ref_columns.as_slice(), + on_delete: on_delete.as_ref(), + on_update: on_update.as_ref(), + }, + )) + } else { + None + } + } else { + None + } + }) + .collect(); + + // Count FKs per ref_table for disambiguation detection + let mut ref_table_fk_count: HashMap<&str, usize> = HashMap::new(); + for fk in fk_by_col.values() { + *ref_table_fk_count.entry(fk.ref_table).or_default() += 1; + } + + // Render scalar fields + inline relation fields + for col in &table.columns { + let col_name = col.name.as_str(); + let in_pk = pk_columns.contains(col_name); + let is_single_pk = in_pk && !is_composite_pk; + let auto_inc = is_single_pk && pk_info.auto_increment; + let is_unique = unique_single.get(col_name).copied(); + + if let Some(ref comment) = col.comment { + let comment = comment.replace('\n', " "); + lines.push(format!(" /// {comment}")); + } + + let (type_str, native_attr) = column_type_to_prisma(&col.r#type, col.nullable); + let mut attrs: Vec = Vec::new(); + + if is_single_pk { + attrs.push("@id".into()); + if auto_inc { + attrs.push("@default(autoincrement())".into()); + } + } + + if !auto_inc && let Some(ref default) = col.default { + attrs.push(prisma_default_attr(&default.to_sql(), &col.r#type)); + } + + if let Some(unique_name) = is_unique + && !is_single_pk + { + match unique_name { + Some(n) => attrs.push(format!("@unique(map: \"{n}\")")), + None => attrs.push("@unique".into()), + } + } + + if let Some(ref native) = native_attr { + attrs.push(native.clone()); + } + + let attrs_str = if attrs.is_empty() { + String::new() + } else { + format!(" {}", attrs.join(" ")) + }; + + lines.push(format!(" {col_name} {type_str}{attrs_str}")); + + // Emit inline relation field for FK columns + if let Some(fk) = fk_by_col.get(col_name) { + let rel_field_name = infer_relation_field_name(col_name); + let rel_model = to_pascal_case(fk.ref_table); + let rel_type = if col.nullable { + format!("{rel_model}?") + } else { + rel_model.clone() + }; + + let multi_fk = ref_table_fk_count.get(fk.ref_table).copied().unwrap_or(0) > 1; + let is_self_ref = fk.ref_table == table.name.as_str(); + let needs_name = multi_fk || is_self_ref; + + let mut rel_args: Vec = Vec::new(); + if needs_name { + let table_pascal = to_pascal_case(&table.name); + let field_pascal = to_pascal_case(&rel_field_name); + rel_args.push(format!("\"{table_pascal}{field_pascal}\"")); + } + rel_args.push(format!("fields: [{col_name}]")); + rel_args.push(format!( + "references: [{}]", + fk.ref_cols + .iter() + .map(ColumnName::as_str) + .collect::>() + .join(", ") + )); + if let Some(od) = fk.on_delete { + let action = reference_action_to_prisma(od); + rel_args.push(format!("onDelete: {action}")); + } + if let Some(ou) = fk.on_update { + let action = reference_action_to_prisma(ou); + rel_args.push(format!("onUpdate: {action}")); + } + + let rel_args_str = rel_args.join(", "); + lines.push(format!( + " {rel_field_name} {rel_type} @relation({rel_args_str})" + )); + } + } + + // Back-relations from schema context + if !schema.is_empty() { + let back_rels = collect_back_relations(&table.name, schema); + for br in &back_rels { + let (field_name, rel_type) = back_rel_field(br); + let rel_attr = match &br.relation_name { + Some(name) => format!(" @relation(\"{name}\")"), + None => String::new(), + }; + lines.push(format!(" {field_name} {rel_type}{rel_attr}")); + } + } + + // Blank line before model-level attributes + lines.push(String::new()); + + // Composite PK + if is_composite_pk { + let pk_cols = pk_info.columns.join(", "); + lines.push(format!(" @@id([{pk_cols}])")); + } + + // Composite unique constraints + for c in &table.constraints { + if let TableConstraint::Unique { name, columns, .. } = c + && columns.len() > 1 + { + let cols = columns.join(", "); + if let Some(n) = name { + lines.push(format!(" @@unique([{cols}], name: \"{n}\")")); + } else { + lines.push(format!(" @@unique([{cols}])")); + } + } + } + + // All index constraints + for c in &table.constraints { + if let TableConstraint::Index { name, columns } = c { + let cols = columns.join(", "); + if let Some(n) = name { + lines.push(format!(" @@index([{cols}], name: \"{n}\")")); + } else { + lines.push(format!(" @@index([{cols}])")); + } + } + } + + // @@map (always present since model is PascalCase but table is snake_case) + let table_name = table.name.as_str(); + lines.push(format!(" @@map(\"{table_name}\")")); + lines.push("}".into()); + + lines.join("\n") +} + +fn prisma_default_attr(default_sql: &str, col_type: &ColumnType) -> String { + if default_sql == "true" { + return "@default(true)".into(); + } + if default_sql == "false" { + return "@default(false)".into(); + } + + let lower = default_sql.to_lowercase(); + if lower.contains("now()") || lower.starts_with("current_timestamp") { + return "@default(now())".into(); + } + if lower.contains("gen_random_uuid()") + || lower.contains("uuid_generate_v4()") + || lower.contains("newid()") + { + return "@default(uuid())".into(); + } + + // Any remaining function call → dbgenerated + if default_sql.contains('(') { + let escaped = default_sql.replace('"', "\\\""); + return format!("@default(dbgenerated(\"{escaped}\"))"); + } + + // String literal with quotes — may be an enum value + if default_sql.starts_with('\'') || default_sql.starts_with('"') { + let stripped = default_sql.trim_matches(|c| c == '\'' || c == '"'); + if let ColumnType::Complex(ComplexColumnType::Enum { + values: EnumValues::String(variants), + .. + }) = col_type + && variants.iter().any(|v| v.as_str() == stripped) + { + let variant = to_screaming_snake(stripped); + return format!("@default({variant})"); + } + let s = stripped.replace('\\', "\\\\").replace('"', "\\\""); + return format!("@default(\"{s}\")"); + } + + // Numeric + if default_sql.parse::().is_ok() { + return format!("@default({default_sql})"); + } + + // Fallback + let escaped = default_sql.replace('"', "\\\""); + format!("@default(dbgenerated(\"{escaped}\"))") +} + +fn reference_action_to_prisma(action: &ReferenceAction) -> &'static str { + match action { + ReferenceAction::Cascade => "Cascade", + ReferenceAction::Restrict => "Restrict", + ReferenceAction::SetNull => "SetNull", + ReferenceAction::SetDefault => "SetDefault", + // Includes NoAction and unknown/future referential actions. + _ => "NoAction", + } +} + +fn infer_relation_field_name(fk_col: &str) -> String { + fk_col.strip_suffix("_id").unwrap_or(fk_col).to_string() +} diff --git a/crates/vespertide-exporter/src/prisma/types.rs b/crates/vespertide-exporter/src/prisma/types.rs new file mode 100644 index 00000000..d5f3ed73 --- /dev/null +++ b/crates/vespertide-exporter/src/prisma/types.rs @@ -0,0 +1,57 @@ +use vespertide_core::schema::column::{ColumnType, ComplexColumnType, SimpleColumnType}; + +use super::enums::to_pascal_case; + +pub(super) fn column_type_to_prisma(ty: &ColumnType, nullable: bool) -> (String, Option) { + let q = if nullable { "?" } else { "" }; + + match ty { + ColumnType::Simple(simple) => { + let (base, native) = match simple { + SimpleColumnType::SmallInt => ("Int", Some("@db.SmallInt")), + SimpleColumnType::Integer => ("Int", None), + SimpleColumnType::BigInt => ("BigInt", None), + SimpleColumnType::Real => ("Float", Some("@db.Real")), + SimpleColumnType::DoublePrecision => ("Float", None), + SimpleColumnType::Text => ("String", Some("@db.Text")), + SimpleColumnType::Boolean => ("Boolean", None), + SimpleColumnType::Date => ("DateTime", Some("@db.Date")), + SimpleColumnType::Time => ("DateTime", Some("@db.Time")), + SimpleColumnType::Timestamp => ("DateTime", Some("@db.Timestamp")), + SimpleColumnType::Timestamptz => ("DateTime", Some("@db.Timestamptz")), + SimpleColumnType::Interval => ("String", Some("@db.Interval")), + SimpleColumnType::Bytea => ("Bytes", None), + SimpleColumnType::Uuid => ("String", Some("@db.Uuid")), + SimpleColumnType::Json => ("Json", None), + SimpleColumnType::Inet => ("String", Some("@db.Inet")), + SimpleColumnType::Cidr => ("String", Some("@db.Cidr")), + SimpleColumnType::Macaddr => ("String", Some("@db.Macaddr")), + SimpleColumnType::Xml => ("String", Some("@db.Xml")), + // Unknown/future simple types fall back to a plain String column. + _ => ("String", None), + }; + (format!("{base}{q}"), native.map(str::to_string)) + } + ColumnType::Complex(complex) => match complex { + ComplexColumnType::Varchar { length } => { + (format!("String{q}"), Some(format!("@db.VarChar({length})"))) + } + ComplexColumnType::Char { length } => { + (format!("String{q}"), Some(format!("@db.Char({length})"))) + } + ComplexColumnType::Numeric { precision, scale } => ( + format!("Decimal{q}"), + Some(format!("@db.Decimal({precision}, {scale})")), + ), + ComplexColumnType::Custom { custom_type } => { + (format!("Unsupported(\"{custom_type}\"){q}"), None) + } + ComplexColumnType::Enum { name, .. } => { + let pascal = to_pascal_case(name); + (format!("{pascal}{q}"), None) + } + // Unknown/future complex types fall back to a plain String column. + _ => (format!("String{q}"), None), + }, + } +} From dcbff76bf515e626ef40a85ecc3bfce42d71cbc5 Mon Sep 17 00:00:00 2001 From: L33gn21 Date: Wed, 24 Jun 2026 00:24:58 +0900 Subject: [PATCH 04/25] fix(exporter): correct Prisma constraint names, integer-enum defaults, relation field naming - @@unique/@@index DB constraint names now emit `map:` instead of `name:` (`name:` on @@index is invalid in Prisma 2.30+; on @@unique it sets the Prisma Client accessor, not the DB constraint name) - integer-backed enum @default(...) now resolves to a variant identifier via numeric-value or variant-name match (e.g. @default(COMPLETED)), falling back to dbgenerated() when unmatched, instead of emitting an invalid bare int - inline FK relation field gets a `_rel` suffix when stripping `_id` would collide with the scalar column; self-referential back-relation is emitted - regenerate affected Prisma snapshots Co-Authored-By: Claude Opus 4.8 --- .../vespertide-exporter/src/prisma/enums.rs | 8 +++- crates/vespertide-exporter/src/prisma/mod.rs | 8 +++- .../vespertide-exporter/src/prisma/render.rs | 40 +++++++++++++++++-- ...snapshot@composite_constraints_Prisma.snap | 5 ++- ...index_snapshot@composite_index_Prisma.snap | 3 +- ...ot@composite_unique_constraint_Prisma.snap | 3 +- ...ique_snapshot@composite_unique_Prisma.snap | 3 +- ...h_schema_snapshots@username_fk_Prisma.snap | 2 +- ...k_snapshot@self_referencing_fk_Prisma.snap | 1 + ...es_snapshot@table_with_indexes_Prisma.snap | 3 +- 10 files changed, 62 insertions(+), 14 deletions(-) diff --git a/crates/vespertide-exporter/src/prisma/enums.rs b/crates/vespertide-exporter/src/prisma/enums.rs index d7c025b3..f38c5436 100644 --- a/crates/vespertide-exporter/src/prisma/enums.rs +++ b/crates/vespertide-exporter/src/prisma/enums.rs @@ -55,5 +55,11 @@ pub(super) fn to_screaming_snake(s: &str) -> String { prev_lower = false; } } - result.trim_end_matches('_').to_string() + let result = result.trim_end_matches('_').to_string(); + // Prisma identifiers cannot start with a digit; prefix with '_' to keep it valid. + if result.starts_with(|c: char| c.is_ascii_digit()) { + format!("_{result}") + } else { + result + } } diff --git a/crates/vespertide-exporter/src/prisma/mod.rs b/crates/vespertide-exporter/src/prisma/mod.rs index b24d01a5..5514137e 100644 --- a/crates/vespertide-exporter/src/prisma/mod.rs +++ b/crates/vespertide-exporter/src/prisma/mod.rs @@ -98,9 +98,13 @@ fn collect_table_enums(table: &TableDef) -> Vec<(&str, &EnumValues)> { result } -/// Render enum blocks + model block without schema context (no back-relations). +/// Render enum blocks + model block without schema context. +/// +/// Passes the table itself as a one-element schema so that self-referential FK +/// back-relations are always emitted (Prisma requires both sides of a relation to +/// be present in the model, including self-referential ones). pub fn render_entity(table: &TableDef) -> String { - render_entity_with_schema(table, &[]) + render_entity_with_schema(table, std::slice::from_ref(table)) } /// Render enum blocks + model block with full schema context (includes back-relations). diff --git a/crates/vespertide-exporter/src/prisma/render.rs b/crates/vespertide-exporter/src/prisma/render.rs index 5773d255..67a0389c 100644 --- a/crates/vespertide-exporter/src/prisma/render.rs +++ b/crates/vespertide-exporter/src/prisma/render.rs @@ -249,7 +249,16 @@ pub(super) fn render_model(table: &TableDef, schema: &[TableDef]) -> String { // Emit inline relation field for FK columns if let Some(fk) = fk_by_col.get(col_name) { - let rel_field_name = infer_relation_field_name(col_name); + // `rel_name_segment` drives @relation("…") naming — must stay consistent + // with the segment computed by collect_back_relations on the other side. + let rel_name_segment = infer_relation_field_name(col_name); + // When no `_id` was stripped the inferred name equals the FK column name, + // which would collide with the scalar field. Append `_rel` to deduplicate. + let rel_field_name = if rel_name_segment == col_name { + format!("{col_name}_rel") + } else { + rel_name_segment.clone() + }; let rel_model = to_pascal_case(fk.ref_table); let rel_type = if col.nullable { format!("{rel_model}?") @@ -263,8 +272,9 @@ pub(super) fn render_model(table: &TableDef, schema: &[TableDef]) -> String { let mut rel_args: Vec = Vec::new(); if needs_name { + // Use rel_name_segment (pre-dedup) so the name matches back-relations. let table_pascal = to_pascal_case(&table.name); - let field_pascal = to_pascal_case(&rel_field_name); + let field_pascal = to_pascal_case(&rel_name_segment); rel_args.push(format!("\"{table_pascal}{field_pascal}\"")); } rel_args.push(format!("fields: [{col_name}]")); @@ -321,7 +331,7 @@ pub(super) fn render_model(table: &TableDef, schema: &[TableDef]) -> String { { let cols = columns.join(", "); if let Some(n) = name { - lines.push(format!(" @@unique([{cols}], name: \"{n}\")")); + lines.push(format!(" @@unique([{cols}], map: \"{n}\")")); } else { lines.push(format!(" @@unique([{cols}])")); } @@ -333,7 +343,7 @@ pub(super) fn render_model(table: &TableDef, schema: &[TableDef]) -> String { if let TableConstraint::Index { name, columns } = c { let cols = columns.join(", "); if let Some(n) = name { - lines.push(format!(" @@index([{cols}], name: \"{n}\")")); + lines.push(format!(" @@index([{cols}], map: \"{n}\")")); } else { lines.push(format!(" @@index([{cols}])")); } @@ -349,6 +359,28 @@ pub(super) fn render_model(table: &TableDef, schema: &[TableDef]) -> String { } fn prisma_default_attr(default_sql: &str, col_type: &ColumnType) -> String { + // Integer-backed enum: resolve to a variant identifier (SCREAMING_SNAKE), never a bare int. + if let ColumnType::Complex(ComplexColumnType::Enum { + values: EnumValues::Integer(int_values), + .. + }) = col_type + { + let key = default_sql.trim_matches(|c: char| c == '\'' || c == '"'); + // 1) numeric value match → variant name + if let Ok(n) = key.parse::() + && let Some(v) = int_values.iter().find(|v| v.value == n) + { + return format!("@default({})", to_screaming_snake(&v.name)); + } + // 2) exact variant-name match → variant name + if let Some(v) = int_values.iter().find(|v| v.name == key) { + return format!("@default({})", to_screaming_snake(&v.name)); + } + // 3) no match → dbgenerated fallback (valid PSL; avoids bare-int type error) + let escaped = key.replace('"', "\\\""); + return format!("@default(dbgenerated(\"{escaped}\"))"); + } + if default_sql == "true" { return "@default(true)".into(); } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Prisma.snap index c439a591..94c7016a 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Prisma.snap @@ -1,5 +1,6 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 172 expression: rendered --- model OrderItems { @@ -10,7 +11,7 @@ model OrderItems { quantity Int @@id([order_id, product_id]) - @@unique([order_id, product_id], name: "uq_order_items__order_product") - @@index([order_id], name: "ix_order_items__order_id") + @@unique([order_id, product_id], map: "uq_order_items__order_product") + @@index([order_id], map: "ix_order_items__order_id") @@map("order_items") } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_Prisma.snap index f4c1907c..29d05393 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_Prisma.snap @@ -1,5 +1,6 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 182 expression: rendered --- model CompositeIndex { @@ -7,6 +8,6 @@ model CompositeIndex { tenant_id Int name String @db.Text - @@index([tenant_id, name], name: "idx_tenant_name") + @@index([tenant_id, name], map: "idx_tenant_name") @@map("composite_index") } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Prisma.snap index 2ef045c3..50ed0cef 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Prisma.snap @@ -1,5 +1,6 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 252 expression: rendered --- model AccountAliases { @@ -7,6 +8,6 @@ model AccountAliases { tenant_id Int slug String @db.Text - @@unique([tenant_id, slug], name: "uq_account_aliases__tenant_slug") + @@unique([tenant_id, slug], map: "uq_account_aliases__tenant_slug") @@map("account_aliases") } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Prisma.snap index c62e246a..63f9f6ad 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Prisma.snap @@ -1,5 +1,6 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 177 expression: rendered --- model CompositeUnique { @@ -7,6 +8,6 @@ model CompositeUnique { tenant_id Int name String @db.Text - @@unique([tenant_id, name], name: "uq_tenant_name") + @@unique([tenant_id, name], map: "uq_tenant_name") @@map("composite_unique") } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Prisma.snap index f20329df..4dcb5492 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Prisma.snap @@ -5,7 +5,7 @@ expression: rendered model Session { id String @id @db.Uuid username String @db.Text - username User @relation(fields: [username], references: [username]) + username_rel User @relation(fields: [username], references: [username]) @@map("session") } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__self_referencing_fk_snapshot@self_referencing_fk_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__self_referencing_fk_snapshot@self_referencing_fk_Prisma.snap index 36a999c3..ebb58630 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__self_referencing_fk_snapshot@self_referencing_fk_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__self_referencing_fk_snapshot@self_referencing_fk_Prisma.snap @@ -6,6 +6,7 @@ model Employees { id Int @id manager_id Int? manager Employees? @relation("EmployeesManager", fields: [manager_id], references: [id], onDelete: SetNull) + manager_employees Employees[] @relation("EmployeesManager") @@map("employees") } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_Prisma.snap index a69cbe60..19c1740c 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_Prisma.snap @@ -1,5 +1,6 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 131 expression: rendered --- model Articles { @@ -7,7 +8,7 @@ model Articles { title String @db.Text created_at DateTime @db.Timestamptz - @@index([created_at], name: "idx_articles_created_at") + @@index([created_at], map: "idx_articles_created_at") @@index([title]) @@map("articles") } From c93b6aaad55b5d72253fdbb1c6e39bef4c98f1ad Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:15:04 +0900 Subject: [PATCH 05/25] fix(exporter): drop Prisma provider/clientOutput config, make relationMode an enum --- crates/vespertide-config/src/config.rs | 72 ++++++++++--------- crates/vespertide-config/src/lib.rs | 2 + crates/vespertide-config/src/relation_mode.rs | 60 ++++++++++++++++ crates/vespertide-exporter/src/prisma/mod.rs | 47 ++++++++---- schemas/config.schema.json | 38 +++++----- 5 files changed, 154 insertions(+), 65 deletions(-) create mode 100644 crates/vespertide-config/src/relation_mode.rs diff --git a/crates/vespertide-config/src/config.rs b/crates/vespertide-config/src/config.rs index c4a7690e..a7fca332 100644 --- a/crates/vespertide-config/src/config.rs +++ b/crates/vespertide-config/src/config.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; use crate::file_format::FileFormat; use crate::name_case::NameCase; +use crate::relation_mode::RelationMode; /// Default migration filename pattern: zero-padded version + sanitized comment. pub fn default_migration_filename_pattern() -> String { @@ -79,47 +80,28 @@ impl SeaOrmConfig { } /// Prisma-specific export configuration. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +/// +/// The `datasource` provider is always PostgreSQL (the exporter only emits +/// PostgreSQL-native `@db.*` types), and the generated Prisma Client's output +/// path is left to Prisma's own default — neither is configurable here, since +/// vespertide only manages the schema/model definition, not Prisma Client +/// codegen. See `RelationMode` for the one setting that is configurable. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct PrismaConfig { - /// Database provider: postgresql, mysql, sqlite, sqlserver, mongodb, cockroachdb. - #[serde(default = "default_prisma_provider")] - pub provider: String, - /// Optional output path for the generated Prisma client. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub client_output: Option, - /// Optional relationMode override ("foreignKeys" or "prisma"). + /// Override for the `datasource` `relationMode` setting. `None` (default) + /// omits the line and lets Prisma use its own default (`foreignKeys`). + /// See [`RelationMode`] for what each variant means. #[serde(default, skip_serializing_if = "Option::is_none")] - pub relation_mode: Option, -} - -fn default_prisma_provider() -> String { - "postgresql".to_string() -} - -impl Default for PrismaConfig { - fn default() -> Self { - Self { - provider: default_prisma_provider(), - client_output: None, - relation_mode: None, - } - } + pub relation_mode: Option, } impl PrismaConfig { - pub fn provider(&self) -> &str { - &self.provider - } - - pub fn client_output(&self) -> Option<&str> { - self.client_output.as_deref() - } - - pub fn relation_mode(&self) -> Option<&str> { - self.relation_mode.as_deref() + /// The configured `relationMode` override, if any. + pub fn relation_mode(&self) -> Option { + self.relation_mode } } @@ -345,4 +327,28 @@ mod tests { assert_eq!(config.prefix(), ""); assert_eq!(config.apply_prefix("users"), "users"); } + + #[test] + fn prisma_config_default_has_no_relation_mode() { + let config = PrismaConfig::default(); + assert_eq!(config.relation_mode(), None); + } + + #[test] + fn prisma_config_deserializes_from_empty_object() { + let config: PrismaConfig = serde_json::from_str("{}").unwrap(); + assert_eq!(config.relation_mode(), None); + } + + #[test] + fn prisma_config_relation_mode_roundtrips() { + let config = PrismaConfig { + relation_mode: Some(RelationMode::Prisma), + ..Default::default() + }; + let json = serde_json::to_string(&config).unwrap(); + assert_eq!(json, "{\"relationMode\":\"prisma\"}"); + let back: PrismaConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(back.relation_mode(), Some(RelationMode::Prisma)); + } } diff --git a/crates/vespertide-config/src/lib.rs b/crates/vespertide-config/src/lib.rs index 0e727655..a9f3e0d4 100644 --- a/crates/vespertide-config/src/lib.rs +++ b/crates/vespertide-config/src/lib.rs @@ -6,10 +6,12 @@ pub mod config; pub mod file_format; pub mod name_case; +pub mod relation_mode; pub use config::{PrismaConfig, SeaOrmConfig, VespertideConfig, default_migration_filename_pattern}; pub use file_format::FileFormat; pub use name_case::NameCase; +pub use relation_mode::RelationMode; #[cfg(test)] mod tests { diff --git a/crates/vespertide-config/src/relation_mode.rs b/crates/vespertide-config/src/relation_mode.rs new file mode 100644 index 00000000..46e83c7f --- /dev/null +++ b/crates/vespertide-config/src/relation_mode.rs @@ -0,0 +1,60 @@ +use serde::{Deserialize, Serialize}; + +/// Prisma `relationMode` datasource setting. +/// +/// Controls whether Prisma relies on real database-level foreign key +/// constraints or emulates referential integrity in Prisma Client. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum RelationMode { + /// Use real database-level foreign key constraints. This is Prisma's + /// default and is correct for any backend that supports FKs (e.g. + /// PostgreSQL, MySQL, SQLite). + ForeignKeys, + /// Emulate referential integrity in Prisma Client instead of relying on + /// database-level foreign keys. Needed for datastores that don't support + /// FK constraints (e.g. PlanetScale, MongoDB). + Prisma, +} + +impl RelationMode { + /// The literal string Prisma expects for `relationMode = "..."`. + pub fn as_str(self) -> &'static str { + match self { + RelationMode::ForeignKeys => "foreignKeys", + RelationMode::Prisma => "prisma", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn as_str_matches_prisma_literals() { + assert_eq!(RelationMode::ForeignKeys.as_str(), "foreignKeys"); + assert_eq!(RelationMode::Prisma.as_str(), "prisma"); + } + + #[test] + fn serde_round_trip() { + assert_eq!( + serde_json::to_string(&RelationMode::ForeignKeys).unwrap(), + "\"foreignKeys\"" + ); + assert_eq!( + serde_json::to_string(&RelationMode::Prisma).unwrap(), + "\"prisma\"" + ); + assert_eq!( + serde_json::from_str::("\"foreignKeys\"").unwrap(), + RelationMode::ForeignKeys + ); + assert_eq!( + serde_json::from_str::("\"prisma\"").unwrap(), + RelationMode::Prisma + ); + } +} diff --git a/crates/vespertide-exporter/src/prisma/mod.rs b/crates/vespertide-exporter/src/prisma/mod.rs index 5514137e..1f885868 100644 --- a/crates/vespertide-exporter/src/prisma/mod.rs +++ b/crates/vespertide-exporter/src/prisma/mod.rs @@ -53,27 +53,20 @@ impl<'a> PrismaExporterWithConfig<'a> { let mut parts: Vec = Vec::new(); - let provider = self.config.provider(); + // Provider is always PostgreSQL: the exporter only emits PostgreSQL-native + // `@db.*` types (see `types::column_type_to_prisma`), so it isn't configurable. let mut datasource = vec![ "datasource db {".to_string(), - format!(" provider = \"{provider}\""), + " provider = \"postgresql\"".to_string(), " url = env(\"DATABASE_URL\")".to_string(), ]; if let Some(rm) = self.config.relation_mode() { - datasource.push(format!(" relationMode = \"{rm}\"")); + datasource.push(format!(" relationMode = \"{}\"", rm.as_str())); } datasource.push("}".to_string()); parts.push(datasource.join("\n")); - let mut generator = vec![ - "generator client {".to_string(), - " provider = \"prisma-client-js\"".to_string(), - ]; - if let Some(output) = self.config.client_output() { - generator.push(format!(" output = \"{output}\"")); - } - generator.push("}".to_string()); - parts.push(generator.join("\n")); + parts.push("generator client {\n provider = \"prisma-client-js\"\n}".to_string()); parts.extend(enum_blocks); @@ -136,3 +129,33 @@ pub fn export(schema: &[TableDef]) -> Result { pub fn to_pascal_case_for_tests(s: &str) -> String { enums::to_pascal_case(s) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::tests::fixtures::basic_single_pk; + use vespertide_config::RelationMode; + + #[test] + fn render_schema_hardcodes_postgresql_provider_and_omits_client_output() { + let config = PrismaConfig::default(); + let tables = vec![basic_single_pk()]; + let schema = PrismaExporterWithConfig::new(&config).render_schema(&tables); + + assert!(schema.contains("provider = \"postgresql\"")); + assert!(!schema.contains("output")); + assert!(!schema.contains("relationMode")); + } + + #[test] + fn render_schema_emits_relation_mode_when_configured() { + let config = PrismaConfig { + relation_mode: Some(RelationMode::Prisma), + ..Default::default() + }; + let tables = vec![basic_single_pk()]; + let schema = PrismaExporterWithConfig::new(&config).render_schema(&tables); + + assert!(schema.contains("relationMode = \"prisma\"")); + } +} diff --git a/schemas/config.schema.json b/schemas/config.schema.json index f310b779..1bcd9f67 100644 --- a/schemas/config.schema.json +++ b/schemas/config.schema.json @@ -47,9 +47,7 @@ "prisma": { "description": "Prisma-specific export configuration.", "$ref": "#/$defs/PrismaConfig", - "default": { - "provider": "postgresql" - } + "default": {} }, "seaorm": { "description": "SeaORM-specific export configuration.", @@ -102,30 +100,30 @@ ] }, "PrismaConfig": { - "description": "Prisma-specific export configuration.", + "description": "Prisma-specific export configuration.\n\nThe `datasource` provider is always PostgreSQL (the exporter only emits\nPostgreSQL-native `@db.*` types), and the generated Prisma Client's output\npath is left to Prisma's own default — neither is configurable here, since\nvespertide only manages the schema/model definition, not Prisma Client\ncodegen. See `RelationMode` for the one setting that is configurable.", "type": "object", "properties": { - "clientOutput": { - "description": "Optional output path for the generated Prisma client.", - "type": [ - "string", - "null" - ] - }, - "provider": { - "description": "Database provider: postgresql, mysql, sqlite, sqlserver, mongodb, cockroachdb.", - "type": "string", - "default": "postgresql" - }, "relationMode": { - "description": "Optional relationMode override (\"foreignKeys\" or \"prisma\").", - "type": [ - "string", - "null" + "description": "Override for the `datasource` `relationMode` setting. `None` (default)\nomits the line and lets Prisma use its own default (`foreignKeys`).\nSee [`RelationMode`] for what each variant means.", + "anyOf": [ + { + "$ref": "#/$defs/RelationMode" + }, + { + "type": "null" + } ] } } }, + "RelationMode": { + "description": "Prisma `relationMode` datasource setting.\n\nControls whether Prisma relies on real database-level foreign key\nconstraints or emulates referential integrity in Prisma Client.", + "type": "string", + "enum": [ + "foreignKeys", + "prisma" + ] + }, "SeaOrmConfig": { "description": "SeaORM-specific export configuration.", "type": "object", From be8b305bc2f95dd4aef3e1af7e3c1bce3dc7f140 Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:34:57 +0900 Subject: [PATCH 06/25] fix(exporter): build Prisma test config via deserialization instead of a struct literal --- Cargo.lock | 23 ++++++++++---------- crates/vespertide-exporter/Cargo.toml | 1 + crates/vespertide-exporter/src/prisma/mod.rs | 9 ++++---- schemas/config.schema.json | 15 +++++++++---- 4 files changed, 28 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d102c03a..f9c00260 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2836,7 +2836,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vespertide" -version = "0.1.61" +version = "0.2.1" dependencies = [ "sea-orm", "tokio", @@ -2846,7 +2846,7 @@ dependencies = [ [[package]] name = "vespertide-cli" -version = "0.1.61" +version = "0.2.1" dependencies = [ "anyhow", "assert_cmd", @@ -2878,7 +2878,7 @@ dependencies = [ [[package]] name = "vespertide-config" -version = "0.1.61" +version = "0.2.1" dependencies = [ "clap", "schemars", @@ -2888,7 +2888,7 @@ dependencies = [ [[package]] name = "vespertide-core" -version = "0.1.61" +version = "0.2.1" dependencies = [ "criterion", "proptest", @@ -2903,13 +2903,14 @@ dependencies = [ [[package]] name = "vespertide-exporter" -version = "0.1.61" +version = "0.2.1" dependencies = [ "criterion", "insta", "proptest", "rayon", "rstest", + "serde_json", "thiserror 2.0.18", "vespertide-config", "vespertide-core", @@ -2932,7 +2933,7 @@ dependencies = [ [[package]] name = "vespertide-loader" -version = "0.1.61" +version = "0.2.1" dependencies = [ "anyhow", "rayon", @@ -2948,7 +2949,7 @@ dependencies = [ [[package]] name = "vespertide-lsp" -version = "0.1.61" +version = "0.2.1" dependencies = [ "dashmap", "insta", @@ -2977,7 +2978,7 @@ dependencies = [ [[package]] name = "vespertide-macro" -version = "0.1.61" +version = "0.2.1" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -2996,7 +2997,7 @@ dependencies = [ [[package]] name = "vespertide-naming" -version = "0.1.61" +version = "0.2.1" dependencies = [ "criterion", "proptest", @@ -3004,7 +3005,7 @@ dependencies = [ [[package]] name = "vespertide-planner" -version = "0.1.61" +version = "0.2.1" dependencies = [ "criterion", "insta", @@ -3019,7 +3020,7 @@ dependencies = [ [[package]] name = "vespertide-query" -version = "0.1.61" +version = "0.2.1" dependencies = [ "criterion", "insta", diff --git a/crates/vespertide-exporter/Cargo.toml b/crates/vespertide-exporter/Cargo.toml index 35f13948..98addd72 100644 --- a/crates/vespertide-exporter/Cargo.toml +++ b/crates/vespertide-exporter/Cargo.toml @@ -23,6 +23,7 @@ criterion = { version = "0.8", features = ["html_reports"] } rstest = "0.26" insta = { version = "1.47", features = ["yaml"] } proptest = "1" +serde_json = "1" [[bench]] name = "codegen_benchmarks" diff --git a/crates/vespertide-exporter/src/prisma/mod.rs b/crates/vespertide-exporter/src/prisma/mod.rs index 1f885868..8ec18d4b 100644 --- a/crates/vespertide-exporter/src/prisma/mod.rs +++ b/crates/vespertide-exporter/src/prisma/mod.rs @@ -134,7 +134,6 @@ pub fn to_pascal_case_for_tests(s: &str) -> String { mod tests { use super::*; use crate::tests::fixtures::basic_single_pk; - use vespertide_config::RelationMode; #[test] fn render_schema_hardcodes_postgresql_provider_and_omits_client_output() { @@ -149,10 +148,10 @@ mod tests { #[test] fn render_schema_emits_relation_mode_when_configured() { - let config = PrismaConfig { - relation_mode: Some(RelationMode::Prisma), - ..Default::default() - }; + // `PrismaConfig` is `#[non_exhaustive]` in a different crate, so it can't be + // built with a struct literal here — go through its real construction path + // (JSON deserialization, same as loading `vespertide.json`) instead. + let config: PrismaConfig = serde_json::from_str(r#"{"relationMode":"prisma"}"#).unwrap(); let tables = vec![basic_single_pk()]; let schema = PrismaExporterWithConfig::new(&config).render_schema(&tables); diff --git a/schemas/config.schema.json b/schemas/config.schema.json index 1bcd9f67..2f9b73ba 100644 --- a/schemas/config.schema.json +++ b/schemas/config.schema.json @@ -118,10 +118,17 @@ }, "RelationMode": { "description": "Prisma `relationMode` datasource setting.\n\nControls whether Prisma relies on real database-level foreign key\nconstraints or emulates referential integrity in Prisma Client.", - "type": "string", - "enum": [ - "foreignKeys", - "prisma" + "oneOf": [ + { + "description": "Use real database-level foreign key constraints. This is Prisma's\ndefault and is correct for any backend that supports FKs (e.g.\nPostgreSQL, MySQL, SQLite).", + "type": "string", + "const": "foreignKeys" + }, + { + "description": "Emulate referential integrity in Prisma Client instead of relying on\ndatabase-level foreign keys. Needed for datastores that don't support\nFK constraints (e.g. PlanetScale, MongoDB).", + "type": "string", + "const": "prisma" + } ] }, "SeaOrmConfig": { From 4ba12f2a31b606fdd6f97b7b8d264beae1afbbc3 Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:53:29 +0900 Subject: [PATCH 07/25] test(exporter): accept stale Prisma snapshots predating dcbff76's fixes --- ...enum_special_values_snapshot@enum_special_values_Prisma.snap | 2 +- ..._with_default_snapshot@integer_enum_with_default_Prisma.snap | 2 +- ...fault_snapshot@integer_enum_with_variant_default_Prisma.snap | 2 +- ...__unique_and_indexed_snapshot@unique_and_indexed_Prisma.snap | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_Prisma.snap index cb2b4c24..1123cf65 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_Prisma.snap @@ -6,7 +6,7 @@ enum EventSeverity { INFO_LEVEL @map("info-level") WARNING_LEVEL @map("warning_level") ERROR_LEVEL - 1CRITICAL @map("1critical") + _1CRITICAL @map("1critical") } model Events { diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_default_snapshot@integer_enum_with_default_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_default_snapshot@integer_enum_with_default_Prisma.snap index dec7034d..dec9f410 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_default_snapshot@integer_enum_with_default_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_default_snapshot@integer_enum_with_default_Prisma.snap @@ -9,7 +9,7 @@ enum TaskStatus { model Tasks { id Int - status TaskStatus @default(1) + status TaskStatus @default(dbgenerated("1")) @@map("tasks") } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_variant_default_snapshot@integer_enum_with_variant_default_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_variant_default_snapshot@integer_enum_with_variant_default_Prisma.snap index f34d556a..8a245154 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_variant_default_snapshot@integer_enum_with_variant_default_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_variant_default_snapshot@integer_enum_with_variant_default_Prisma.snap @@ -9,7 +9,7 @@ enum TaskRunStatus { model TaskRuns { id Int - status TaskRunStatus @default(dbgenerated("Completed")) + status TaskRunStatus @default(COMPLETED) @@map("task_runs") } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Prisma.snap index 326c4d69..fdc505d0 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Prisma.snap @@ -9,6 +9,6 @@ model Users { department String? @db.Text status String @default("active") @db.Text - @@index([department], name: "idx_department") + @@index([department], map: "idx_department") @@map("users") } From cd560ef5a5177fc6133aff29e7eeef705499ba25 Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:00:08 +0900 Subject: [PATCH 08/25] =?UTF-8?q?fix(exporter):=20Prisma=EC=97=90=20?= =?UTF-8?q?=EC=A1=B4=EC=9E=AC=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20@db?= =?UTF-8?q?.Interval/@db.Cidr/@db.Macaddr=20=EC=86=8D=EC=84=B1=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/vespertide-exporter/src/prisma/types.rs | 6 ++---- ...__all_simple_types_snapshot@all_simple_types_Prisma.snap | 6 +++--- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/crates/vespertide-exporter/src/prisma/types.rs b/crates/vespertide-exporter/src/prisma/types.rs index d5f3ed73..5166e610 100644 --- a/crates/vespertide-exporter/src/prisma/types.rs +++ b/crates/vespertide-exporter/src/prisma/types.rs @@ -19,15 +19,13 @@ pub(super) fn column_type_to_prisma(ty: &ColumnType, nullable: bool) -> (String, SimpleColumnType::Time => ("DateTime", Some("@db.Time")), SimpleColumnType::Timestamp => ("DateTime", Some("@db.Timestamp")), SimpleColumnType::Timestamptz => ("DateTime", Some("@db.Timestamptz")), - SimpleColumnType::Interval => ("String", Some("@db.Interval")), SimpleColumnType::Bytea => ("Bytes", None), SimpleColumnType::Uuid => ("String", Some("@db.Uuid")), SimpleColumnType::Json => ("Json", None), SimpleColumnType::Inet => ("String", Some("@db.Inet")), - SimpleColumnType::Cidr => ("String", Some("@db.Cidr")), - SimpleColumnType::Macaddr => ("String", Some("@db.Macaddr")), SimpleColumnType::Xml => ("String", Some("@db.Xml")), - // Unknown/future simple types fall back to a plain String column. + // Interval/Cidr/Macaddr have no Prisma native type; they and + // unknown/future simple types fall back to a plain String column. _ => ("String", None), }; (format!("{base}{q}"), native.map(str::to_string)) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Prisma.snap index d9449b4e..15c47595 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Prisma.snap @@ -14,13 +14,13 @@ model AllTypes { time_col DateTime @db.Time ts_col DateTime @db.Timestamp tstz_col DateTime @db.Timestamptz - interval_col String @db.Interval + interval_col String bytea_col Bytes uuid_col String @db.Uuid json_col Json inet_col String @db.Inet - cidr_col String @db.Cidr - macaddr_col String @db.Macaddr + cidr_col String + macaddr_col String xml_col String @db.Xml @@map("all_types") From 891f8dcd00b7e047c72848aa2f4de76db5379e61 Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:00:08 +0900 Subject: [PATCH 09/25] =?UTF-8?q?style:=20=EC=9D=B4=EC=A0=84=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B=EC=97=90=EC=84=9C=20=EB=88=84=EB=9D=BD=EB=90=9C=20car?= =?UTF-8?q?go=20fmt=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/vespertide-cli/src/commands/export.rs | 2 +- crates/vespertide-config/src/lib.rs | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/vespertide-cli/src/commands/export.rs b/crates/vespertide-cli/src/commands/export.rs index 76b0db79..dc4758c9 100644 --- a/crates/vespertide-cli/src/commands/export.rs +++ b/crates/vespertide-cli/src/commands/export.rs @@ -9,7 +9,7 @@ use tokio::fs; use vespertide_config::VespertideConfig; use vespertide_core::TableDef; use vespertide_exporter::{ - Orm, render_entity_with_schema, prisma::PrismaExporterWithConfig, + Orm, prisma::PrismaExporterWithConfig, render_entity_with_schema, seaorm::SeaOrmExporterWithConfig, }; diff --git a/crates/vespertide-config/src/lib.rs b/crates/vespertide-config/src/lib.rs index a9f3e0d4..7d0b7048 100644 --- a/crates/vespertide-config/src/lib.rs +++ b/crates/vespertide-config/src/lib.rs @@ -8,7 +8,9 @@ pub mod file_format; pub mod name_case; pub mod relation_mode; -pub use config::{PrismaConfig, SeaOrmConfig, VespertideConfig, default_migration_filename_pattern}; +pub use config::{ + PrismaConfig, SeaOrmConfig, VespertideConfig, default_migration_filename_pattern, +}; pub use file_format::FileFormat; pub use name_case::NameCase; pub use relation_mode::RelationMode; From 3990cb2d4615dbe3173b67ab27723cbbe81141e6 Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:10:42 +0900 Subject: [PATCH 10/25] =?UTF-8?q?feat(exporter):=20Prisma=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=20=EC=83=9D=EC=84=B1=EC=97=90=20=EB=B0=B1?= =?UTF-8?q?=EC=97=94=EB=93=9C=EB=B3=84=20provider/@db.*=20=EB=A7=A4?= =?UTF-8?q?=ED=95=91=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/vespertide-exporter/src/prisma/mod.rs | 71 ++++++- .../vespertide-exporter/src/prisma/render.rs | 28 ++- .../vespertide-exporter/src/prisma/types.rs | 177 +++++++++++++++--- 3 files changed, 234 insertions(+), 42 deletions(-) diff --git a/crates/vespertide-exporter/src/prisma/mod.rs b/crates/vespertide-exporter/src/prisma/mod.rs index 8ec18d4b..a0ef3d42 100644 --- a/crates/vespertide-exporter/src/prisma/mod.rs +++ b/crates/vespertide-exporter/src/prisma/mod.rs @@ -11,6 +11,31 @@ use vespertide_core::schema::column::{ColumnType, ComplexColumnType, EnumValues} pub struct PrismaExporter; +/// Target database provider for the generated `schema.prisma`. +/// +/// A Prisma schema declares exactly one datasource provider, and native +/// `@db.*` type attributes are validated against that provider's connector — +/// so the same tables render differently per backend. Mirrors the backends +/// supported by `vespertide-query`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PrismaProvider { + #[default] + Postgres, + MySql, + Sqlite, +} + +impl PrismaProvider { + /// The `provider = "..."` string used in the `datasource` block. + pub fn as_datasource_str(self) -> &'static str { + match self { + PrismaProvider::Postgres => "postgresql", + PrismaProvider::MySql => "mysql", + PrismaProvider::Sqlite => "sqlite", + } + } +} + impl OrmExporter for PrismaExporter { fn render_entity(&self, table: &TableDef) -> Result { Ok(render_entity(table)) @@ -30,11 +55,16 @@ impl OrmExporter for PrismaExporter { /// Assembles a complete `schema.prisma` file from a full table list. pub struct PrismaExporterWithConfig<'a> { pub config: &'a PrismaConfig, + pub provider: PrismaProvider, } impl<'a> PrismaExporterWithConfig<'a> { pub fn new(config: &'a PrismaConfig) -> Self { - Self { config } + Self::with_provider(config, PrismaProvider::default()) + } + + pub fn with_provider(config: &'a PrismaConfig, provider: PrismaProvider) -> Self { + Self { config, provider } } /// Render a complete `schema.prisma` file for all tables. @@ -53,11 +83,9 @@ impl<'a> PrismaExporterWithConfig<'a> { let mut parts: Vec = Vec::new(); - // Provider is always PostgreSQL: the exporter only emits PostgreSQL-native - // `@db.*` types (see `types::column_type_to_prisma`), so it isn't configurable. let mut datasource = vec![ "datasource db {".to_string(), - " provider = \"postgresql\"".to_string(), + format!(" provider = \"{}\"", self.provider.as_datasource_str()), " url = env(\"DATABASE_URL\")".to_string(), ]; if let Some(rm) = self.config.relation_mode() { @@ -71,7 +99,7 @@ impl<'a> PrismaExporterWithConfig<'a> { parts.extend(enum_blocks); for table in tables { - parts.push(render::render_model(table, tables)); + parts.push(render::render_model(table, tables, self.provider)); } parts.join("\n\n") + "\n" @@ -101,12 +129,19 @@ pub fn render_entity(table: &TableDef) -> String { } /// Render enum blocks + model block with full schema context (includes back-relations). +/// +/// Uses the default provider (PostgreSQL) — provider-specific output goes +/// through [`PrismaExporterWithConfig::with_provider`]. pub fn render_entity_with_schema(table: &TableDef, schema: &[TableDef]) -> String { let mut parts: Vec = Vec::new(); for (name, values) in collect_table_enums(table) { parts.push(enums::render_enum(name, values)); } - parts.push(render::render_model(table, schema)); + parts.push(render::render_model( + table, + schema, + PrismaProvider::default(), + )); parts.join("\n\n") } @@ -132,18 +167,36 @@ pub fn to_pascal_case_for_tests(s: &str) -> String { #[cfg(test)] mod tests { + use rstest::rstest; + use super::*; use crate::tests::fixtures::basic_single_pk; + #[rstest] + #[case::postgres(PrismaProvider::Postgres, "provider = \"postgresql\"")] + #[case::mysql(PrismaProvider::MySql, "provider = \"mysql\"")] + #[case::sqlite(PrismaProvider::Sqlite, "provider = \"sqlite\"")] + fn render_schema_emits_configured_provider( + #[case] provider: PrismaProvider, + #[case] expected_line: &str, + ) { + let config = PrismaConfig::default(); + let tables = vec![basic_single_pk()]; + let schema = + PrismaExporterWithConfig::with_provider(&config, provider).render_schema(&tables); + + assert!(schema.contains(expected_line)); + assert!(!schema.contains("output")); + assert!(!schema.contains("relationMode")); + } + #[test] - fn render_schema_hardcodes_postgresql_provider_and_omits_client_output() { + fn new_defaults_to_postgresql_provider() { let config = PrismaConfig::default(); let tables = vec![basic_single_pk()]; let schema = PrismaExporterWithConfig::new(&config).render_schema(&tables); assert!(schema.contains("provider = \"postgresql\"")); - assert!(!schema.contains("output")); - assert!(!schema.contains("relationMode")); } #[test] diff --git a/crates/vespertide-exporter/src/prisma/render.rs b/crates/vespertide-exporter/src/prisma/render.rs index 67a0389c..1cc6b315 100644 --- a/crates/vespertide-exporter/src/prisma/render.rs +++ b/crates/vespertide-exporter/src/prisma/render.rs @@ -6,6 +6,7 @@ use vespertide_core::schema::constraint::TableConstraint; use vespertide_core::schema::names::ColumnName; use vespertide_core::schema::reference::ReferenceAction; +use super::PrismaProvider; use super::enums::{to_pascal_case, to_screaming_snake}; use super::types::column_type_to_prisma; @@ -127,7 +128,11 @@ pub(super) fn collect_back_relations(target_table: &str, schema: &[TableDef]) -> result } -pub(super) fn render_model(table: &TableDef, schema: &[TableDef]) -> String { +pub(super) fn render_model( + table: &TableDef, + schema: &[TableDef], + provider: PrismaProvider, +) -> String { let mut lines: Vec = Vec::new(); if let Some(desc) = &table.description { @@ -212,7 +217,7 @@ pub(super) fn render_model(table: &TableDef, schema: &[TableDef]) -> String { lines.push(format!(" /// {comment}")); } - let (type_str, native_attr) = column_type_to_prisma(&col.r#type, col.nullable); + let (type_str, native_attr) = column_type_to_prisma(&col.r#type, col.nullable, provider); let mut attrs: Vec = Vec::new(); if is_single_pk { @@ -223,7 +228,11 @@ pub(super) fn render_model(table: &TableDef, schema: &[TableDef]) -> String { } if !auto_inc && let Some(ref default) = col.default { - attrs.push(prisma_default_attr(&default.to_sql(), &col.r#type)); + attrs.push(prisma_default_attr( + &default.to_sql(), + &col.r#type, + provider, + )); } if let Some(unique_name) = is_unique @@ -358,7 +367,11 @@ pub(super) fn render_model(table: &TableDef, schema: &[TableDef]) -> String { lines.join("\n") } -fn prisma_default_attr(default_sql: &str, col_type: &ColumnType) -> String { +fn prisma_default_attr( + default_sql: &str, + col_type: &ColumnType, + provider: PrismaProvider, +) -> String { // Integer-backed enum: resolve to a variant identifier (SCREAMING_SNAKE), never a bare int. if let ColumnType::Complex(ComplexColumnType::Enum { values: EnumValues::Integer(int_values), @@ -396,7 +409,12 @@ fn prisma_default_attr(default_sql: &str, col_type: &ColumnType) -> String { || lower.contains("uuid_generate_v4()") || lower.contains("newid()") { - return "@default(uuid())".into(); + // On MySQL the uuid column is BINARY(16) and Prisma's uuid() only + // applies to String fields — mirror the DDL's DB-side default instead. + return match provider { + PrismaProvider::MySql => "@default(dbgenerated(\"(uuid())\"))".into(), + _ => "@default(uuid())".into(), + }; } // Any remaining function call → dbgenerated diff --git a/crates/vespertide-exporter/src/prisma/types.rs b/crates/vespertide-exporter/src/prisma/types.rs index 5166e610..2fe25048 100644 --- a/crates/vespertide-exporter/src/prisma/types.rs +++ b/crates/vespertide-exporter/src/prisma/types.rs @@ -1,45 +1,78 @@ use vespertide_core::schema::column::{ColumnType, ComplexColumnType, SimpleColumnType}; +use super::PrismaProvider; use super::enums::to_pascal_case; -pub(super) fn column_type_to_prisma(ty: &ColumnType, nullable: bool) -> (String, Option) { +pub(super) fn column_type_to_prisma( + ty: &ColumnType, + nullable: bool, + provider: PrismaProvider, +) -> (String, Option) { let q = if nullable { "?" } else { "" }; match ty { + // vespertide stores MySQL UUIDs as BINARY(16) (see vespertide-query's + // uuid mapping), so the Prisma scalar itself differs per provider. + ColumnType::Simple(SimpleColumnType::Uuid) => { + let (base, native) = match provider { + PrismaProvider::Postgres => ("String", Some("@db.Uuid")), + PrismaProvider::MySql => ("Bytes", Some("@db.Binary(16)")), + PrismaProvider::Sqlite => ("String", None), + }; + (format!("{base}{q}"), native.map(str::to_string)) + } ColumnType::Simple(simple) => { - let (base, native) = match simple { - SimpleColumnType::SmallInt => ("Int", Some("@db.SmallInt")), - SimpleColumnType::Integer => ("Int", None), - SimpleColumnType::BigInt => ("BigInt", None), - SimpleColumnType::Real => ("Float", Some("@db.Real")), - SimpleColumnType::DoublePrecision => ("Float", None), - SimpleColumnType::Text => ("String", Some("@db.Text")), - SimpleColumnType::Boolean => ("Boolean", None), - SimpleColumnType::Date => ("DateTime", Some("@db.Date")), - SimpleColumnType::Time => ("DateTime", Some("@db.Time")), - SimpleColumnType::Timestamp => ("DateTime", Some("@db.Timestamp")), - SimpleColumnType::Timestamptz => ("DateTime", Some("@db.Timestamptz")), - SimpleColumnType::Bytea => ("Bytes", None), - SimpleColumnType::Uuid => ("String", Some("@db.Uuid")), - SimpleColumnType::Json => ("Json", None), - SimpleColumnType::Inet => ("String", Some("@db.Inet")), - SimpleColumnType::Xml => ("String", Some("@db.Xml")), - // Interval/Cidr/Macaddr have no Prisma native type; they and - // unknown/future simple types fall back to a plain String column. - _ => ("String", None), + // (scalar, postgres native attr, mysql native attr) — mirrors the + // per-backend DDL mapping in vespertide-query's `apply_simple_column_type`. + // SQLite never gets native attrs (its connector supports none). + let (base, pg_native, mysql_native) = match simple { + SimpleColumnType::SmallInt => ("Int", Some("@db.SmallInt"), Some("@db.SmallInt")), + SimpleColumnType::Integer => ("Int", None, None), + SimpleColumnType::BigInt => ("BigInt", None, None), + SimpleColumnType::Real => ("Float", Some("@db.Real"), Some("@db.Float")), + SimpleColumnType::DoublePrecision => ("Float", None, None), + SimpleColumnType::Text => ("String", Some("@db.Text"), Some("@db.Text")), + SimpleColumnType::Boolean => ("Boolean", None, None), + SimpleColumnType::Date => ("DateTime", Some("@db.Date"), Some("@db.Date")), + SimpleColumnType::Time => ("DateTime", Some("@db.Time"), Some("@db.Time")), + SimpleColumnType::Timestamp => { + ("DateTime", Some("@db.Timestamp"), Some("@db.Timestamp")) + } + // MySQL has no TIMESTAMPTZ; the DDL degrades it to TIMESTAMP. + SimpleColumnType::Timestamptz => { + ("DateTime", Some("@db.Timestamptz"), Some("@db.Timestamp")) + } + SimpleColumnType::Bytea => ("Bytes", None, None), + SimpleColumnType::Json => ("Json", None, None), + SimpleColumnType::Inet => ("String", Some("@db.Inet"), Some("@db.Text")), + SimpleColumnType::Xml => ("String", Some("@db.Xml"), Some("@db.Text")), + // Interval/Cidr/Macaddr have no Prisma native type on PostgreSQL; + // the MySQL DDL degrades all three to TEXT. + SimpleColumnType::Interval | SimpleColumnType::Cidr | SimpleColumnType::Macaddr => { + ("String", None, Some("@db.Text")) + } + // Unknown/future simple types fall back to a plain String column. + _ => ("String", None, None), + }; + let native = match provider { + PrismaProvider::Postgres => pg_native, + PrismaProvider::MySql => mysql_native, + PrismaProvider::Sqlite => None, }; (format!("{base}{q}"), native.map(str::to_string)) } ColumnType::Complex(complex) => match complex { - ComplexColumnType::Varchar { length } => { - (format!("String{q}"), Some(format!("@db.VarChar({length})"))) - } - ComplexColumnType::Char { length } => { - (format!("String{q}"), Some(format!("@db.Char({length})"))) - } + ComplexColumnType::Varchar { length } => ( + format!("String{q}"), + sized_native(provider, format!("@db.VarChar({length})")), + ), + ComplexColumnType::Char { length } => ( + format!("String{q}"), + sized_native(provider, format!("@db.Char({length})")), + ), ComplexColumnType::Numeric { precision, scale } => ( format!("Decimal{q}"), - Some(format!("@db.Decimal({precision}, {scale})")), + sized_native(provider, format!("@db.Decimal({precision}, {scale})")), ), ComplexColumnType::Custom { custom_type } => { (format!("Unsupported(\"{custom_type}\"){q}"), None) @@ -53,3 +86,91 @@ pub(super) fn column_type_to_prisma(ty: &ColumnType, nullable: bool) -> (String, }, } } + +/// VarChar/Char/Decimal natives are spelled identically on PostgreSQL and +/// MySQL; SQLite supports no native attrs at all. +fn sized_native(provider: PrismaProvider, attr: String) -> Option { + match provider { + PrismaProvider::Postgres | PrismaProvider::MySql => Some(attr), + PrismaProvider::Sqlite => None, + } +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + use vespertide_core::schema::column::{ColumnType, ComplexColumnType, SimpleColumnType}; + + use super::*; + + #[rstest] + #[case::postgres(PrismaProvider::Postgres, "String", Some("@db.Uuid"))] + #[case::mysql(PrismaProvider::MySql, "Bytes", Some("@db.Binary(16)"))] + #[case::sqlite(PrismaProvider::Sqlite, "String", None)] + fn uuid_scalar_and_native_follow_provider( + #[case] provider: PrismaProvider, + #[case] scalar: &str, + #[case] native: Option<&str>, + ) { + let ty = ColumnType::Simple(SimpleColumnType::Uuid); + let (rendered, attr) = column_type_to_prisma(&ty, false, provider); + assert_eq!(rendered, scalar); + assert_eq!(attr.as_deref(), native); + } + + #[rstest] + #[case::postgres(PrismaProvider::Postgres, Some("@db.Timestamptz"))] + #[case::mysql(PrismaProvider::MySql, Some("@db.Timestamp"))] + #[case::sqlite(PrismaProvider::Sqlite, None)] + fn timestamptz_native_follows_provider( + #[case] provider: PrismaProvider, + #[case] native: Option<&str>, + ) { + let ty = ColumnType::Simple(SimpleColumnType::Timestamptz); + let (rendered, attr) = column_type_to_prisma(&ty, false, provider); + assert_eq!(rendered, "DateTime"); + assert_eq!(attr.as_deref(), native); + } + + #[rstest] + #[case::postgres(PrismaProvider::Postgres, None)] + #[case::mysql(PrismaProvider::MySql, Some("@db.Text"))] + #[case::sqlite(PrismaProvider::Sqlite, None)] + fn interval_native_follows_provider( + #[case] provider: PrismaProvider, + #[case] native: Option<&str>, + ) { + let ty = ColumnType::Simple(SimpleColumnType::Interval); + let (rendered, attr) = column_type_to_prisma(&ty, false, provider); + assert_eq!(rendered, "String"); + assert_eq!(attr.as_deref(), native); + } + + #[rstest] + #[case::postgres(PrismaProvider::Postgres, Some("@db.Inet"))] + #[case::mysql(PrismaProvider::MySql, Some("@db.Text"))] + #[case::sqlite(PrismaProvider::Sqlite, None)] + fn inet_native_follows_provider( + #[case] provider: PrismaProvider, + #[case] native: Option<&str>, + ) { + let ty = ColumnType::Simple(SimpleColumnType::Inet); + let (rendered, attr) = column_type_to_prisma(&ty, true, provider); + assert_eq!(rendered, "String?"); + assert_eq!(attr.as_deref(), native); + } + + #[rstest] + #[case::postgres(PrismaProvider::Postgres, Some("@db.VarChar(255)"))] + #[case::mysql(PrismaProvider::MySql, Some("@db.VarChar(255)"))] + #[case::sqlite(PrismaProvider::Sqlite, None)] + fn varchar_native_follows_provider( + #[case] provider: PrismaProvider, + #[case] native: Option<&str>, + ) { + let ty = ColumnType::Complex(ComplexColumnType::Varchar { length: 255 }); + let (rendered, attr) = column_type_to_prisma(&ty, false, provider); + assert_eq!(rendered, "String"); + assert_eq!(attr.as_deref(), native); + } +} From 2f4095bdf53e31629452c5017940b2bf96739880 Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:16:12 +0900 Subject: [PATCH 11/25] =?UTF-8?q?feat(cli):=20export=20--orm=20prisma?= =?UTF-8?q?=EC=97=90=20--backend(-b)=20=ED=94=8C=EB=9E=98=EA=B7=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/vespertide-cli/src/commands/export.rs | 116 ++++++++++++++++--- crates/vespertide-cli/src/main.rs | 9 +- 2 files changed, 110 insertions(+), 15 deletions(-) diff --git a/crates/vespertide-cli/src/commands/export.rs b/crates/vespertide-cli/src/commands/export.rs index dc4758c9..823f5d38 100644 --- a/crates/vespertide-cli/src/commands/export.rs +++ b/crates/vespertide-cli/src/commands/export.rs @@ -9,9 +9,12 @@ use tokio::fs; use vespertide_config::VespertideConfig; use vespertide_core::TableDef; use vespertide_exporter::{ - Orm, prisma::PrismaExporterWithConfig, render_entity_with_schema, + Orm, + prisma::{PrismaExporterWithConfig, PrismaProvider}, + render_entity_with_schema, seaorm::SeaOrmExporterWithConfig, }; +use vespertide_query::DatabaseBackend; use crate::parallel_config::{EXPORT_RENDER_PAR_MIN_LEN, EXPORT_RENDER_PAR_THRESHOLD}; use crate::utils::load_config; @@ -37,7 +40,21 @@ impl From for Orm { } } -pub async fn cmd_export(orm: OrmArg, export_dir: Option) -> Result<()> { +/// A Prisma schema is provider-specific (`datasource` provider + `@db.*` +/// native attrs), so the export backend maps onto the Prisma provider. +fn prisma_provider_for_backend(backend: DatabaseBackend) -> PrismaProvider { + match backend { + DatabaseBackend::Postgres => PrismaProvider::Postgres, + DatabaseBackend::MySql => PrismaProvider::MySql, + DatabaseBackend::Sqlite => PrismaProvider::Sqlite, + } +} + +pub async fn cmd_export( + orm: OrmArg, + export_dir: Option, + backend: DatabaseBackend, +) -> Result<()> { let config = load_config()?; let models = load_models_recursive(config.models_dir()) .await @@ -58,7 +75,8 @@ pub async fn cmd_export(orm: OrmArg, export_dir: Option) -> Result<()> // Prisma uses a single-file output strategy if matches!(orm, OrmArg::Prisma) { - return cmd_export_prisma(config, normalized_models, target_root).await; + let provider = prisma_provider_for_backend(backend); + return cmd_export_prisma(config, normalized_models, target_root, provider).await; } // Clean the export directory before regenerating @@ -183,7 +201,7 @@ fn render_export_entity( /// Derive `crate::` prefix from the export directory path. /// -/// For example: `src/models` → `crate::models`, `src/db/entities` → `crate::db::entities`. +/// For example: `src/models` ??`crate::models`, `src/db/entities` ??`crate::db::entities`. /// If the path doesn't start with `src/`, returns empty string (fallback to `super::` behavior). fn export_dir_to_crate_prefix(export_dir: &Path) -> String { let normalized = export_dir.to_string_lossy().replace('\\', "/"); @@ -199,8 +217,8 @@ fn export_dir_to_crate_prefix(export_dir: &Path) -> String { /// Convert a relative model file path to Rust module path segments. /// -/// For example: `admin/admin.json` → `["admin", "admin"]` -/// `estimate/estimate_checker.vespertide.json` → `["estimate", "estimate_checker"]` +/// For example: `admin/admin.json` ??`["admin", "admin"]` +/// `estimate/estimate_checker.vespertide.json` ??`["estimate", "estimate_checker"]` fn rel_path_to_module_segments(rel_path: &Path) -> Vec { let mut segments = Vec::new(); @@ -442,9 +460,11 @@ async fn cmd_export_prisma( config: VespertideConfig, normalized_models: Vec<(TableDef, PathBuf)>, target_root: PathBuf, + provider: PrismaProvider, ) -> Result<()> { let all_tables: Vec = normalized_models.iter().map(|(t, _)| t.clone()).collect(); - let content = PrismaExporterWithConfig::new(config.prisma()).render_schema(&all_tables); + let content = PrismaExporterWithConfig::with_provider(config.prisma(), provider) + .render_schema(&all_tables); clean_export_dir(&target_root, Orm::Prisma).await?; @@ -560,7 +580,9 @@ mod tests { let model = sample_table("users"); write_model(Path::new("models/users.json"), &model); - cmd_export(OrmArg::Seaorm, None).await.unwrap(); + cmd_export(OrmArg::Seaorm, None, DatabaseBackend::Postgres) + .await + .unwrap(); let out = PathBuf::from("src/models/users.rs"); assert!(out.exists()); @@ -585,9 +607,13 @@ mod tests { write_model(Path::new("models/blog/posts.json"), &model); let custom = PathBuf::from("out_dir"); - cmd_export(OrmArg::Seaorm, Some(custom.clone())) - .await - .unwrap(); + cmd_export( + OrmArg::Seaorm, + Some(custom.clone()), + DatabaseBackend::Postgres, + ) + .await + .unwrap(); let out = custom.join("blog/posts.rs"); assert!(out.exists()); @@ -615,7 +641,9 @@ mod tests { let model = sample_table("items"); write_model(Path::new("models/items.json"), &model); - cmd_export(OrmArg::Sqlalchemy, None).await.unwrap(); + cmd_export(OrmArg::Sqlalchemy, None, DatabaseBackend::Postgres) + .await + .unwrap(); let out = PathBuf::from("src/models/items.py"); assert!(out.exists()); @@ -633,7 +661,9 @@ mod tests { let model = sample_table("orders"); write_model(Path::new("models/orders.json"), &model); - cmd_export(OrmArg::Sqlmodel, None).await.unwrap(); + cmd_export(OrmArg::Sqlmodel, None, DatabaseBackend::Postgres) + .await + .unwrap(); let out = PathBuf::from("src/models/orders.py"); assert!(out.exists()); @@ -641,6 +671,63 @@ mod tests { assert!(content.contains("orders")); } + #[rstest] + #[case::postgres( + DatabaseBackend::Postgres, + "provider = \"postgresql\"", + "@db.Timestamptz", + None + )] + #[case::mysql( + DatabaseBackend::MySql, + "provider = \"mysql\"", + "@db.Timestamp", + Some("@db.Timestamptz") + )] + #[case::sqlite( + DatabaseBackend::Sqlite, + "provider = \"sqlite\"", + "DateTime", + Some("@db.") + )] + #[tokio::test] + #[serial] + async fn export_prisma_writes_provider_specific_schema( + #[case] backend: DatabaseBackend, + #[case] provider_line: &str, + #[case] expected: &str, + #[case] absent: Option<&str>, + ) { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + + let mut model = sample_table("events"); + model.columns.push(ColumnDef { + name: "occurred_at".into(), + r#type: ColumnType::Simple(SimpleColumnType::Timestamptz), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }); + write_model(Path::new("models/events.json"), &model); + + cmd_export(OrmArg::Prisma, None, backend).await.unwrap(); + + let out = PathBuf::from("src/models/schema.prisma"); + assert!(out.exists()); + let content = std_fs::read_to_string(out).unwrap(); + assert!(content.contains(provider_line)); + assert!(content.contains(expected)); + if let Some(absent) = absent { + assert!(!content.contains(absent)); + } + } + #[tokio::test] #[serial] async fn load_models_recursive_returns_empty_when_absent() { @@ -730,6 +817,7 @@ mod tests { #[case(OrmArg::Sqlalchemy, Orm::SqlAlchemy)] #[case(OrmArg::Sqlmodel, Orm::SqlModel)] #[case(OrmArg::Jpa, Orm::Jpa)] + #[case(OrmArg::Prisma, Orm::Prisma)] fn orm_arg_maps_to_enum(#[case] arg: OrmArg, #[case] expected: Orm) { assert_eq!(Orm::from(arg), expected); } @@ -979,7 +1067,7 @@ mod tests { use std::path::Path; let root = Path::new("src/models"); - // snake_case model → PascalCase .java + // snake_case model ??PascalCase .java let rel_path = Path::new("order_item.json"); let out = build_output_path(root, rel_path, Orm::Jpa); assert_eq!(out, Path::new("src/models/OrderItem.java")); diff --git a/crates/vespertide-cli/src/main.rs b/crates/vespertide-cli/src/main.rs index 8088303b..eadec8eb 100644 --- a/crates/vespertide-cli/src/main.rs +++ b/crates/vespertide-cli/src/main.rs @@ -100,6 +100,9 @@ enum Commands { /// Output directory (defaults to config modelsDir or src/models). #[arg(short = 'd', long = "export-dir")] export_dir: Option, + /// Database backend for Prisma schema generation (other ORMs ignore this). + #[arg(short = 'b', long = "backend", value_enum, default_value = "postgres")] + backend: BackendArg, }, /// Export schema as ERD diagrams (SVG, Mermaid, Graphviz DOT). Erd { @@ -143,7 +146,11 @@ async fn main() -> Result<()> { delete_null_rows, }) => cmd_revision(message, fill_with, delete_null_rows).await, Some(Commands::Init) => cmd_init().await, - Some(Commands::Export { orm, export_dir }) => cmd_export(orm, export_dir).await, + Some(Commands::Export { + orm, + export_dir, + backend, + }) => cmd_export(orm, export_dir, backend.into()).await, Some(Commands::Erd { format, output, From ce6d60de8cf0c853f757e3b4e5100254a083c254 Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:18:36 +0900 Subject: [PATCH 12/25] =?UTF-8?q?fix(exporter):=20=EC=B5=9C=EC=8B=A0=20Pri?= =?UTF-8?q?sma=20=ED=98=95=EC=8B=9D=EC=97=90=20=EB=A7=9E=EC=B6=B0=20dataso?= =?UTF-8?q?urce=20url=20=EC=A4=84=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/vespertide-exporter/src/prisma/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/vespertide-exporter/src/prisma/mod.rs b/crates/vespertide-exporter/src/prisma/mod.rs index a0ef3d42..50e9a6bc 100644 --- a/crates/vespertide-exporter/src/prisma/mod.rs +++ b/crates/vespertide-exporter/src/prisma/mod.rs @@ -83,10 +83,12 @@ impl<'a> PrismaExporterWithConfig<'a> { let mut parts: Vec = Vec::new(); + // No `url` here: current Prisma moved connection config out of the + // schema file (`prisma.config.ts`), which also matches vespertide's + // models-only scope. let mut datasource = vec![ "datasource db {".to_string(), format!(" provider = \"{}\"", self.provider.as_datasource_str()), - " url = env(\"DATABASE_URL\")".to_string(), ]; if let Some(rm) = self.config.relation_mode() { datasource.push(format!(" relationMode = \"{}\"", rm.as_str())); From 803da810689a108bbb6bae1ca4933685f2fb6ab9 Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:26:17 +0900 Subject: [PATCH 13/25] =?UTF-8?q?chore:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=B2=84=EC=A0=84=20=EC=82=B0=EC=A0=95=EC=9A=A9=20changepack?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20(exporter/config/cli=20Minor)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changepacks/changepack_log_vKVGmtRxkfW3nE7rIhMcz.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changepacks/changepack_log_vKVGmtRxkfW3nE7rIhMcz.json diff --git a/.changepacks/changepack_log_vKVGmtRxkfW3nE7rIhMcz.json b/.changepacks/changepack_log_vKVGmtRxkfW3nE7rIhMcz.json new file mode 100644 index 00000000..67509a15 --- /dev/null +++ b/.changepacks/changepack_log_vKVGmtRxkfW3nE7rIhMcz.json @@ -0,0 +1 @@ +{"changes":{"crates/vespertide-exporter/Cargo.toml":"Minor","crates/vespertide-config/Cargo.toml":"Minor","crates/vespertide-cli/Cargo.toml":"Minor"},"note":"Prisma ORM exporter 추가","date":"2026-07-09T14:25:30.6412773Z"} \ No newline at end of file From a69138ec933ba2de9a12cfcad76d34626e3e7c6a Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Fri, 10 Jul 2026 04:01:01 +0900 Subject: [PATCH 14/25] =?UTF-8?q?test:=20=EB=AE=A4=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=8C=80=EC=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/vespertide-config/src/config.rs | 15 +++ .../vespertide-exporter/src/prisma/render.rs | 22 ++++ .../vespertide-exporter/src/prisma/types.rs | 122 ++++++++++++++++++ 3 files changed, 159 insertions(+) diff --git a/crates/vespertide-config/src/config.rs b/crates/vespertide-config/src/config.rs index a7fca332..33a1cd19 100644 --- a/crates/vespertide-config/src/config.rs +++ b/crates/vespertide-config/src/config.rs @@ -351,4 +351,19 @@ mod tests { let back: PrismaConfig = serde_json::from_str(&json).unwrap(); assert_eq!(back.relation_mode(), Some(RelationMode::Prisma)); } + + #[test] + fn vespertide_config_prisma_accessor_returns_deserialized_value() { + let config: VespertideConfig = serde_json::from_str( + r#"{ + "modelsDir": "models", + "migrationsDir": "migrations", + "tableNamingCase": "snake", + "columnNamingCase": "snake", + "prisma": {"relationMode": "prisma"} + }"#, + ) + .unwrap(); + assert_eq!(config.prisma().relation_mode(), Some(RelationMode::Prisma)); + } } diff --git a/crates/vespertide-exporter/src/prisma/render.rs b/crates/vespertide-exporter/src/prisma/render.rs index 1cc6b315..9291a49c 100644 --- a/crates/vespertide-exporter/src/prisma/render.rs +++ b/crates/vespertide-exporter/src/prisma/render.rs @@ -463,3 +463,25 @@ fn reference_action_to_prisma(action: &ReferenceAction) -> &'static str { fn infer_relation_field_name(fk_col: &str) -> String { fk_col.strip_suffix("_id").unwrap_or(fk_col).to_string() } + +#[cfg(test)] +mod tests { + use rstest::rstest; + use vespertide_core::schema::column::SimpleColumnType; + + use super::*; + + #[rstest] + #[case::postgres(PrismaProvider::Postgres, "@default(uuid())")] + #[case::mysql(PrismaProvider::MySql, "@default(dbgenerated(\"(uuid())\"))")] + #[case::sqlite(PrismaProvider::Sqlite, "@default(uuid())")] + fn uuid_generating_default_follows_provider( + #[case] provider: PrismaProvider, + #[case] expected: &str, + ) { + let ty = ColumnType::Simple(SimpleColumnType::Uuid); + for default_sql in ["gen_random_uuid()", "uuid_generate_v4()", "NEWID()"] { + assert_eq!(prisma_default_attr(default_sql, &ty, provider), expected); + } + } +} diff --git a/crates/vespertide-exporter/src/prisma/types.rs b/crates/vespertide-exporter/src/prisma/types.rs index 2fe25048..d368dfe5 100644 --- a/crates/vespertide-exporter/src/prisma/types.rs +++ b/crates/vespertide-exporter/src/prisma/types.rs @@ -173,4 +173,126 @@ mod tests { assert_eq!(rendered, "String"); assert_eq!(attr.as_deref(), native); } + + /// Exhaustive oracle for every simple type except `Uuid` (whose scalar + /// itself is provider-dependent — covered by its dedicated test above). + /// Each row is independent test data, so flipping any production mapping + /// tuple (mutation testing) fails here immediately. + #[rstest] + #[case::postgres(PrismaProvider::Postgres)] + #[case::mysql(PrismaProvider::MySql)] + #[case::sqlite(PrismaProvider::Sqlite)] + fn simple_type_mapping_matrix(#[case] provider: PrismaProvider) { + use SimpleColumnType as S; + // (type, scalar, postgres attr, mysql attr) — sqlite is always attr-less. + let rows: &[(S, &str, Option<&str>, Option<&str>)] = &[ + ( + S::SmallInt, + "Int", + Some("@db.SmallInt"), + Some("@db.SmallInt"), + ), + (S::Integer, "Int", None, None), + (S::BigInt, "BigInt", None, None), + (S::Real, "Float", Some("@db.Real"), Some("@db.Float")), + (S::DoublePrecision, "Float", None, None), + (S::Text, "String", Some("@db.Text"), Some("@db.Text")), + (S::Boolean, "Boolean", None, None), + (S::Date, "DateTime", Some("@db.Date"), Some("@db.Date")), + (S::Time, "DateTime", Some("@db.Time"), Some("@db.Time")), + ( + S::Timestamp, + "DateTime", + Some("@db.Timestamp"), + Some("@db.Timestamp"), + ), + ( + S::Timestamptz, + "DateTime", + Some("@db.Timestamptz"), + Some("@db.Timestamp"), + ), + (S::Interval, "String", None, Some("@db.Text")), + (S::Bytea, "Bytes", None, None), + (S::Json, "Json", None, None), + (S::Inet, "String", Some("@db.Inet"), Some("@db.Text")), + (S::Cidr, "String", None, Some("@db.Text")), + (S::Macaddr, "String", None, Some("@db.Text")), + (S::Xml, "String", Some("@db.Xml"), Some("@db.Text")), + ]; + + for (ty, scalar, pg, mysql) in rows { + let expected = match provider { + PrismaProvider::Postgres => *pg, + PrismaProvider::MySql => *mysql, + PrismaProvider::Sqlite => None, + }; + let (rendered, attr) = column_type_to_prisma(&ColumnType::Simple(*ty), false, provider); + assert_eq!(rendered, *scalar, "{ty:?} scalar under {provider:?}"); + assert_eq!(attr.as_deref(), expected, "{ty:?} attr under {provider:?}"); + } + } + + #[rstest] + #[case::postgres( + PrismaProvider::Postgres, + Some("@db.Char(3)"), + Some("@db.Decimal(10, 2)") + )] + #[case::mysql(PrismaProvider::MySql, Some("@db.Char(3)"), Some("@db.Decimal(10, 2)"))] + #[case::sqlite(PrismaProvider::Sqlite, None, None)] + fn char_and_numeric_natives_follow_provider( + #[case] provider: PrismaProvider, + #[case] char_native: Option<&str>, + #[case] numeric_native: Option<&str>, + ) { + let (rendered, attr) = column_type_to_prisma( + &ColumnType::Complex(ComplexColumnType::Char { length: 3 }), + false, + provider, + ); + assert_eq!(rendered, "String"); + assert_eq!(attr.as_deref(), char_native); + + let (rendered, attr) = column_type_to_prisma( + &ColumnType::Complex(ComplexColumnType::Numeric { + precision: 10, + scale: 2, + }), + true, + provider, + ); + assert_eq!(rendered, "Decimal?"); + assert_eq!(attr.as_deref(), numeric_native); + } + + #[rstest] + #[case::postgres(PrismaProvider::Postgres)] + #[case::mysql(PrismaProvider::MySql)] + #[case::sqlite(PrismaProvider::Sqlite)] + fn custom_and_enum_types_are_provider_invariant(#[case] provider: PrismaProvider) { + let (rendered, attr) = column_type_to_prisma( + &ColumnType::Complex(ComplexColumnType::Custom { + custom_type: "ltree".into(), + }), + false, + provider, + ); + assert_eq!(rendered, "Unsupported(\"ltree\")"); + assert_eq!(attr, None); + + let (rendered, attr) = column_type_to_prisma( + &ColumnType::Complex(ComplexColumnType::Enum { + name: "order_status".into(), + values: vespertide_core::schema::column::EnumValues::String(vec![ + "open".into(), + "closed".into(), + ]), + }), + false, + provider, + ); + assert_eq!(rendered, "OrderStatus"); + assert_eq!(attr, None); + } } From f68a8bdd88a8880489cb7e79e83df32cab636636 Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Fri, 10 Jul 2026 04:01:01 +0900 Subject: [PATCH 15/25] =?UTF-8?q?test:=20=EC=BB=A4=EB=B2=84=EB=A6=AC?= =?UTF-8?q?=EC=A7=80=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/vespertide-cli/src/commands/export.rs | 23 +++++++ crates/vespertide-exporter/src/prisma/mod.rs | 11 ++++ .../vespertide-exporter/src/prisma/render.rs | 66 +++++++++++++++++-- .../vespertide-exporter/src/prisma/types.rs | 6 ++ 4 files changed, 101 insertions(+), 5 deletions(-) diff --git a/crates/vespertide-cli/src/commands/export.rs b/crates/vespertide-cli/src/commands/export.rs index 823f5d38..7fd01fcc 100644 --- a/crates/vespertide-cli/src/commands/export.rs +++ b/crates/vespertide-cli/src/commands/export.rs @@ -859,6 +859,29 @@ mod tests { assert_eq!(out_py, Path::new("src/models/users.py")); } + #[test] + fn build_output_path_prisma_uses_prisma_extension() { + use std::path::Path; + let root = Path::new("src/models"); + let out = build_output_path(root, Path::new("user.json"), Orm::Prisma); + assert_eq!(out, Path::new("src/models/user.prisma")); + } + + #[tokio::test] + #[serial] + async fn clean_export_dir_removes_stale_prisma_files() { + let tmp = tempdir().unwrap(); + let root = tmp.path().join("out"); + std_fs::create_dir_all(&root).unwrap(); + std_fs::write(root.join("schema.prisma"), "stale").unwrap(); + std_fs::write(root.join("keep.txt"), "keep").unwrap(); + + clean_export_dir(&root, Orm::Prisma).await.unwrap(); + + assert!(!root.join("schema.prisma").exists()); + assert!(root.join("keep.txt").exists()); + } + #[test] fn build_output_path_handles_special_path_components() { use std::path::Path; diff --git a/crates/vespertide-exporter/src/prisma/mod.rs b/crates/vespertide-exporter/src/prisma/mod.rs index 50e9a6bc..0dad1ba4 100644 --- a/crates/vespertide-exporter/src/prisma/mod.rs +++ b/crates/vespertide-exporter/src/prisma/mod.rs @@ -201,6 +201,17 @@ mod tests { assert!(schema.contains("provider = \"postgresql\"")); } + #[test] + fn render_schema_emits_shared_enum_block_once() { + let config = PrismaConfig::default(); + let t1 = crate::tests::fixtures::enum_shared(); + let mut t2 = crate::tests::fixtures::enum_shared(); + t2.name = "archived_documents".into(); + let schema = PrismaExporterWithConfig::new(&config).render_schema(&[t1, t2]); + + assert_eq!(schema.matches("enum DocStatus {").count(), 1); + } + #[test] fn render_schema_emits_relation_mode_when_configured() { // `PrismaConfig` is `#[non_exhaustive]` in a different crate, so it can't be diff --git a/crates/vespertide-exporter/src/prisma/render.rs b/crates/vespertide-exporter/src/prisma/render.rs index 9291a49c..7aaba351 100644 --- a/crates/vespertide-exporter/src/prisma/render.rs +++ b/crates/vespertide-exporter/src/prisma/render.rs @@ -351,10 +351,11 @@ pub(super) fn render_model( for c in &table.constraints { if let TableConstraint::Index { name, columns } = c { let cols = columns.join(", "); - if let Some(n) = name { - lines.push(format!(" @@index([{cols}], map: \"{n}\")")); - } else { - lines.push(format!(" @@index([{cols}])")); + // `match` instead of `if let Some`: LLVM coverage attributes match + // arms reliably where this if/else was misattributed as uncovered. + match name { + Some(n) => lines.push(format!(" @@index([{cols}], map: \"{n}\")")), + None => lines.push(format!(" @@index([{cols}])")), } } } @@ -467,10 +468,65 @@ fn infer_relation_field_name(fk_col: &str) -> String { #[cfg(test)] mod tests { use rstest::rstest; - use vespertide_core::schema::column::SimpleColumnType; + use vespertide_core::schema::column::{NumValue, SimpleColumnType}; use super::*; + #[rstest] + #[case::cascade(ReferenceAction::Cascade, "Cascade")] + #[case::restrict(ReferenceAction::Restrict, "Restrict")] + #[case::set_null(ReferenceAction::SetNull, "SetNull")] + #[case::set_default(ReferenceAction::SetDefault, "SetDefault")] + #[case::no_action(ReferenceAction::NoAction, "NoAction")] + fn reference_actions_map_to_prisma(#[case] action: ReferenceAction, #[case] expected: &str) { + assert_eq!(reference_action_to_prisma(&action), expected); + } + + #[test] + fn integer_enum_default_resolves_numeric_value_to_variant_name() { + let ty = ColumnType::Complex(ComplexColumnType::Enum { + name: "priority".into(), + values: EnumValues::Integer(vec![ + NumValue { + name: "low".into(), + value: 100, + }, + NumValue { + name: "high".into(), + value: 200, + }, + ]), + }); + assert_eq!( + prisma_default_attr("100", &ty, PrismaProvider::Postgres), + "@default(LOW)" + ); + } + + #[test] + fn fk_on_update_action_is_rendered() { + let mut table = crate::tests::fixtures::table_with_fk(); + for c in &mut table.constraints { + if let TableConstraint::ForeignKey { on_update, .. } = c { + *on_update = Some(ReferenceAction::Cascade); + } + } + let rendered = render_model( + &table, + std::slice::from_ref(&table), + PrismaProvider::default(), + ); + assert!(rendered.contains("onUpdate: Cascade")); + } + + #[test] + fn named_index_is_rendered_with_map() { + let table = crate::tests::fixtures::table_with_indexes(); + let rendered = render_model(&table, &[], PrismaProvider::default()); + assert!(rendered.contains("@@index([created_at], map: \"idx_articles_created_at\")")); + assert!(rendered.contains("@@index([title])")); + } + #[rstest] #[case::postgres(PrismaProvider::Postgres, "@default(uuid())")] #[case::mysql(PrismaProvider::MySql, "@default(dbgenerated(\"(uuid())\"))")] diff --git a/crates/vespertide-exporter/src/prisma/types.rs b/crates/vespertide-exporter/src/prisma/types.rs index d368dfe5..d0dca863 100644 --- a/crates/vespertide-exporter/src/prisma/types.rs +++ b/crates/vespertide-exporter/src/prisma/types.rs @@ -52,6 +52,9 @@ pub(super) fn column_type_to_prisma( ("String", None, Some("@db.Text")) } // Unknown/future simple types fall back to a plain String column. + // `SimpleColumnType` is #[non_exhaustive]; every current variant is + // matched above, so this arm is currently unreachable. + #[cfg(not(tarpaulin_include))] _ => ("String", None, None), }; let native = match provider { @@ -82,6 +85,9 @@ pub(super) fn column_type_to_prisma( (format!("{pascal}{q}"), None) } // Unknown/future complex types fall back to a plain String column. + // `ComplexColumnType` is #[non_exhaustive]; every current variant is + // matched above, so this arm is currently unreachable. + #[cfg(not(tarpaulin_include))] _ => (format!("String{q}"), None), }, } From 911c22f5addf0e94ebf4c9bd8b613d032fc8b96e Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:07:33 +0900 Subject: [PATCH 16/25] =?UTF-8?q?chore:=20RustSec=20=EA=B6=8C=EA=B3=A0=20?= =?UTF-8?q?=EB=8C=80=EC=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f9c00260..866612ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -93,9 +93,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "ar_archive_writer" @@ -462,9 +462,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] From d4ea60b5e04d8634defec1308883aaaa5a2834f0 Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:07:33 +0900 Subject: [PATCH 17/25] =?UTF-8?q?test:=20ERD=20=EC=BB=A4=EB=B2=84=EB=A6=AC?= =?UTF-8?q?=EC=A7=80=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/commands/erd/tests/mod.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/vespertide-cli/src/commands/erd/tests/mod.rs b/crates/vespertide-cli/src/commands/erd/tests/mod.rs index 2d39a862..108d16f0 100644 --- a/crates/vespertide-cli/src/commands/erd/tests/mod.rs +++ b/crates/vespertide-cli/src/commands/erd/tests/mod.rs @@ -664,6 +664,24 @@ fn malformed_inline_fk_reference_is_ignored() { assert!(collect_foreign_key_relations(&[users, bad]).is_empty()); } +#[test] +fn table_level_fk_to_missing_table_is_ignored() { + let mut orphan = table( + "orphan", + vec![primary_key("id", integer()), column("ghost_id", integer())], + ); + orphan.constraints.push(TableConstraint::ForeignKey { + name: Some("fk_orphan_ghost".into()), + columns: vec!["ghost_id".into()], + ref_table: "ghost".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + orphan_strategy: Default::default(), + }); + assert!(collect_foreign_key_relations(&[orphan]).is_empty()); +} + #[test] fn inline_unique_group_variants_drive_one_to_one_detection() { let users = normalize(&table("users", vec![primary_key("id", integer())])); From 12d239ccbeb32f2b8202591bf14f4abea8086a2a Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:42:39 +0900 Subject: [PATCH 18/25] =?UTF-8?q?style(cli):=20export=20=EC=A3=BC=EC=84=9D?= =?UTF-8?q?=EC=9D=98=20=EA=B9=A8=EC=A7=84=20=ED=99=94=EC=82=B4=ED=91=9C=20?= =?UTF-8?q?=EB=AC=B8=EC=9E=90=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/vespertide-cli/src/commands/export.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/vespertide-cli/src/commands/export.rs b/crates/vespertide-cli/src/commands/export.rs index 7fd01fcc..39946e12 100644 --- a/crates/vespertide-cli/src/commands/export.rs +++ b/crates/vespertide-cli/src/commands/export.rs @@ -201,7 +201,7 @@ fn render_export_entity( /// Derive `crate::` prefix from the export directory path. /// -/// For example: `src/models` ??`crate::models`, `src/db/entities` ??`crate::db::entities`. +/// For example: `src/models` → `crate::models`, `src/db/entities` → `crate::db::entities`. /// If the path doesn't start with `src/`, returns empty string (fallback to `super::` behavior). fn export_dir_to_crate_prefix(export_dir: &Path) -> String { let normalized = export_dir.to_string_lossy().replace('\\', "/"); @@ -217,8 +217,8 @@ fn export_dir_to_crate_prefix(export_dir: &Path) -> String { /// Convert a relative model file path to Rust module path segments. /// -/// For example: `admin/admin.json` ??`["admin", "admin"]` -/// `estimate/estimate_checker.vespertide.json` ??`["estimate", "estimate_checker"]` +/// For example: `admin/admin.json` → `["admin", "admin"]` +/// `estimate/estimate_checker.vespertide.json` → `["estimate", "estimate_checker"]` fn rel_path_to_module_segments(rel_path: &Path) -> Vec { let mut segments = Vec::new(); @@ -1090,7 +1090,7 @@ mod tests { use std::path::Path; let root = Path::new("src/models"); - // snake_case model ??PascalCase .java + // snake_case model → PascalCase .java let rel_path = Path::new("order_item.json"); let out = build_output_path(root, rel_path, Orm::Jpa); assert_eq!(out, Path::new("src/models/OrderItem.java")); From 925dd80e541ecc8e95ee7db336677b3f5db2de87 Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:46:41 +0900 Subject: [PATCH 19/25] =?UTF-8?q?refactor(config):=20Prisma=20relationMode?= =?UTF-8?q?=20=EC=98=B5=EC=85=98=20=EB=B0=8F=20PrismaConfig=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../changepack_log_vKVGmtRxkfW3nE7rIhMcz.json | 2 +- crates/vespertide-cli/src/commands/export.rs | 8 +- crates/vespertide-config/src/config.rs | 75 ------------- crates/vespertide-config/src/lib.rs | 6 +- crates/vespertide-config/src/relation_mode.rs | 60 ---------- crates/vespertide-exporter/src/prisma/mod.rs | 105 +++++------------- schemas/config.schema.json | 37 ------ 7 files changed, 33 insertions(+), 260 deletions(-) delete mode 100644 crates/vespertide-config/src/relation_mode.rs diff --git a/.changepacks/changepack_log_vKVGmtRxkfW3nE7rIhMcz.json b/.changepacks/changepack_log_vKVGmtRxkfW3nE7rIhMcz.json index 67509a15..0eea7ab9 100644 --- a/.changepacks/changepack_log_vKVGmtRxkfW3nE7rIhMcz.json +++ b/.changepacks/changepack_log_vKVGmtRxkfW3nE7rIhMcz.json @@ -1 +1 @@ -{"changes":{"crates/vespertide-exporter/Cargo.toml":"Minor","crates/vespertide-config/Cargo.toml":"Minor","crates/vespertide-cli/Cargo.toml":"Minor"},"note":"Prisma ORM exporter 추가","date":"2026-07-09T14:25:30.6412773Z"} \ No newline at end of file +{"changes":{"crates/vespertide-exporter/Cargo.toml":"Minor","crates/vespertide-cli/Cargo.toml":"Minor"},"note":"Prisma ORM exporter 추가","date":"2026-07-09T14:25:30.6412773Z"} \ No newline at end of file diff --git a/crates/vespertide-cli/src/commands/export.rs b/crates/vespertide-cli/src/commands/export.rs index 39946e12..7a3c4dc3 100644 --- a/crates/vespertide-cli/src/commands/export.rs +++ b/crates/vespertide-cli/src/commands/export.rs @@ -10,7 +10,7 @@ use vespertide_config::VespertideConfig; use vespertide_core::TableDef; use vespertide_exporter::{ Orm, - prisma::{PrismaExporterWithConfig, PrismaProvider}, + prisma::{self, PrismaProvider}, render_entity_with_schema, seaorm::SeaOrmExporterWithConfig, }; @@ -76,7 +76,7 @@ pub async fn cmd_export( // Prisma uses a single-file output strategy if matches!(orm, OrmArg::Prisma) { let provider = prisma_provider_for_backend(backend); - return cmd_export_prisma(config, normalized_models, target_root, provider).await; + return cmd_export_prisma(normalized_models, target_root, provider).await; } // Clean the export directory before regenerating @@ -457,14 +457,12 @@ async fn ensure_mod_chain(root: &Path, rel_path: &Path) -> Result<()> { } async fn cmd_export_prisma( - config: VespertideConfig, normalized_models: Vec<(TableDef, PathBuf)>, target_root: PathBuf, provider: PrismaProvider, ) -> Result<()> { let all_tables: Vec = normalized_models.iter().map(|(t, _)| t.clone()).collect(); - let content = PrismaExporterWithConfig::with_provider(config.prisma(), provider) - .render_schema(&all_tables); + let content = prisma::render_schema(&all_tables, provider); clean_export_dir(&target_root, Orm::Prisma).await?; diff --git a/crates/vespertide-config/src/config.rs b/crates/vespertide-config/src/config.rs index 33a1cd19..126c28c3 100644 --- a/crates/vespertide-config/src/config.rs +++ b/crates/vespertide-config/src/config.rs @@ -4,7 +4,6 @@ use serde::{Deserialize, Serialize}; use crate::file_format::FileFormat; use crate::name_case::NameCase; -use crate::relation_mode::RelationMode; /// Default migration filename pattern: zero-padded version + sanitized comment. pub fn default_migration_filename_pattern() -> String { @@ -79,32 +78,6 @@ impl SeaOrmConfig { } } -/// Prisma-specific export configuration. -/// -/// The `datasource` provider is always PostgreSQL (the exporter only emits -/// PostgreSQL-native `@db.*` types), and the generated Prisma Client's output -/// path is left to Prisma's own default — neither is configurable here, since -/// vespertide only manages the schema/model definition, not Prisma Client -/// codegen. See `RelationMode` for the one setting that is configurable. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -#[serde(rename_all = "camelCase")] -#[non_exhaustive] -pub struct PrismaConfig { - /// Override for the `datasource` `relationMode` setting. `None` (default) - /// omits the line and lets Prisma use its own default (`foreignKeys`). - /// See [`RelationMode`] for what each variant means. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub relation_mode: Option, -} - -impl PrismaConfig { - /// The configured `relationMode` override, if any. - pub fn relation_mode(&self) -> Option { - self.relation_mode - } -} - /// Top-level vespertide configuration. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] @@ -127,9 +100,6 @@ pub struct VespertideConfig { /// SeaORM-specific export configuration. #[serde(default)] pub seaorm: SeaOrmConfig, - /// Prisma-specific export configuration. - #[serde(default)] - pub prisma: PrismaConfig, /// Prefix to add to all table names (including migration version table). /// Default: "" (no prefix) #[serde(default)] @@ -168,7 +138,6 @@ impl Default for VespertideConfig { migration_filename_pattern: default_migration_filename_pattern(), model_export_dir: default_model_export_dir(), seaorm: SeaOrmConfig::default(), - prisma: PrismaConfig::default(), prefix: String::new(), lock_timeout_ms: None, statement_timeout_ms: None, @@ -222,11 +191,6 @@ impl VespertideConfig { &self.seaorm } - /// Prisma-specific export configuration. - pub fn prisma(&self) -> &PrismaConfig { - &self.prisma - } - /// Prefix to add to all table names. pub fn prefix(&self) -> &str { &self.prefix @@ -327,43 +291,4 @@ mod tests { assert_eq!(config.prefix(), ""); assert_eq!(config.apply_prefix("users"), "users"); } - - #[test] - fn prisma_config_default_has_no_relation_mode() { - let config = PrismaConfig::default(); - assert_eq!(config.relation_mode(), None); - } - - #[test] - fn prisma_config_deserializes_from_empty_object() { - let config: PrismaConfig = serde_json::from_str("{}").unwrap(); - assert_eq!(config.relation_mode(), None); - } - - #[test] - fn prisma_config_relation_mode_roundtrips() { - let config = PrismaConfig { - relation_mode: Some(RelationMode::Prisma), - ..Default::default() - }; - let json = serde_json::to_string(&config).unwrap(); - assert_eq!(json, "{\"relationMode\":\"prisma\"}"); - let back: PrismaConfig = serde_json::from_str(&json).unwrap(); - assert_eq!(back.relation_mode(), Some(RelationMode::Prisma)); - } - - #[test] - fn vespertide_config_prisma_accessor_returns_deserialized_value() { - let config: VespertideConfig = serde_json::from_str( - r#"{ - "modelsDir": "models", - "migrationsDir": "migrations", - "tableNamingCase": "snake", - "columnNamingCase": "snake", - "prisma": {"relationMode": "prisma"} - }"#, - ) - .unwrap(); - assert_eq!(config.prisma().relation_mode(), Some(RelationMode::Prisma)); - } } diff --git a/crates/vespertide-config/src/lib.rs b/crates/vespertide-config/src/lib.rs index 7d0b7048..e8f6640d 100644 --- a/crates/vespertide-config/src/lib.rs +++ b/crates/vespertide-config/src/lib.rs @@ -6,14 +6,10 @@ pub mod config; pub mod file_format; pub mod name_case; -pub mod relation_mode; -pub use config::{ - PrismaConfig, SeaOrmConfig, VespertideConfig, default_migration_filename_pattern, -}; +pub use config::{SeaOrmConfig, VespertideConfig, default_migration_filename_pattern}; pub use file_format::FileFormat; pub use name_case::NameCase; -pub use relation_mode::RelationMode; #[cfg(test)] mod tests { diff --git a/crates/vespertide-config/src/relation_mode.rs b/crates/vespertide-config/src/relation_mode.rs deleted file mode 100644 index 46e83c7f..00000000 --- a/crates/vespertide-config/src/relation_mode.rs +++ /dev/null @@ -1,60 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// Prisma `relationMode` datasource setting. -/// -/// Controls whether Prisma relies on real database-level foreign key -/// constraints or emulates referential integrity in Prisma Client. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -#[serde(rename_all = "camelCase")] -pub enum RelationMode { - /// Use real database-level foreign key constraints. This is Prisma's - /// default and is correct for any backend that supports FKs (e.g. - /// PostgreSQL, MySQL, SQLite). - ForeignKeys, - /// Emulate referential integrity in Prisma Client instead of relying on - /// database-level foreign keys. Needed for datastores that don't support - /// FK constraints (e.g. PlanetScale, MongoDB). - Prisma, -} - -impl RelationMode { - /// The literal string Prisma expects for `relationMode = "..."`. - pub fn as_str(self) -> &'static str { - match self { - RelationMode::ForeignKeys => "foreignKeys", - RelationMode::Prisma => "prisma", - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn as_str_matches_prisma_literals() { - assert_eq!(RelationMode::ForeignKeys.as_str(), "foreignKeys"); - assert_eq!(RelationMode::Prisma.as_str(), "prisma"); - } - - #[test] - fn serde_round_trip() { - assert_eq!( - serde_json::to_string(&RelationMode::ForeignKeys).unwrap(), - "\"foreignKeys\"" - ); - assert_eq!( - serde_json::to_string(&RelationMode::Prisma).unwrap(), - "\"prisma\"" - ); - assert_eq!( - serde_json::from_str::("\"foreignKeys\"").unwrap(), - RelationMode::ForeignKeys - ); - assert_eq!( - serde_json::from_str::("\"prisma\"").unwrap(), - RelationMode::Prisma - ); - } -} diff --git a/crates/vespertide-exporter/src/prisma/mod.rs b/crates/vespertide-exporter/src/prisma/mod.rs index 0dad1ba4..1ad01ac2 100644 --- a/crates/vespertide-exporter/src/prisma/mod.rs +++ b/crates/vespertide-exporter/src/prisma/mod.rs @@ -5,7 +5,6 @@ mod types; use std::collections::HashSet; use crate::orm::OrmExporter; -use vespertide_config::PrismaConfig; use vespertide_core::TableDef; use vespertide_core::schema::column::{ColumnType, ComplexColumnType, EnumValues}; @@ -50,62 +49,39 @@ impl OrmExporter for PrismaExporter { } } -/// Prisma exporter with configuration support. +/// Render a complete `schema.prisma` file for all tables. /// -/// Assembles a complete `schema.prisma` file from a full table list. -pub struct PrismaExporterWithConfig<'a> { - pub config: &'a PrismaConfig, - pub provider: PrismaProvider, -} - -impl<'a> PrismaExporterWithConfig<'a> { - pub fn new(config: &'a PrismaConfig) -> Self { - Self::with_provider(config, PrismaProvider::default()) - } - - pub fn with_provider(config: &'a PrismaConfig, provider: PrismaProvider) -> Self { - Self { config, provider } - } - - /// Render a complete `schema.prisma` file for all tables. - /// - /// Output order: datasource → generator → (globally deduped) enum blocks → model blocks. - pub fn render_schema(&self, tables: &[TableDef]) -> String { - let mut seen_enums: HashSet = HashSet::new(); - let mut enum_blocks: Vec = Vec::new(); - for table in tables { - for (name, values) in collect_table_enums(table) { - if seen_enums.insert(name.to_string()) { - enum_blocks.push(enums::render_enum(name, values)); - } +/// Output order: datasource → generator → (globally deduped) enum blocks → model blocks. +pub fn render_schema(tables: &[TableDef], provider: PrismaProvider) -> String { + let mut seen_enums: HashSet = HashSet::new(); + let mut enum_blocks: Vec = Vec::new(); + for table in tables { + for (name, values) in collect_table_enums(table) { + if seen_enums.insert(name.to_string()) { + enum_blocks.push(enums::render_enum(name, values)); } } + } - let mut parts: Vec = Vec::new(); - - // No `url` here: current Prisma moved connection config out of the - // schema file (`prisma.config.ts`), which also matches vespertide's - // models-only scope. - let mut datasource = vec![ - "datasource db {".to_string(), - format!(" provider = \"{}\"", self.provider.as_datasource_str()), - ]; - if let Some(rm) = self.config.relation_mode() { - datasource.push(format!(" relationMode = \"{}\"", rm.as_str())); - } - datasource.push("}".to_string()); - parts.push(datasource.join("\n")); + let mut parts: Vec = Vec::new(); - parts.push("generator client {\n provider = \"prisma-client-js\"\n}".to_string()); + // No `url` here: current Prisma moved connection config out of the + // schema file (`prisma.config.ts`), which also matches vespertide's + // models-only scope. + parts.push(format!( + "datasource db {{\n provider = \"{}\"\n}}", + provider.as_datasource_str() + )); - parts.extend(enum_blocks); + parts.push("generator client {\n provider = \"prisma-client-js\"\n}".to_string()); - for table in tables { - parts.push(render::render_model(table, tables, self.provider)); - } + parts.extend(enum_blocks); - parts.join("\n\n") + "\n" + for table in tables { + parts.push(render::render_model(table, tables, provider)); } + + parts.join("\n\n") + "\n" } fn collect_table_enums(table: &TableDef) -> Vec<(&str, &EnumValues)> { @@ -133,7 +109,7 @@ pub fn render_entity(table: &TableDef) -> String { /// Render enum blocks + model block with full schema context (includes back-relations). /// /// Uses the default provider (PostgreSQL) — provider-specific output goes -/// through [`PrismaExporterWithConfig::with_provider`]. +/// through [`render_schema`]. pub fn render_entity_with_schema(table: &TableDef, schema: &[TableDef]) -> String { let mut parts: Vec = Vec::new(); for (name, values) in collect_table_enums(table) { @@ -150,7 +126,7 @@ pub fn render_entity_with_schema(table: &TableDef, schema: &[TableDef]) -> Strin /// Multi-table entry point: render every table (enum + model blocks) with full /// schema context and join them. Mirrors the other ORMs' `export` so the /// cross-ORM test harness can dispatch Prisma through a single call. The -/// `datasource`/`generator` wrapper lives in [`PrismaExporterWithConfig`]. +/// `datasource`/`generator` wrapper lives in [`render_schema`]. pub fn export(schema: &[TableDef]) -> Result { Ok(schema .iter() @@ -182,45 +158,20 @@ mod tests { #[case] provider: PrismaProvider, #[case] expected_line: &str, ) { - let config = PrismaConfig::default(); let tables = vec![basic_single_pk()]; - let schema = - PrismaExporterWithConfig::with_provider(&config, provider).render_schema(&tables); + let schema = render_schema(&tables, provider); assert!(schema.contains(expected_line)); assert!(!schema.contains("output")); - assert!(!schema.contains("relationMode")); - } - - #[test] - fn new_defaults_to_postgresql_provider() { - let config = PrismaConfig::default(); - let tables = vec![basic_single_pk()]; - let schema = PrismaExporterWithConfig::new(&config).render_schema(&tables); - - assert!(schema.contains("provider = \"postgresql\"")); } #[test] fn render_schema_emits_shared_enum_block_once() { - let config = PrismaConfig::default(); let t1 = crate::tests::fixtures::enum_shared(); let mut t2 = crate::tests::fixtures::enum_shared(); t2.name = "archived_documents".into(); - let schema = PrismaExporterWithConfig::new(&config).render_schema(&[t1, t2]); + let schema = render_schema(&[t1, t2], PrismaProvider::Postgres); assert_eq!(schema.matches("enum DocStatus {").count(), 1); } - - #[test] - fn render_schema_emits_relation_mode_when_configured() { - // `PrismaConfig` is `#[non_exhaustive]` in a different crate, so it can't be - // built with a struct literal here — go through its real construction path - // (JSON deserialization, same as loading `vespertide.json`) instead. - let config: PrismaConfig = serde_json::from_str(r#"{"relationMode":"prisma"}"#).unwrap(); - let tables = vec![basic_single_pk()]; - let schema = PrismaExporterWithConfig::new(&config).render_schema(&tables); - - assert!(schema.contains("relationMode = \"prisma\"")); - } } diff --git a/schemas/config.schema.json b/schemas/config.schema.json index 2f9b73ba..fa5f2852 100644 --- a/schemas/config.schema.json +++ b/schemas/config.schema.json @@ -44,11 +44,6 @@ "type": "string", "default": "" }, - "prisma": { - "description": "Prisma-specific export configuration.", - "$ref": "#/$defs/PrismaConfig", - "default": {} - }, "seaorm": { "description": "SeaORM-specific export configuration.", "$ref": "#/$defs/SeaOrmConfig", @@ -99,38 +94,6 @@ "pascal" ] }, - "PrismaConfig": { - "description": "Prisma-specific export configuration.\n\nThe `datasource` provider is always PostgreSQL (the exporter only emits\nPostgreSQL-native `@db.*` types), and the generated Prisma Client's output\npath is left to Prisma's own default — neither is configurable here, since\nvespertide only manages the schema/model definition, not Prisma Client\ncodegen. See `RelationMode` for the one setting that is configurable.", - "type": "object", - "properties": { - "relationMode": { - "description": "Override for the `datasource` `relationMode` setting. `None` (default)\nomits the line and lets Prisma use its own default (`foreignKeys`).\nSee [`RelationMode`] for what each variant means.", - "anyOf": [ - { - "$ref": "#/$defs/RelationMode" - }, - { - "type": "null" - } - ] - } - } - }, - "RelationMode": { - "description": "Prisma `relationMode` datasource setting.\n\nControls whether Prisma relies on real database-level foreign key\nconstraints or emulates referential integrity in Prisma Client.", - "oneOf": [ - { - "description": "Use real database-level foreign key constraints. This is Prisma's\ndefault and is correct for any backend that supports FKs (e.g.\nPostgreSQL, MySQL, SQLite).", - "type": "string", - "const": "foreignKeys" - }, - { - "description": "Emulate referential integrity in Prisma Client instead of relying on\ndatabase-level foreign keys. Needed for datastores that don't support\nFK constraints (e.g. PlanetScale, MongoDB).", - "type": "string", - "const": "prisma" - } - ] - }, "SeaOrmConfig": { "description": "SeaORM-specific export configuration.", "type": "object", From 7f257109209ec6382e873bafd9b350c0df3e2240 Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:49:40 +0900 Subject: [PATCH 20/25] =?UTF-8?q?refactor(exporter):=20PrismaProvider?= =?UTF-8?q?=EB=A5=BC=20DatabaseBackend=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 1 + crates/vespertide-cli/src/commands/export.rs | 22 +---- crates/vespertide-exporter/Cargo.toml | 1 + crates/vespertide-exporter/src/prisma/mod.rs | 49 ++++------ .../vespertide-exporter/src/prisma/render.rs | 23 ++--- .../vespertide-exporter/src/prisma/types.rs | 97 ++++++++++--------- 6 files changed, 88 insertions(+), 105 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 866612ce..3b48e19c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2915,6 +2915,7 @@ dependencies = [ "vespertide-config", "vespertide-core", "vespertide-naming", + "vespertide-query", ] [[package]] diff --git a/crates/vespertide-cli/src/commands/export.rs b/crates/vespertide-cli/src/commands/export.rs index 7a3c4dc3..579d00e3 100644 --- a/crates/vespertide-cli/src/commands/export.rs +++ b/crates/vespertide-cli/src/commands/export.rs @@ -9,10 +9,7 @@ use tokio::fs; use vespertide_config::VespertideConfig; use vespertide_core::TableDef; use vespertide_exporter::{ - Orm, - prisma::{self, PrismaProvider}, - render_entity_with_schema, - seaorm::SeaOrmExporterWithConfig, + Orm, prisma, render_entity_with_schema, seaorm::SeaOrmExporterWithConfig, }; use vespertide_query::DatabaseBackend; @@ -40,16 +37,6 @@ impl From for Orm { } } -/// A Prisma schema is provider-specific (`datasource` provider + `@db.*` -/// native attrs), so the export backend maps onto the Prisma provider. -fn prisma_provider_for_backend(backend: DatabaseBackend) -> PrismaProvider { - match backend { - DatabaseBackend::Postgres => PrismaProvider::Postgres, - DatabaseBackend::MySql => PrismaProvider::MySql, - DatabaseBackend::Sqlite => PrismaProvider::Sqlite, - } -} - pub async fn cmd_export( orm: OrmArg, export_dir: Option, @@ -75,8 +62,7 @@ pub async fn cmd_export( // Prisma uses a single-file output strategy if matches!(orm, OrmArg::Prisma) { - let provider = prisma_provider_for_backend(backend); - return cmd_export_prisma(normalized_models, target_root, provider).await; + return cmd_export_prisma(normalized_models, target_root, backend).await; } // Clean the export directory before regenerating @@ -459,10 +445,10 @@ async fn ensure_mod_chain(root: &Path, rel_path: &Path) -> Result<()> { async fn cmd_export_prisma( normalized_models: Vec<(TableDef, PathBuf)>, target_root: PathBuf, - provider: PrismaProvider, + backend: DatabaseBackend, ) -> Result<()> { let all_tables: Vec = normalized_models.iter().map(|(t, _)| t.clone()).collect(); - let content = prisma::render_schema(&all_tables, provider); + let content = prisma::render_schema(&all_tables, backend); clean_export_dir(&target_root, Orm::Prisma).await?; diff --git a/crates/vespertide-exporter/Cargo.toml b/crates/vespertide-exporter/Cargo.toml index 98addd72..d38d4b90 100644 --- a/crates/vespertide-exporter/Cargo.toml +++ b/crates/vespertide-exporter/Cargo.toml @@ -16,6 +16,7 @@ rayon = { workspace = true } vespertide-core = { workspace = true } vespertide-config = { workspace = true } vespertide-naming = { workspace = true } +vespertide-query = { workspace = true } thiserror = "2" [dev-dependencies] diff --git a/crates/vespertide-exporter/src/prisma/mod.rs b/crates/vespertide-exporter/src/prisma/mod.rs index 1ad01ac2..9ef691bf 100644 --- a/crates/vespertide-exporter/src/prisma/mod.rs +++ b/crates/vespertide-exporter/src/prisma/mod.rs @@ -7,31 +7,20 @@ use std::collections::HashSet; use crate::orm::OrmExporter; use vespertide_core::TableDef; use vespertide_core::schema::column::{ColumnType, ComplexColumnType, EnumValues}; +use vespertide_query::DatabaseBackend; pub struct PrismaExporter; -/// Target database provider for the generated `schema.prisma`. +/// The `provider = "..."` string used in the `datasource` block. /// /// A Prisma schema declares exactly one datasource provider, and native /// `@db.*` type attributes are validated against that provider's connector — -/// so the same tables render differently per backend. Mirrors the backends -/// supported by `vespertide-query`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum PrismaProvider { - #[default] - Postgres, - MySql, - Sqlite, -} - -impl PrismaProvider { - /// The `provider = "..."` string used in the `datasource` block. - pub fn as_datasource_str(self) -> &'static str { - match self { - PrismaProvider::Postgres => "postgresql", - PrismaProvider::MySql => "mysql", - PrismaProvider::Sqlite => "sqlite", - } +/// so the same tables render differently per backend. +fn datasource_provider_str(backend: DatabaseBackend) -> &'static str { + match backend { + DatabaseBackend::Postgres => "postgresql", + DatabaseBackend::MySql => "mysql", + DatabaseBackend::Sqlite => "sqlite", } } @@ -52,7 +41,7 @@ impl OrmExporter for PrismaExporter { /// Render a complete `schema.prisma` file for all tables. /// /// Output order: datasource → generator → (globally deduped) enum blocks → model blocks. -pub fn render_schema(tables: &[TableDef], provider: PrismaProvider) -> String { +pub fn render_schema(tables: &[TableDef], backend: DatabaseBackend) -> String { let mut seen_enums: HashSet = HashSet::new(); let mut enum_blocks: Vec = Vec::new(); for table in tables { @@ -70,7 +59,7 @@ pub fn render_schema(tables: &[TableDef], provider: PrismaProvider) -> String { // models-only scope. parts.push(format!( "datasource db {{\n provider = \"{}\"\n}}", - provider.as_datasource_str() + datasource_provider_str(backend) )); parts.push("generator client {\n provider = \"prisma-client-js\"\n}".to_string()); @@ -78,7 +67,7 @@ pub fn render_schema(tables: &[TableDef], provider: PrismaProvider) -> String { parts.extend(enum_blocks); for table in tables { - parts.push(render::render_model(table, tables, provider)); + parts.push(render::render_model(table, tables, backend)); } parts.join("\n\n") + "\n" @@ -108,7 +97,7 @@ pub fn render_entity(table: &TableDef) -> String { /// Render enum blocks + model block with full schema context (includes back-relations). /// -/// Uses the default provider (PostgreSQL) — provider-specific output goes +/// Uses the default backend (PostgreSQL) — backend-specific output goes /// through [`render_schema`]. pub fn render_entity_with_schema(table: &TableDef, schema: &[TableDef]) -> String { let mut parts: Vec = Vec::new(); @@ -118,7 +107,7 @@ pub fn render_entity_with_schema(table: &TableDef, schema: &[TableDef]) -> Strin parts.push(render::render_model( table, schema, - PrismaProvider::default(), + DatabaseBackend::Postgres, )); parts.join("\n\n") } @@ -151,15 +140,15 @@ mod tests { use crate::tests::fixtures::basic_single_pk; #[rstest] - #[case::postgres(PrismaProvider::Postgres, "provider = \"postgresql\"")] - #[case::mysql(PrismaProvider::MySql, "provider = \"mysql\"")] - #[case::sqlite(PrismaProvider::Sqlite, "provider = \"sqlite\"")] + #[case::postgres(DatabaseBackend::Postgres, "provider = \"postgresql\"")] + #[case::mysql(DatabaseBackend::MySql, "provider = \"mysql\"")] + #[case::sqlite(DatabaseBackend::Sqlite, "provider = \"sqlite\"")] fn render_schema_emits_configured_provider( - #[case] provider: PrismaProvider, + #[case] backend: DatabaseBackend, #[case] expected_line: &str, ) { let tables = vec![basic_single_pk()]; - let schema = render_schema(&tables, provider); + let schema = render_schema(&tables, backend); assert!(schema.contains(expected_line)); assert!(!schema.contains("output")); @@ -170,7 +159,7 @@ mod tests { let t1 = crate::tests::fixtures::enum_shared(); let mut t2 = crate::tests::fixtures::enum_shared(); t2.name = "archived_documents".into(); - let schema = render_schema(&[t1, t2], PrismaProvider::Postgres); + let schema = render_schema(&[t1, t2], DatabaseBackend::Postgres); assert_eq!(schema.matches("enum DocStatus {").count(), 1); } diff --git a/crates/vespertide-exporter/src/prisma/render.rs b/crates/vespertide-exporter/src/prisma/render.rs index 7aaba351..07ffedcc 100644 --- a/crates/vespertide-exporter/src/prisma/render.rs +++ b/crates/vespertide-exporter/src/prisma/render.rs @@ -6,7 +6,8 @@ use vespertide_core::schema::constraint::TableConstraint; use vespertide_core::schema::names::ColumnName; use vespertide_core::schema::reference::ReferenceAction; -use super::PrismaProvider; +use vespertide_query::DatabaseBackend; + use super::enums::{to_pascal_case, to_screaming_snake}; use super::types::column_type_to_prisma; @@ -131,7 +132,7 @@ pub(super) fn collect_back_relations(target_table: &str, schema: &[TableDef]) -> pub(super) fn render_model( table: &TableDef, schema: &[TableDef], - provider: PrismaProvider, + provider: DatabaseBackend, ) -> String { let mut lines: Vec = Vec::new(); @@ -371,7 +372,7 @@ pub(super) fn render_model( fn prisma_default_attr( default_sql: &str, col_type: &ColumnType, - provider: PrismaProvider, + provider: DatabaseBackend, ) -> String { // Integer-backed enum: resolve to a variant identifier (SCREAMING_SNAKE), never a bare int. if let ColumnType::Complex(ComplexColumnType::Enum { @@ -413,7 +414,7 @@ fn prisma_default_attr( // On MySQL the uuid column is BINARY(16) and Prisma's uuid() only // applies to String fields — mirror the DDL's DB-side default instead. return match provider { - PrismaProvider::MySql => "@default(dbgenerated(\"(uuid())\"))".into(), + DatabaseBackend::MySql => "@default(dbgenerated(\"(uuid())\"))".into(), _ => "@default(uuid())".into(), }; } @@ -498,7 +499,7 @@ mod tests { ]), }); assert_eq!( - prisma_default_attr("100", &ty, PrismaProvider::Postgres), + prisma_default_attr("100", &ty, DatabaseBackend::Postgres), "@default(LOW)" ); } @@ -514,7 +515,7 @@ mod tests { let rendered = render_model( &table, std::slice::from_ref(&table), - PrismaProvider::default(), + DatabaseBackend::Postgres, ); assert!(rendered.contains("onUpdate: Cascade")); } @@ -522,17 +523,17 @@ mod tests { #[test] fn named_index_is_rendered_with_map() { let table = crate::tests::fixtures::table_with_indexes(); - let rendered = render_model(&table, &[], PrismaProvider::default()); + let rendered = render_model(&table, &[], DatabaseBackend::Postgres); assert!(rendered.contains("@@index([created_at], map: \"idx_articles_created_at\")")); assert!(rendered.contains("@@index([title])")); } #[rstest] - #[case::postgres(PrismaProvider::Postgres, "@default(uuid())")] - #[case::mysql(PrismaProvider::MySql, "@default(dbgenerated(\"(uuid())\"))")] - #[case::sqlite(PrismaProvider::Sqlite, "@default(uuid())")] + #[case::postgres(DatabaseBackend::Postgres, "@default(uuid())")] + #[case::mysql(DatabaseBackend::MySql, "@default(dbgenerated(\"(uuid())\"))")] + #[case::sqlite(DatabaseBackend::Sqlite, "@default(uuid())")] fn uuid_generating_default_follows_provider( - #[case] provider: PrismaProvider, + #[case] provider: DatabaseBackend, #[case] expected: &str, ) { let ty = ColumnType::Simple(SimpleColumnType::Uuid); diff --git a/crates/vespertide-exporter/src/prisma/types.rs b/crates/vespertide-exporter/src/prisma/types.rs index d0dca863..a5dc4602 100644 --- a/crates/vespertide-exporter/src/prisma/types.rs +++ b/crates/vespertide-exporter/src/prisma/types.rs @@ -1,12 +1,13 @@ use vespertide_core::schema::column::{ColumnType, ComplexColumnType, SimpleColumnType}; -use super::PrismaProvider; +use vespertide_query::DatabaseBackend; + use super::enums::to_pascal_case; pub(super) fn column_type_to_prisma( ty: &ColumnType, nullable: bool, - provider: PrismaProvider, + provider: DatabaseBackend, ) -> (String, Option) { let q = if nullable { "?" } else { "" }; @@ -15,9 +16,9 @@ pub(super) fn column_type_to_prisma( // uuid mapping), so the Prisma scalar itself differs per provider. ColumnType::Simple(SimpleColumnType::Uuid) => { let (base, native) = match provider { - PrismaProvider::Postgres => ("String", Some("@db.Uuid")), - PrismaProvider::MySql => ("Bytes", Some("@db.Binary(16)")), - PrismaProvider::Sqlite => ("String", None), + DatabaseBackend::Postgres => ("String", Some("@db.Uuid")), + DatabaseBackend::MySql => ("Bytes", Some("@db.Binary(16)")), + DatabaseBackend::Sqlite => ("String", None), }; (format!("{base}{q}"), native.map(str::to_string)) } @@ -58,9 +59,9 @@ pub(super) fn column_type_to_prisma( _ => ("String", None, None), }; let native = match provider { - PrismaProvider::Postgres => pg_native, - PrismaProvider::MySql => mysql_native, - PrismaProvider::Sqlite => None, + DatabaseBackend::Postgres => pg_native, + DatabaseBackend::MySql => mysql_native, + DatabaseBackend::Sqlite => None, }; (format!("{base}{q}"), native.map(str::to_string)) } @@ -95,10 +96,10 @@ pub(super) fn column_type_to_prisma( /// VarChar/Char/Decimal natives are spelled identically on PostgreSQL and /// MySQL; SQLite supports no native attrs at all. -fn sized_native(provider: PrismaProvider, attr: String) -> Option { +fn sized_native(provider: DatabaseBackend, attr: String) -> Option { match provider { - PrismaProvider::Postgres | PrismaProvider::MySql => Some(attr), - PrismaProvider::Sqlite => None, + DatabaseBackend::Postgres | DatabaseBackend::MySql => Some(attr), + DatabaseBackend::Sqlite => None, } } @@ -110,11 +111,11 @@ mod tests { use super::*; #[rstest] - #[case::postgres(PrismaProvider::Postgres, "String", Some("@db.Uuid"))] - #[case::mysql(PrismaProvider::MySql, "Bytes", Some("@db.Binary(16)"))] - #[case::sqlite(PrismaProvider::Sqlite, "String", None)] + #[case::postgres(DatabaseBackend::Postgres, "String", Some("@db.Uuid"))] + #[case::mysql(DatabaseBackend::MySql, "Bytes", Some("@db.Binary(16)"))] + #[case::sqlite(DatabaseBackend::Sqlite, "String", None)] fn uuid_scalar_and_native_follow_provider( - #[case] provider: PrismaProvider, + #[case] provider: DatabaseBackend, #[case] scalar: &str, #[case] native: Option<&str>, ) { @@ -125,11 +126,11 @@ mod tests { } #[rstest] - #[case::postgres(PrismaProvider::Postgres, Some("@db.Timestamptz"))] - #[case::mysql(PrismaProvider::MySql, Some("@db.Timestamp"))] - #[case::sqlite(PrismaProvider::Sqlite, None)] + #[case::postgres(DatabaseBackend::Postgres, Some("@db.Timestamptz"))] + #[case::mysql(DatabaseBackend::MySql, Some("@db.Timestamp"))] + #[case::sqlite(DatabaseBackend::Sqlite, None)] fn timestamptz_native_follows_provider( - #[case] provider: PrismaProvider, + #[case] provider: DatabaseBackend, #[case] native: Option<&str>, ) { let ty = ColumnType::Simple(SimpleColumnType::Timestamptz); @@ -139,11 +140,11 @@ mod tests { } #[rstest] - #[case::postgres(PrismaProvider::Postgres, None)] - #[case::mysql(PrismaProvider::MySql, Some("@db.Text"))] - #[case::sqlite(PrismaProvider::Sqlite, None)] + #[case::postgres(DatabaseBackend::Postgres, None)] + #[case::mysql(DatabaseBackend::MySql, Some("@db.Text"))] + #[case::sqlite(DatabaseBackend::Sqlite, None)] fn interval_native_follows_provider( - #[case] provider: PrismaProvider, + #[case] provider: DatabaseBackend, #[case] native: Option<&str>, ) { let ty = ColumnType::Simple(SimpleColumnType::Interval); @@ -153,11 +154,11 @@ mod tests { } #[rstest] - #[case::postgres(PrismaProvider::Postgres, Some("@db.Inet"))] - #[case::mysql(PrismaProvider::MySql, Some("@db.Text"))] - #[case::sqlite(PrismaProvider::Sqlite, None)] + #[case::postgres(DatabaseBackend::Postgres, Some("@db.Inet"))] + #[case::mysql(DatabaseBackend::MySql, Some("@db.Text"))] + #[case::sqlite(DatabaseBackend::Sqlite, None)] fn inet_native_follows_provider( - #[case] provider: PrismaProvider, + #[case] provider: DatabaseBackend, #[case] native: Option<&str>, ) { let ty = ColumnType::Simple(SimpleColumnType::Inet); @@ -167,11 +168,11 @@ mod tests { } #[rstest] - #[case::postgres(PrismaProvider::Postgres, Some("@db.VarChar(255)"))] - #[case::mysql(PrismaProvider::MySql, Some("@db.VarChar(255)"))] - #[case::sqlite(PrismaProvider::Sqlite, None)] + #[case::postgres(DatabaseBackend::Postgres, Some("@db.VarChar(255)"))] + #[case::mysql(DatabaseBackend::MySql, Some("@db.VarChar(255)"))] + #[case::sqlite(DatabaseBackend::Sqlite, None)] fn varchar_native_follows_provider( - #[case] provider: PrismaProvider, + #[case] provider: DatabaseBackend, #[case] native: Option<&str>, ) { let ty = ColumnType::Complex(ComplexColumnType::Varchar { length: 255 }); @@ -185,10 +186,10 @@ mod tests { /// Each row is independent test data, so flipping any production mapping /// tuple (mutation testing) fails here immediately. #[rstest] - #[case::postgres(PrismaProvider::Postgres)] - #[case::mysql(PrismaProvider::MySql)] - #[case::sqlite(PrismaProvider::Sqlite)] - fn simple_type_mapping_matrix(#[case] provider: PrismaProvider) { + #[case::postgres(DatabaseBackend::Postgres)] + #[case::mysql(DatabaseBackend::MySql)] + #[case::sqlite(DatabaseBackend::Sqlite)] + fn simple_type_mapping_matrix(#[case] provider: DatabaseBackend) { use SimpleColumnType as S; // (type, scalar, postgres attr, mysql attr) — sqlite is always attr-less. let rows: &[(S, &str, Option<&str>, Option<&str>)] = &[ @@ -229,9 +230,9 @@ mod tests { for (ty, scalar, pg, mysql) in rows { let expected = match provider { - PrismaProvider::Postgres => *pg, - PrismaProvider::MySql => *mysql, - PrismaProvider::Sqlite => None, + DatabaseBackend::Postgres => *pg, + DatabaseBackend::MySql => *mysql, + DatabaseBackend::Sqlite => None, }; let (rendered, attr) = column_type_to_prisma(&ColumnType::Simple(*ty), false, provider); assert_eq!(rendered, *scalar, "{ty:?} scalar under {provider:?}"); @@ -241,14 +242,18 @@ mod tests { #[rstest] #[case::postgres( - PrismaProvider::Postgres, + DatabaseBackend::Postgres, + Some("@db.Char(3)"), + Some("@db.Decimal(10, 2)") + )] + #[case::mysql( + DatabaseBackend::MySql, Some("@db.Char(3)"), Some("@db.Decimal(10, 2)") )] - #[case::mysql(PrismaProvider::MySql, Some("@db.Char(3)"), Some("@db.Decimal(10, 2)"))] - #[case::sqlite(PrismaProvider::Sqlite, None, None)] + #[case::sqlite(DatabaseBackend::Sqlite, None, None)] fn char_and_numeric_natives_follow_provider( - #[case] provider: PrismaProvider, + #[case] provider: DatabaseBackend, #[case] char_native: Option<&str>, #[case] numeric_native: Option<&str>, ) { @@ -273,10 +278,10 @@ mod tests { } #[rstest] - #[case::postgres(PrismaProvider::Postgres)] - #[case::mysql(PrismaProvider::MySql)] - #[case::sqlite(PrismaProvider::Sqlite)] - fn custom_and_enum_types_are_provider_invariant(#[case] provider: PrismaProvider) { + #[case::postgres(DatabaseBackend::Postgres)] + #[case::mysql(DatabaseBackend::MySql)] + #[case::sqlite(DatabaseBackend::Sqlite)] + fn custom_and_enum_types_are_provider_invariant(#[case] provider: DatabaseBackend) { let (rendered, attr) = column_type_to_prisma( &ColumnType::Complex(ComplexColumnType::Custom { custom_type: "ltree".into(), From de11f226ba5ae94e0632cbb129fc6f80c1673dcf Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:52:55 +0900 Subject: [PATCH 21/25] =?UTF-8?q?test(cli):=20export=20Prisma=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A5=BC=20tests=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/vespertide-cli/src/commands/export.rs | 1112 ----------------- .../vespertide-cli/src/commands/export/mod.rs | 512 ++++++++ .../src/commands/export/tests/mod.rs | 521 ++++++++ .../src/commands/export/tests/prisma.rs | 81 ++ 4 files changed, 1114 insertions(+), 1112 deletions(-) delete mode 100644 crates/vespertide-cli/src/commands/export.rs create mode 100644 crates/vespertide-cli/src/commands/export/mod.rs create mode 100644 crates/vespertide-cli/src/commands/export/tests/mod.rs create mode 100644 crates/vespertide-cli/src/commands/export/tests/prisma.rs diff --git a/crates/vespertide-cli/src/commands/export.rs b/crates/vespertide-cli/src/commands/export.rs deleted file mode 100644 index 579d00e3..00000000 --- a/crates/vespertide-cli/src/commands/export.rs +++ /dev/null @@ -1,1112 +0,0 @@ -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result}; -use clap::ValueEnum; -use futures::future::try_join_all; -use rayon::prelude::*; -use tokio::fs; -use vespertide_config::VespertideConfig; -use vespertide_core::TableDef; -use vespertide_exporter::{ - Orm, prisma, render_entity_with_schema, seaorm::SeaOrmExporterWithConfig, -}; -use vespertide_query::DatabaseBackend; - -use crate::parallel_config::{EXPORT_RENDER_PAR_MIN_LEN, EXPORT_RENDER_PAR_THRESHOLD}; -use crate::utils::load_config; - -#[derive(Copy, Clone, Debug, ValueEnum)] -pub enum OrmArg { - Seaorm, - Sqlalchemy, - Sqlmodel, - Jpa, - Prisma, -} - -impl From for Orm { - fn from(value: OrmArg) -> Self { - match value { - OrmArg::Seaorm => Orm::SeaOrm, - OrmArg::Sqlalchemy => Orm::SqlAlchemy, - OrmArg::Sqlmodel => Orm::SqlModel, - OrmArg::Jpa => Orm::Jpa, - OrmArg::Prisma => Orm::Prisma, - } - } -} - -pub async fn cmd_export( - orm: OrmArg, - export_dir: Option, - backend: DatabaseBackend, -) -> Result<()> { - let config = load_config()?; - let models = load_models_recursive(config.models_dir()) - .await - .context("load models recursively")?; - - // Normalize tables to convert inline constraints (primary_key, foreign_key, etc.) to table-level constraints - let normalized_models: Vec<(TableDef, PathBuf)> = models - .into_iter() - .map(|(table, rel_path)| { - table - .normalize() - .map_err(|e| anyhow::anyhow!("Failed to normalize table '{}': {}", table.name, e)) - .map(|normalized| (normalized, rel_path)) - }) - .collect::, _>>()?; - - let target_root = resolve_export_dir(export_dir, &config); - - // Prisma uses a single-file output strategy - if matches!(orm, OrmArg::Prisma) { - return cmd_export_prisma(normalized_models, target_root, backend).await; - } - - // Clean the export directory before regenerating - let orm_kind: Orm = orm.into(); - clean_export_dir(&target_root, orm_kind).await?; - - if !target_root.exists() { - fs::create_dir_all(&target_root) - .await - .with_context(|| format!("create export dir {}", target_root.display()))?; - } - - // Extract all tables for schema context (used for FK chain resolution) - let all_tables: Vec = normalized_models.iter().map(|(t, _)| t.clone()).collect(); - - // Build module path mappings for SeaORM cross-directory relation resolution. - // Maps table_name -> module path segments (e.g., "admin" -> ["admin", "admin"]) - let module_paths: HashMap> = normalized_models - .iter() - .map(|(table, rel_path)| { - let segments = rel_path_to_module_segments(rel_path); - (table.name.to_string(), segments) - }) - .collect(); - - // Derive crate:: prefix from export directory (e.g., "src/models" -> "crate::models") - let crate_prefix = export_dir_to_crate_prefix(&target_root); - - // Create SeaORM exporter with config if needed - let seaorm_exporter = SeaOrmExporterWithConfig::new(config.seaorm(), config.prefix()); - let render_context = ExportRenderContext { - target_root: &target_root, - all_tables: &all_tables, - module_paths: &module_paths, - crate_prefix: &crate_prefix, - seaorm_exporter: &seaorm_exporter, - orm_kind, - }; - - // Generate all entity code (CPU-bound, done synchronously) - let entities: Vec<(String, PathBuf, String)> = - if normalized_models.len() < EXPORT_RENDER_PAR_THRESHOLD { - normalized_models - .iter() - .map(|model| render_export_entity(model, &render_context)) - .collect::>>()? - } else { - normalized_models - .par_iter() - .with_min_len(EXPORT_RENDER_PAR_MIN_LEN) - .map(|model| render_export_entity(model, &render_context)) - .collect::>>()? - }; - - // Write all files in parallel - let write_futures: Vec<_> = entities - .iter() - .map(|(name, out_path, code)| { - let name = name.clone(); - let out_path = out_path.clone(); - let code = code.clone(); - async move { - if let Some(parent) = out_path.parent() { - fs::create_dir_all(parent) - .await - .with_context(|| format!("create parent dir {}", parent.display()))?; - } - fs::write(&out_path, &code) - .await - .with_context(|| format!("write {}", out_path.display()))?; - println!("Exported {} -> {}", name, out_path.display()); - Ok::<(), anyhow::Error>(()) - } - }) - .collect(); - - try_join_all(write_futures).await?; - - // Ensure mod chain for SeaORM (must be done after all files are written) - if matches!(orm_kind, Orm::SeaOrm) { - for (_table, rel_path) in &normalized_models { - let out_path = build_output_path(&target_root, rel_path, orm_kind); - ensure_mod_chain(&target_root, rel_path) - .await - .with_context(|| format!("ensure mod chain for {}", out_path.display()))?; - } - } - - Ok(()) -} - -struct ExportRenderContext<'a> { - target_root: &'a Path, - all_tables: &'a [TableDef], - module_paths: &'a HashMap>, - crate_prefix: &'a str, - seaorm_exporter: &'a SeaOrmExporterWithConfig<'a>, - orm_kind: Orm, -} - -fn render_export_entity( - (table, rel_path): &(TableDef, PathBuf), - context: &ExportRenderContext<'_>, -) -> Result<(String, PathBuf, String)> { - let code = match context.orm_kind { - Orm::SeaOrm => context - .seaorm_exporter - .render_entity_with_schema_and_paths( - table, - context.all_tables, - context.module_paths, - context.crate_prefix, - ) - .map_err(|e| anyhow::anyhow!(e)), - _ => render_entity_with_schema(context.orm_kind, table, context.all_tables) - .map_err(|e| anyhow::anyhow!(e)), - }?; - let out_path = build_output_path(context.target_root, rel_path, context.orm_kind); - - Ok((table.name.to_string(), out_path, code)) -} - -/// Derive `crate::` prefix from the export directory path. -/// -/// For example: `src/models` → `crate::models`, `src/db/entities` → `crate::db::entities`. -/// If the path doesn't start with `src/`, returns empty string (fallback to `super::` behavior). -fn export_dir_to_crate_prefix(export_dir: &Path) -> String { - let normalized = export_dir.to_string_lossy().replace('\\', "/"); - let stripped = normalized.strip_prefix("./").unwrap_or(&normalized); - - if let Some(after_src) = stripped.strip_prefix("src/") { - let module_path = after_src.trim_end_matches('/').replace('/', "::"); - format!("crate::{module_path}") - } else { - String::new() - } -} - -/// Convert a relative model file path to Rust module path segments. -/// -/// For example: `admin/admin.json` → `["admin", "admin"]` -/// `estimate/estimate_checker.vespertide.json` → `["estimate", "estimate_checker"]` -fn rel_path_to_module_segments(rel_path: &Path) -> Vec { - let mut segments = Vec::new(); - - // Add directory components - if let Some(parent) = rel_path.parent() { - for component in parent.components() { - if let std::path::Component::Normal(name) = component - && let Some(s) = name.to_str() - { - segments.push(sanitize_filename(s).clone()); - } - } - } - - // Add file stem (strip extensions and .vespertide suffix) - if let Some(file_name) = rel_path.file_name().and_then(|n| n.to_str()) { - let (stem, _) = if let Some(dot_idx) = file_name.rfind('.') { - file_name.split_at(dot_idx) - } else { - (file_name, "") - }; - let stem = stem.strip_suffix(".vespertide").unwrap_or(stem); - segments.push(sanitize_filename(stem).clone()); - } - - segments -} - -fn resolve_export_dir(export_dir: Option, config: &VespertideConfig) -> PathBuf { - if let Some(dir) = export_dir { - return dir; - } - // Prefer explicit model_export_dir from config, fallback to default inside config. - config.model_export_dir().to_path_buf() -} - -/// 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<()> { - if !root.exists() { - return Ok(()); - } - - let ext = match orm { - Orm::SeaOrm => "rs", - Orm::SqlAlchemy | Orm::SqlModel => "py", - Orm::Jpa => "java", - Orm::Prisma => "prisma", - }; - - clean_dir_recursive(root, ext).await?; - Ok(()) -} - -/// Recursively remove files with the given extension and empty directories. -/// Uses async I/O for parallel file operations. -async fn clean_dir_recursive(dir: &Path, ext: &str) -> Result<()> { - if !dir.is_dir() { - return Ok(()); - } - - let mut entries = fs::read_dir(dir) - .await - .with_context(|| format!("read dir {}", dir.display()))?; - - let mut subdirs = Vec::new(); - let mut files_to_remove = Vec::new(); - - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - if path.is_dir() { - subdirs.push(path); - } else if path.extension().and_then(|e| e.to_str()) == Some(ext) { - files_to_remove.push(path); - } - } - - // Remove files in parallel - let remove_futures: Vec<_> = files_to_remove - .into_iter() - .map(|path| async move { - fs::remove_file(&path) - .await - .with_context(|| format!("remove file {}", path.display())) - }) - .collect(); - - try_join_all(remove_futures).await?; - - // Recursively clean subdirectories - let subdir_futures: Vec<_> = subdirs - .iter() - .map(|subdir| clean_dir_recursive(subdir, ext)) - .collect(); - - try_join_all(subdir_futures).await?; - - // Remove empty directories - for subdir in subdirs { - let mut entries = fs::read_dir(&subdir).await?; - if entries.next_entry().await?.is_none() { - fs::remove_dir(&subdir) - .await - .with_context(|| format!("remove empty dir {}", subdir.display()))?; - } - } - - Ok(()) -} - -fn build_output_path(root: &Path, rel_path: &Path, orm: Orm) -> PathBuf { - // Sanitize file name: replace spaces with underscores - let mut out = root.to_path_buf(); - - // Reconstruct path with sanitized file name - for component in rel_path.components() { - if let std::path::Component::Normal(name) = component { - out.push(name); - } else { - out.push(component.as_os_str()); - } - } - - // Sanitize the file name (last component) - if let Some(file_name) = out.file_name().and_then(|n| n.to_str()) { - // Remove extension, sanitize, then add new extension - let (stem, _ext) = if let Some(dot_idx) = file_name.rfind('.') { - file_name.split_at(dot_idx) - } else { - (file_name, "") - }; - - // Strip ".vespertide" suffix if present (e.g., "user.vespertide" -> "user") - let stem = stem.strip_suffix(".vespertide").unwrap_or(stem); - - let sanitized = sanitize_filename(stem); - let ext = match orm { - Orm::SeaOrm => "rs", - Orm::SqlAlchemy | Orm::SqlModel => "py", - Orm::Jpa => "java", - Orm::Prisma => "prisma", - }; - // Java requires filename to match PascalCase class name - let file_stem = if matches!(orm, Orm::Jpa) { - to_pascal_case(&sanitized) - } else { - sanitized - }; - out.set_file_name(format!("{file_stem}.{ext}")); - } - - out -} - -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 sanitize_filename(name: &str) -> String { - name.chars() - .map(|ch| { - if ch.is_alphanumeric() || ch == '_' || ch == '-' { - ch - } else { - '_' - } - }) - .collect::() -} - -async fn load_models_recursive(base: &Path) -> Result> { - let mut out = Vec::new(); - if !base.exists() { - return Ok(out); - } - walk_models(base, base, &mut out).await?; - Ok(out) -} - -async fn ensure_mod_chain(root: &Path, rel_path: &Path) -> Result<()> { - // SeaORM exports use a standard nested Rust module tree. - let path_without_ext = rel_path.with_extension(""); - let path_stripped = if let Some(stem) = path_without_ext.file_stem().and_then(|s| s.to_str()) { - let stripped_stem = stem.strip_suffix(".vespertide").unwrap_or(stem); - if let Some(parent) = path_without_ext.parent() { - parent.join(stripped_stem) - } else { - PathBuf::from(stripped_stem) - } - } else { - path_without_ext - }; - let mut comps: Vec = path_stripped - .components() - .filter_map(|c| c.as_os_str().to_str().map(|s| sanitize_filename(s).clone())) - .collect(); - if comps.is_empty() { - return Ok(()); - } - - while let Some(child) = comps.pop() { - let dir = root.join(comps.join(std::path::MAIN_SEPARATOR_STR)); - let mod_path = dir.join("mod.rs"); - if let Some(parent) = mod_path.parent() - && !parent.exists() - { - fs::create_dir_all(parent).await?; - } - - let mut content = if mod_path.exists() { - fs::read_to_string(&mod_path).await? - } else { - String::new() - }; - - let decl = format!("pub mod {child};"); - if !content.lines().any(|line| line.trim() == decl) { - if !content.is_empty() && !content.ends_with('\n') { - content.push('\n'); - } - content.push_str(&decl); - content.push('\n'); - fs::write(mod_path, content).await?; - } - } - - Ok(()) -} - -async fn cmd_export_prisma( - normalized_models: Vec<(TableDef, PathBuf)>, - target_root: PathBuf, - backend: DatabaseBackend, -) -> Result<()> { - let all_tables: Vec = normalized_models.iter().map(|(t, _)| t.clone()).collect(); - let content = prisma::render_schema(&all_tables, backend); - - 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()))?; - } - - let out_path = target_root.join("schema.prisma"); - 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, - current: &Path, - acc: &mut Vec<(TableDef, PathBuf)>, -) -> Result<()> { - let mut entries = fs::read_dir(current) - .await - .with_context(|| format!("read {}", current.display()))?; - - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - if path.is_dir() { - walk_models(root, &path, acc).await?; - continue; - } - let ext = path.extension().and_then(|s| s.to_str()); - if !matches!(ext, Some("json" | "yaml" | "yml")) { - continue; - } - let content = fs::read_to_string(&path) - .await - .with_context(|| format!("read model file: {}", path.display()))?; - let table: TableDef = if ext == Some("json") { - serde_json::from_str(&content) - .with_context(|| format!("parse JSON model: {}", path.display()))? - } else { - serde_yaml::from_str(&content) - .with_context(|| format!("parse YAML model: {}", path.display()))? - }; - let rel = path.strip_prefix(root).unwrap_or(&path).to_path_buf(); - acc.push((table, rel)); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::test_support::CwdGuard; - use rstest::rstest; - use serial_test::serial; - use std::fs as std_fs; - use tempfile::tempdir; - use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType, TableConstraint}; - - fn write_config() { - let cfg = VespertideConfig::default(); - let text = serde_json::to_string_pretty(&cfg).unwrap(); - std_fs::write("vespertide.json", text).unwrap(); - } - - fn write_model(path: &Path, table: &TableDef) { - if let Some(parent) = path.parent() { - std_fs::create_dir_all(parent).unwrap(); - } - std_fs::write(path, serde_json::to_string_pretty(table).unwrap()).unwrap(); - } - - fn sample_table(name: &str) -> TableDef { - TableDef { - name: name.into(), - description: None, - columns: vec![ColumnDef { - name: "id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Integer), - 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()], - strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), - }], - } - } - - #[tokio::test] - #[serial] - async fn export_writes_seaorm_files_to_default_dir() { - let tmp = tempdir().unwrap(); - let _guard = CwdGuard::new(&tmp.path().to_path_buf()); - write_config(); - - let model = sample_table("users"); - write_model(Path::new("models/users.json"), &model); - - cmd_export(OrmArg::Seaorm, None, DatabaseBackend::Postgres) - .await - .unwrap(); - - let out = PathBuf::from("src/models/users.rs"); - assert!(out.exists()); - let content = std_fs::read_to_string(out).unwrap(); - assert!(content.contains("#[sea_orm(table_name = \"users\")]")); - - // mod.rs wiring at root - let root_mod = PathBuf::from("src/models/mod.rs"); - assert!(root_mod.exists()); - let root_mod_content = std_fs::read_to_string(root_mod).unwrap(); - assert!(root_mod_content.contains("pub mod users;")); - } - - #[tokio::test] - #[serial] - async fn export_respects_custom_output_dir() { - let tmp = tempdir().unwrap(); - let _guard = CwdGuard::new(&tmp.path().to_path_buf()); - write_config(); - - let model = sample_table("posts"); - write_model(Path::new("models/blog/posts.json"), &model); - - let custom = PathBuf::from("out_dir"); - cmd_export( - OrmArg::Seaorm, - Some(custom.clone()), - DatabaseBackend::Postgres, - ) - .await - .unwrap(); - - let out = custom.join("blog/posts.rs"); - assert!(out.exists()); - let content = std_fs::read_to_string(out).unwrap(); - assert!(content.contains("#[sea_orm(table_name = \"posts\")]")); - - // nested mod.rs wiring - let root_mod = custom.join("mod.rs"); - let blog_mod = custom.join("blog/mod.rs"); - assert!(root_mod.exists()); - assert!(blog_mod.exists()); - let root_mod_content = std_fs::read_to_string(root_mod).unwrap(); - let blog_mod_content = std_fs::read_to_string(blog_mod).unwrap(); - assert!(root_mod_content.contains("pub mod blog;")); - assert!(blog_mod_content.contains("pub mod posts;")); - } - - #[tokio::test] - #[serial] - async fn export_with_sqlalchemy_sets_py_extension() { - let tmp = tempdir().unwrap(); - let _guard = CwdGuard::new(&tmp.path().to_path_buf()); - write_config(); - - let model = sample_table("items"); - write_model(Path::new("models/items.json"), &model); - - cmd_export(OrmArg::Sqlalchemy, None, DatabaseBackend::Postgres) - .await - .unwrap(); - - let out = PathBuf::from("src/models/items.py"); - assert!(out.exists()); - let content = std_fs::read_to_string(out).unwrap(); - assert!(content.contains("items")); - } - - #[tokio::test] - #[serial] - async fn export_with_sqlmodel_sets_py_extension() { - let tmp = tempdir().unwrap(); - let _guard = CwdGuard::new(&tmp.path().to_path_buf()); - write_config(); - - let model = sample_table("orders"); - write_model(Path::new("models/orders.json"), &model); - - cmd_export(OrmArg::Sqlmodel, None, DatabaseBackend::Postgres) - .await - .unwrap(); - - let out = PathBuf::from("src/models/orders.py"); - assert!(out.exists()); - let content = std_fs::read_to_string(out).unwrap(); - assert!(content.contains("orders")); - } - - #[rstest] - #[case::postgres( - DatabaseBackend::Postgres, - "provider = \"postgresql\"", - "@db.Timestamptz", - None - )] - #[case::mysql( - DatabaseBackend::MySql, - "provider = \"mysql\"", - "@db.Timestamp", - Some("@db.Timestamptz") - )] - #[case::sqlite( - DatabaseBackend::Sqlite, - "provider = \"sqlite\"", - "DateTime", - Some("@db.") - )] - #[tokio::test] - #[serial] - async fn export_prisma_writes_provider_specific_schema( - #[case] backend: DatabaseBackend, - #[case] provider_line: &str, - #[case] expected: &str, - #[case] absent: Option<&str>, - ) { - let tmp = tempdir().unwrap(); - let _guard = CwdGuard::new(&tmp.path().to_path_buf()); - write_config(); - - let mut model = sample_table("events"); - model.columns.push(ColumnDef { - name: "occurred_at".into(), - r#type: ColumnType::Simple(SimpleColumnType::Timestamptz), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }); - write_model(Path::new("models/events.json"), &model); - - cmd_export(OrmArg::Prisma, None, backend).await.unwrap(); - - let out = PathBuf::from("src/models/schema.prisma"); - assert!(out.exists()); - let content = std_fs::read_to_string(out).unwrap(); - assert!(content.contains(provider_line)); - assert!(content.contains(expected)); - if let Some(absent) = absent { - assert!(!content.contains(absent)); - } - } - - #[tokio::test] - #[serial] - async fn load_models_recursive_returns_empty_when_absent() { - let tmp = tempdir().unwrap(); - let _guard = CwdGuard::new(&tmp.path().to_path_buf()); - let models = load_models_recursive(Path::new("no_models")).await.unwrap(); - assert!(models.is_empty()); - } - - #[tokio::test] - #[serial] - async fn load_models_recursive_ignores_non_model_files() { - let tmp = tempdir().unwrap(); - let _guard = CwdGuard::new(&tmp.path().to_path_buf()); - write_config(); - - std_fs::create_dir_all("models").unwrap(); - std_fs::write("models/ignore.txt", "hello").unwrap(); - write_model(Path::new("models/valid.json"), &sample_table("valid")); - - let models = load_models_recursive(Path::new("models")).await.unwrap(); - assert_eq!(models.len(), 1); - assert_eq!(models[0].0.name, "valid"); - } - - #[tokio::test] - #[serial] - async fn load_models_recursive_parses_yaml_branch() { - let tmp = tempdir().unwrap(); - let _guard = CwdGuard::new(&tmp.path().to_path_buf()); - write_config(); - - std_fs::create_dir_all("models").unwrap(); - let table = sample_table("yaml_table"); - let yaml = serde_yaml::to_string(&table).unwrap(); - std_fs::write("models/yaml_table.yaml", yaml).unwrap(); - - let models = load_models_recursive(Path::new("models")).await.unwrap(); - assert_eq!(models.len(), 1); - assert_eq!(models[0].0.name, "yaml_table"); - } - - #[tokio::test] - #[serial] - async fn ensure_mod_chain_adds_to_existing_file_without_trailing_newline() { - let tmp = tempdir().unwrap(); - let root = tmp.path().join("src/models"); - std_fs::create_dir_all(&root).unwrap(); - std_fs::write(root.join("mod.rs"), "pub mod existing;").unwrap(); - - ensure_mod_chain(&root, Path::new("blog/posts.rs")) - .await - .unwrap(); - - let root_mod = std_fs::read_to_string(root.join("mod.rs")).unwrap(); - let blog_mod = std_fs::read_to_string(root.join("blog/mod.rs")).unwrap(); - assert!(root_mod.contains("pub mod existing;")); - assert!(root_mod.contains("pub mod blog;")); - assert!(blog_mod.contains("pub mod posts;")); - // ensure newline appended if missing - assert!(root_mod.ends_with('\n')); - } - - #[tokio::test] - async fn ensure_mod_chain_no_components_is_noop() { - let tmp = tempdir().unwrap(); - let root = tmp.path().join("src/models"); - std_fs::create_dir_all(&root).unwrap(); - // empty path should not error - assert!(ensure_mod_chain(&root, Path::new("")).await.is_ok()); - } - - #[test] - #[serial] - fn resolve_export_dir_prefers_override() { - let tmp = tempdir().unwrap(); - let _guard = CwdGuard::new(&tmp.path().to_path_buf()); - write_config(); - let cfg = VespertideConfig::default(); - let override_dir = PathBuf::from("custom_out"); - let resolved = super::resolve_export_dir(Some(override_dir.clone()), &cfg); - assert_eq!(resolved, override_dir); - } - - #[rstest] - #[case(OrmArg::Seaorm, Orm::SeaOrm)] - #[case(OrmArg::Sqlalchemy, Orm::SqlAlchemy)] - #[case(OrmArg::Sqlmodel, Orm::SqlModel)] - #[case(OrmArg::Jpa, Orm::Jpa)] - #[case(OrmArg::Prisma, Orm::Prisma)] - fn orm_arg_maps_to_enum(#[case] arg: OrmArg, #[case] expected: Orm) { - assert_eq!(Orm::from(arg), expected); - } - - #[rstest] - #[case("normal_name", "normal_name")] - #[case("user copy", "user_copy")] - #[case("user copy", "user__copy")] - #[case("user-copy", "user-copy")] - #[case("user.copy", "user_copy")] - #[case("user copy.json", "user_copy_json")] - fn test_sanitize_filename(#[case] input: &str, #[case] expected: &str) { - assert_eq!(sanitize_filename(input), expected); - } - - #[test] - fn build_output_path_sanitizes_spaces() { - use std::path::Path; - let root = Path::new("src/models"); - let rel_path = Path::new("user copy.json"); - let out = build_output_path(root, rel_path, Orm::SeaOrm); - assert_eq!(out, Path::new("src/models/user_copy.rs")); - - let rel_path2 = Path::new("blog/post name.yaml"); - let out2 = build_output_path(root, rel_path2, Orm::SeaOrm); - assert_eq!(out2, Path::new("src/models/blog/post_name.rs")); - } - - #[test] - fn build_output_path_handles_file_without_extension() { - use std::path::Path; - let root = Path::new("src/models"); - // File without extension - covers line 88 (else branch) - let rel_path = Path::new("users"); - let out = build_output_path(root, rel_path, Orm::SeaOrm); - assert_eq!(out, Path::new("src/models/users.rs")); - - let out_py = build_output_path(root, rel_path, Orm::SqlAlchemy); - assert_eq!(out_py, Path::new("src/models/users.py")); - } - - #[test] - fn build_output_path_prisma_uses_prisma_extension() { - use std::path::Path; - let root = Path::new("src/models"); - let out = build_output_path(root, Path::new("user.json"), Orm::Prisma); - assert_eq!(out, Path::new("src/models/user.prisma")); - } - - #[tokio::test] - #[serial] - async fn clean_export_dir_removes_stale_prisma_files() { - let tmp = tempdir().unwrap(); - let root = tmp.path().join("out"); - std_fs::create_dir_all(&root).unwrap(); - std_fs::write(root.join("schema.prisma"), "stale").unwrap(); - std_fs::write(root.join("keep.txt"), "keep").unwrap(); - - clean_export_dir(&root, Orm::Prisma).await.unwrap(); - - assert!(!root.join("schema.prisma").exists()); - assert!(root.join("keep.txt").exists()); - } - - #[test] - fn build_output_path_handles_special_path_components() { - use std::path::Path; - let root = Path::new("src/models"); - // Path with CurDir component (.) - covers line 78 (non-Normal component branch) - let rel_path = Path::new("./blog/posts.json"); - let out = build_output_path(root, rel_path, Orm::SeaOrm); - // The . component gets pushed via the else branch - assert!(out.to_string_lossy().contains("posts")); - - // Path with ParentDir component (..) - let rel_path2 = Path::new("../other/items.yaml"); - let out2 = build_output_path(root, rel_path2, Orm::SeaOrm); - assert!(out2.to_string_lossy().contains("items")); - } - - #[test] - fn build_output_path_strips_vespertide_suffix() { - use std::path::Path; - let root = Path::new("src/models"); - - // .vespertide.json -> .rs (strips ".vespertide" from stem) - let rel_path = Path::new("user.vespertide.json"); - let out = build_output_path(root, rel_path, Orm::SeaOrm); - assert_eq!(out, Path::new("src/models/user.rs")); - - // Nested path with .vespertide.json - let rel_path2 = Path::new("blog/post.vespertide.json"); - let out2 = build_output_path(root, rel_path2, Orm::SeaOrm); - assert_eq!(out2, Path::new("src/models/blog/post.rs")); - - // .vespertide.yaml -> .py - let rel_path3 = Path::new("order.vespertide.yaml"); - let out3 = build_output_path(root, rel_path3, Orm::SqlAlchemy); - assert_eq!(out3, Path::new("src/models/order.py")); - - // Regular .json without .vespertide suffix still works - let rel_path4 = Path::new("item.json"); - let out4 = build_output_path(root, rel_path4, Orm::SeaOrm); - assert_eq!(out4, Path::new("src/models/item.rs")); - } - - #[tokio::test] - #[serial] - async fn ensure_mod_chain_strips_vespertide_suffix() { - let tmp = tempdir().unwrap(); - let root = tmp.path().join("src/models"); - std_fs::create_dir_all(&root).unwrap(); - - // File with .vespertide suffix should produce mod declaration without it - ensure_mod_chain(&root, Path::new("user.vespertide.json")) - .await - .unwrap(); - - let root_mod = std_fs::read_to_string(root.join("mod.rs")).unwrap(); - // Should be "pub mod user;" not "pub mod user_vespertide;" - assert!(root_mod.contains("pub mod user;")); - assert!(!root_mod.contains("user_vespertide")); - - // Nested path with .vespertide suffix - ensure_mod_chain(&root, Path::new("blog/post.vespertide.json")) - .await - .unwrap(); - let root_mod = std_fs::read_to_string(root.join("mod.rs")).unwrap(); - let blog_mod = std_fs::read_to_string(root.join("blog/mod.rs")).unwrap(); - assert!(root_mod.contains("pub mod blog;")); - assert!(blog_mod.contains("pub mod post;")); - assert!(!root_mod.contains("post_vespertide")); - assert!(!blog_mod.contains("post_vespertide")); - } - - #[tokio::test] - async fn clean_export_dir_removes_rs_files_for_seaorm() { - let tmp = tempdir().unwrap(); - let root = tmp.path().join("export_dir"); - std_fs::create_dir_all(&root).unwrap(); - - // Create some .rs files that should be cleaned - std_fs::write(root.join("old_model.rs"), "// old rust file").unwrap(); - std_fs::write(root.join("another.rs"), "// another rust file").unwrap(); - // Create a non-.rs file that should NOT be cleaned - std_fs::write(root.join("keep.txt"), "keep this").unwrap(); - - clean_export_dir(&root, Orm::SeaOrm).await.unwrap(); - - // .rs files should be gone - assert!(!root.join("old_model.rs").exists()); - assert!(!root.join("another.rs").exists()); - // .txt file should remain - assert!(root.join("keep.txt").exists()); - } - - #[tokio::test] - async fn clean_export_dir_removes_py_files_for_sqlalchemy() { - let tmp = tempdir().unwrap(); - let root = tmp.path().join("export_dir"); - std_fs::create_dir_all(&root).unwrap(); - - // Create some .py files that should be cleaned - std_fs::write(root.join("old_model.py"), "# old python file").unwrap(); - // Create a .rs file that should NOT be cleaned - std_fs::write(root.join("keep.rs"), "// keep this").unwrap(); - - clean_export_dir(&root, Orm::SqlAlchemy).await.unwrap(); - - // .py files should be gone - assert!(!root.join("old_model.py").exists()); - // .rs file should remain - assert!(root.join("keep.rs").exists()); - } - - #[tokio::test] - async fn clean_export_dir_removes_py_files_for_sqlmodel() { - let tmp = tempdir().unwrap(); - let root = tmp.path().join("export_dir"); - std_fs::create_dir_all(&root).unwrap(); - - std_fs::write(root.join("model.py"), "# python file").unwrap(); - - clean_export_dir(&root, Orm::SqlModel).await.unwrap(); - - assert!(!root.join("model.py").exists()); - } - - #[tokio::test] - async fn clean_export_dir_removes_java_files_for_jpa() { - let tmp = tempdir().unwrap(); - let root = tmp.path().join("export_dir"); - std_fs::create_dir_all(&root).unwrap(); - - std_fs::write(root.join("User.java"), "// java entity").unwrap(); - std_fs::write(root.join("Order.java"), "// java entity").unwrap(); - std_fs::write(root.join("keep.rs"), "// keep this").unwrap(); - - clean_export_dir(&root, Orm::Jpa).await.unwrap(); - - assert!(!root.join("User.java").exists()); - assert!(!root.join("Order.java").exists()); - assert!(root.join("keep.rs").exists()); - } - - #[tokio::test] - async fn clean_export_dir_handles_missing_directory() { - let tmp = tempdir().unwrap(); - let root = tmp.path().join("nonexistent_dir"); - - // Should not error on missing directory - let result = clean_export_dir(&root, Orm::SeaOrm).await; - assert!(result.is_ok()); - } - - #[tokio::test] - async fn clean_dir_recursive_cleans_subdirectories() { - let tmp = tempdir().unwrap(); - let root = tmp.path().join("export_dir"); - let subdir = root.join("nested"); - std_fs::create_dir_all(&subdir).unwrap(); - - // Create files in root and subdirectory - std_fs::write(root.join("root.rs"), "// root").unwrap(); - std_fs::write(subdir.join("nested.rs"), "// nested").unwrap(); - std_fs::write(subdir.join("keep.txt"), "keep").unwrap(); - - clean_dir_recursive(&root, "rs").await.unwrap(); - - // .rs files should be gone - assert!(!root.join("root.rs").exists()); - assert!(!subdir.join("nested.rs").exists()); - // .txt file should remain - assert!(subdir.join("keep.txt").exists()); - // subdir should still exist (has .txt file) - assert!(subdir.exists()); - } - - #[tokio::test] - async fn clean_dir_recursive_removes_empty_subdirectories() { - let tmp = tempdir().unwrap(); - let root = tmp.path().join("export_dir"); - let subdir = root.join("empty_after_clean"); - std_fs::create_dir_all(&subdir).unwrap(); - - // Create only .rs files in subdirectory - std_fs::write(subdir.join("only.rs"), "// only").unwrap(); - - clean_dir_recursive(&root, "rs").await.unwrap(); - - // .rs file should be gone - assert!(!subdir.join("only.rs").exists()); - // Empty subdirectory should be removed - assert!(!subdir.exists()); - } - - #[tokio::test] - async fn clean_dir_recursive_handles_non_directory() { - let tmp = tempdir().unwrap(); - let file_path = tmp.path().join("not_a_dir.txt"); - std_fs::write(&file_path, "content").unwrap(); - - // Should not error when called on a file instead of directory - let result = clean_dir_recursive(&file_path, "rs").await; - assert!(result.is_ok()); - } - - #[test] - fn build_output_path_jpa_uses_pascal_case_java_extension() { - use std::path::Path; - let root = Path::new("src/models"); - - // snake_case model → PascalCase .java - let rel_path = Path::new("order_item.json"); - let out = build_output_path(root, rel_path, Orm::Jpa); - assert_eq!(out, Path::new("src/models/OrderItem.java")); - - // Single word - let rel_path2 = Path::new("users.json"); - let out2 = build_output_path(root, rel_path2, Orm::Jpa); - assert_eq!(out2, Path::new("src/models/Users.java")); - - // Nested path - let rel_path3 = Path::new("blog/post_comment.yaml"); - let out3 = build_output_path(root, rel_path3, Orm::Jpa); - assert_eq!(out3, Path::new("src/models/blog/PostComment.java")); - } - - #[test] - fn build_output_path_jpa_strips_vespertide_suffix() { - use std::path::Path; - let root = Path::new("src/models"); - - let rel_path = Path::new("user.vespertide.json"); - let out = build_output_path(root, rel_path, Orm::Jpa); - assert_eq!(out, Path::new("src/models/User.java")); - } - - #[rstest] - #[case("order_item", "OrderItem")] - #[case("users", "Users")] - #[case("a", "A")] - #[case("user_profile_image", "UserProfileImage")] - #[case("a__b", "AB")] - fn test_to_pascal_case(#[case] input: &str, #[case] expected: &str) { - assert_eq!(to_pascal_case(input), expected); - } -} diff --git a/crates/vespertide-cli/src/commands/export/mod.rs b/crates/vespertide-cli/src/commands/export/mod.rs new file mode 100644 index 00000000..91fe3b57 --- /dev/null +++ b/crates/vespertide-cli/src/commands/export/mod.rs @@ -0,0 +1,512 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use clap::ValueEnum; +use futures::future::try_join_all; +use rayon::prelude::*; +use tokio::fs; +use vespertide_config::VespertideConfig; +use vespertide_core::TableDef; +use vespertide_exporter::{ + Orm, prisma, render_entity_with_schema, seaorm::SeaOrmExporterWithConfig, +}; +use vespertide_query::DatabaseBackend; + +use crate::parallel_config::{EXPORT_RENDER_PAR_MIN_LEN, EXPORT_RENDER_PAR_THRESHOLD}; +use crate::utils::load_config; + +#[derive(Copy, Clone, Debug, ValueEnum)] +pub enum OrmArg { + Seaorm, + Sqlalchemy, + Sqlmodel, + Jpa, + Prisma, +} + +impl From for Orm { + fn from(value: OrmArg) -> Self { + match value { + OrmArg::Seaorm => Orm::SeaOrm, + OrmArg::Sqlalchemy => Orm::SqlAlchemy, + OrmArg::Sqlmodel => Orm::SqlModel, + OrmArg::Jpa => Orm::Jpa, + OrmArg::Prisma => Orm::Prisma, + } + } +} + +pub async fn cmd_export( + orm: OrmArg, + export_dir: Option, + backend: DatabaseBackend, +) -> Result<()> { + let config = load_config()?; + let models = load_models_recursive(config.models_dir()) + .await + .context("load models recursively")?; + + // Normalize tables to convert inline constraints (primary_key, foreign_key, etc.) to table-level constraints + let normalized_models: Vec<(TableDef, PathBuf)> = models + .into_iter() + .map(|(table, rel_path)| { + table + .normalize() + .map_err(|e| anyhow::anyhow!("Failed to normalize table '{}': {}", table.name, e)) + .map(|normalized| (normalized, rel_path)) + }) + .collect::, _>>()?; + + let target_root = resolve_export_dir(export_dir, &config); + + // Prisma uses a single-file output strategy + if matches!(orm, OrmArg::Prisma) { + return cmd_export_prisma(normalized_models, target_root, backend).await; + } + + // Clean the export directory before regenerating + let orm_kind: Orm = orm.into(); + clean_export_dir(&target_root, orm_kind).await?; + + if !target_root.exists() { + fs::create_dir_all(&target_root) + .await + .with_context(|| format!("create export dir {}", target_root.display()))?; + } + + // Extract all tables for schema context (used for FK chain resolution) + let all_tables: Vec = normalized_models.iter().map(|(t, _)| t.clone()).collect(); + + // Build module path mappings for SeaORM cross-directory relation resolution. + // Maps table_name -> module path segments (e.g., "admin" -> ["admin", "admin"]) + let module_paths: HashMap> = normalized_models + .iter() + .map(|(table, rel_path)| { + let segments = rel_path_to_module_segments(rel_path); + (table.name.to_string(), segments) + }) + .collect(); + + // Derive crate:: prefix from export directory (e.g., "src/models" -> "crate::models") + let crate_prefix = export_dir_to_crate_prefix(&target_root); + + // Create SeaORM exporter with config if needed + let seaorm_exporter = SeaOrmExporterWithConfig::new(config.seaorm(), config.prefix()); + let render_context = ExportRenderContext { + target_root: &target_root, + all_tables: &all_tables, + module_paths: &module_paths, + crate_prefix: &crate_prefix, + seaorm_exporter: &seaorm_exporter, + orm_kind, + }; + + // Generate all entity code (CPU-bound, done synchronously) + let entities: Vec<(String, PathBuf, String)> = + if normalized_models.len() < EXPORT_RENDER_PAR_THRESHOLD { + normalized_models + .iter() + .map(|model| render_export_entity(model, &render_context)) + .collect::>>()? + } else { + normalized_models + .par_iter() + .with_min_len(EXPORT_RENDER_PAR_MIN_LEN) + .map(|model| render_export_entity(model, &render_context)) + .collect::>>()? + }; + + // Write all files in parallel + let write_futures: Vec<_> = entities + .iter() + .map(|(name, out_path, code)| { + let name = name.clone(); + let out_path = out_path.clone(); + let code = code.clone(); + async move { + if let Some(parent) = out_path.parent() { + fs::create_dir_all(parent) + .await + .with_context(|| format!("create parent dir {}", parent.display()))?; + } + fs::write(&out_path, &code) + .await + .with_context(|| format!("write {}", out_path.display()))?; + println!("Exported {} -> {}", name, out_path.display()); + Ok::<(), anyhow::Error>(()) + } + }) + .collect(); + + try_join_all(write_futures).await?; + + // Ensure mod chain for SeaORM (must be done after all files are written) + if matches!(orm_kind, Orm::SeaOrm) { + for (_table, rel_path) in &normalized_models { + let out_path = build_output_path(&target_root, rel_path, orm_kind); + ensure_mod_chain(&target_root, rel_path) + .await + .with_context(|| format!("ensure mod chain for {}", out_path.display()))?; + } + } + + Ok(()) +} + +struct ExportRenderContext<'a> { + target_root: &'a Path, + all_tables: &'a [TableDef], + module_paths: &'a HashMap>, + crate_prefix: &'a str, + seaorm_exporter: &'a SeaOrmExporterWithConfig<'a>, + orm_kind: Orm, +} + +fn render_export_entity( + (table, rel_path): &(TableDef, PathBuf), + context: &ExportRenderContext<'_>, +) -> Result<(String, PathBuf, String)> { + let code = match context.orm_kind { + Orm::SeaOrm => context + .seaorm_exporter + .render_entity_with_schema_and_paths( + table, + context.all_tables, + context.module_paths, + context.crate_prefix, + ) + .map_err(|e| anyhow::anyhow!(e)), + _ => render_entity_with_schema(context.orm_kind, table, context.all_tables) + .map_err(|e| anyhow::anyhow!(e)), + }?; + let out_path = build_output_path(context.target_root, rel_path, context.orm_kind); + + Ok((table.name.to_string(), out_path, code)) +} + +/// Derive `crate::` prefix from the export directory path. +/// +/// For example: `src/models` → `crate::models`, `src/db/entities` → `crate::db::entities`. +/// If the path doesn't start with `src/`, returns empty string (fallback to `super::` behavior). +fn export_dir_to_crate_prefix(export_dir: &Path) -> String { + let normalized = export_dir.to_string_lossy().replace('\\', "/"); + let stripped = normalized.strip_prefix("./").unwrap_or(&normalized); + + if let Some(after_src) = stripped.strip_prefix("src/") { + let module_path = after_src.trim_end_matches('/').replace('/', "::"); + format!("crate::{module_path}") + } else { + String::new() + } +} + +/// Convert a relative model file path to Rust module path segments. +/// +/// For example: `admin/admin.json` → `["admin", "admin"]` +/// `estimate/estimate_checker.vespertide.json` → `["estimate", "estimate_checker"]` +fn rel_path_to_module_segments(rel_path: &Path) -> Vec { + let mut segments = Vec::new(); + + // Add directory components + if let Some(parent) = rel_path.parent() { + for component in parent.components() { + if let std::path::Component::Normal(name) = component + && let Some(s) = name.to_str() + { + segments.push(sanitize_filename(s).clone()); + } + } + } + + // Add file stem (strip extensions and .vespertide suffix) + if let Some(file_name) = rel_path.file_name().and_then(|n| n.to_str()) { + let (stem, _) = if let Some(dot_idx) = file_name.rfind('.') { + file_name.split_at(dot_idx) + } else { + (file_name, "") + }; + let stem = stem.strip_suffix(".vespertide").unwrap_or(stem); + segments.push(sanitize_filename(stem).clone()); + } + + segments +} + +fn resolve_export_dir(export_dir: Option, config: &VespertideConfig) -> PathBuf { + if let Some(dir) = export_dir { + return dir; + } + // Prefer explicit model_export_dir from config, fallback to default inside config. + config.model_export_dir().to_path_buf() +} + +/// 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<()> { + if !root.exists() { + return Ok(()); + } + + let ext = match orm { + Orm::SeaOrm => "rs", + Orm::SqlAlchemy | Orm::SqlModel => "py", + Orm::Jpa => "java", + Orm::Prisma => "prisma", + }; + + clean_dir_recursive(root, ext).await?; + Ok(()) +} + +/// Recursively remove files with the given extension and empty directories. +/// Uses async I/O for parallel file operations. +async fn clean_dir_recursive(dir: &Path, ext: &str) -> Result<()> { + if !dir.is_dir() { + return Ok(()); + } + + let mut entries = fs::read_dir(dir) + .await + .with_context(|| format!("read dir {}", dir.display()))?; + + let mut subdirs = Vec::new(); + let mut files_to_remove = Vec::new(); + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.is_dir() { + subdirs.push(path); + } else if path.extension().and_then(|e| e.to_str()) == Some(ext) { + files_to_remove.push(path); + } + } + + // Remove files in parallel + let remove_futures: Vec<_> = files_to_remove + .into_iter() + .map(|path| async move { + fs::remove_file(&path) + .await + .with_context(|| format!("remove file {}", path.display())) + }) + .collect(); + + try_join_all(remove_futures).await?; + + // Recursively clean subdirectories + let subdir_futures: Vec<_> = subdirs + .iter() + .map(|subdir| clean_dir_recursive(subdir, ext)) + .collect(); + + try_join_all(subdir_futures).await?; + + // Remove empty directories + for subdir in subdirs { + let mut entries = fs::read_dir(&subdir).await?; + if entries.next_entry().await?.is_none() { + fs::remove_dir(&subdir) + .await + .with_context(|| format!("remove empty dir {}", subdir.display()))?; + } + } + + Ok(()) +} + +fn build_output_path(root: &Path, rel_path: &Path, orm: Orm) -> PathBuf { + // Sanitize file name: replace spaces with underscores + let mut out = root.to_path_buf(); + + // Reconstruct path with sanitized file name + for component in rel_path.components() { + if let std::path::Component::Normal(name) = component { + out.push(name); + } else { + out.push(component.as_os_str()); + } + } + + // Sanitize the file name (last component) + if let Some(file_name) = out.file_name().and_then(|n| n.to_str()) { + // Remove extension, sanitize, then add new extension + let (stem, _ext) = if let Some(dot_idx) = file_name.rfind('.') { + file_name.split_at(dot_idx) + } else { + (file_name, "") + }; + + // Strip ".vespertide" suffix if present (e.g., "user.vespertide" -> "user") + let stem = stem.strip_suffix(".vespertide").unwrap_or(stem); + + let sanitized = sanitize_filename(stem); + let ext = match orm { + Orm::SeaOrm => "rs", + Orm::SqlAlchemy | Orm::SqlModel => "py", + Orm::Jpa => "java", + Orm::Prisma => "prisma", + }; + // Java requires filename to match PascalCase class name + let file_stem = if matches!(orm, Orm::Jpa) { + to_pascal_case(&sanitized) + } else { + sanitized + }; + out.set_file_name(format!("{file_stem}.{ext}")); + } + + out +} + +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 sanitize_filename(name: &str) -> String { + name.chars() + .map(|ch| { + if ch.is_alphanumeric() || ch == '_' || ch == '-' { + ch + } else { + '_' + } + }) + .collect::() +} + +async fn load_models_recursive(base: &Path) -> Result> { + let mut out = Vec::new(); + if !base.exists() { + return Ok(out); + } + walk_models(base, base, &mut out).await?; + Ok(out) +} + +async fn ensure_mod_chain(root: &Path, rel_path: &Path) -> Result<()> { + // SeaORM exports use a standard nested Rust module tree. + let path_without_ext = rel_path.with_extension(""); + let path_stripped = if let Some(stem) = path_without_ext.file_stem().and_then(|s| s.to_str()) { + let stripped_stem = stem.strip_suffix(".vespertide").unwrap_or(stem); + if let Some(parent) = path_without_ext.parent() { + parent.join(stripped_stem) + } else { + PathBuf::from(stripped_stem) + } + } else { + path_without_ext + }; + let mut comps: Vec = path_stripped + .components() + .filter_map(|c| c.as_os_str().to_str().map(|s| sanitize_filename(s).clone())) + .collect(); + if comps.is_empty() { + return Ok(()); + } + + while let Some(child) = comps.pop() { + let dir = root.join(comps.join(std::path::MAIN_SEPARATOR_STR)); + let mod_path = dir.join("mod.rs"); + if let Some(parent) = mod_path.parent() + && !parent.exists() + { + fs::create_dir_all(parent).await?; + } + + let mut content = if mod_path.exists() { + fs::read_to_string(&mod_path).await? + } else { + String::new() + }; + + let decl = format!("pub mod {child};"); + if !content.lines().any(|line| line.trim() == decl) { + if !content.is_empty() && !content.ends_with('\n') { + content.push('\n'); + } + content.push_str(&decl); + content.push('\n'); + fs::write(mod_path, content).await?; + } + } + + Ok(()) +} + +async fn cmd_export_prisma( + normalized_models: Vec<(TableDef, PathBuf)>, + target_root: PathBuf, + backend: DatabaseBackend, +) -> Result<()> { + let all_tables: Vec = normalized_models.iter().map(|(t, _)| t.clone()).collect(); + let content = prisma::render_schema(&all_tables, backend); + + 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()))?; + } + + let out_path = target_root.join("schema.prisma"); + 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, + current: &Path, + acc: &mut Vec<(TableDef, PathBuf)>, +) -> Result<()> { + let mut entries = fs::read_dir(current) + .await + .with_context(|| format!("read {}", current.display()))?; + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.is_dir() { + walk_models(root, &path, acc).await?; + continue; + } + let ext = path.extension().and_then(|s| s.to_str()); + if !matches!(ext, Some("json" | "yaml" | "yml")) { + continue; + } + let content = fs::read_to_string(&path) + .await + .with_context(|| format!("read model file: {}", path.display()))?; + let table: TableDef = if ext == Some("json") { + serde_json::from_str(&content) + .with_context(|| format!("parse JSON model: {}", path.display()))? + } else { + serde_yaml::from_str(&content) + .with_context(|| format!("parse YAML model: {}", path.display()))? + }; + let rel = path.strip_prefix(root).unwrap_or(&path).to_path_buf(); + acc.push((table, rel)); + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/vespertide-cli/src/commands/export/tests/mod.rs b/crates/vespertide-cli/src/commands/export/tests/mod.rs new file mode 100644 index 00000000..1aa49b32 --- /dev/null +++ b/crates/vespertide-cli/src/commands/export/tests/mod.rs @@ -0,0 +1,521 @@ +use super::*; +pub(super) use crate::test_support::CwdGuard; +pub(super) use rstest::rstest; +pub(super) use serial_test::serial; +pub(super) use std::fs as std_fs; +pub(super) use tempfile::tempdir; +pub(super) use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType, TableConstraint}; + +mod prisma; + +fn write_config() { + let cfg = VespertideConfig::default(); + let text = serde_json::to_string_pretty(&cfg).unwrap(); + std_fs::write("vespertide.json", text).unwrap(); +} + +fn write_model(path: &Path, table: &TableDef) { + if let Some(parent) = path.parent() { + std_fs::create_dir_all(parent).unwrap(); + } + std_fs::write(path, serde_json::to_string_pretty(table).unwrap()).unwrap(); +} + +fn sample_table(name: &str) -> TableDef { + TableDef { + name: name.into(), + description: None, + columns: vec![ColumnDef { + name: "id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + 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()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + } +} + +#[tokio::test] +#[serial] +async fn export_writes_seaorm_files_to_default_dir() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + + let model = sample_table("users"); + write_model(Path::new("models/users.json"), &model); + + cmd_export(OrmArg::Seaorm, None, DatabaseBackend::Postgres) + .await + .unwrap(); + + let out = PathBuf::from("src/models/users.rs"); + assert!(out.exists()); + let content = std_fs::read_to_string(out).unwrap(); + assert!(content.contains("#[sea_orm(table_name = \"users\")]")); + + // mod.rs wiring at root + let root_mod = PathBuf::from("src/models/mod.rs"); + assert!(root_mod.exists()); + let root_mod_content = std_fs::read_to_string(root_mod).unwrap(); + assert!(root_mod_content.contains("pub mod users;")); +} + +#[tokio::test] +#[serial] +async fn export_respects_custom_output_dir() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + + let model = sample_table("posts"); + write_model(Path::new("models/blog/posts.json"), &model); + + let custom = PathBuf::from("out_dir"); + cmd_export( + OrmArg::Seaorm, + Some(custom.clone()), + DatabaseBackend::Postgres, + ) + .await + .unwrap(); + + let out = custom.join("blog/posts.rs"); + assert!(out.exists()); + let content = std_fs::read_to_string(out).unwrap(); + assert!(content.contains("#[sea_orm(table_name = \"posts\")]")); + + // nested mod.rs wiring + let root_mod = custom.join("mod.rs"); + let blog_mod = custom.join("blog/mod.rs"); + assert!(root_mod.exists()); + assert!(blog_mod.exists()); + let root_mod_content = std_fs::read_to_string(root_mod).unwrap(); + let blog_mod_content = std_fs::read_to_string(blog_mod).unwrap(); + assert!(root_mod_content.contains("pub mod blog;")); + assert!(blog_mod_content.contains("pub mod posts;")); +} + +#[tokio::test] +#[serial] +async fn export_with_sqlalchemy_sets_py_extension() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + + let model = sample_table("items"); + write_model(Path::new("models/items.json"), &model); + + cmd_export(OrmArg::Sqlalchemy, None, DatabaseBackend::Postgres) + .await + .unwrap(); + + let out = PathBuf::from("src/models/items.py"); + assert!(out.exists()); + let content = std_fs::read_to_string(out).unwrap(); + assert!(content.contains("items")); +} + +#[tokio::test] +#[serial] +async fn export_with_sqlmodel_sets_py_extension() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + + let model = sample_table("orders"); + write_model(Path::new("models/orders.json"), &model); + + cmd_export(OrmArg::Sqlmodel, None, DatabaseBackend::Postgres) + .await + .unwrap(); + + let out = PathBuf::from("src/models/orders.py"); + assert!(out.exists()); + let content = std_fs::read_to_string(out).unwrap(); + assert!(content.contains("orders")); +} + +#[tokio::test] +#[serial] +async fn load_models_recursive_returns_empty_when_absent() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + let models = load_models_recursive(Path::new("no_models")).await.unwrap(); + assert!(models.is_empty()); +} + +#[tokio::test] +#[serial] +async fn load_models_recursive_ignores_non_model_files() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + + std_fs::create_dir_all("models").unwrap(); + std_fs::write("models/ignore.txt", "hello").unwrap(); + write_model(Path::new("models/valid.json"), &sample_table("valid")); + + let models = load_models_recursive(Path::new("models")).await.unwrap(); + assert_eq!(models.len(), 1); + assert_eq!(models[0].0.name, "valid"); +} + +#[tokio::test] +#[serial] +async fn load_models_recursive_parses_yaml_branch() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + + std_fs::create_dir_all("models").unwrap(); + let table = sample_table("yaml_table"); + let yaml = serde_yaml::to_string(&table).unwrap(); + std_fs::write("models/yaml_table.yaml", yaml).unwrap(); + + let models = load_models_recursive(Path::new("models")).await.unwrap(); + assert_eq!(models.len(), 1); + assert_eq!(models[0].0.name, "yaml_table"); +} + +#[tokio::test] +#[serial] +async fn ensure_mod_chain_adds_to_existing_file_without_trailing_newline() { + let tmp = tempdir().unwrap(); + let root = tmp.path().join("src/models"); + std_fs::create_dir_all(&root).unwrap(); + std_fs::write(root.join("mod.rs"), "pub mod existing;").unwrap(); + + ensure_mod_chain(&root, Path::new("blog/posts.rs")) + .await + .unwrap(); + + let root_mod = std_fs::read_to_string(root.join("mod.rs")).unwrap(); + let blog_mod = std_fs::read_to_string(root.join("blog/mod.rs")).unwrap(); + assert!(root_mod.contains("pub mod existing;")); + assert!(root_mod.contains("pub mod blog;")); + assert!(blog_mod.contains("pub mod posts;")); + // ensure newline appended if missing + assert!(root_mod.ends_with('\n')); +} + +#[tokio::test] +async fn ensure_mod_chain_no_components_is_noop() { + let tmp = tempdir().unwrap(); + let root = tmp.path().join("src/models"); + std_fs::create_dir_all(&root).unwrap(); + // empty path should not error + assert!(ensure_mod_chain(&root, Path::new("")).await.is_ok()); +} + +#[test] +#[serial] +fn resolve_export_dir_prefers_override() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + let cfg = VespertideConfig::default(); + let override_dir = PathBuf::from("custom_out"); + let resolved = super::resolve_export_dir(Some(override_dir.clone()), &cfg); + assert_eq!(resolved, override_dir); +} + +#[rstest] +#[case(OrmArg::Seaorm, Orm::SeaOrm)] +#[case(OrmArg::Sqlalchemy, Orm::SqlAlchemy)] +#[case(OrmArg::Sqlmodel, Orm::SqlModel)] +#[case(OrmArg::Jpa, Orm::Jpa)] +#[case(OrmArg::Prisma, Orm::Prisma)] +fn orm_arg_maps_to_enum(#[case] arg: OrmArg, #[case] expected: Orm) { + assert_eq!(Orm::from(arg), expected); +} + +#[rstest] +#[case("normal_name", "normal_name")] +#[case("user copy", "user_copy")] +#[case("user copy", "user__copy")] +#[case("user-copy", "user-copy")] +#[case("user.copy", "user_copy")] +#[case("user copy.json", "user_copy_json")] +fn test_sanitize_filename(#[case] input: &str, #[case] expected: &str) { + assert_eq!(sanitize_filename(input), expected); +} + +#[test] +fn build_output_path_sanitizes_spaces() { + use std::path::Path; + let root = Path::new("src/models"); + let rel_path = Path::new("user copy.json"); + let out = build_output_path(root, rel_path, Orm::SeaOrm); + assert_eq!(out, Path::new("src/models/user_copy.rs")); + + let rel_path2 = Path::new("blog/post name.yaml"); + let out2 = build_output_path(root, rel_path2, Orm::SeaOrm); + assert_eq!(out2, Path::new("src/models/blog/post_name.rs")); +} + +#[test] +fn build_output_path_handles_file_without_extension() { + use std::path::Path; + let root = Path::new("src/models"); + // File without extension - covers line 88 (else branch) + let rel_path = Path::new("users"); + let out = build_output_path(root, rel_path, Orm::SeaOrm); + assert_eq!(out, Path::new("src/models/users.rs")); + + let out_py = build_output_path(root, rel_path, Orm::SqlAlchemy); + assert_eq!(out_py, Path::new("src/models/users.py")); +} + +#[test] +fn build_output_path_handles_special_path_components() { + use std::path::Path; + let root = Path::new("src/models"); + // Path with CurDir component (.) - covers line 78 (non-Normal component branch) + let rel_path = Path::new("./blog/posts.json"); + let out = build_output_path(root, rel_path, Orm::SeaOrm); + // The . component gets pushed via the else branch + assert!(out.to_string_lossy().contains("posts")); + + // Path with ParentDir component (..) + let rel_path2 = Path::new("../other/items.yaml"); + let out2 = build_output_path(root, rel_path2, Orm::SeaOrm); + assert!(out2.to_string_lossy().contains("items")); +} + +#[test] +fn build_output_path_strips_vespertide_suffix() { + use std::path::Path; + let root = Path::new("src/models"); + + // .vespertide.json -> .rs (strips ".vespertide" from stem) + let rel_path = Path::new("user.vespertide.json"); + let out = build_output_path(root, rel_path, Orm::SeaOrm); + assert_eq!(out, Path::new("src/models/user.rs")); + + // Nested path with .vespertide.json + let rel_path2 = Path::new("blog/post.vespertide.json"); + let out2 = build_output_path(root, rel_path2, Orm::SeaOrm); + assert_eq!(out2, Path::new("src/models/blog/post.rs")); + + // .vespertide.yaml -> .py + let rel_path3 = Path::new("order.vespertide.yaml"); + let out3 = build_output_path(root, rel_path3, Orm::SqlAlchemy); + assert_eq!(out3, Path::new("src/models/order.py")); + + // Regular .json without .vespertide suffix still works + let rel_path4 = Path::new("item.json"); + let out4 = build_output_path(root, rel_path4, Orm::SeaOrm); + assert_eq!(out4, Path::new("src/models/item.rs")); +} + +#[tokio::test] +#[serial] +async fn ensure_mod_chain_strips_vespertide_suffix() { + let tmp = tempdir().unwrap(); + let root = tmp.path().join("src/models"); + std_fs::create_dir_all(&root).unwrap(); + + // File with .vespertide suffix should produce mod declaration without it + ensure_mod_chain(&root, Path::new("user.vespertide.json")) + .await + .unwrap(); + + let root_mod = std_fs::read_to_string(root.join("mod.rs")).unwrap(); + // Should be "pub mod user;" not "pub mod user_vespertide;" + assert!(root_mod.contains("pub mod user;")); + assert!(!root_mod.contains("user_vespertide")); + + // Nested path with .vespertide suffix + ensure_mod_chain(&root, Path::new("blog/post.vespertide.json")) + .await + .unwrap(); + let root_mod = std_fs::read_to_string(root.join("mod.rs")).unwrap(); + let blog_mod = std_fs::read_to_string(root.join("blog/mod.rs")).unwrap(); + assert!(root_mod.contains("pub mod blog;")); + assert!(blog_mod.contains("pub mod post;")); + assert!(!root_mod.contains("post_vespertide")); + assert!(!blog_mod.contains("post_vespertide")); +} + +#[tokio::test] +async fn clean_export_dir_removes_rs_files_for_seaorm() { + let tmp = tempdir().unwrap(); + let root = tmp.path().join("export_dir"); + std_fs::create_dir_all(&root).unwrap(); + + // Create some .rs files that should be cleaned + std_fs::write(root.join("old_model.rs"), "// old rust file").unwrap(); + std_fs::write(root.join("another.rs"), "// another rust file").unwrap(); + // Create a non-.rs file that should NOT be cleaned + std_fs::write(root.join("keep.txt"), "keep this").unwrap(); + + clean_export_dir(&root, Orm::SeaOrm).await.unwrap(); + + // .rs files should be gone + assert!(!root.join("old_model.rs").exists()); + assert!(!root.join("another.rs").exists()); + // .txt file should remain + assert!(root.join("keep.txt").exists()); +} + +#[tokio::test] +async fn clean_export_dir_removes_py_files_for_sqlalchemy() { + let tmp = tempdir().unwrap(); + let root = tmp.path().join("export_dir"); + std_fs::create_dir_all(&root).unwrap(); + + // Create some .py files that should be cleaned + std_fs::write(root.join("old_model.py"), "# old python file").unwrap(); + // Create a .rs file that should NOT be cleaned + std_fs::write(root.join("keep.rs"), "// keep this").unwrap(); + + clean_export_dir(&root, Orm::SqlAlchemy).await.unwrap(); + + // .py files should be gone + assert!(!root.join("old_model.py").exists()); + // .rs file should remain + assert!(root.join("keep.rs").exists()); +} + +#[tokio::test] +async fn clean_export_dir_removes_py_files_for_sqlmodel() { + let tmp = tempdir().unwrap(); + let root = tmp.path().join("export_dir"); + std_fs::create_dir_all(&root).unwrap(); + + std_fs::write(root.join("model.py"), "# python file").unwrap(); + + clean_export_dir(&root, Orm::SqlModel).await.unwrap(); + + assert!(!root.join("model.py").exists()); +} + +#[tokio::test] +async fn clean_export_dir_removes_java_files_for_jpa() { + let tmp = tempdir().unwrap(); + let root = tmp.path().join("export_dir"); + std_fs::create_dir_all(&root).unwrap(); + + std_fs::write(root.join("User.java"), "// java entity").unwrap(); + std_fs::write(root.join("Order.java"), "// java entity").unwrap(); + std_fs::write(root.join("keep.rs"), "// keep this").unwrap(); + + clean_export_dir(&root, Orm::Jpa).await.unwrap(); + + assert!(!root.join("User.java").exists()); + assert!(!root.join("Order.java").exists()); + assert!(root.join("keep.rs").exists()); +} + +#[tokio::test] +async fn clean_export_dir_handles_missing_directory() { + let tmp = tempdir().unwrap(); + let root = tmp.path().join("nonexistent_dir"); + + // Should not error on missing directory + let result = clean_export_dir(&root, Orm::SeaOrm).await; + assert!(result.is_ok()); +} + +#[tokio::test] +async fn clean_dir_recursive_cleans_subdirectories() { + let tmp = tempdir().unwrap(); + let root = tmp.path().join("export_dir"); + let subdir = root.join("nested"); + std_fs::create_dir_all(&subdir).unwrap(); + + // Create files in root and subdirectory + std_fs::write(root.join("root.rs"), "// root").unwrap(); + std_fs::write(subdir.join("nested.rs"), "// nested").unwrap(); + std_fs::write(subdir.join("keep.txt"), "keep").unwrap(); + + clean_dir_recursive(&root, "rs").await.unwrap(); + + // .rs files should be gone + assert!(!root.join("root.rs").exists()); + assert!(!subdir.join("nested.rs").exists()); + // .txt file should remain + assert!(subdir.join("keep.txt").exists()); + // subdir should still exist (has .txt file) + assert!(subdir.exists()); +} + +#[tokio::test] +async fn clean_dir_recursive_removes_empty_subdirectories() { + let tmp = tempdir().unwrap(); + let root = tmp.path().join("export_dir"); + let subdir = root.join("empty_after_clean"); + std_fs::create_dir_all(&subdir).unwrap(); + + // Create only .rs files in subdirectory + std_fs::write(subdir.join("only.rs"), "// only").unwrap(); + + clean_dir_recursive(&root, "rs").await.unwrap(); + + // .rs file should be gone + assert!(!subdir.join("only.rs").exists()); + // Empty subdirectory should be removed + assert!(!subdir.exists()); +} + +#[tokio::test] +async fn clean_dir_recursive_handles_non_directory() { + let tmp = tempdir().unwrap(); + let file_path = tmp.path().join("not_a_dir.txt"); + std_fs::write(&file_path, "content").unwrap(); + + // Should not error when called on a file instead of directory + let result = clean_dir_recursive(&file_path, "rs").await; + assert!(result.is_ok()); +} + +#[test] +fn build_output_path_jpa_uses_pascal_case_java_extension() { + use std::path::Path; + let root = Path::new("src/models"); + + // snake_case model → PascalCase .java + let rel_path = Path::new("order_item.json"); + let out = build_output_path(root, rel_path, Orm::Jpa); + assert_eq!(out, Path::new("src/models/OrderItem.java")); + + // Single word + let rel_path2 = Path::new("users.json"); + let out2 = build_output_path(root, rel_path2, Orm::Jpa); + assert_eq!(out2, Path::new("src/models/Users.java")); + + // Nested path + let rel_path3 = Path::new("blog/post_comment.yaml"); + let out3 = build_output_path(root, rel_path3, Orm::Jpa); + assert_eq!(out3, Path::new("src/models/blog/PostComment.java")); +} + +#[test] +fn build_output_path_jpa_strips_vespertide_suffix() { + use std::path::Path; + let root = Path::new("src/models"); + + let rel_path = Path::new("user.vespertide.json"); + let out = build_output_path(root, rel_path, Orm::Jpa); + assert_eq!(out, Path::new("src/models/User.java")); +} + +#[rstest] +#[case("order_item", "OrderItem")] +#[case("users", "Users")] +#[case("a", "A")] +#[case("user_profile_image", "UserProfileImage")] +#[case("a__b", "AB")] +fn test_to_pascal_case(#[case] input: &str, #[case] expected: &str) { + assert_eq!(to_pascal_case(input), expected); +} diff --git a/crates/vespertide-cli/src/commands/export/tests/prisma.rs b/crates/vespertide-cli/src/commands/export/tests/prisma.rs new file mode 100644 index 00000000..417fbd88 --- /dev/null +++ b/crates/vespertide-cli/src/commands/export/tests/prisma.rs @@ -0,0 +1,81 @@ +use super::*; + +#[rstest] +#[case::postgres( + DatabaseBackend::Postgres, + "provider = \"postgresql\"", + "@db.Timestamptz", + None +)] +#[case::mysql( + DatabaseBackend::MySql, + "provider = \"mysql\"", + "@db.Timestamp", + Some("@db.Timestamptz") +)] +#[case::sqlite( + DatabaseBackend::Sqlite, + "provider = \"sqlite\"", + "DateTime", + Some("@db.") +)] +#[tokio::test] +#[serial] +async fn export_prisma_writes_provider_specific_schema( + #[case] backend: DatabaseBackend, + #[case] provider_line: &str, + #[case] expected: &str, + #[case] absent: Option<&str>, +) { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + + let mut model = sample_table("events"); + model.columns.push(ColumnDef { + name: "occurred_at".into(), + r#type: ColumnType::Simple(SimpleColumnType::Timestamptz), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }); + write_model(Path::new("models/events.json"), &model); + + cmd_export(OrmArg::Prisma, None, backend).await.unwrap(); + + let out = PathBuf::from("src/models/schema.prisma"); + assert!(out.exists()); + let content = std_fs::read_to_string(out).unwrap(); + assert!(content.contains(provider_line)); + assert!(content.contains(expected)); + if let Some(absent) = absent { + assert!(!content.contains(absent)); + } +} + +#[test] +fn build_output_path_prisma_uses_prisma_extension() { + use std::path::Path; + let root = Path::new("src/models"); + let out = build_output_path(root, Path::new("user.json"), Orm::Prisma); + assert_eq!(out, Path::new("src/models/user.prisma")); +} + +#[tokio::test] +#[serial] +async fn clean_export_dir_removes_stale_prisma_files() { + let tmp = tempdir().unwrap(); + let root = tmp.path().join("out"); + std_fs::create_dir_all(&root).unwrap(); + std_fs::write(root.join("schema.prisma"), "stale").unwrap(); + std_fs::write(root.join("keep.txt"), "keep").unwrap(); + + clean_export_dir(&root, Orm::Prisma).await.unwrap(); + + assert!(!root.join("schema.prisma").exists()); + assert!(root.join("keep.txt").exists()); +} From 158dd43135e1200676272008cda7e17624e273fe Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:16:34 +0900 Subject: [PATCH 22/25] =?UTF-8?q?refactor(exporter):=20Prisma=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=20=EC=83=9D=EC=84=B1=20=EB=B0=B1=EC=97=94?= =?UTF-8?q?=EB=93=9C=20=EC=A4=91=EB=A6=BD=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 1 - .../vespertide-cli/src/commands/export/mod.rs | 12 +- .../src/commands/export/tests/mod.rs | 22 +- .../src/commands/export/tests/prisma.rs | 36 +-- crates/vespertide-cli/src/main.rs | 9 +- crates/vespertide-exporter/Cargo.toml | 1 - crates/vespertide-exporter/src/prisma/mod.rs | 56 +--- .../vespertide-exporter/src/prisma/render.rs | 67 ++-- .../vespertide-exporter/src/prisma/types.rs | 306 +++++------------- ...ypes_snapshot@all_simple_types_Prisma.snap | 21 +- ...le_pk_snapshot@basic_single_pk_Prisma.snap | 3 +- ...t@basic_table_with_description_Prisma.snap | 5 +- ...x_types_snapshot@complex_types_Prisma.snap | 7 +- ...index_snapshot@composite_index_Prisma.snap | 4 +- ...snapshot@composite_primary_key_Prisma.snap | 3 +- ...ot@composite_unique_constraint_Prisma.snap | 4 +- ...ique_snapshot@composite_unique_Prisma.snap | 4 +- ...ts__defaults_snapshot@defaults_Prisma.snap | 3 +- ...ith_comment_and_auto_increment_Prisma.snap | 3 +- ...__inline_pk_snapshot@inline_pk_Prisma.snap | 5 +- ...umns_snapshot@nullable_columns_Prisma.snap | 5 +- ...snapshot@numeric_default_value_Prisma.snap | 3 +- ...er_snapshot@pk_and_fk_together_Prisma.snap | 9 +- ...apshots@dual_reverse_relations_Prisma.snap | 3 +- ...any_to_many_multiple_junctions_Prisma.snap | 3 +- ...ma_snapshots@many_to_many_user_Prisma.snap | 3 +- ...apshots@multiple_fk_same_table_Prisma.snap | 7 +- ...ots@multiple_has_one_relations_Prisma.snap | 3 +- ...ots@multiple_reverse_relations_Prisma.snap | 3 +- ...shots@triple_reverse_relations_Prisma.snap | 3 +- ...h_schema_snapshots@username_fk_Prisma.snap | 5 +- ...shot@reserved_word_identifiers_Prisma.snap | 3 +- ...erver_default_and_true_boolean_Prisma.snap | 7 +- ...aults_snapshot@server_defaults_Prisma.snap | 5 +- ...@small_multi_schema_sequential_Prisma.snap | 5 +- ...efault_snapshot@string_default_Prisma.snap | 3 +- ...vel_pk_snapshot@table_level_pk_Prisma.snap | 7 +- ...apshot@table_with_composite_fk_Prisma.snap | 3 +- ...with_fk_snapshot@table_with_fk_Prisma.snap | 3 +- ...es_snapshot@table_with_indexes_Prisma.snap | 6 +- ...ed_snapshot@unique_and_indexed_Prisma.snap | 9 +- ...pshot@unknown_constant_default_Prisma.snap | 3 +- ...pshot@unknown_function_default_Prisma.snap | 3 +- ...pshot@unnamed_index_and_unique_Prisma.snap | 3 +- 44 files changed, 225 insertions(+), 454 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3b48e19c..866612ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2915,7 +2915,6 @@ dependencies = [ "vespertide-config", "vespertide-core", "vespertide-naming", - "vespertide-query", ] [[package]] diff --git a/crates/vespertide-cli/src/commands/export/mod.rs b/crates/vespertide-cli/src/commands/export/mod.rs index 91fe3b57..05fec433 100644 --- a/crates/vespertide-cli/src/commands/export/mod.rs +++ b/crates/vespertide-cli/src/commands/export/mod.rs @@ -11,7 +11,6 @@ use vespertide_core::TableDef; use vespertide_exporter::{ Orm, prisma, render_entity_with_schema, seaorm::SeaOrmExporterWithConfig, }; -use vespertide_query::DatabaseBackend; use crate::parallel_config::{EXPORT_RENDER_PAR_MIN_LEN, EXPORT_RENDER_PAR_THRESHOLD}; use crate::utils::load_config; @@ -37,11 +36,7 @@ impl From for Orm { } } -pub async fn cmd_export( - orm: OrmArg, - export_dir: Option, - backend: DatabaseBackend, -) -> Result<()> { +pub async fn cmd_export(orm: OrmArg, export_dir: Option) -> Result<()> { let config = load_config()?; let models = load_models_recursive(config.models_dir()) .await @@ -62,7 +57,7 @@ pub async fn cmd_export( // Prisma uses a single-file output strategy if matches!(orm, OrmArg::Prisma) { - return cmd_export_prisma(normalized_models, target_root, backend).await; + return cmd_export_prisma(normalized_models, target_root).await; } // Clean the export directory before regenerating @@ -445,10 +440,9 @@ async fn ensure_mod_chain(root: &Path, rel_path: &Path) -> Result<()> { async fn cmd_export_prisma( normalized_models: Vec<(TableDef, PathBuf)>, target_root: PathBuf, - backend: DatabaseBackend, ) -> Result<()> { let all_tables: Vec = normalized_models.iter().map(|(t, _)| t.clone()).collect(); - let content = prisma::render_schema(&all_tables, backend); + let content = prisma::render_schema(&all_tables); clean_export_dir(&target_root, Orm::Prisma).await?; diff --git a/crates/vespertide-cli/src/commands/export/tests/mod.rs b/crates/vespertide-cli/src/commands/export/tests/mod.rs index 1aa49b32..10a76895 100644 --- a/crates/vespertide-cli/src/commands/export/tests/mod.rs +++ b/crates/vespertide-cli/src/commands/export/tests/mod.rs @@ -54,9 +54,7 @@ async fn export_writes_seaorm_files_to_default_dir() { let model = sample_table("users"); write_model(Path::new("models/users.json"), &model); - cmd_export(OrmArg::Seaorm, None, DatabaseBackend::Postgres) - .await - .unwrap(); + cmd_export(OrmArg::Seaorm, None).await.unwrap(); let out = PathBuf::from("src/models/users.rs"); assert!(out.exists()); @@ -81,13 +79,9 @@ async fn export_respects_custom_output_dir() { write_model(Path::new("models/blog/posts.json"), &model); let custom = PathBuf::from("out_dir"); - cmd_export( - OrmArg::Seaorm, - Some(custom.clone()), - DatabaseBackend::Postgres, - ) - .await - .unwrap(); + cmd_export(OrmArg::Seaorm, Some(custom.clone())) + .await + .unwrap(); let out = custom.join("blog/posts.rs"); assert!(out.exists()); @@ -115,9 +109,7 @@ async fn export_with_sqlalchemy_sets_py_extension() { let model = sample_table("items"); write_model(Path::new("models/items.json"), &model); - cmd_export(OrmArg::Sqlalchemy, None, DatabaseBackend::Postgres) - .await - .unwrap(); + cmd_export(OrmArg::Sqlalchemy, None).await.unwrap(); let out = PathBuf::from("src/models/items.py"); assert!(out.exists()); @@ -135,9 +127,7 @@ async fn export_with_sqlmodel_sets_py_extension() { let model = sample_table("orders"); write_model(Path::new("models/orders.json"), &model); - cmd_export(OrmArg::Sqlmodel, None, DatabaseBackend::Postgres) - .await - .unwrap(); + cmd_export(OrmArg::Sqlmodel, None).await.unwrap(); let out = PathBuf::from("src/models/orders.py"); assert!(out.exists()); diff --git a/crates/vespertide-cli/src/commands/export/tests/prisma.rs b/crates/vespertide-cli/src/commands/export/tests/prisma.rs index 417fbd88..da450719 100644 --- a/crates/vespertide-cli/src/commands/export/tests/prisma.rs +++ b/crates/vespertide-cli/src/commands/export/tests/prisma.rs @@ -1,32 +1,8 @@ use super::*; -#[rstest] -#[case::postgres( - DatabaseBackend::Postgres, - "provider = \"postgresql\"", - "@db.Timestamptz", - None -)] -#[case::mysql( - DatabaseBackend::MySql, - "provider = \"mysql\"", - "@db.Timestamp", - Some("@db.Timestamptz") -)] -#[case::sqlite( - DatabaseBackend::Sqlite, - "provider = \"sqlite\"", - "DateTime", - Some("@db.") -)] #[tokio::test] #[serial] -async fn export_prisma_writes_provider_specific_schema( - #[case] backend: DatabaseBackend, - #[case] provider_line: &str, - #[case] expected: &str, - #[case] absent: Option<&str>, -) { +async fn export_prisma_writes_backend_neutral_schema() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(&tmp.path().to_path_buf()); write_config(); @@ -45,16 +21,14 @@ async fn export_prisma_writes_provider_specific_schema( }); write_model(Path::new("models/events.json"), &model); - cmd_export(OrmArg::Prisma, None, backend).await.unwrap(); + cmd_export(OrmArg::Prisma, None).await.unwrap(); let out = PathBuf::from("src/models/schema.prisma"); assert!(out.exists()); let content = std_fs::read_to_string(out).unwrap(); - assert!(content.contains(provider_line)); - assert!(content.contains(expected)); - if let Some(absent) = absent { - assert!(!content.contains(absent)); - } + assert!(content.contains("provider = \"postgresql\"")); + assert!(content.contains("occurred_at DateTime")); + assert!(!content.contains("@db.")); } #[test] diff --git a/crates/vespertide-cli/src/main.rs b/crates/vespertide-cli/src/main.rs index eadec8eb..8088303b 100644 --- a/crates/vespertide-cli/src/main.rs +++ b/crates/vespertide-cli/src/main.rs @@ -100,9 +100,6 @@ enum Commands { /// Output directory (defaults to config modelsDir or src/models). #[arg(short = 'd', long = "export-dir")] export_dir: Option, - /// Database backend for Prisma schema generation (other ORMs ignore this). - #[arg(short = 'b', long = "backend", value_enum, default_value = "postgres")] - backend: BackendArg, }, /// Export schema as ERD diagrams (SVG, Mermaid, Graphviz DOT). Erd { @@ -146,11 +143,7 @@ async fn main() -> Result<()> { delete_null_rows, }) => cmd_revision(message, fill_with, delete_null_rows).await, Some(Commands::Init) => cmd_init().await, - Some(Commands::Export { - orm, - export_dir, - backend, - }) => cmd_export(orm, export_dir, backend.into()).await, + Some(Commands::Export { orm, export_dir }) => cmd_export(orm, export_dir).await, Some(Commands::Erd { format, output, diff --git a/crates/vespertide-exporter/Cargo.toml b/crates/vespertide-exporter/Cargo.toml index d38d4b90..98addd72 100644 --- a/crates/vespertide-exporter/Cargo.toml +++ b/crates/vespertide-exporter/Cargo.toml @@ -16,7 +16,6 @@ rayon = { workspace = true } vespertide-core = { workspace = true } vespertide-config = { workspace = true } vespertide-naming = { workspace = true } -vespertide-query = { workspace = true } thiserror = "2" [dev-dependencies] diff --git a/crates/vespertide-exporter/src/prisma/mod.rs b/crates/vespertide-exporter/src/prisma/mod.rs index 9ef691bf..540437a2 100644 --- a/crates/vespertide-exporter/src/prisma/mod.rs +++ b/crates/vespertide-exporter/src/prisma/mod.rs @@ -7,23 +7,9 @@ use std::collections::HashSet; use crate::orm::OrmExporter; use vespertide_core::TableDef; use vespertide_core::schema::column::{ColumnType, ComplexColumnType, EnumValues}; -use vespertide_query::DatabaseBackend; pub struct PrismaExporter; -/// The `provider = "..."` string used in the `datasource` block. -/// -/// A Prisma schema declares exactly one datasource provider, and native -/// `@db.*` type attributes are validated against that provider's connector — -/// so the same tables render differently per backend. -fn datasource_provider_str(backend: DatabaseBackend) -> &'static str { - match backend { - DatabaseBackend::Postgres => "postgresql", - DatabaseBackend::MySql => "mysql", - DatabaseBackend::Sqlite => "sqlite", - } -} - impl OrmExporter for PrismaExporter { fn render_entity(&self, table: &TableDef) -> Result { Ok(render_entity(table)) @@ -41,7 +27,12 @@ impl OrmExporter for PrismaExporter { /// Render a complete `schema.prisma` file for all tables. /// /// Output order: datasource → generator → (globally deduped) enum blocks → model blocks. -pub fn render_schema(tables: &[TableDef], backend: DatabaseBackend) -> String { +/// +/// The output is backend-neutral: model bodies carry no `@db.*` native +/// attributes, so the file stays valid regardless of which backend vespertide +/// targets. The datasource provider is fixed to `postgresql` (Prisma requires +/// declaring exactly one); users on another backend only change that one line. +pub fn render_schema(tables: &[TableDef]) -> String { let mut seen_enums: HashSet = HashSet::new(); let mut enum_blocks: Vec = Vec::new(); for table in tables { @@ -57,17 +48,14 @@ pub fn render_schema(tables: &[TableDef], backend: DatabaseBackend) -> String { // No `url` here: current Prisma moved connection config out of the // schema file (`prisma.config.ts`), which also matches vespertide's // models-only scope. - parts.push(format!( - "datasource db {{\n provider = \"{}\"\n}}", - datasource_provider_str(backend) - )); + parts.push("datasource db {\n provider = \"postgresql\"\n}".to_string()); parts.push("generator client {\n provider = \"prisma-client-js\"\n}".to_string()); parts.extend(enum_blocks); for table in tables { - parts.push(render::render_model(table, tables, backend)); + parts.push(render::render_model(table, tables)); } parts.join("\n\n") + "\n" @@ -96,19 +84,12 @@ pub fn render_entity(table: &TableDef) -> String { } /// Render enum blocks + model block with full schema context (includes back-relations). -/// -/// Uses the default backend (PostgreSQL) — backend-specific output goes -/// through [`render_schema`]. pub fn render_entity_with_schema(table: &TableDef, schema: &[TableDef]) -> String { let mut parts: Vec = Vec::new(); for (name, values) in collect_table_enums(table) { parts.push(enums::render_enum(name, values)); } - parts.push(render::render_model( - table, - schema, - DatabaseBackend::Postgres, - )); + parts.push(render::render_model(table, schema)); parts.join("\n\n") } @@ -134,23 +115,16 @@ pub fn to_pascal_case_for_tests(s: &str) -> String { #[cfg(test)] mod tests { - use rstest::rstest; - use super::*; use crate::tests::fixtures::basic_single_pk; - #[rstest] - #[case::postgres(DatabaseBackend::Postgres, "provider = \"postgresql\"")] - #[case::mysql(DatabaseBackend::MySql, "provider = \"mysql\"")] - #[case::sqlite(DatabaseBackend::Sqlite, "provider = \"sqlite\"")] - fn render_schema_emits_configured_provider( - #[case] backend: DatabaseBackend, - #[case] expected_line: &str, - ) { + #[test] + fn render_schema_emits_postgresql_provider() { let tables = vec![basic_single_pk()]; - let schema = render_schema(&tables, backend); + let schema = render_schema(&tables); - assert!(schema.contains(expected_line)); + assert!(schema.contains("provider = \"postgresql\"")); + assert!(!schema.contains("@db.")); assert!(!schema.contains("output")); } @@ -159,7 +133,7 @@ mod tests { let t1 = crate::tests::fixtures::enum_shared(); let mut t2 = crate::tests::fixtures::enum_shared(); t2.name = "archived_documents".into(); - let schema = render_schema(&[t1, t2], DatabaseBackend::Postgres); + let schema = render_schema(&[t1, t2]); assert_eq!(schema.matches("enum DocStatus {").count(), 1); } diff --git a/crates/vespertide-exporter/src/prisma/render.rs b/crates/vespertide-exporter/src/prisma/render.rs index 07ffedcc..262c67af 100644 --- a/crates/vespertide-exporter/src/prisma/render.rs +++ b/crates/vespertide-exporter/src/prisma/render.rs @@ -6,8 +6,6 @@ use vespertide_core::schema::constraint::TableConstraint; use vespertide_core::schema::names::ColumnName; use vespertide_core::schema::reference::ReferenceAction; -use vespertide_query::DatabaseBackend; - use super::enums::{to_pascal_case, to_screaming_snake}; use super::types::column_type_to_prisma; @@ -129,11 +127,7 @@ pub(super) fn collect_back_relations(target_table: &str, schema: &[TableDef]) -> result } -pub(super) fn render_model( - table: &TableDef, - schema: &[TableDef], - provider: DatabaseBackend, -) -> String { +pub(super) fn render_model(table: &TableDef, schema: &[TableDef]) -> String { let mut lines: Vec = Vec::new(); if let Some(desc) = &table.description { @@ -218,7 +212,7 @@ pub(super) fn render_model( lines.push(format!(" /// {comment}")); } - let (type_str, native_attr) = column_type_to_prisma(&col.r#type, col.nullable, provider); + let (type_str, trailing_comment) = column_type_to_prisma(&col.r#type, col.nullable); let mut attrs: Vec = Vec::new(); if is_single_pk { @@ -229,11 +223,7 @@ pub(super) fn render_model( } if !auto_inc && let Some(ref default) = col.default { - attrs.push(prisma_default_attr( - &default.to_sql(), - &col.r#type, - provider, - )); + attrs.push(prisma_default_attr(&default.to_sql(), &col.r#type)); } if let Some(unique_name) = is_unique @@ -245,17 +235,17 @@ pub(super) fn render_model( } } - if let Some(ref native) = native_attr { - attrs.push(native.clone()); - } - let attrs_str = if attrs.is_empty() { String::new() } else { format!(" {}", attrs.join(" ")) }; - lines.push(format!(" {col_name} {type_str}{attrs_str}")); + let comment_str = trailing_comment + .map(|c| format!(" // {c}")) + .unwrap_or_default(); + + lines.push(format!(" {col_name} {type_str}{attrs_str}{comment_str}")); // Emit inline relation field for FK columns if let Some(fk) = fk_by_col.get(col_name) { @@ -369,11 +359,7 @@ pub(super) fn render_model( lines.join("\n") } -fn prisma_default_attr( - default_sql: &str, - col_type: &ColumnType, - provider: DatabaseBackend, -) -> String { +fn prisma_default_attr(default_sql: &str, col_type: &ColumnType) -> String { // Integer-backed enum: resolve to a variant identifier (SCREAMING_SNAKE), never a bare int. if let ColumnType::Complex(ComplexColumnType::Enum { values: EnumValues::Integer(int_values), @@ -411,12 +397,7 @@ fn prisma_default_attr( || lower.contains("uuid_generate_v4()") || lower.contains("newid()") { - // On MySQL the uuid column is BINARY(16) and Prisma's uuid() only - // applies to String fields — mirror the DDL's DB-side default instead. - return match provider { - DatabaseBackend::MySql => "@default(dbgenerated(\"(uuid())\"))".into(), - _ => "@default(uuid())".into(), - }; + return "@default(uuid())".into(); } // Any remaining function call → dbgenerated @@ -498,10 +479,7 @@ mod tests { }, ]), }); - assert_eq!( - prisma_default_attr("100", &ty, DatabaseBackend::Postgres), - "@default(LOW)" - ); + assert_eq!(prisma_default_attr("100", &ty), "@default(LOW)"); } #[test] @@ -512,33 +490,24 @@ mod tests { *on_update = Some(ReferenceAction::Cascade); } } - let rendered = render_model( - &table, - std::slice::from_ref(&table), - DatabaseBackend::Postgres, - ); + let rendered = render_model(&table, std::slice::from_ref(&table)); assert!(rendered.contains("onUpdate: Cascade")); } #[test] fn named_index_is_rendered_with_map() { let table = crate::tests::fixtures::table_with_indexes(); - let rendered = render_model(&table, &[], DatabaseBackend::Postgres); + let rendered = render_model(&table, &[]); assert!(rendered.contains("@@index([created_at], map: \"idx_articles_created_at\")")); assert!(rendered.contains("@@index([title])")); } #[rstest] - #[case::postgres(DatabaseBackend::Postgres, "@default(uuid())")] - #[case::mysql(DatabaseBackend::MySql, "@default(dbgenerated(\"(uuid())\"))")] - #[case::sqlite(DatabaseBackend::Sqlite, "@default(uuid())")] - fn uuid_generating_default_follows_provider( - #[case] provider: DatabaseBackend, - #[case] expected: &str, - ) { + #[case::postgres_gen_random_uuid("gen_random_uuid()")] + #[case::postgres_uuid_generate_v4("uuid_generate_v4()")] + #[case::mssql_newid("NEWID()")] + fn uuid_generating_default_maps_to_client_uuid(#[case] default_sql: &str) { let ty = ColumnType::Simple(SimpleColumnType::Uuid); - for default_sql in ["gen_random_uuid()", "uuid_generate_v4()", "NEWID()"] { - assert_eq!(prisma_default_attr(default_sql, &ty, provider), expected); - } + assert_eq!(prisma_default_attr(default_sql, &ty), "@default(uuid())"); } } diff --git a/crates/vespertide-exporter/src/prisma/types.rs b/crates/vespertide-exporter/src/prisma/types.rs index a5dc4602..da99a4c3 100644 --- a/crates/vespertide-exporter/src/prisma/types.rs +++ b/crates/vespertide-exporter/src/prisma/types.rs @@ -1,83 +1,49 @@ use vespertide_core::schema::column::{ColumnType, ComplexColumnType, SimpleColumnType}; -use vespertide_query::DatabaseBackend; - use super::enums::to_pascal_case; -pub(super) fn column_type_to_prisma( - ty: &ColumnType, - nullable: bool, - provider: DatabaseBackend, -) -> (String, Option) { +/// Maps a vespertide column type to a `(Prisma scalar type, optional trailing +/// `// ...` comment)` pair. +/// +/// The output is backend-neutral: no `@db.*` native attributes are emitted, so +/// the same model body is valid under every Prisma provider. Physical column +/// types are owned by vespertide's own DDL generation, not the Prisma schema. +pub(super) fn column_type_to_prisma(ty: &ColumnType, nullable: bool) -> (String, Option) { let q = if nullable { "?" } else { "" }; match ty { - // vespertide stores MySQL UUIDs as BINARY(16) (see vespertide-query's - // uuid mapping), so the Prisma scalar itself differs per provider. - ColumnType::Simple(SimpleColumnType::Uuid) => { - let (base, native) = match provider { - DatabaseBackend::Postgres => ("String", Some("@db.Uuid")), - DatabaseBackend::MySql => ("Bytes", Some("@db.Binary(16)")), - DatabaseBackend::Sqlite => ("String", None), - }; - (format!("{base}{q}"), native.map(str::to_string)) - } ColumnType::Simple(simple) => { - // (scalar, postgres native attr, mysql native attr) — mirrors the - // per-backend DDL mapping in vespertide-query's `apply_simple_column_type`. - // SQLite never gets native attrs (its connector supports none). - let (base, pg_native, mysql_native) = match simple { - SimpleColumnType::SmallInt => ("Int", Some("@db.SmallInt"), Some("@db.SmallInt")), - SimpleColumnType::Integer => ("Int", None, None), - SimpleColumnType::BigInt => ("BigInt", None, None), - SimpleColumnType::Real => ("Float", Some("@db.Real"), Some("@db.Float")), - SimpleColumnType::DoublePrecision => ("Float", None, None), - SimpleColumnType::Text => ("String", Some("@db.Text"), Some("@db.Text")), - SimpleColumnType::Boolean => ("Boolean", None, None), - SimpleColumnType::Date => ("DateTime", Some("@db.Date"), Some("@db.Date")), - SimpleColumnType::Time => ("DateTime", Some("@db.Time"), Some("@db.Time")), - SimpleColumnType::Timestamp => { - ("DateTime", Some("@db.Timestamp"), Some("@db.Timestamp")) - } - // MySQL has no TIMESTAMPTZ; the DDL degrades it to TIMESTAMP. - SimpleColumnType::Timestamptz => { - ("DateTime", Some("@db.Timestamptz"), Some("@db.Timestamp")) - } - SimpleColumnType::Bytea => ("Bytes", None, None), - SimpleColumnType::Json => ("Json", None, None), - SimpleColumnType::Inet => ("String", Some("@db.Inet"), Some("@db.Text")), - SimpleColumnType::Xml => ("String", Some("@db.Xml"), Some("@db.Text")), - // Interval/Cidr/Macaddr have no Prisma native type on PostgreSQL; - // the MySQL DDL degrades all three to TEXT. - SimpleColumnType::Interval | SimpleColumnType::Cidr | SimpleColumnType::Macaddr => { - ("String", None, Some("@db.Text")) - } + let base = match simple { + SimpleColumnType::SmallInt | SimpleColumnType::Integer => "Int", + SimpleColumnType::BigInt => "BigInt", + SimpleColumnType::Real | SimpleColumnType::DoublePrecision => "Float", + SimpleColumnType::Boolean => "Boolean", + SimpleColumnType::Date + | SimpleColumnType::Time + | SimpleColumnType::Timestamp + | SimpleColumnType::Timestamptz => "DateTime", + SimpleColumnType::Bytea => "Bytes", + SimpleColumnType::Json => "Json", + SimpleColumnType::Uuid + | SimpleColumnType::Text + | SimpleColumnType::Interval + | SimpleColumnType::Inet + | SimpleColumnType::Cidr + | SimpleColumnType::Macaddr + | SimpleColumnType::Xml => "String", // Unknown/future simple types fall back to a plain String column. // `SimpleColumnType` is #[non_exhaustive]; every current variant is // matched above, so this arm is currently unreachable. #[cfg(not(tarpaulin_include))] - _ => ("String", None, None), - }; - let native = match provider { - DatabaseBackend::Postgres => pg_native, - DatabaseBackend::MySql => mysql_native, - DatabaseBackend::Sqlite => None, + _ => "String", }; - (format!("{base}{q}"), native.map(str::to_string)) + (format!("{base}{q}"), None) } ColumnType::Complex(complex) => match complex { - ComplexColumnType::Varchar { length } => ( - format!("String{q}"), - sized_native(provider, format!("@db.VarChar({length})")), - ), - ComplexColumnType::Char { length } => ( - format!("String{q}"), - sized_native(provider, format!("@db.Char({length})")), - ), - ComplexColumnType::Numeric { precision, scale } => ( - format!("Decimal{q}"), - sized_native(provider, format!("@db.Decimal({precision}, {scale})")), - ), + ComplexColumnType::Varchar { .. } | ComplexColumnType::Char { .. } => { + (format!("String{q}"), None) + } + ComplexColumnType::Numeric { .. } => (format!("Decimal{q}"), None), ComplexColumnType::Custom { custom_type } => { (format!("Unsupported(\"{custom_type}\"){q}"), None) } @@ -94,15 +60,6 @@ pub(super) fn column_type_to_prisma( } } -/// VarChar/Char/Decimal natives are spelled identically on PostgreSQL and -/// MySQL; SQLite supports no native attrs at all. -fn sized_native(provider: DatabaseBackend, attr: String) -> Option { - match provider { - DatabaseBackend::Postgres | DatabaseBackend::MySql => Some(attr), - DatabaseBackend::Sqlite => None, - } -} - #[cfg(test)] mod tests { use rstest::rstest; @@ -110,189 +67,81 @@ mod tests { use super::*; + /// Exhaustive oracle for every simple type. Each row is independent test + /// data, so flipping any production mapping arm fails here immediately. #[rstest] - #[case::postgres(DatabaseBackend::Postgres, "String", Some("@db.Uuid"))] - #[case::mysql(DatabaseBackend::MySql, "Bytes", Some("@db.Binary(16)"))] - #[case::sqlite(DatabaseBackend::Sqlite, "String", None)] - fn uuid_scalar_and_native_follow_provider( - #[case] provider: DatabaseBackend, - #[case] scalar: &str, - #[case] native: Option<&str>, - ) { - let ty = ColumnType::Simple(SimpleColumnType::Uuid); - let (rendered, attr) = column_type_to_prisma(&ty, false, provider); + #[case::small_int(SimpleColumnType::SmallInt, "Int")] + #[case::integer(SimpleColumnType::Integer, "Int")] + #[case::big_int(SimpleColumnType::BigInt, "BigInt")] + #[case::real(SimpleColumnType::Real, "Float")] + #[case::double_precision(SimpleColumnType::DoublePrecision, "Float")] + #[case::boolean(SimpleColumnType::Boolean, "Boolean")] + #[case::date(SimpleColumnType::Date, "DateTime")] + #[case::time(SimpleColumnType::Time, "DateTime")] + #[case::timestamp(SimpleColumnType::Timestamp, "DateTime")] + #[case::timestamptz(SimpleColumnType::Timestamptz, "DateTime")] + #[case::bytea(SimpleColumnType::Bytea, "Bytes")] + #[case::json(SimpleColumnType::Json, "Json")] + #[case::uuid(SimpleColumnType::Uuid, "String")] + #[case::text(SimpleColumnType::Text, "String")] + #[case::interval(SimpleColumnType::Interval, "String")] + #[case::inet(SimpleColumnType::Inet, "String")] + #[case::cidr(SimpleColumnType::Cidr, "String")] + #[case::macaddr(SimpleColumnType::Macaddr, "String")] + #[case::xml(SimpleColumnType::Xml, "String")] + fn simple_types_map_to_neutral_scalars(#[case] simple: SimpleColumnType, #[case] scalar: &str) { + let (rendered, comment) = column_type_to_prisma(&ColumnType::Simple(simple), false); assert_eq!(rendered, scalar); - assert_eq!(attr.as_deref(), native); + assert_eq!(comment, None); } - #[rstest] - #[case::postgres(DatabaseBackend::Postgres, Some("@db.Timestamptz"))] - #[case::mysql(DatabaseBackend::MySql, Some("@db.Timestamp"))] - #[case::sqlite(DatabaseBackend::Sqlite, None)] - fn timestamptz_native_follows_provider( - #[case] provider: DatabaseBackend, - #[case] native: Option<&str>, - ) { + #[test] + fn nullable_appends_question_mark() { let ty = ColumnType::Simple(SimpleColumnType::Timestamptz); - let (rendered, attr) = column_type_to_prisma(&ty, false, provider); - assert_eq!(rendered, "DateTime"); - assert_eq!(attr.as_deref(), native); - } - - #[rstest] - #[case::postgres(DatabaseBackend::Postgres, None)] - #[case::mysql(DatabaseBackend::MySql, Some("@db.Text"))] - #[case::sqlite(DatabaseBackend::Sqlite, None)] - fn interval_native_follows_provider( - #[case] provider: DatabaseBackend, - #[case] native: Option<&str>, - ) { - let ty = ColumnType::Simple(SimpleColumnType::Interval); - let (rendered, attr) = column_type_to_prisma(&ty, false, provider); - assert_eq!(rendered, "String"); - assert_eq!(attr.as_deref(), native); - } - - #[rstest] - #[case::postgres(DatabaseBackend::Postgres, Some("@db.Inet"))] - #[case::mysql(DatabaseBackend::MySql, Some("@db.Text"))] - #[case::sqlite(DatabaseBackend::Sqlite, None)] - fn inet_native_follows_provider( - #[case] provider: DatabaseBackend, - #[case] native: Option<&str>, - ) { - let ty = ColumnType::Simple(SimpleColumnType::Inet); - let (rendered, attr) = column_type_to_prisma(&ty, true, provider); - assert_eq!(rendered, "String?"); - assert_eq!(attr.as_deref(), native); + let (rendered, comment) = column_type_to_prisma(&ty, true); + assert_eq!(rendered, "DateTime?"); + assert_eq!(comment, None); } - #[rstest] - #[case::postgres(DatabaseBackend::Postgres, Some("@db.VarChar(255)"))] - #[case::mysql(DatabaseBackend::MySql, Some("@db.VarChar(255)"))] - #[case::sqlite(DatabaseBackend::Sqlite, None)] - fn varchar_native_follows_provider( - #[case] provider: DatabaseBackend, - #[case] native: Option<&str>, - ) { - let ty = ColumnType::Complex(ComplexColumnType::Varchar { length: 255 }); - let (rendered, attr) = column_type_to_prisma(&ty, false, provider); + #[test] + fn sized_complex_types_drop_size_info() { + let (rendered, comment) = column_type_to_prisma( + &ColumnType::Complex(ComplexColumnType::Varchar { length: 255 }), + false, + ); assert_eq!(rendered, "String"); - assert_eq!(attr.as_deref(), native); - } - - /// Exhaustive oracle for every simple type except `Uuid` (whose scalar - /// itself is provider-dependent — covered by its dedicated test above). - /// Each row is independent test data, so flipping any production mapping - /// tuple (mutation testing) fails here immediately. - #[rstest] - #[case::postgres(DatabaseBackend::Postgres)] - #[case::mysql(DatabaseBackend::MySql)] - #[case::sqlite(DatabaseBackend::Sqlite)] - fn simple_type_mapping_matrix(#[case] provider: DatabaseBackend) { - use SimpleColumnType as S; - // (type, scalar, postgres attr, mysql attr) — sqlite is always attr-less. - let rows: &[(S, &str, Option<&str>, Option<&str>)] = &[ - ( - S::SmallInt, - "Int", - Some("@db.SmallInt"), - Some("@db.SmallInt"), - ), - (S::Integer, "Int", None, None), - (S::BigInt, "BigInt", None, None), - (S::Real, "Float", Some("@db.Real"), Some("@db.Float")), - (S::DoublePrecision, "Float", None, None), - (S::Text, "String", Some("@db.Text"), Some("@db.Text")), - (S::Boolean, "Boolean", None, None), - (S::Date, "DateTime", Some("@db.Date"), Some("@db.Date")), - (S::Time, "DateTime", Some("@db.Time"), Some("@db.Time")), - ( - S::Timestamp, - "DateTime", - Some("@db.Timestamp"), - Some("@db.Timestamp"), - ), - ( - S::Timestamptz, - "DateTime", - Some("@db.Timestamptz"), - Some("@db.Timestamp"), - ), - (S::Interval, "String", None, Some("@db.Text")), - (S::Bytea, "Bytes", None, None), - (S::Json, "Json", None, None), - (S::Inet, "String", Some("@db.Inet"), Some("@db.Text")), - (S::Cidr, "String", None, Some("@db.Text")), - (S::Macaddr, "String", None, Some("@db.Text")), - (S::Xml, "String", Some("@db.Xml"), Some("@db.Text")), - ]; + assert_eq!(comment, None); - for (ty, scalar, pg, mysql) in rows { - let expected = match provider { - DatabaseBackend::Postgres => *pg, - DatabaseBackend::MySql => *mysql, - DatabaseBackend::Sqlite => None, - }; - let (rendered, attr) = column_type_to_prisma(&ColumnType::Simple(*ty), false, provider); - assert_eq!(rendered, *scalar, "{ty:?} scalar under {provider:?}"); - assert_eq!(attr.as_deref(), expected, "{ty:?} attr under {provider:?}"); - } - } - - #[rstest] - #[case::postgres( - DatabaseBackend::Postgres, - Some("@db.Char(3)"), - Some("@db.Decimal(10, 2)") - )] - #[case::mysql( - DatabaseBackend::MySql, - Some("@db.Char(3)"), - Some("@db.Decimal(10, 2)") - )] - #[case::sqlite(DatabaseBackend::Sqlite, None, None)] - fn char_and_numeric_natives_follow_provider( - #[case] provider: DatabaseBackend, - #[case] char_native: Option<&str>, - #[case] numeric_native: Option<&str>, - ) { - let (rendered, attr) = column_type_to_prisma( + let (rendered, comment) = column_type_to_prisma( &ColumnType::Complex(ComplexColumnType::Char { length: 3 }), false, - provider, ); assert_eq!(rendered, "String"); - assert_eq!(attr.as_deref(), char_native); + assert_eq!(comment, None); - let (rendered, attr) = column_type_to_prisma( + let (rendered, comment) = column_type_to_prisma( &ColumnType::Complex(ComplexColumnType::Numeric { precision: 10, scale: 2, }), true, - provider, ); assert_eq!(rendered, "Decimal?"); - assert_eq!(attr.as_deref(), numeric_native); + assert_eq!(comment, None); } - #[rstest] - #[case::postgres(DatabaseBackend::Postgres)] - #[case::mysql(DatabaseBackend::MySql)] - #[case::sqlite(DatabaseBackend::Sqlite)] - fn custom_and_enum_types_are_provider_invariant(#[case] provider: DatabaseBackend) { - let (rendered, attr) = column_type_to_prisma( + #[test] + fn custom_and_enum_types_render_without_comment() { + let (rendered, comment) = column_type_to_prisma( &ColumnType::Complex(ComplexColumnType::Custom { custom_type: "ltree".into(), }), false, - provider, ); assert_eq!(rendered, "Unsupported(\"ltree\")"); - assert_eq!(attr, None); + assert_eq!(comment, None); - let (rendered, attr) = column_type_to_prisma( + let (rendered, comment) = column_type_to_prisma( &ColumnType::Complex(ComplexColumnType::Enum { name: "order_status".into(), values: vespertide_core::schema::column::EnumValues::String(vec![ @@ -301,9 +150,8 @@ mod tests { ]), }), false, - provider, ); assert_eq!(rendered, "OrderStatus"); - assert_eq!(attr, None); + assert_eq!(comment, None); } } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Prisma.snap index 15c47595..ef045cc0 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Prisma.snap @@ -1,27 +1,28 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 138 expression: rendered --- model AllTypes { id Int @id - small Int @db.SmallInt + small Int big BigInt - real_num Float @db.Real + real_num Float double_num Float - text_col String @db.Text + text_col String bool_col Boolean - date_col DateTime @db.Date - time_col DateTime @db.Time - ts_col DateTime @db.Timestamp - tstz_col DateTime @db.Timestamptz + date_col DateTime + time_col DateTime + ts_col DateTime + tstz_col DateTime interval_col String bytea_col Bytes - uuid_col String @db.Uuid + uuid_col String json_col Json - inet_col String @db.Inet + inet_col String cidr_col String macaddr_col String - xml_col String @db.Xml + xml_col String @@map("all_types") } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_single_pk_snapshot@basic_single_pk_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_single_pk_snapshot@basic_single_pk_Prisma.snap index 8e48a736..11514319 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_single_pk_snapshot@basic_single_pk_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_single_pk_snapshot@basic_single_pk_Prisma.snap @@ -1,10 +1,11 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 66 expression: rendered --- model Users { id Int @id - display_name String? @db.Text + display_name String? @@map("users") } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_table_with_description_snapshot@basic_table_with_description_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_table_with_description_snapshot@basic_table_with_description_Prisma.snap index 8e8c5a5d..132723fe 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_table_with_description_snapshot@basic_table_with_description_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_table_with_description_snapshot@basic_table_with_description_Prisma.snap @@ -1,5 +1,6 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 61 expression: rendered --- /// User accounts table @@ -7,8 +8,8 @@ model Users { /// Primary key id Int @id @default(autoincrement()) /// User email address - email String @unique @db.Text - name String? @db.Text + email String @unique + name String? @@map("users") } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_Prisma.snap index 0db0f663..1d5d1cc8 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_Prisma.snap @@ -1,12 +1,13 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 143 expression: rendered --- model ComplexTypes { id Int @id - varchar_col String @db.VarChar(100) - char_col String @db.Char(10) - numeric_col Decimal @db.Decimal(10, 2) + varchar_col String + char_col String + numeric_col Decimal custom_col Unsupported("CUSTOM_TYPE") @@map("complex_types") diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_Prisma.snap index 29d05393..f579d991 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_Prisma.snap @@ -1,12 +1,12 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs -assertion_line: 182 +assertion_line: 179 expression: rendered --- model CompositeIndex { id Int @id tenant_id Int - name String @db.Text + name String @@index([tenant_id, name], map: "idx_tenant_name") @@map("composite_index") diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_Prisma.snap index 698e9d36..7defd856 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_Prisma.snap @@ -1,11 +1,12 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 244 expression: rendered --- model Membership { tenant_id Int user_id Int - role String @db.Text + role String @@id([tenant_id, user_id]) @@map("membership") diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Prisma.snap index 50ed0cef..3b529783 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Prisma.snap @@ -1,12 +1,12 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs -assertion_line: 252 +assertion_line: 249 expression: rendered --- model AccountAliases { id Int @id tenant_id Int - slug String @db.Text + slug String @@unique([tenant_id, slug], map: "uq_account_aliases__tenant_slug") @@map("account_aliases") diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Prisma.snap index 63f9f6ad..11c04b69 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Prisma.snap @@ -1,12 +1,12 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs -assertion_line: 177 +assertion_line: 174 expression: rendered --- model CompositeUnique { id Int @id tenant_id Int - name String @db.Text + name String @@unique([tenant_id, name], map: "uq_tenant_name") @@map("composite_unique") diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Prisma.snap index a3cb11e2..33cc1046 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Prisma.snap @@ -1,12 +1,13 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 153 expression: rendered --- model Articles { id Int @id @default(autoincrement()) published Boolean @default(false) view_count Int @default(0) - status String @default("draft") @db.Text + status String @default("draft") @@map("articles") } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_with_comment_and_auto_increment_snapshot@fk_with_comment_and_auto_increment_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_with_comment_and_auto_increment_snapshot@fk_with_comment_and_auto_increment_Prisma.snap index 36c10606..c7b043c2 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_with_comment_and_auto_increment_snapshot@fk_with_comment_and_auto_increment_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_with_comment_and_auto_increment_snapshot@fk_with_comment_and_auto_increment_Prisma.snap @@ -1,12 +1,13 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 224 expression: rendered --- model Child { /// References parent table parent_id Int @id @default(autoincrement()) parent Parent @relation(fields: [parent_id], references: [id]) - value String @db.Text + value String @@map("child") } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__inline_pk_snapshot@inline_pk_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__inline_pk_snapshot@inline_pk_Prisma.snap index a1a87358..73b194a2 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__inline_pk_snapshot@inline_pk_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__inline_pk_snapshot@inline_pk_Prisma.snap @@ -1,10 +1,11 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 86 expression: rendered --- model Users { - id String @default(uuid()) @db.Uuid - email String @db.Text + id String @default(uuid()) + email String @@map("users") } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Prisma.snap index e820cb09..a1ec5b80 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Prisma.snap @@ -1,11 +1,12 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 164 expression: rendered --- model Profiles { id Int @id @default(autoincrement()) - bio String? @db.Text - avatar_url String? @db.VarChar(500) + bio String? + avatar_url String? @@map("profiles") } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__numeric_default_value_snapshot@numeric_default_value_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__numeric_default_value_snapshot@numeric_default_value_Prisma.snap index a6393f2d..f28edde8 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__numeric_default_value_snapshot@numeric_default_value_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__numeric_default_value_snapshot@numeric_default_value_Prisma.snap @@ -1,10 +1,11 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 259 expression: rendered --- model Products { id Int - price Decimal @default(0.00) @db.Decimal(10, 2) + price Decimal @default(0.00) @@map("products") } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Prisma.snap index 4751d68c..33355247 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Prisma.snap @@ -1,16 +1,17 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 87 expression: rendered --- model ArticleUser { - article_id String @db.Uuid + article_id String article Article @relation(fields: [article_id], references: [id], onDelete: Cascade) - user_id String @db.Uuid + user_id String user User @relation(fields: [user_id], references: [id], onDelete: Cascade) author_order Int @default(1) - role String @default("contributor") @db.VarChar(20) + role String @default("contributor") is_lead Boolean @default(false) - created_at DateTime @default(now()) @db.Timestamptz + created_at DateTime @default(now()) @@id([article_id, user_id]) @@index([article_id]) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@dual_reverse_relations_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@dual_reverse_relations_Prisma.snap index 0a6af1d7..2adcc9ab 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@dual_reverse_relations_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@dual_reverse_relations_Prisma.snap @@ -1,9 +1,10 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 376 expression: rendered --- model Dual { - username String @id @db.Text + username String @id username_dual_rel DualRel[] @relation("DualRelUsername") checker_username_dual_rel DualRel[] @relation("DualRelCheckerUsername") diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_multiple_junctions_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_multiple_junctions_Prisma.snap index ba3f7323..a2c1f402 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_multiple_junctions_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_multiple_junctions_Prisma.snap @@ -1,9 +1,10 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 376 expression: rendered --- model User { - id String @id @db.Uuid + id String @id user_media_role UserMediaRole[] user_media_favorite UserMediaFavorite[] diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_user_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_user_Prisma.snap index 2ee63a6a..d94535f6 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_user_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_user_Prisma.snap @@ -1,9 +1,10 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 376 expression: rendered --- model User { - id String @id @db.Uuid + id String @id article_user ArticleUser[] @@map("user") diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_fk_same_table_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_fk_same_table_Prisma.snap index e90b4fd8..e6468f44 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_fk_same_table_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_fk_same_table_Prisma.snap @@ -1,12 +1,13 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 376 expression: rendered --- model Post { - id String @id @db.Uuid - creator_user_id String @db.Uuid + id String @id + creator_user_id String creator_user User @relation("PostCreatorUser", fields: [creator_user_id], references: [id]) - used_by_user_id String @db.Uuid + used_by_user_id String used_by_user User @relation("PostUsedByUser", fields: [used_by_user_id], references: [id]) @@map("post") diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_has_one_relations_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_has_one_relations_Prisma.snap index 824aa181..212d1310 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_has_one_relations_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_has_one_relations_Prisma.snap @@ -1,9 +1,10 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 376 expression: rendered --- model User { - id String @id @db.Uuid + id String @id created_by_user_settings Settings? @relation("SettingsCreatedByUser") updated_by_user_settings Settings? @relation("SettingsUpdatedByUser") diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_reverse_relations_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_reverse_relations_Prisma.snap index 2f8b8d4a..497148a1 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_reverse_relations_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_reverse_relations_Prisma.snap @@ -1,9 +1,10 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 376 expression: rendered --- model User { - id String @id @db.Uuid + id String @id preferred_user_profile Profile[] @relation("ProfilePreferredUser") backup_user_profile Profile[] @relation("ProfileBackupUser") diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@triple_reverse_relations_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@triple_reverse_relations_Prisma.snap index 1dbab1e2..10832fe0 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@triple_reverse_relations_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@triple_reverse_relations_Prisma.snap @@ -1,9 +1,10 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 376 expression: rendered --- model Dual { - username String @id @db.Text + username String @id username_triple_rel TripleRel[] @relation("TripleRelUsername") checker_username_triple_rel TripleRel[] @relation("TripleRelCheckerUsername") other_username_triple_rel TripleRel[] @relation("TripleRelOtherUsername") diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Prisma.snap index 4dcb5492..814066c7 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Prisma.snap @@ -1,10 +1,11 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 376 expression: rendered --- model Session { - id String @id @db.Uuid - username String @db.Text + id String @id + username String username_rel User @relation(fields: [username], references: [username]) @@map("session") diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reserved_word_identifiers_snapshot@reserved_word_identifiers_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reserved_word_identifiers_snapshot@reserved_word_identifiers_Prisma.snap index dbd9361c..fa9c2aaf 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reserved_word_identifiers_snapshot@reserved_word_identifiers_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reserved_word_identifiers_snapshot@reserved_word_identifiers_Prisma.snap @@ -1,10 +1,11 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 239 expression: rendered --- model Order { id Int @id - user String @db.Text + user String select Int @@map("order") diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_default_and_true_boolean_snapshot@server_default_and_true_boolean_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_default_and_true_boolean_snapshot@server_default_and_true_boolean_Prisma.snap index 2838a1fa..8163a502 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_default_and_true_boolean_snapshot@server_default_and_true_boolean_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_default_and_true_boolean_snapshot@server_default_and_true_boolean_Prisma.snap @@ -1,13 +1,14 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 159 expression: rendered --- model Logs { id Int @id @default(autoincrement()) active Boolean @default(true) - created_at DateTime @default(now()) @db.Timestamptz - score Float @default(1.5) @db.Real - tag String @default(dbgenerated("UNKNOWN_EXPR")) @db.Text + created_at DateTime @default(now()) + score Float @default(1.5) + tag String @default(dbgenerated("UNKNOWN_EXPR")) @@map("logs") } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_defaults_snapshot@server_defaults_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_defaults_snapshot@server_defaults_Prisma.snap index 8953edc9..f62f1246 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_defaults_snapshot@server_defaults_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_defaults_snapshot@server_defaults_Prisma.snap @@ -1,11 +1,12 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 154 expression: rendered --- model WithDefaults { id Int @id - created_at DateTime @default(now()) @db.Timestamptz - status String @default("active") @db.Text + created_at DateTime @default(now()) + status String @default("active") count Int @default(0) @@map("with_defaults") diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__small_multi_schema_sequential_snapshot@small_multi_schema_sequential_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__small_multi_schema_sequential_snapshot@small_multi_schema_sequential_Prisma.snap index 14772602..899faae3 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__small_multi_schema_sequential_snapshot@small_multi_schema_sequential_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__small_multi_schema_sequential_snapshot@small_multi_schema_sequential_Prisma.snap @@ -1,10 +1,11 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 290 expression: rendered --- model Users { id Int @id - display_name String? @db.Text + display_name String? posts Posts[] @@map("users") @@ -14,7 +15,7 @@ model Posts { id Int @id user_id Int user Users @relation(fields: [user_id], references: [id]) - title String @db.Text + title String @@map("posts") } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__string_default_snapshot@string_default_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__string_default_snapshot@string_default_Prisma.snap index bd34221a..61eabf4a 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__string_default_snapshot@string_default_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__string_default_snapshot@string_default_Prisma.snap @@ -1,10 +1,11 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 204 expression: rendered --- model StringDefaults { id Int @id - status String @default("active") @db.Text + status String @default("active") @@map("string_defaults") } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_pk_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_pk_Prisma.snap index 5d7872c2..ab763380 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_pk_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_pk_Prisma.snap @@ -1,11 +1,12 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 133 expression: rendered --- model Orders { - id String @id @db.Uuid - customer_id String @db.Uuid - total Float @db.Real + id String @id + customer_id String + total Float @@map("orders") } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_composite_fk_snapshot@table_with_composite_fk_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_composite_fk_snapshot@table_with_composite_fk_Prisma.snap index 74988b55..ffaee027 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_composite_fk_snapshot@table_with_composite_fk_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_composite_fk_snapshot@table_with_composite_fk_Prisma.snap @@ -1,12 +1,13 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 81 expression: rendered --- model LineItems { id Int @id order_id Int order_version Int - sku String @db.Text + sku String @@map("line_items") } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_fk_snapshot@table_with_fk_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_fk_snapshot@table_with_fk_Prisma.snap index 2535a68e..4b8bfc56 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_fk_snapshot@table_with_fk_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_fk_snapshot@table_with_fk_Prisma.snap @@ -1,12 +1,13 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 76 expression: rendered --- model Posts { id Int @id user_id Int user Users @relation(fields: [user_id], references: [id]) - title String @db.Text + title String @@map("posts") } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_Prisma.snap index 19c1740c..1d8c6af7 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_Prisma.snap @@ -1,12 +1,12 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs -assertion_line: 131 +assertion_line: 128 expression: rendered --- model Articles { id Int @id - title String @db.Text - created_at DateTime @db.Timestamptz + title String + created_at DateTime @@index([created_at], map: "idx_articles_created_at") @@index([title]) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Prisma.snap index fdc505d0..25520b55 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Prisma.snap @@ -1,13 +1,14 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 123 expression: rendered --- model Users { id Int - email String @unique @db.Text - username String @unique(map: "uq_username") @db.Text - department String? @db.Text - status String @default("active") @db.Text + email String @unique + username String @unique(map: "uq_username") + department String? + status String @default("active") @@index([department], map: "idx_department") @@map("users") diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_constant_default_snapshot@unknown_constant_default_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_constant_default_snapshot@unknown_constant_default_Prisma.snap index 601d431e..507e34de 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_constant_default_snapshot@unknown_constant_default_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_constant_default_snapshot@unknown_constant_default_Prisma.snap @@ -1,10 +1,11 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 219 expression: rendered --- model UnknownDefault { id Int @id - value String @default(dbgenerated("SOME_CONSTANT")) @db.Text + value String @default(dbgenerated("SOME_CONSTANT")) @@map("unknown_default") } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_function_default_snapshot@unknown_function_default_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_function_default_snapshot@unknown_function_default_Prisma.snap index 8eeb4d72..02377a87 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_function_default_snapshot@unknown_function_default_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_function_default_snapshot@unknown_function_default_Prisma.snap @@ -1,10 +1,11 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 214 expression: rendered --- model UnknownDefaults { id Int @id - code String @default(dbgenerated("gen_code()")) @db.Text + code String @default(dbgenerated("gen_code()")) @@map("unknown_defaults") } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_index_and_unique_snapshot@unnamed_index_and_unique_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_index_and_unique_snapshot@unnamed_index_and_unique_Prisma.snap index fd0d4664..0f4f1229 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_index_and_unique_snapshot@unnamed_index_and_unique_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_index_and_unique_snapshot@unnamed_index_and_unique_Prisma.snap @@ -1,11 +1,12 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 184 expression: rendered --- model Events { id Int @id venue_id Int - date DateTime @db.Date + date DateTime @@unique([venue_id, date]) @@index([venue_id, date]) From 115b58592adb40bf0174cf394a65cb7d99800d92 Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:18:22 +0900 Subject: [PATCH 23/25] =?UTF-8?q?feat(exporter):=20Prisma=20uuid=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=EC=97=90=20MySQL=20binary(16)=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vespertide-exporter/src/prisma/types.rs | 27 ++++++++++++++++--- ...ypes_snapshot@all_simple_types_Prisma.snap | 2 +- ...__inline_pk_snapshot@inline_pk_Prisma.snap | 2 +- ...er_snapshot@pk_and_fk_together_Prisma.snap | 4 +-- ...any_to_many_multiple_junctions_Prisma.snap | 2 +- ...ma_snapshots@many_to_many_user_Prisma.snap | 2 +- ...apshots@multiple_fk_same_table_Prisma.snap | 6 ++--- ...ots@multiple_has_one_relations_Prisma.snap | 2 +- ...ots@multiple_reverse_relations_Prisma.snap | 2 +- ...h_schema_snapshots@username_fk_Prisma.snap | 2 +- ...vel_pk_snapshot@table_level_pk_Prisma.snap | 4 +-- 11 files changed, 37 insertions(+), 18 deletions(-) diff --git a/crates/vespertide-exporter/src/prisma/types.rs b/crates/vespertide-exporter/src/prisma/types.rs index da99a4c3..d83e6c67 100644 --- a/crates/vespertide-exporter/src/prisma/types.rs +++ b/crates/vespertide-exporter/src/prisma/types.rs @@ -12,6 +12,13 @@ pub(super) fn column_type_to_prisma(ty: &ColumnType, nullable: bool) -> (String, let q = if nullable { "?" } else { "" }; match ty { + // vespertide's MySQL DDL stores uuid as BINARY(16) — the one physical + // type that diverges from the neutral scalar, so the field carries a + // warning comment instead of a backend-specific type. + ColumnType::Simple(SimpleColumnType::Uuid) => ( + format!("String{q}"), + Some("stored as binary(16) on MySQL backends".to_string()), + ), ColumnType::Simple(simple) => { let base = match simple { SimpleColumnType::SmallInt | SimpleColumnType::Integer => "Int", @@ -24,8 +31,7 @@ pub(super) fn column_type_to_prisma(ty: &ColumnType, nullable: bool) -> (String, | SimpleColumnType::Timestamptz => "DateTime", SimpleColumnType::Bytea => "Bytes", SimpleColumnType::Json => "Json", - SimpleColumnType::Uuid - | SimpleColumnType::Text + SimpleColumnType::Text | SimpleColumnType::Interval | SimpleColumnType::Inet | SimpleColumnType::Cidr @@ -33,7 +39,7 @@ pub(super) fn column_type_to_prisma(ty: &ColumnType, nullable: bool) -> (String, | SimpleColumnType::Xml => "String", // Unknown/future simple types fall back to a plain String column. // `SimpleColumnType` is #[non_exhaustive]; every current variant is - // matched above, so this arm is currently unreachable. + // handled above, so this arm is currently unreachable. #[cfg(not(tarpaulin_include))] _ => "String", }; @@ -82,7 +88,6 @@ mod tests { #[case::timestamptz(SimpleColumnType::Timestamptz, "DateTime")] #[case::bytea(SimpleColumnType::Bytea, "Bytes")] #[case::json(SimpleColumnType::Json, "Json")] - #[case::uuid(SimpleColumnType::Uuid, "String")] #[case::text(SimpleColumnType::Text, "String")] #[case::interval(SimpleColumnType::Interval, "String")] #[case::inet(SimpleColumnType::Inet, "String")] @@ -95,6 +100,20 @@ mod tests { assert_eq!(comment, None); } + #[test] + fn uuid_maps_to_string_with_mysql_binary16_note() { + let ty = ColumnType::Simple(SimpleColumnType::Uuid); + let (rendered, comment) = column_type_to_prisma(&ty, false); + assert_eq!(rendered, "String"); + assert_eq!( + comment.as_deref(), + Some("stored as binary(16) on MySQL backends") + ); + + let (rendered, _) = column_type_to_prisma(&ty, true); + assert_eq!(rendered, "String?"); + } + #[test] fn nullable_appends_question_mark() { let ty = ColumnType::Simple(SimpleColumnType::Timestamptz); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Prisma.snap index ef045cc0..d5f217f0 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Prisma.snap @@ -17,7 +17,7 @@ model AllTypes { tstz_col DateTime interval_col String bytea_col Bytes - uuid_col String + uuid_col String // stored as binary(16) on MySQL backends json_col Json inet_col String cidr_col String diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__inline_pk_snapshot@inline_pk_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__inline_pk_snapshot@inline_pk_Prisma.snap index 73b194a2..367e721a 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__inline_pk_snapshot@inline_pk_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__inline_pk_snapshot@inline_pk_Prisma.snap @@ -4,7 +4,7 @@ assertion_line: 86 expression: rendered --- model Users { - id String @default(uuid()) + id String @default(uuid()) // stored as binary(16) on MySQL backends email String @@map("users") diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Prisma.snap index 33355247..ac9b83a3 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Prisma.snap @@ -4,9 +4,9 @@ assertion_line: 87 expression: rendered --- model ArticleUser { - article_id String + article_id String // stored as binary(16) on MySQL backends article Article @relation(fields: [article_id], references: [id], onDelete: Cascade) - user_id String + user_id String // stored as binary(16) on MySQL backends user User @relation(fields: [user_id], references: [id], onDelete: Cascade) author_order Int @default(1) role String @default("contributor") diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_multiple_junctions_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_multiple_junctions_Prisma.snap index a2c1f402..c381d22d 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_multiple_junctions_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_multiple_junctions_Prisma.snap @@ -4,7 +4,7 @@ assertion_line: 376 expression: rendered --- model User { - id String @id + id String @id // stored as binary(16) on MySQL backends user_media_role UserMediaRole[] user_media_favorite UserMediaFavorite[] diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_user_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_user_Prisma.snap index d94535f6..ad510a77 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_user_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_user_Prisma.snap @@ -4,7 +4,7 @@ assertion_line: 376 expression: rendered --- model User { - id String @id + id String @id // stored as binary(16) on MySQL backends article_user ArticleUser[] @@map("user") diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_fk_same_table_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_fk_same_table_Prisma.snap index e6468f44..dfdbf79b 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_fk_same_table_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_fk_same_table_Prisma.snap @@ -4,10 +4,10 @@ assertion_line: 376 expression: rendered --- model Post { - id String @id - creator_user_id String + id String @id // stored as binary(16) on MySQL backends + creator_user_id String // stored as binary(16) on MySQL backends creator_user User @relation("PostCreatorUser", fields: [creator_user_id], references: [id]) - used_by_user_id String + used_by_user_id String // stored as binary(16) on MySQL backends used_by_user User @relation("PostUsedByUser", fields: [used_by_user_id], references: [id]) @@map("post") diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_has_one_relations_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_has_one_relations_Prisma.snap index 212d1310..c038ac0e 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_has_one_relations_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_has_one_relations_Prisma.snap @@ -4,7 +4,7 @@ assertion_line: 376 expression: rendered --- model User { - id String @id + id String @id // stored as binary(16) on MySQL backends created_by_user_settings Settings? @relation("SettingsCreatedByUser") updated_by_user_settings Settings? @relation("SettingsUpdatedByUser") diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_reverse_relations_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_reverse_relations_Prisma.snap index 497148a1..1f3dee9b 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_reverse_relations_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_reverse_relations_Prisma.snap @@ -4,7 +4,7 @@ assertion_line: 376 expression: rendered --- model User { - id String @id + id String @id // stored as binary(16) on MySQL backends preferred_user_profile Profile[] @relation("ProfilePreferredUser") backup_user_profile Profile[] @relation("ProfileBackupUser") diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Prisma.snap index 814066c7..2b4b4f10 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Prisma.snap @@ -4,7 +4,7 @@ assertion_line: 376 expression: rendered --- model Session { - id String @id + id String @id // stored as binary(16) on MySQL backends username String username_rel User @relation(fields: [username], references: [username]) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_pk_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_pk_Prisma.snap index ab763380..750ae86f 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_pk_Prisma.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_pk_Prisma.snap @@ -4,8 +4,8 @@ assertion_line: 133 expression: rendered --- model Orders { - id String @id - customer_id String + id String @id // stored as binary(16) on MySQL backends + customer_id String // stored as binary(16) on MySQL backends total Float @@map("orders") From aadc00eb9ce0f9d3958a1061e660f33d6d2d81ab Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:39:34 +0900 Subject: [PATCH 24/25] =?UTF-8?q?chore:=20clippy=201.97=20=EC=8B=A0?= =?UTF-8?q?=EA=B7=9C=20=EB=A6=B0=ED=8A=B8=20=EB=8C=80=EC=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/vespertide-core/src/schema/table/tests/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/vespertide-core/src/schema/table/tests/mod.rs b/crates/vespertide-core/src/schema/table/tests/mod.rs index 8f7fa584..2b195c93 100644 --- a/crates/vespertide-core/src/schema/table/tests/mod.rs +++ b/crates/vespertide-core/src/schema/table/tests/mod.rs @@ -774,7 +774,7 @@ fn normalize_inline_unique_with_array_existing_constraint() { let unique_constraint = &normalized.constraints[0]; assert!(matches!( unique_constraint, - TableConstraint::Unique { name: Some(n), columns: _, .. } + TableConstraint::Unique { name: Some(n), .. } if n == "uq_group" )); if let TableConstraint::Unique { columns, .. } = unique_constraint { From 3173c6072bc228abfac0e972d1f79ff7fefdd4be Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:39:34 +0900 Subject: [PATCH 25/25] =?UTF-8?q?test:=20=EB=AE=A4=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=8C=80=EC=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/commands/export/tests/mod.rs | 75 +++++++++++++++++-- 1 file changed, 70 insertions(+), 5 deletions(-) diff --git a/crates/vespertide-cli/src/commands/export/tests/mod.rs b/crates/vespertide-cli/src/commands/export/tests/mod.rs index 10a76895..3462f530 100644 --- a/crates/vespertide-cli/src/commands/export/tests/mod.rs +++ b/crates/vespertide-cli/src/commands/export/tests/mod.rs @@ -135,6 +135,59 @@ async fn export_with_sqlmodel_sets_py_extension() { assert!(content.contains("orders")); } +#[tokio::test] +#[serial] +async fn export_seaorm_uses_absolute_crate_paths_for_cross_directory_fk() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + + let user = sample_table("user"); + write_model(Path::new("models/admin/user.json"), &user); + + let mut post = sample_table("post"); + post.columns.push(ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }); + post.constraints.push(TableConstraint::ForeignKey { + name: Some("fk_post__user_id".into()), + columns: vec!["user_id".into()], + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + orphan_strategy: Default::default(), + }); + write_model(Path::new("models/blog/post.json"), &post); + + cmd_export(OrmArg::Seaorm, None).await.unwrap(); + + // Cross-directory FK relations must use the absolute `crate::` module path + // derived from the export dir, not a sibling `super::` path. + let content = std_fs::read_to_string("src/models/blog/post.rs").unwrap(); + assert!(content.contains("crate::models::admin::user")); +} + +#[tokio::test] +#[serial] +async fn export_creates_output_dir_even_without_models() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + + cmd_export(OrmArg::Seaorm, None).await.unwrap(); + + assert!(PathBuf::from("src/models").exists()); +} + #[tokio::test] #[serial] async fn load_models_recursive_returns_empty_when_absent() { @@ -191,11 +244,23 @@ async fn ensure_mod_chain_adds_to_existing_file_without_trailing_newline() { let root_mod = std_fs::read_to_string(root.join("mod.rs")).unwrap(); let blog_mod = std_fs::read_to_string(root.join("blog/mod.rs")).unwrap(); - assert!(root_mod.contains("pub mod existing;")); - assert!(root_mod.contains("pub mod blog;")); - assert!(blog_mod.contains("pub mod posts;")); - // ensure newline appended if missing - assert!(root_mod.ends_with('\n')); + // exact contents: a newline separates the existing decl from the new one + assert_eq!(root_mod, "pub mod existing;\npub mod blog;\n"); + assert_eq!(blog_mod, "pub mod posts;\n"); +} + +#[tokio::test] +async fn ensure_mod_chain_creates_fresh_mod_rs_without_leading_newline() { + let tmp = tempdir().unwrap(); + let root = tmp.path().join("src/models"); + std_fs::create_dir_all(&root).unwrap(); + + ensure_mod_chain(&root, Path::new("users.json")) + .await + .unwrap(); + + let root_mod = std_fs::read_to_string(root.join("mod.rs")).unwrap(); + assert_eq!(root_mod, "pub mod users;\n"); } #[tokio::test]