Skip to content

Commit 134dd14

Browse files
committed
Add link rendering
1 parent b72552f commit 134dd14

2 files changed

Lines changed: 200 additions & 17 deletions

File tree

  • crates

crates/vespertide-cli/src/commands/export.rs

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ pub async fn cmd_export(orm: OrmArg, export_dir: Option<PathBuf>) -> Result<()>
125125

126126
// Ensure mod chain for SeaORM (must be done after all files are written)
127127
if matches!(orm_kind, Orm::SeaOrm) {
128-
for (_, rel_path) in &normalized_models {
128+
for (_table, rel_path) in &normalized_models {
129129
let out_path = build_output_path(&target_root, rel_path, orm_kind);
130130
ensure_mod_chain(&target_root, rel_path)
131131
.await
@@ -342,8 +342,7 @@ async fn load_models_recursive(base: &Path) -> Result<Vec<(TableDef, PathBuf)>>
342342
}
343343

344344
async fn ensure_mod_chain(root: &Path, rel_path: &Path) -> Result<()> {
345-
// Only needed for SeaORM (Rust) exports to wire modules.
346-
// Strip extension and ".vespertide" suffix from filename
345+
// SeaORM exports use a standard nested Rust module tree.
347346
let path_without_ext = rel_path.with_extension("");
348347
let path_stripped = if let Some(stem) = path_without_ext.file_stem().and_then(|s| s.to_str()) {
349348
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<()> {
366365
if comps.is_empty() {
367366
return Ok(());
368367
}
369-
// Build from deepest file up to root: dir/mod.rs should include child module.
368+
370369
while let Some(child) = comps.pop() {
371370
let dir = root.join(comps.join(std::path::MAIN_SEPARATOR_STR));
372371
let mod_path = dir.join("mod.rs");
@@ -375,13 +374,15 @@ async fn ensure_mod_chain(root: &Path, rel_path: &Path) -> Result<()> {
375374
{
376375
fs::create_dir_all(parent).await?;
377376
}
377+
378378
let mut content = if mod_path.exists() {
379379
fs::read_to_string(&mod_path).await?
380380
} else {
381381
String::new()
382382
};
383-
let decl = format!("pub mod {};", child);
384-
if !content.lines().any(|l| l.trim() == decl) {
383+
384+
let decl = format!("pub mod {child};");
385+
if !content.lines().any(|line| line.trim() == decl) {
385386
if !content.is_empty() && !content.ends_with('\n') {
386387
content.push('\n');
387388
}
@@ -390,6 +391,7 @@ async fn ensure_mod_chain(root: &Path, rel_path: &Path) -> Result<()> {
390391
fs::write(mod_path, content).await?;
391392
}
392393
}
394+
393395
Ok(())
394396
}
395397

@@ -535,7 +537,7 @@ mod tests {
535537
let content = std_fs::read_to_string(out).unwrap();
536538
assert!(content.contains("#[sea_orm(table_name = \"posts\")]"));
537539

538-
// mod.rs wiring
540+
// nested mod.rs wiring
539541
let root_mod = custom.join("mod.rs");
540542
let blog_mod = custom.join("blog/mod.rs");
541543
assert!(root_mod.exists());
@@ -779,6 +781,7 @@ mod tests {
779781
let blog_mod = std_fs::read_to_string(root.join("blog/mod.rs")).unwrap();
780782
assert!(root_mod.contains("pub mod blog;"));
781783
assert!(blog_mod.contains("pub mod post;"));
784+
assert!(!root_mod.contains("post_vespertide"));
782785
assert!(!blog_mod.contains("post_vespertide"));
783786
}
784787

crates/vespertide-exporter/src/seaorm/mod.rs

Lines changed: 190 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ fn absolute_module_path(crate_prefix: &str, to_module: &[String]) -> String {
2525
/// Look up the module path for a table name from the module_paths map.
2626
/// Uses `super::` for sibling modules in the same folder, `crate::` absolute paths for
2727
/// cross-directory relations when mappings are available, and falls back to `super::{table_name}`.
28+
#[cfg(test)]
2829
fn resolve_entity_module_path(
2930
current_table: &str,
3031
target_table: &str,
@@ -50,6 +51,44 @@ fn resolve_entity_module_path(
5051
format!("super::{target_table}")
5152
}
5253

54+
/// Resolve relation field entity paths for SeaORM model macros.
55+
///
56+
/// Rule:
57+
/// - same folder → `super::{table}`
58+
/// - different folder → absolute `crate::...` path
59+
///
60+
/// This avoids generating brittle `super::super::...` paths for cross-folder relations.
61+
fn resolve_relation_entity_module_path(
62+
current_table: &str,
63+
target_table: &str,
64+
module_paths: &HashMap<String, Vec<String>>,
65+
crate_prefix: &str,
66+
) -> String {
67+
if let (Some(current), Some(target)) = (
68+
module_paths.get(current_table),
69+
module_paths.get(target_table),
70+
) {
71+
let current_parent = current.split_last().map_or(&[][..], |(_, parent)| parent);
72+
let target_parent = target.split_last().map_or(&[][..], |(_, parent)| parent);
73+
74+
if current_parent == target_parent {
75+
return format!("super::{target_table}");
76+
}
77+
78+
if !crate_prefix.is_empty() {
79+
return absolute_module_path(crate_prefix, target);
80+
}
81+
82+
return format!("super::{target_table}");
83+
}
84+
85+
if !crate_prefix.is_empty() {
86+
return format!("{crate_prefix}::{target_table}");
87+
}
88+
89+
format!("super::{target_table}")
90+
}
91+
5392
pub struct SeaOrmExporter;
5493

5594
/// SeaORM exporter with configuration support.
@@ -248,7 +287,7 @@ pub fn render_entity_with_config_and_paths(
248287

249288
lines.push("impl ActiveModelBehavior for ActiveModel {}".into());
250289

251-
let self_ref_links = render_self_ref_link_helpers(table, schema);
290+
let self_ref_links = render_self_ref_link_helpers(table, schema, module_paths, crate_prefix);
252291
if !self_ref_links.is_empty() {
253292
lines.push(String::new());
254293
lines.extend(self_ref_links);
@@ -658,8 +697,12 @@ fn relation_field_defs_with_schema(
658697
};
659698

660699
out.push(attr);
661-
let entity_path =
662-
resolve_entity_module_path(&table.name, resolved_table, module_paths, crate_prefix);
700+
let entity_path = resolve_relation_entity_module_path(
701+
&table.name,
702+
resolved_table,
703+
module_paths,
704+
crate_prefix,
705+
);
663706
out.push(format!(
664707
" pub {field_name}: HasOne<{entity_path}::Entity>,"
665708
));
@@ -791,7 +834,39 @@ fn self_ref_link_name(
791834
)
792835
}
793836

794-
fn render_self_ref_link_helpers(table: &TableDef, schema: &[TableDef]) -> Vec<String> {
837+
fn resolve_self_ref_link_module_path(
838+
current_table: &str,
839+
junction_table: &str,
840+
module_paths: &HashMap<String, Vec<String>>,
841+
crate_prefix: &str,
842+
) -> String {
843+
if let (Some(current), Some(target)) = (
844+
module_paths.get(current_table),
845+
module_paths.get(junction_table),
846+
) {
847+
let current_parent = current.split_last().map_or(&[][..], |(_, parent)| parent);
848+
let target_parent = target.split_last().map_or(&[][..], |(_, parent)| parent);
849+
850+
if current_parent == target_parent {
851+
return format!("super::{junction_table}");
852+
}
853+
854+
if !crate_prefix.is_empty() {
855+
return absolute_module_path(crate_prefix, target);
856+
}
857+
858+
return absolute_module_path("crate::models", target);
859+
}
860+
861+
format!("super::{junction_table}")
862+
}
863+
864+
fn render_self_ref_link_helpers(
865+
table: &TableDef,
866+
schema: &[TableDef],
867+
module_paths: &HashMap<String, Vec<String>>,
868+
crate_prefix: &str,
869+
) -> Vec<String> {
795870
let mut out = Vec::new();
796871

797872
for other_table in schema {
@@ -805,6 +880,13 @@ fn render_self_ref_link_helpers(table: &TableDef, schema: &[TableDef]) -> Vec<St
805880
continue;
806881
};
807882

883+
let junction_entity_path = resolve_self_ref_link_module_path(
884+
&table.name,
885+
&self_ref_junction.junction_table,
886+
module_paths,
887+
crate_prefix,
888+
);
889+
808890
if self_ref_junction.role_columns.len() < 2 {
809891
continue;
810892
}
@@ -824,12 +906,12 @@ fn render_self_ref_link_helpers(table: &TableDef, schema: &[TableDef]) -> Vec<St
824906
out.push(" fn link(&self) -> Vec<RelationDef> {".into());
825907
out.push(" vec![".into());
826908
out.push(format!(
827-
" super::{}::Relation::{}.def().rev(),",
828-
self_ref_junction.junction_table, from_role
909+
" {junction_entity_path}::Relation::{}.def().rev(),",
910+
from_role
829911
));
830912
out.push(format!(
831-
" super::{}::Relation::{}.def(),",
832-
self_ref_junction.junction_table, to_role
913+
" {junction_entity_path}::Relation::{}.def(),",
914+
to_role
833915
));
834916
out.push(" ]".into());
835917
out.push(" }".into());
@@ -1252,8 +1334,12 @@ fn reverse_relation_field_defs(
12521334
};
12531335

12541336
out.push(attr);
1255-
let entity_path =
1256-
resolve_entity_module_path(&table.name, &rel.target_entity, module_paths, crate_prefix);
1337+
let entity_path = resolve_relation_entity_module_path(
1338+
&table.name,
1339+
&rel.target_entity,
1340+
module_paths,
1341+
crate_prefix,
1342+
);
12571343
out.push(format!(
12581344
" pub {field_name}: {rust_type}<{entity_path}::Entity>,"
12591345
));
@@ -1607,6 +1693,21 @@ fn to_snake_case(s: &str) -> String {
16071693
#[cfg(test)]
16081694
mod module_path_tests {
16091695
use super::*;
1696+
use vespertide_core::{ColumnType, SimpleColumnType};
1697+
1698+
fn test_pk_column(name: &str) -> ColumnDef {
1699+
ColumnDef {
1700+
name: name.into(),
1701+
r#type: ColumnType::Simple(SimpleColumnType::Text),
1702+
nullable: false,
1703+
default: None,
1704+
comment: None,
1705+
primary_key: Some(vespertide_core::schema::primary_key::PrimaryKeySyntax::Bool(true)),
1706+
unique: None,
1707+
index: None,
1708+
foreign_key: None,
1709+
}
1710+
}
16101711

16111712
#[test]
16121713
fn absolute_module_path_builds_correct_path() {
@@ -1670,6 +1771,85 @@ mod module_path_tests {
16701771
let result = resolve_entity_module_path("user", "admin", &module_paths, "");
16711772
assert_eq!(result, "super::admin");
16721773
}
1774+
1775+
#[test]
1776+
fn resolve_relation_entity_module_path_uses_crate_for_cross_directory_nested_models() {
1777+
let mut module_paths = HashMap::new();
1778+
module_paths.insert("admin".into(), vec!["admin".into(), "admin".into()]);
1779+
module_paths.insert(
1780+
"estimate".into(),
1781+
vec!["estimate".into(), "estimate".into()],
1782+
);
1783+
1784+
let result = resolve_relation_entity_module_path(
1785+
"admin",
1786+
"estimate",
1787+
&module_paths,
1788+
"crate::models",
1789+
);
1790+
assert_eq!(result, "crate::models::estimate::estimate");
1791+
}
1792+
1793+
#[test]
1794+
fn self_ref_link_helpers_use_crate_path_for_cross_directory_junctions() {
1795+
let admin = TableDef {
1796+
name: "admin".into(),
1797+
description: None,
1798+
columns: vec![test_pk_column("username")],
1799+
constraints: vec![],
1800+
};
1801+
1802+
let estimate_user_checker_setting = TableDef {
1803+
name: "estimate_user_checker_setting".into(),
1804+
description: None,
1805+
columns: vec![
1806+
test_pk_column("username"),
1807+
test_pk_column("checker_username"),
1808+
],
1809+
constraints: vec![
1810+
TableConstraint::ForeignKey {
1811+
name: None,
1812+
columns: vec!["username".into()],
1813+
ref_table: "admin".into(),
1814+
ref_columns: vec!["username".into()],
1815+
on_delete: None,
1816+
on_update: None,
1817+
},
1818+
TableConstraint::ForeignKey {
1819+
name: None,
1820+
columns: vec!["checker_username".into()],
1821+
ref_table: "admin".into(),
1822+
ref_columns: vec!["username".into()],
1823+
on_delete: None,
1824+
on_update: None,
1825+
},
1826+
],
1827+
};
1828+
1829+
let schema = vec![admin.clone(), estimate_user_checker_setting];
1830+
let mut module_paths = HashMap::new();
1831+
module_paths.insert("admin".into(), vec!["admin".into(), "admin".into()]);
1832+
module_paths.insert(
1833+
"estimate_user_checker_setting".into(),
1834+
vec!["estimate".into(), "estimate_user_checker_setting".into()],
1835+
);
1836+
1837+
let rendered = render_entity_with_config_and_paths(
1838+
&admin,
1839+
&schema,
1840+
&SeaOrmConfig::default(),
1841+
"",
1842+
&module_paths,
1843+
"crate::models",
1844+
);
1845+
1846+
assert!(rendered.contains(
1847+
"crate::models::estimate::estimate_user_checker_setting::Relation::Username.def().rev()"
1848+
));
1849+
assert!(rendered.contains(
1850+
"crate::models::estimate::estimate_user_checker_setting::Relation::CheckerUsername.def()"
1851+
));
1852+
}
16731853
}
16741854

16751855
#[cfg(test)]

0 commit comments

Comments
 (0)