diff --git a/.changepacks/changepack_log_yNXxbjBfCU5yfBpY1nXMQ.json b/.changepacks/changepack_log_yNXxbjBfCU5yfBpY1nXMQ.json new file mode 100644 index 0000000..b08b607 --- /dev/null +++ b/.changepacks/changepack_log_yNXxbjBfCU5yfBpY1nXMQ.json @@ -0,0 +1 @@ +{"changes":{"crates/vespertide-macro/Cargo.toml":"Patch","crates/vespertide-exporter/Cargo.toml":"Patch","crates/vespertide-query/Cargo.toml":"Patch","crates/vespertide-core/Cargo.toml":"Patch","crates/vespertide-naming/Cargo.toml":"Patch","crates/vespertide/Cargo.toml":"Patch","crates/vespertide-cli/Cargo.toml":"Patch","crates/vespertide-planner/Cargo.toml":"Patch","crates/vespertide-loader/Cargo.toml":"Patch","crates/vespertide-config/Cargo.toml":"Patch"},"note":"Fix export issue","date":"2026-04-01T19:42:05.213092500Z"} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 2c0bfbc..e322797 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -584,9 +584,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.56" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "shlex", @@ -684,25 +684,12 @@ dependencies = [ [[package]] name = "console" -version = "0.15.11" +version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" dependencies = [ "encode_unicode", "libc", - "once_cell", - "windows-sys 0.59.0", -] - -[[package]] -name = "console" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4" -dependencies = [ - "encode_unicode", - "libc", - "once_cell", "unicode-width", "windows-sys 0.61.2", ] @@ -898,7 +885,7 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" dependencies = [ - "console 0.16.2", + "console", "shell-words", "tempfile", "zeroize", @@ -1598,11 +1585,11 @@ dependencies = [ [[package]] name = "insta" -version = "1.46.1" +version = "1.47.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "248b42847813a1550dafd15296fd9748c651d0c32194559dbc05d804d54b21e8" +checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" dependencies = [ - "console 0.15.11", + "console", "once_cell", "serde", "similar", @@ -1626,15 +1613,15 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" dependencies = [ "once_cell", "wasm-bindgen", @@ -1959,9 +1946,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -3353,12 +3340,12 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tempfile" -version = "3.26.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.59.0", @@ -3685,9 +3672,9 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vespera" -version = "0.1.43" +version = "0.1.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2379c3d45c2af97ca9cfc6a2c2f6656d97314ca9b2a701e8d73705a83d637c32" +checksum = "12b3bac41bd5e069d7f1b31eb6a3fd0225402a97f4845f8b4a6d77a10e39be0d" dependencies = [ "axum", "axum-extra", @@ -3702,9 +3689,9 @@ dependencies = [ [[package]] name = "vespera_core" -version = "0.1.43" +version = "0.1.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f6782ea90a8c9acd3817529f0e5e1805357f1e29d2adb4276a59564813f670" +checksum = "1198fde536e67801f3a091a9c353d223095be6182328d70a0036e97b09b52533" dependencies = [ "serde", "serde_json", @@ -3712,9 +3699,9 @@ dependencies = [ [[package]] name = "vespera_macro" -version = "0.1.43" +version = "0.1.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb90670fca1976f73dcf0adf5cdbea21df41030ada05dd4224a5297b25a06c24" +checksum = "acf76c9614257c8c16d4d37b828da36aa32ebe14e5be22a7b0669244c21b83ab" dependencies = [ "proc-macro2", "quote", @@ -3911,9 +3898,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" dependencies = [ "cfg-if", "once_cell", @@ -3924,9 +3911,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3934,9 +3921,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" dependencies = [ "bumpalo", "proc-macro2", @@ -3947,9 +3934,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" dependencies = [ "unicode-ident", ] diff --git a/crates/vespertide-cli/src/commands/export.rs b/crates/vespertide-cli/src/commands/export.rs index b0c4576..da27403 100644 --- a/crates/vespertide-cli/src/commands/export.rs +++ b/crates/vespertide-cli/src/commands/export.rs @@ -125,7 +125,7 @@ pub async fn cmd_export(orm: OrmArg, export_dir: Option) -> Result<()> // Ensure mod chain for SeaORM (must be done after all files are written) if matches!(orm_kind, Orm::SeaOrm) { - for (_, rel_path) in &normalized_models { + 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 @@ -342,8 +342,7 @@ async fn load_models_recursive(base: &Path) -> Result> } async fn ensure_mod_chain(root: &Path, rel_path: &Path) -> Result<()> { - // Only needed for SeaORM (Rust) exports to wire modules. - // Strip extension and ".vespertide" suffix from filename + // 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); @@ -366,7 +365,7 @@ async fn ensure_mod_chain(root: &Path, rel_path: &Path) -> Result<()> { if comps.is_empty() { return Ok(()); } - // Build from deepest file up to root: dir/mod.rs should include child module. + while let Some(child) = comps.pop() { let dir = root.join(comps.join(std::path::MAIN_SEPARATOR_STR)); let mod_path = dir.join("mod.rs"); @@ -375,13 +374,15 @@ async fn ensure_mod_chain(root: &Path, rel_path: &Path) -> Result<()> { { 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(|l| l.trim() == decl) { + + 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'); } @@ -390,6 +391,7 @@ async fn ensure_mod_chain(root: &Path, rel_path: &Path) -> Result<()> { fs::write(mod_path, content).await?; } } + Ok(()) } @@ -535,7 +537,7 @@ mod tests { let content = std_fs::read_to_string(out).unwrap(); assert!(content.contains("#[sea_orm(table_name = \"posts\")]")); - // mod.rs wiring + // nested mod.rs wiring let root_mod = custom.join("mod.rs"); let blog_mod = custom.join("blog/mod.rs"); assert!(root_mod.exists()); @@ -779,6 +781,7 @@ mod tests { 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")); } diff --git a/crates/vespertide-exporter/Cargo.toml b/crates/vespertide-exporter/Cargo.toml index 79ff04a..2da99d3 100644 --- a/crates/vespertide-exporter/Cargo.toml +++ b/crates/vespertide-exporter/Cargo.toml @@ -16,7 +16,7 @@ thiserror = "2" [dev-dependencies] rstest = "0.26" -insta = { version = "1.46", features = ["yaml"] } +insta = { version = "1.47", features = ["yaml"] } [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] } diff --git a/crates/vespertide-exporter/src/seaorm/mod.rs b/crates/vespertide-exporter/src/seaorm/mod.rs index 752bbe5..b6a63ca 100644 --- a/crates/vespertide-exporter/src/seaorm/mod.rs +++ b/crates/vespertide-exporter/src/seaorm/mod.rs @@ -25,6 +25,7 @@ fn absolute_module_path(crate_prefix: &str, to_module: &[String]) -> String { /// Look up the module path for a table name from the module_paths map. /// Uses `super::` for sibling modules in the same folder, `crate::` absolute paths for /// cross-directory relations when mappings are available, and falls back to `super::{table_name}`. +#[cfg(test)] fn resolve_entity_module_path( current_table: &str, target_table: &str, @@ -50,6 +51,44 @@ fn resolve_entity_module_path( format!("super::{target_table}") } +/// Resolve relation field entity paths for SeaORM model macros. +/// +/// Rule: +/// - same folder → `super::{table}` +/// - different folder → absolute `crate::...` path +/// +/// This avoids generating brittle `super::super::...` paths for cross-folder relations. +fn resolve_relation_entity_module_path( + current_table: &str, + target_table: &str, + module_paths: &HashMap>, + crate_prefix: &str, +) -> String { + if let (Some(current), Some(target)) = ( + module_paths.get(current_table), + module_paths.get(target_table), + ) { + let current_parent = current.split_last().map_or(&[][..], |(_, parent)| parent); + let target_parent = target.split_last().map_or(&[][..], |(_, parent)| parent); + + if current_parent == target_parent { + return format!("super::{target_table}"); + } + + if !crate_prefix.is_empty() { + return absolute_module_path(crate_prefix, target); + } + + return format!("super::{target_table}"); + } + + if !crate_prefix.is_empty() { + return format!("{crate_prefix}::{target_table}"); + } + + format!("super::{target_table}") +} + pub struct SeaOrmExporter; /// SeaORM exporter with configuration support. @@ -248,6 +287,18 @@ pub fn render_entity_with_config_and_paths( lines.push("impl ActiveModelBehavior for ActiveModel {}".into()); + let self_ref_links = render_self_ref_link_helpers(table, schema, module_paths, crate_prefix); + if !self_ref_links.is_empty() { + lines.push(String::new()); + lines.extend(self_ref_links); + } + + let self_ref_query_helpers = render_self_ref_query_helpers(table, schema); + if !self_ref_query_helpers.is_empty() { + lines.push(String::new()); + lines.extend(self_ref_query_helpers); + } + lines.push(String::new()); lines.join("\n") @@ -646,8 +697,12 @@ fn relation_field_defs_with_schema( }; out.push(attr); - let entity_path = - resolve_entity_module_path(&table.name, resolved_table, module_paths, crate_prefix); + let entity_path = resolve_relation_entity_module_path( + &table.name, + resolved_table, + module_paths, + crate_prefix, + ); out.push(format!( " pub {field_name}: HasOne<{entity_path}::Entity>," )); @@ -684,6 +739,246 @@ fn generate_relation_enum_name(columns: &[String]) -> String { to_pascal_case(without_id) } +fn unique_relation_enum_name( + preferred: String, + source_table: &str, + base_relation_enum: &str, + used_relation_enums: &HashSet, +) -> String { + if !used_relation_enums.contains(&preferred) { + return preferred; + } + + let source_prefixed = format!("{}{}", to_pascal_case(source_table), base_relation_enum); + if !used_relation_enums.contains(&source_prefixed) { + return source_prefixed; + } + + let mut index = 2; + loop { + let candidate = format!( + "{}{}{}", + to_pascal_case(source_table), + base_relation_enum, + index + ); + if !used_relation_enums.contains(&candidate) { + return candidate; + } + index += 1; + } +} + +fn collect_self_ref_junction( + current_table: &TableDef, + junction_table: &TableDef, + junction_pk: &HashSet, +) -> Option { + if junction_pk.len() < 2 { + return None; + } + + let fks: Vec<_> = junction_table + .constraints + .iter() + .filter_map(|c| { + if let TableConstraint::ForeignKey { + columns, ref_table, .. + } = c + { + Some((columns.clone(), ref_table.clone())) + } else { + None + } + }) + .collect(); + + if fks.len() < 2 { + return None; + } + + let all_fk_cols_in_pk = fks + .iter() + .all(|(cols, _)| cols.iter().all(|c| junction_pk.contains(c))); + if !all_fk_cols_in_pk { + return None; + } + + if !fks + .iter() + .all(|(_, ref_table)| ref_table == ¤t_table.name) + { + return None; + } + + Some(SelfRefJunction { + junction_table: junction_table.name.clone(), + role_columns: fks.iter().map(|(cols, _)| cols[0].clone()).collect(), + role_relations: fks + .iter() + .map(|(cols, _)| generate_relation_enum_name(cols)) + .collect(), + }) +} + +fn self_ref_link_name( + self_ref_junction: &SelfRefJunction, + from_idx: usize, + to_idx: usize, +) -> String { + format!( + "{}To{}Via{}", + to_pascal_case(&self_ref_junction.role_columns[from_idx]), + to_pascal_case(&self_ref_junction.role_columns[to_idx]), + to_pascal_case(&self_ref_junction.junction_table) + ) +} + +fn resolve_self_ref_link_module_path( + current_table: &str, + junction_table: &str, + module_paths: &HashMap>, + crate_prefix: &str, +) -> String { + if let (Some(current), Some(target)) = ( + module_paths.get(current_table), + module_paths.get(junction_table), + ) { + let current_parent = current.split_last().map_or(&[][..], |(_, parent)| parent); + let target_parent = target.split_last().map_or(&[][..], |(_, parent)| parent); + + if current_parent == target_parent { + return format!("super::{junction_table}"); + } + + if !crate_prefix.is_empty() { + return absolute_module_path(crate_prefix, target); + } + + return absolute_module_path("crate::models", target); + } + + format!("super::{junction_table}") +} + +fn render_self_ref_link_helpers( + table: &TableDef, + schema: &[TableDef], + module_paths: &HashMap>, + crate_prefix: &str, +) -> Vec { + let mut out = Vec::new(); + + for other_table in schema { + if other_table.name == table.name { + continue; + } + + let other_pk = primary_key_columns(other_table); + let Some(self_ref_junction) = collect_self_ref_junction(table, other_table, &other_pk) + else { + continue; + }; + + let junction_entity_path = resolve_self_ref_link_module_path( + &table.name, + &self_ref_junction.junction_table, + module_paths, + crate_prefix, + ); + + for (from_idx, from_role) in self_ref_junction.role_relations.iter().enumerate() { + for (to_idx, to_role) in self_ref_junction.role_relations.iter().enumerate() { + if from_idx == to_idx { + continue; + } + + let link_name = self_ref_link_name(&self_ref_junction, from_idx, to_idx); + out.push(format!("pub struct {link_name};")); + out.push(format!("impl Linked for {link_name} {{")); + out.push(" type FromEntity = Entity;".into()); + out.push(" type ToEntity = Entity;".into()); + out.push(String::new()); + out.push(" fn link(&self) -> Vec {".into()); + out.push(" vec![".into()); + out.push(format!( + " {junction_entity_path}::Relation::{}.def().rev(),", + from_role + )); + out.push(format!( + " {junction_entity_path}::Relation::{}.def(),", + to_role + )); + out.push(" ]".into()); + out.push(" }".into()); + out.push("}".into()); + out.push(String::new()); + } + } + } + + while out.last().is_some_and(String::is_empty) { + out.pop(); + } + + out +} + +fn render_self_ref_query_helpers(table: &TableDef, schema: &[TableDef]) -> Vec { + let mut methods = Vec::new(); + let mut used_method_names = HashSet::new(); + + for other_table in schema { + if other_table.name == table.name { + continue; + } + + let other_pk = primary_key_columns(other_table); + let Some(self_ref_junction) = collect_self_ref_junction(table, other_table, &other_pk) + else { + continue; + }; + + for (from_idx, from_col) in self_ref_junction.role_columns.iter().enumerate() { + for (to_idx, to_col) in self_ref_junction.role_columns.iter().enumerate() { + if from_idx == to_idx { + continue; + } + + let link_name = self_ref_link_name(&self_ref_junction, from_idx, to_idx); + let method_base = format!( + "find_{}_via_{}_from_{}", + pluralize(&sanitize_field_name(to_col)), + sanitize_field_name(&self_ref_junction.junction_table), + sanitize_field_name(from_col) + ); + let method_name = unique_name(&method_base, &mut used_method_names); + + methods.push(format!( + " pub fn {method_name}(&self) -> Select {{" + )); + methods.push(format!(" self.find_linked({link_name})")); + methods.push(" }".into()); + methods.push(String::new()); + } + } + } + + while methods.last().is_some_and(String::is_empty) { + methods.pop(); + } + + if methods.is_empty() { + return methods; + } + + let mut out = Vec::new(); + out.push("impl Model {".into()); + out.extend(methods); + out.push("}".into()); + out +} + /// Infer a field name from a single FK column. /// For "creator_user_id" with to="id", tries "creator_user" first. /// If the FK column still follows common suffix naming like `_id`/`_idx`, @@ -752,10 +1047,18 @@ struct ReverseRelation { has_multiple_fks: bool, /// Optional via clause for M2M relations via: Option, + /// Optional via_rel clause for reverse diamond relations + via_rel: Option, /// Whether this is a M2M relation (through junction table) is_m2m: bool, } +struct SelfRefJunction { + junction_table: String, + role_columns: Vec, + role_relations: Vec, +} + /// Collect target entities from reverse relations (for counting across all relations). fn collect_reverse_relation_targets(table: &TableDef, schema: &[TableDef]) -> Vec { let mut targets = Vec::new(); @@ -948,6 +1251,7 @@ fn reverse_relation_field_defs( source_table: other_table.name.clone(), has_multiple_fks, via: None, + via_rel: Some(generate_relation_enum_name(columns)), is_m2m: false, }); } @@ -981,16 +1285,12 @@ fn reverse_relation_field_defs( .unwrap_or(false); let attr = if needs_relation_enum { - // When multiple HasMany/HasOne target the same Entity, ALL need `via` - // - M2M relations: via = junction_table - // - Direct FK relations: via = source_table (the table with the FK) - let via_value = rel.via.as_ref().unwrap_or(&rel.source_table); - - let relation_enum_name = if rel.is_m2m { + let preferred_relation_enum_name = if rel.is_m2m { // M2M: use {Target}Via{Junction} pattern directly // e.g., "MediaViaUserMediaRole" rel.base_relation_enum.clone() } else { + let via_value = rel.via.as_ref().unwrap_or(&rel.source_table); // Direct: use via table name, fall back to FK-based on collision let base_enum = to_pascal_case(via_value); if used_relation_enums.contains(&base_enum) { @@ -999,11 +1299,25 @@ fn reverse_relation_field_defs( base_enum } }; + let relation_enum_name = unique_relation_enum_name( + preferred_relation_enum_name, + &rel.source_table, + &rel.base_relation_enum, + used_relation_enums, + ); used_relation_enums.insert(relation_enum_name.clone()); - format!( - " #[sea_orm({relation_type}, relation_enum = \"{relation_enum_name}\", via = \"{via_value}\")]" - ) + if let Some(via_rel) = &rel.via_rel { + format!( + " #[sea_orm({relation_type}, relation_enum = \"{relation_enum_name}\", via_rel = \"{via_rel}\")]" + ) + } else if let Some(via) = &rel.via { + format!( + " #[sea_orm({relation_type}, relation_enum = \"{relation_enum_name}\", via = \"{via}\")]" + ) + } else { + format!(" #[sea_orm({relation_type}, relation_enum = \"{relation_enum_name}\")]") + } } else if let Some(via) = &rel.via { // No ambiguity - just via without relation_enum format!(" #[sea_orm({relation_type}, via = \"{via}\")]") @@ -1012,8 +1326,12 @@ fn reverse_relation_field_defs( }; out.push(attr); - let entity_path = - resolve_entity_module_path(&table.name, &rel.target_entity, module_paths, crate_prefix); + let entity_path = resolve_relation_entity_module_path( + &table.name, + &rel.target_entity, + module_paths, + crate_prefix, + ); out.push(format!( " pub {field_name}: {rust_type}<{entity_path}::Entity>," )); @@ -1072,6 +1390,16 @@ fn collect_many_to_many_relations( let mut relations = Vec::new(); + let self_ref_fks: Vec<_> = fks + .iter() + .filter(|(_, ref_table)| ref_table == ¤t_table.name) + .cloned() + .collect(); + + if self_ref_fks.len() == fks.len() { + return None; + } + // First, add has_many to the junction table itself (direct relation, not M2M) let junction_base = pluralize(&sanitize_field_name(&junction_table.name)); relations.push(ReverseRelation { @@ -1082,6 +1410,7 @@ fn collect_many_to_many_relations( source_table: junction_table.name.clone(), has_multiple_fks: false, via: None, + via_rel: None, is_m2m: false, }); @@ -1121,6 +1450,7 @@ fn collect_many_to_many_relations( source_table: junction_table.name.clone(), has_multiple_fks: false, via: Some(junction_table.name.clone()), + via_rel: None, is_m2m: true, }); } @@ -1168,7 +1498,6 @@ fn render_indexes(lines: &mut Vec, constraints: &[TableConstraint]) { if index_constraints.is_empty() { return; } - lines.push(String::new()); lines.push("// Index definitions (SeaORM uses Statement builders externally)".into()); for (name, columns) in index_constraints { let cols = columns.join(", "); @@ -1356,6 +1685,21 @@ fn to_snake_case(s: &str) -> String { #[cfg(test)] mod module_path_tests { use super::*; + use vespertide_core::{ColumnType, SimpleColumnType}; + + fn test_pk_column(name: &str) -> ColumnDef { + ColumnDef { + name: name.into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: false, + default: None, + comment: None, + primary_key: Some(vespertide_core::schema::primary_key::PrimaryKeySyntax::Bool(true)), + unique: None, + index: None, + foreign_key: None, + } + } #[test] fn absolute_module_path_builds_correct_path() { @@ -1419,6 +1763,154 @@ mod module_path_tests { let result = resolve_entity_module_path("user", "admin", &module_paths, ""); assert_eq!(result, "super::admin"); } + + #[test] + fn resolve_relation_entity_module_path_uses_crate_for_cross_directory_nested_models() { + let mut module_paths = HashMap::new(); + module_paths.insert("admin".into(), vec!["admin".into(), "admin".into()]); + module_paths.insert( + "estimate".into(), + vec!["estimate".into(), "estimate".into()], + ); + + let result = resolve_relation_entity_module_path( + "admin", + "estimate", + &module_paths, + "crate::models", + ); + assert_eq!(result, "crate::models::estimate::estimate"); + } + + #[test] + fn resolve_relation_entity_module_path_uses_super_for_same_directory() { + let mut module_paths = HashMap::new(); + module_paths.insert("admin".into(), vec!["shared".into(), "admin".into()]); + module_paths.insert( + "admin_stamp".into(), + vec!["shared".into(), "admin_stamp".into()], + ); + let result = resolve_relation_entity_module_path( + "admin", + "admin_stamp", + &module_paths, + "crate::models", + ); + assert_eq!(result, "super::admin_stamp"); + } + + #[test] + fn resolve_relation_entity_module_path_fallback_super_when_empty_prefix_cross_directory() { + let mut module_paths = HashMap::new(); + module_paths.insert("admin".into(), vec!["admin".into(), "admin".into()]); + module_paths.insert( + "estimate".into(), + vec!["estimate".into(), "estimate".into()], + ); + let result = resolve_relation_entity_module_path("admin", "estimate", &module_paths, ""); + assert_eq!(result, "super::estimate"); + } + + #[test] + fn resolve_relation_entity_module_path_uses_crate_prefix_when_not_in_module_paths() { + let module_paths = HashMap::new(); + let result = resolve_relation_entity_module_path( + "admin", + "estimate", + &module_paths, + "crate::models", + ); + assert_eq!(result, "crate::models::estimate"); + } + + #[test] + fn resolve_self_ref_link_module_path_uses_super_for_same_directory() { + let mut module_paths = HashMap::new(); + module_paths.insert("admin".into(), vec!["shared".into(), "admin".into()]); + module_paths.insert( + "admin_friendship".into(), + vec!["shared".into(), "admin_friendship".into()], + ); + let result = resolve_self_ref_link_module_path( + "admin", + "admin_friendship", + &module_paths, + "crate::models", + ); + assert_eq!(result, "super::admin_friendship"); + } + + #[test] + fn resolve_self_ref_link_module_path_absolute_fallback_when_empty_prefix() { + let mut module_paths = HashMap::new(); + module_paths.insert("admin".into(), vec!["admin".into(), "admin".into()]); + module_paths.insert( + "admin_friendship".into(), + vec!["social".into(), "admin_friendship".into()], + ); + let result = + resolve_self_ref_link_module_path("admin", "admin_friendship", &module_paths, ""); + assert_eq!(result, "crate::models::social::admin_friendship"); + } + + #[test] + fn self_ref_link_helpers_use_crate_path_for_cross_directory_junctions() { + let admin = TableDef { + name: "admin".into(), + description: None, + columns: vec![test_pk_column("username")], + constraints: vec![], + }; + + let estimate_user_checker_setting = TableDef { + name: "estimate_user_checker_setting".into(), + description: None, + columns: vec![ + test_pk_column("username"), + test_pk_column("checker_username"), + ], + constraints: vec![ + TableConstraint::ForeignKey { + name: None, + columns: vec!["username".into()], + ref_table: "admin".into(), + ref_columns: vec!["username".into()], + on_delete: None, + on_update: None, + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["checker_username".into()], + ref_table: "admin".into(), + ref_columns: vec!["username".into()], + on_delete: None, + on_update: None, + }, + ], + }; + + let schema = vec![admin.clone(), estimate_user_checker_setting]; + let mut module_paths = HashMap::new(); + module_paths.insert("admin".into(), vec!["admin".into(), "admin".into()]); + module_paths.insert( + "estimate_user_checker_setting".into(), + vec!["estimate".into(), "estimate_user_checker_setting".into()], + ); + + let rendered = render_entity_with_config_and_paths( + &admin, + &schema, + &SeaOrmConfig::default(), + "", + &module_paths, + "crate::models", + ); + + assert!(rendered.contains( + "crate::models::estimate::estimate_user_checker_setting::Relation::Username.def().rev()" + )); + assert!(rendered.contains("crate::models::estimate::estimate_user_checker_setting::Relation::CheckerUsername.def()")); + } } #[cfg(test)] @@ -1545,6 +2037,40 @@ mod helper_tests { assert_eq!(unique_name("other", &mut used), "other_1"); } + #[test] + fn test_unique_relation_enum_name_preferred_available() { + let used = HashSet::new(); + let result = unique_relation_enum_name("User".into(), "post", "User", &used); + assert_eq!(result, "User"); + } + + #[test] + fn test_unique_relation_enum_name_source_prefixed() { + let mut used = HashSet::new(); + used.insert("User".into()); + let result = unique_relation_enum_name("User".into(), "post", "User", &used); + assert_eq!(result, "PostUser"); + } + + #[test] + fn test_unique_relation_enum_name_numbered_fallback() { + let mut used = HashSet::new(); + used.insert("User".into()); + used.insert("PostUser".into()); + let result = unique_relation_enum_name("User".into(), "post", "User", &used); + assert_eq!(result, "PostUser2"); + } + + #[test] + fn test_unique_relation_enum_name_numbered_fallback_skips_taken() { + let mut used = HashSet::new(); + used.insert("User".into()); + used.insert("PostUser".into()); + used.insert("PostUser2".into()); + let result = unique_relation_enum_name("User".into(), "post", "User", &used); + assert_eq!(result, "PostUser3"); + } + #[rstest] #[case(vec!["creator_user_id".into()], "CreatorUser")] #[case(vec!["used_by_user_id".into()], "UsedByUser")] @@ -1618,7 +2144,7 @@ mod helper_tests { assert!(column_type_supports_eq(&ColumnType::Complex( ComplexColumnType::Numeric { precision: 10, - scale: 2, + scale: 2 } ))); } @@ -2810,6 +3336,8 @@ mod tests { #[case("multiple_fk_same_table")] #[case("username_fk")] #[case("multiple_reverse_relations")] + #[case("dual_reverse_relations")] + #[case("triple_reverse_relations")] #[case("multiple_has_one_relations")] fn render_entity_with_schema_snapshots(#[case] name: &str) { use vespertide_core::SimpleColumnType::*; @@ -3096,6 +3624,48 @@ mod tests { ); (user.clone(), vec![user, profile]) } + "dual_reverse_relations" => { + let dual = table_with_pk( + "dual", + vec![col("username", ColumnType::Simple(Text))], + vec!["username"], + ); + let dual_rel = table_with_pk_and_fk( + "dual_rel", + vec![ + col("username", ColumnType::Simple(Text)), + col("checker_username", ColumnType::Simple(Text)), + ], + vec!["username", "checker_username"], + vec![ + (vec!["username"], "dual", vec!["username"]), + (vec!["checker_username"], "dual", vec!["username"]), + ], + ); + (dual.clone(), vec![dual, dual_rel]) + } + "triple_reverse_relations" => { + let dual = table_with_pk( + "dual", + vec![col("username", ColumnType::Simple(Text))], + vec!["username"], + ); + let triple_rel = table_with_pk_and_fk( + "triple_rel", + vec![ + col("username", ColumnType::Simple(Text)), + col("checker_username", ColumnType::Simple(Text)), + col("other_username", ColumnType::Simple(Text)), + ], + vec!["username", "checker_username", "other_username"], + vec![ + (vec!["username"], "dual", vec!["username"]), + (vec!["checker_username"], "dual", vec!["username"]), + (vec!["other_username"], "dual", vec!["username"]), + ], + ); + (dual.clone(), vec![dual, triple_rel]) + } "multiple_has_one_relations" => { // Test case where user has multiple has_one relations (UNIQUE FK) let user = table_with_pk( @@ -3651,6 +4221,142 @@ mod tests { assert!(result.contains("#[sea_orm(table_name = \"users\")]")); } + #[test] + fn test_junction_relation_enum_without_via_when_entity_appears_multiple_times() { + use vespertide_core::schema::primary_key::PrimaryKeySyntax; + + // user has a forward FK to user_tag (composite FK), making user_tag appear + // in both forward and reverse targets => entity_count > 1 for user_tag. + // The junction table entry from collect_many_to_many_relations has via=None, via_rel=None, + // so when needs_relation_enum is true, it hits the branch with only relation_enum (no via/via_rel). + let user = TableDef { + name: "user".into(), + description: None, + columns: vec![ + ColumnDef { + name: "id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: Some(PrimaryKeySyntax::Bool(true)), + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "pinned_user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: true, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "pinned_tag_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: true, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![TableConstraint::ForeignKey { + name: None, + columns: vec!["pinned_user_id".into(), "pinned_tag_id".into()], + ref_table: "user_tag".into(), + ref_columns: vec!["user_id".into(), "tag_id".into()], + on_delete: None, + on_update: None, + }], + }; + + let user_tag = TableDef { + name: "user_tag".into(), + description: None, + columns: vec![ + ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: Some(PrimaryKeySyntax::Bool(true)), + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "tag_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: Some(PrimaryKeySyntax::Bool(true)), + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![ + TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["tag_id".into()], + ref_table: "tag".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + ], + }; + + let tag = TableDef { + name: "tag".into(), + description: None, + columns: vec![ColumnDef { + name: "id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: Some(PrimaryKeySyntax::Bool(true)), + unique: None, + index: None, + foreign_key: None, + }], + constraints: vec![], + }; + + let schema = vec![user.clone(), user_tag, tag]; + let rendered = render_entity_with_schema(&user, &schema); + + // The junction table "user_tag" appears in both forward (composite FK) and reverse (M2M junction), + // so it gets relation_enum without via/via_rel + assert!(rendered.contains("relation_enum")); + // Verify we have a has_many to user_tag with relation_enum but no via + let has_user_tag_relation_enum_without_via = rendered.lines().any(|line| { + line.contains("has_many") && line.contains("relation_enum") && !line.contains("via") + }); + assert!( + has_user_tag_relation_enum_without_via, + "Expected has_many with relation_enum but no via for junction table entity, got:\n{rendered}" + ); + } + #[test] fn test_json_default_value_escapes_double_quotes() { let table = TableDef { diff --git a/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_snapshots@params_pk_and_fk_together.snap b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_snapshots@params_pk_and_fk_together.snap index 8b83f06..c2c091b 100644 --- a/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_snapshots@params_pk_and_fk_together.snap +++ b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_snapshots@params_pk_and_fk_together.snap @@ -26,7 +26,6 @@ pub struct Model { pub user: HasOne, } - // Index definitions (SeaORM uses Statement builders externally) // (unnamed) on [article_id] // (unnamed) on [user_id] diff --git a/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_snapshots@params_unique_and_indexed.snap b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_snapshots@params_unique_and_indexed.snap index 048b49f..9e109d3 100644 --- a/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_snapshots@params_unique_and_indexed.snap +++ b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_snapshots@params_unique_and_indexed.snap @@ -20,7 +20,6 @@ pub struct Model { pub status: String, } - // Index definitions (SeaORM uses Statement builders externally) // idx_department on [department] vespera::schema_type!(Schema from Model, name = "UsersSchema"); diff --git a/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_with_schema_snapshots@schema_dual_reverse_relations.snap b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_with_schema_snapshots@schema_dual_reverse_relations.snap new file mode 100644 index 0000000..a5d9f84 --- /dev/null +++ b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_with_schema_snapshots@schema_dual_reverse_relations.snap @@ -0,0 +1,56 @@ +--- +source: crates/vespertide-exporter/src/seaorm/mod.rs +expression: rendered +--- +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "dual")] +pub struct Model { + #[sea_orm(primary_key)] + pub username: String, + #[sea_orm(has_many, relation_enum = "DualRel", via_rel = "Username")] + pub username_dual_rels: HasMany, + #[sea_orm(has_many, relation_enum = "CheckerUsername", via_rel = "CheckerUsername")] + pub checker_username_dual_rels: HasMany, +} + +vespera::schema_type!(Schema from Model, name = "DualSchema"); +impl ActiveModelBehavior for ActiveModel {} + +pub struct UsernameToCheckerUsernameViaDualRel; +impl Linked for UsernameToCheckerUsernameViaDualRel { + type FromEntity = Entity; + type ToEntity = Entity; + + fn link(&self) -> Vec { + vec![ + super::dual_rel::Relation::Username.def().rev(), + super::dual_rel::Relation::CheckerUsername.def(), + ] + } +} + +pub struct CheckerUsernameToUsernameViaDualRel; +impl Linked for CheckerUsernameToUsernameViaDualRel { + type FromEntity = Entity; + type ToEntity = Entity; + + fn link(&self) -> Vec { + vec![ + super::dual_rel::Relation::CheckerUsername.def().rev(), + super::dual_rel::Relation::Username.def(), + ] + } +} + +impl Model { + pub fn find_checker_usernames_via_dual_rel_from_username(&self) -> Select { + self.find_linked(UsernameToCheckerUsernameViaDualRel) + } + + pub fn find_usernames_via_dual_rel_from_checker_username(&self) -> Select { + self.find_linked(CheckerUsernameToUsernameViaDualRel) + } +} diff --git a/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_with_schema_snapshots@schema_multiple_has_one_relations.snap b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_with_schema_snapshots@schema_multiple_has_one_relations.snap index dbbde96..66105b7 100644 --- a/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_with_schema_snapshots@schema_multiple_has_one_relations.snap +++ b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_with_schema_snapshots@schema_multiple_has_one_relations.snap @@ -10,9 +10,9 @@ use sea_orm::entity::prelude::*; pub struct Model { #[sea_orm(primary_key)] pub id: Uuid, - #[sea_orm(has_one, relation_enum = "Settings", via = "settings")] + #[sea_orm(has_one, relation_enum = "Settings", via_rel = "CreatedByUser")] pub created_by_user: HasOne, - #[sea_orm(has_one, relation_enum = "UpdatedByUser", via = "settings")] + #[sea_orm(has_one, relation_enum = "UpdatedByUser", via_rel = "UpdatedByUser")] pub updated_by_user: HasOne, } diff --git a/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_with_schema_snapshots@schema_multiple_reverse_relations.snap b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_with_schema_snapshots@schema_multiple_reverse_relations.snap index 14c6260..9133a8d 100644 --- a/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_with_schema_snapshots@schema_multiple_reverse_relations.snap +++ b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_with_schema_snapshots@schema_multiple_reverse_relations.snap @@ -10,9 +10,9 @@ use sea_orm::entity::prelude::*; pub struct Model { #[sea_orm(primary_key)] pub id: Uuid, - #[sea_orm(has_many, relation_enum = "Profile", via = "profile")] + #[sea_orm(has_many, relation_enum = "Profile", via_rel = "PreferredUser")] pub preferred_user_profiles: HasMany, - #[sea_orm(has_many, relation_enum = "BackupUser", via = "profile")] + #[sea_orm(has_many, relation_enum = "BackupUser", via_rel = "BackupUser")] pub backup_user_profiles: HasMany, } diff --git a/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_with_schema_snapshots@schema_triple_reverse_relations.snap b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_with_schema_snapshots@schema_triple_reverse_relations.snap new file mode 100644 index 0000000..b411664 --- /dev/null +++ b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_with_schema_snapshots@schema_triple_reverse_relations.snap @@ -0,0 +1,126 @@ +--- +source: crates/vespertide-exporter/src/seaorm/mod.rs +expression: rendered +--- +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "dual")] +pub struct Model { + #[sea_orm(primary_key)] + pub username: String, + #[sea_orm(has_many, relation_enum = "TripleRel", via_rel = "Username")] + pub username_triple_rels: HasMany, + #[sea_orm(has_many, relation_enum = "CheckerUsername", via_rel = "CheckerUsername")] + pub checker_username_triple_rels: HasMany, + #[sea_orm(has_many, relation_enum = "OtherUsername", via_rel = "OtherUsername")] + pub other_username_triple_rels: HasMany, +} + +vespera::schema_type!(Schema from Model, name = "DualSchema"); +impl ActiveModelBehavior for ActiveModel {} + +pub struct UsernameToCheckerUsernameViaTripleRel; +impl Linked for UsernameToCheckerUsernameViaTripleRel { + type FromEntity = Entity; + type ToEntity = Entity; + + fn link(&self) -> Vec { + vec![ + super::triple_rel::Relation::Username.def().rev(), + super::triple_rel::Relation::CheckerUsername.def(), + ] + } +} + +pub struct UsernameToOtherUsernameViaTripleRel; +impl Linked for UsernameToOtherUsernameViaTripleRel { + type FromEntity = Entity; + type ToEntity = Entity; + + fn link(&self) -> Vec { + vec![ + super::triple_rel::Relation::Username.def().rev(), + super::triple_rel::Relation::OtherUsername.def(), + ] + } +} + +pub struct CheckerUsernameToUsernameViaTripleRel; +impl Linked for CheckerUsernameToUsernameViaTripleRel { + type FromEntity = Entity; + type ToEntity = Entity; + + fn link(&self) -> Vec { + vec![ + super::triple_rel::Relation::CheckerUsername.def().rev(), + super::triple_rel::Relation::Username.def(), + ] + } +} + +pub struct CheckerUsernameToOtherUsernameViaTripleRel; +impl Linked for CheckerUsernameToOtherUsernameViaTripleRel { + type FromEntity = Entity; + type ToEntity = Entity; + + fn link(&self) -> Vec { + vec![ + super::triple_rel::Relation::CheckerUsername.def().rev(), + super::triple_rel::Relation::OtherUsername.def(), + ] + } +} + +pub struct OtherUsernameToUsernameViaTripleRel; +impl Linked for OtherUsernameToUsernameViaTripleRel { + type FromEntity = Entity; + type ToEntity = Entity; + + fn link(&self) -> Vec { + vec![ + super::triple_rel::Relation::OtherUsername.def().rev(), + super::triple_rel::Relation::Username.def(), + ] + } +} + +pub struct OtherUsernameToCheckerUsernameViaTripleRel; +impl Linked for OtherUsernameToCheckerUsernameViaTripleRel { + type FromEntity = Entity; + type ToEntity = Entity; + + fn link(&self) -> Vec { + vec![ + super::triple_rel::Relation::OtherUsername.def().rev(), + super::triple_rel::Relation::CheckerUsername.def(), + ] + } +} + +impl Model { + pub fn find_checker_usernames_via_triple_rel_from_username(&self) -> Select { + self.find_linked(UsernameToCheckerUsernameViaTripleRel) + } + + pub fn find_other_usernames_via_triple_rel_from_username(&self) -> Select { + self.find_linked(UsernameToOtherUsernameViaTripleRel) + } + + pub fn find_usernames_via_triple_rel_from_checker_username(&self) -> Select { + self.find_linked(CheckerUsernameToUsernameViaTripleRel) + } + + pub fn find_other_usernames_via_triple_rel_from_checker_username(&self) -> Select { + self.find_linked(CheckerUsernameToOtherUsernameViaTripleRel) + } + + pub fn find_usernames_via_triple_rel_from_other_username(&self) -> Select { + self.find_linked(OtherUsernameToUsernameViaTripleRel) + } + + pub fn find_checker_usernames_via_triple_rel_from_other_username(&self) -> Select { + self.find_linked(OtherUsernameToCheckerUsernameViaTripleRel) + } +} diff --git a/crates/vespertide-planner/Cargo.toml b/crates/vespertide-planner/Cargo.toml index 09bca3d..de0ceb0 100644 --- a/crates/vespertide-planner/Cargo.toml +++ b/crates/vespertide-planner/Cargo.toml @@ -15,4 +15,4 @@ thiserror = "2" [dev-dependencies] rstest = "0.26" -insta = "1.46" +insta = "1.47" diff --git a/crates/vespertide-query/Cargo.toml b/crates/vespertide-query/Cargo.toml index 3e366c3..3f579fd 100644 --- a/crates/vespertide-query/Cargo.toml +++ b/crates/vespertide-query/Cargo.toml @@ -17,4 +17,4 @@ sea-query = "0.32" [dev-dependencies] rstest = "0.26" -insta = "1.46" +insta = "1.47" diff --git a/examples/app/Cargo.toml b/examples/app/Cargo.toml index 3bf6014..f4fe8df 100644 --- a/examples/app/Cargo.toml +++ b/examples/app/Cargo.toml @@ -10,4 +10,4 @@ tokio = { version = "1", features = ["full"] } sea-orm = { version = "2.0.0-rc.37", features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-native-tls", "macros"] } anyhow = "1" serde = { version = "1", features = ["derive"] } -vespera = "0.1.43" +vespera = "0.1.48" diff --git a/examples/app/models/article_user.json b/examples/app/models/article_user.json index 70da12a..0df820f 100644 --- a/examples/app/models/article_user.json +++ b/examples/app/models/article_user.json @@ -34,7 +34,7 @@ }, { "name": "role", - "type": { "kind": "enum", "name": "role1", "values": ["contributor"] }, + "type": { "kind": "enum", "name": "role", "values": ["lead", "contributor"] }, "nullable": false, "default": "'contributor'" }, diff --git a/examples/app/models/dual.json b/examples/app/models/dual.json new file mode 100644 index 0000000..440f832 --- /dev/null +++ b/examples/app/models/dual.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/model.schema.json", + "name": "dual", + "columns": [ + { + "name": "username", + "type": { "kind": "varchar", "length": 32 }, + "nullable": false, + "primary_key": true + } + ] +} diff --git a/examples/app/models/dual_rel.json b/examples/app/models/dual_rel.json new file mode 100644 index 0000000..e8d8b4f --- /dev/null +++ b/examples/app/models/dual_rel.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/model.schema.json", + "name": "dual_rel", + "columns": [ + { + "name": "username", + "type": { "kind": "varchar", "length": 32 }, + "nullable": false, + "primary_key": true, + "foreign_key": { + "ref_table": "dual", + "ref_columns": ["username"], + "on_delete": "cascade" + } + }, + { + "name": "checker_username", + "type": { "kind": "varchar", "length": 32 }, + "nullable": false, + "primary_key": true, + "foreign_key": { + "ref_table": "dual", + "ref_columns": ["username"], + "on_delete": "cascade" + } + } + ] +} diff --git a/examples/app/models/single.json b/examples/app/models/single.json new file mode 100644 index 0000000..8e65f4c --- /dev/null +++ b/examples/app/models/single.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/model.schema.json", + "name": "single", + "columns": [ + { + "name": "username", + "type": { "kind": "varchar", "length": 32 }, + "nullable": false, + "primary_key": true + } + ] +} diff --git a/examples/app/models/single_rel.json b/examples/app/models/single_rel.json new file mode 100644 index 0000000..7eb6773 --- /dev/null +++ b/examples/app/models/single_rel.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/model.schema.json", + "name": "single_rel", + "columns": [ + { + "name": "username", + "type": { "kind": "varchar", "length": 32 }, + "nullable": false, + "primary_key": true, + "foreign_key": { + "ref_table": "single", + "ref_columns": ["username"], + "on_delete": "cascade" + } + } + ] +} diff --git a/examples/app/models/triple.json b/examples/app/models/triple.json new file mode 100644 index 0000000..02b44f5 --- /dev/null +++ b/examples/app/models/triple.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/model.schema.json", + "name": "triple", + "columns": [ + { + "name": "username", + "type": { "kind": "varchar", "length": 32 }, + "nullable": false, + "primary_key": true + } + ] +} diff --git a/examples/app/models/triple_rel.json b/examples/app/models/triple_rel.json new file mode 100644 index 0000000..1fc4a28 --- /dev/null +++ b/examples/app/models/triple_rel.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/model.schema.json", + "name": "triple_rel", + "columns": [ + { + "name": "username", + "type": { "kind": "varchar", "length": 32 }, + "nullable": false, + "primary_key": true, + "foreign_key": { + "ref_table": "triple", + "ref_columns": ["username"], + "on_delete": "cascade" + } + }, + { + "name": "checker_username", + "type": { "kind": "varchar", "length": 32 }, + "nullable": false, + "primary_key": true, + "foreign_key": { + "ref_table": "triple", + "ref_columns": ["username"], + "on_delete": "cascade" + } + }, + { + "name": "other_username", + "type": { "kind": "varchar", "length": 32 }, + "nullable": false, + "primary_key": true, + "foreign_key": { + "ref_table": "triple", + "ref_columns": ["username"], + "on_delete": "cascade" + } + } + ] +} diff --git a/examples/app/src/models/dual.rs b/examples/app/src/models/dual.rs new file mode 100644 index 0000000..8379fee --- /dev/null +++ b/examples/app/src/models/dual.rs @@ -0,0 +1,56 @@ +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "dual")] +pub struct Model { + #[sea_orm(primary_key)] + pub username: String, + #[sea_orm(has_many, relation_enum = "DualRel", via_rel = "Username")] + pub username_dual_rels: HasMany, + #[sea_orm( + has_many, + relation_enum = "CheckerUsername", + via_rel = "CheckerUsername" + )] + pub checker_username_dual_rels: HasMany, +} + +vespera::schema_type!(Schema from Model, name = "DualSchema"); +impl ActiveModelBehavior for ActiveModel {} + +pub struct UsernameToCheckerUsernameViaDualRel; +impl Linked for UsernameToCheckerUsernameViaDualRel { + type FromEntity = Entity; + type ToEntity = Entity; + + fn link(&self) -> Vec { + vec![ + super::dual_rel::Relation::Username.def().rev(), + super::dual_rel::Relation::CheckerUsername.def(), + ] + } +} + +pub struct CheckerUsernameToUsernameViaDualRel; +impl Linked for CheckerUsernameToUsernameViaDualRel { + type FromEntity = Entity; + type ToEntity = Entity; + + fn link(&self) -> Vec { + vec![ + super::dual_rel::Relation::CheckerUsername.def().rev(), + super::dual_rel::Relation::Username.def(), + ] + } +} + +impl Model { + pub fn find_checker_usernames_via_dual_rel_from_username(&self) -> Select { + self.find_linked(UsernameToCheckerUsernameViaDualRel) + } + + pub fn find_usernames_via_dual_rel_from_checker_username(&self) -> Select { + self.find_linked(CheckerUsernameToUsernameViaDualRel) + } +} diff --git a/examples/app/src/models/dual_rel.rs b/examples/app/src/models/dual_rel.rs new file mode 100644 index 0000000..21e6636 --- /dev/null +++ b/examples/app/src/models/dual_rel.rs @@ -0,0 +1,28 @@ +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "dual_rel")] +pub struct Model { + #[sea_orm(primary_key)] + pub username: String, + #[sea_orm(primary_key)] + pub checker_username: String, + #[sea_orm( + belongs_to, + relation_enum = "Username", + from = "username", + to = "username" + )] + pub dual: HasOne, + #[sea_orm( + belongs_to, + relation_enum = "CheckerUsername", + from = "checker_username", + to = "username" + )] + pub checker: HasOne, +} + +vespera::schema_type!(Schema from Model, name = "DualRelSchema"); +impl ActiveModelBehavior for ActiveModel {} diff --git a/examples/app/src/models/mod.rs b/examples/app/src/models/mod.rs index a3aa491..dc6bedf 100644 --- a/examples/app/src/models/mod.rs +++ b/examples/app/src/models/mod.rs @@ -1,5 +1,11 @@ pub mod article; pub mod article_user; +pub mod dual; +pub mod dual_rel; pub mod media; +pub mod single; +pub mod single_rel; +pub mod triple; +pub mod triple_rel; pub mod user; pub mod user_media_role; diff --git a/examples/app/src/models/single.rs b/examples/app/src/models/single.rs new file mode 100644 index 0000000..331e4c9 --- /dev/null +++ b/examples/app/src/models/single.rs @@ -0,0 +1,14 @@ +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "single")] +pub struct Model { + #[sea_orm(primary_key)] + pub username: String, + #[sea_orm(has_one)] + pub single_rel: HasOne, +} + +vespera::schema_type!(Schema from Model, name = "SingleSchema"); +impl ActiveModelBehavior for ActiveModel {} diff --git a/examples/app/src/models/single_rel.rs b/examples/app/src/models/single_rel.rs new file mode 100644 index 0000000..68e3ac2 --- /dev/null +++ b/examples/app/src/models/single_rel.rs @@ -0,0 +1,14 @@ +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "single_rel")] +pub struct Model { + #[sea_orm(primary_key)] + pub username: String, + #[sea_orm(belongs_to, from = "username", to = "username")] + pub single: HasOne, +} + +vespera::schema_type!(Schema from Model, name = "SingleRelSchema"); +impl ActiveModelBehavior for ActiveModel {} diff --git a/examples/app/src/models/triple.rs b/examples/app/src/models/triple.rs new file mode 100644 index 0000000..61a241a --- /dev/null +++ b/examples/app/src/models/triple.rs @@ -0,0 +1,126 @@ +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "triple")] +pub struct Model { + #[sea_orm(primary_key)] + pub username: String, + #[sea_orm(has_many, relation_enum = "TripleRel", via_rel = "Username")] + pub username_triple_rels: HasMany, + #[sea_orm( + has_many, + relation_enum = "CheckerUsername", + via_rel = "CheckerUsername" + )] + pub checker_username_triple_rels: HasMany, + #[sea_orm(has_many, relation_enum = "OtherUsername", via_rel = "OtherUsername")] + pub other_username_triple_rels: HasMany, +} + +vespera::schema_type!(Schema from Model, name = "TripleSchema"); +impl ActiveModelBehavior for ActiveModel {} + +pub struct UsernameToCheckerUsernameViaTripleRel; +impl Linked for UsernameToCheckerUsernameViaTripleRel { + type FromEntity = Entity; + type ToEntity = Entity; + + fn link(&self) -> Vec { + vec![ + super::triple_rel::Relation::Username.def().rev(), + super::triple_rel::Relation::CheckerUsername.def(), + ] + } +} + +pub struct UsernameToOtherUsernameViaTripleRel; +impl Linked for UsernameToOtherUsernameViaTripleRel { + type FromEntity = Entity; + type ToEntity = Entity; + + fn link(&self) -> Vec { + vec![ + super::triple_rel::Relation::Username.def().rev(), + super::triple_rel::Relation::OtherUsername.def(), + ] + } +} + +pub struct CheckerUsernameToUsernameViaTripleRel; +impl Linked for CheckerUsernameToUsernameViaTripleRel { + type FromEntity = Entity; + type ToEntity = Entity; + + fn link(&self) -> Vec { + vec![ + super::triple_rel::Relation::CheckerUsername.def().rev(), + super::triple_rel::Relation::Username.def(), + ] + } +} + +pub struct CheckerUsernameToOtherUsernameViaTripleRel; +impl Linked for CheckerUsernameToOtherUsernameViaTripleRel { + type FromEntity = Entity; + type ToEntity = Entity; + + fn link(&self) -> Vec { + vec![ + super::triple_rel::Relation::CheckerUsername.def().rev(), + super::triple_rel::Relation::OtherUsername.def(), + ] + } +} + +pub struct OtherUsernameToUsernameViaTripleRel; +impl Linked for OtherUsernameToUsernameViaTripleRel { + type FromEntity = Entity; + type ToEntity = Entity; + + fn link(&self) -> Vec { + vec![ + super::triple_rel::Relation::OtherUsername.def().rev(), + super::triple_rel::Relation::Username.def(), + ] + } +} + +pub struct OtherUsernameToCheckerUsernameViaTripleRel; +impl Linked for OtherUsernameToCheckerUsernameViaTripleRel { + type FromEntity = Entity; + type ToEntity = Entity; + + fn link(&self) -> Vec { + vec![ + super::triple_rel::Relation::OtherUsername.def().rev(), + super::triple_rel::Relation::CheckerUsername.def(), + ] + } +} + +impl Model { + pub fn find_checker_usernames_via_triple_rel_from_username(&self) -> Select { + self.find_linked(UsernameToCheckerUsernameViaTripleRel) + } + + pub fn find_other_usernames_via_triple_rel_from_username(&self) -> Select { + self.find_linked(UsernameToOtherUsernameViaTripleRel) + } + + pub fn find_usernames_via_triple_rel_from_checker_username(&self) -> Select { + self.find_linked(CheckerUsernameToUsernameViaTripleRel) + } + + pub fn find_other_usernames_via_triple_rel_from_checker_username(&self) -> Select { + self.find_linked(CheckerUsernameToOtherUsernameViaTripleRel) + } + + pub fn find_usernames_via_triple_rel_from_other_username(&self) -> Select { + self.find_linked(OtherUsernameToUsernameViaTripleRel) + } + + pub fn find_checker_usernames_via_triple_rel_from_other_username(&self) -> Select { + self.find_linked(OtherUsernameToCheckerUsernameViaTripleRel) + } +} diff --git a/examples/app/src/models/triple_rel.rs b/examples/app/src/models/triple_rel.rs new file mode 100644 index 0000000..15fd637 --- /dev/null +++ b/examples/app/src/models/triple_rel.rs @@ -0,0 +1,37 @@ +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "triple_rel")] +pub struct Model { + #[sea_orm(primary_key)] + pub username: String, + #[sea_orm(primary_key)] + pub checker_username: String, + #[sea_orm(primary_key)] + pub other_username: String, + #[sea_orm( + belongs_to, + relation_enum = "Username", + from = "username", + to = "username" + )] + pub triple: HasOne, + #[sea_orm( + belongs_to, + relation_enum = "CheckerUsername", + from = "checker_username", + to = "username" + )] + pub checker: HasOne, + #[sea_orm( + belongs_to, + relation_enum = "OtherUsername", + from = "other_username", + to = "username" + )] + pub other: HasOne, +} + +vespera::schema_type!(Schema from Model, name = "TripleRelSchema"); +impl ActiveModelBehavior for ActiveModel {} diff --git a/examples/app/src/models/user.rs b/examples/app/src/models/user.rs index 763a609..2f855d1 100644 --- a/examples/app/src/models/user.rs +++ b/examples/app/src/models/user.rs @@ -18,7 +18,7 @@ pub struct Model { pub article_users: HasMany, #[sea_orm(has_many, via = "article_user")] pub articles_via_article_user: HasMany, - #[sea_orm(has_many, relation_enum = "Media", via = "media")] + #[sea_orm(has_many, relation_enum = "Media", via_rel = "Owner")] pub medias: HasMany, #[sea_orm(has_many)] pub user_media_roles: HasMany,