From 61ce5ffca3a671a62710f2f90d1d51d39b6f89fd Mon Sep 17 00:00:00 2001 From: indianapoly Date: Tue, 31 Mar 2026 19:07:20 +0900 Subject: [PATCH 1/3] fix: use crate:: absolute paths for SeaORM cross-directory relations --- .../changepack_log_HAyt50CG5kz_DTEfgfnGb.json | 1 + crates/vespertide-cli/src/commands/export.rs | 68 +++++++++++++++- crates/vespertide-exporter/src/seaorm/mod.rs | 80 +++++++++++++++++-- 3 files changed, 142 insertions(+), 7 deletions(-) create mode 100644 .changepacks/changepack_log_HAyt50CG5kz_DTEfgfnGb.json diff --git a/.changepacks/changepack_log_HAyt50CG5kz_DTEfgfnGb.json b/.changepacks/changepack_log_HAyt50CG5kz_DTEfgfnGb.json new file mode 100644 index 00000000..73f0bf97 --- /dev/null +++ b/.changepacks/changepack_log_HAyt50CG5kz_DTEfgfnGb.json @@ -0,0 +1 @@ +{"changes":{"crates/vespertide/Cargo.toml":"Patch","crates/vespertide-naming/Cargo.toml":"Patch","crates/vespertide-core/Cargo.toml":"Patch","crates/vespertide-cli/Cargo.toml":"Patch","crates/vespertide-planner/Cargo.toml":"Patch","crates/vespertide-config/Cargo.toml":"Patch","crates/vespertide-query/Cargo.toml":"Patch","crates/vespertide-macro/Cargo.toml":"Patch","crates/vespertide-loader/Cargo.toml":"Patch","crates/vespertide-exporter/Cargo.toml":"Patch"},"note":"Use create:: paths for SeaORM cross-directory relations","date":"2026-03-31T10:03:40.393673Z"} \ 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 6eaefdd1..0b9f30ee 100644 --- a/crates/vespertide-cli/src/commands/export.rs +++ b/crates/vespertide-cli/src/commands/export.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; @@ -61,6 +62,19 @@ pub async fn cmd_export(orm: OrmArg, export_dir: Option) -> Result<()> // 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.clone(), 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()); @@ -70,7 +84,7 @@ pub async fn cmd_export(orm: OrmArg, export_dir: Option) -> Result<()> .map(|(table, rel_path)| { let code = match orm_kind { Orm::SeaOrm => seaorm_exporter - .render_entity_with_schema(table, &all_tables) + .render_entity_with_schema_and_paths(table, &all_tables, &module_paths, &crate_prefix) .map_err(|e| anyhow::anyhow!(e)), _ => render_entity_with_schema(orm_kind, table, &all_tables) .map_err(|e| anyhow::anyhow!(e)), @@ -117,6 +131,58 @@ pub async fn cmd_export(orm: OrmArg, export_dir: Option) -> Result<()> Ok(()) } +/// 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 { + if let Some(s) = name.to_str() { + segments.push(sanitize_filename(s).to_string()); + } + } + } + } + + // 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).to_string()); + } + + segments +} + fn resolve_export_dir(export_dir: Option, config: &VespertideConfig) -> PathBuf { if let Some(dir) = export_dir { return dir; diff --git a/crates/vespertide-exporter/src/seaorm/mod.rs b/crates/vespertide-exporter/src/seaorm/mod.rs index 082b1a6b..a66d8169 100644 --- a/crates/vespertide-exporter/src/seaorm/mod.rs +++ b/crates/vespertide-exporter/src/seaorm/mod.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use crate::orm::OrmExporter; use vespertide_config::SeaOrmConfig; @@ -7,6 +7,37 @@ use vespertide_core::{ TableDef, }; +/// Build an absolute `crate::` module path for the target table. +/// +/// `crate_prefix` is derived from the export directory (e.g., `"src/models"` → `"crate::models"`). +/// `to_module` is the module path segments of the target table (e.g., `["admin", "admin"]`). +/// +/// Returns a path like `crate::models::admin::admin`. +fn absolute_module_path(crate_prefix: &str, to_module: &[String]) -> String { + let mut path = crate_prefix.to_string(); + for seg in to_module { + path.push_str("::"); + path.push_str(seg); + } + path +} + +/// Look up the module path for a table name from the module_paths map. +/// Uses `crate::` absolute paths when crate_prefix and module_paths are available. +/// Falls back to `super::{table_name}` when no mapping exists. +fn resolve_entity_module_path( + target_table: &str, + module_paths: &HashMap>, + crate_prefix: &str, +) -> String { + if !crate_prefix.is_empty() { + if let Some(to) = module_paths.get(target_table) { + return absolute_module_path(crate_prefix, to); + } + } + format!("super::{target_table}") +} + pub struct SeaOrmExporter; /// SeaORM exporter with configuration support. @@ -55,6 +86,25 @@ impl<'a> SeaOrmExporterWithConfig<'a> { self.prefix, )) } + + /// Render entity with schema context and module path mappings for correct + /// cross-directory relation paths (e.g., `super::super::admin::admin::Entity`). + pub fn render_entity_with_schema_and_paths( + &self, + table: &TableDef, + schema: &[TableDef], + module_paths: &HashMap>, + crate_prefix: &str, + ) -> Result { + Ok(render_entity_with_config_and_paths( + table, + schema, + self.config, + self.prefix, + module_paths, + crate_prefix, + )) + } } /// Render a single table into SeaORM entity code. @@ -76,10 +126,23 @@ pub fn render_entity_with_config( schema: &[TableDef], config: &SeaOrmConfig, prefix: &str, +) -> String { + render_entity_with_config_and_paths(table, schema, config, prefix, &HashMap::new(), "") +} + +/// Render a single table into SeaORM entity code with schema context, configuration, +/// and module path mappings for correct cross-directory relation paths. +pub fn render_entity_with_config_and_paths( + table: &TableDef, + schema: &[TableDef], + config: &SeaOrmConfig, + prefix: &str, + module_paths: &HashMap>, + crate_prefix: &str, ) -> String { let primary_keys = primary_key_columns(table); let composite_pk = primary_keys.len() > 1; - let relation_fields = relation_field_defs_with_schema(table, schema); + let relation_fields = relation_field_defs_with_schema(table, schema, module_paths, crate_prefix); // Build sets of columns with single-column unique constraints and indexes let unique_columns = single_column_unique_set(&table.constraints); @@ -439,7 +502,7 @@ fn resolve_fk_target<'a>( (ref_table, ref_columns.to_vec()) } -fn relation_field_defs_with_schema(table: &TableDef, schema: &[TableDef]) -> Vec { +fn relation_field_defs_with_schema(table: &TableDef, schema: &[TableDef], module_paths: &HashMap>, crate_prefix: &str) -> Vec { let mut out = Vec::new(); let mut used = HashSet::new(); @@ -550,8 +613,9 @@ fn relation_field_defs_with_schema(table: &TableDef, schema: &[TableDef]) -> Vec }; out.push(attr); + let entity_path = resolve_entity_module_path(resolved_table, module_paths, crate_prefix); out.push(format!( - " pub {field_name}: HasOne," + " pub {field_name}: HasOne<{entity_path}::Entity>," )); } } @@ -563,6 +627,8 @@ fn relation_field_defs_with_schema(table: &TableDef, schema: &[TableDef]) -> Vec &mut used, &entity_count, &mut used_relation_enums, + module_paths, + crate_prefix, ); out.extend(reverse_relations); @@ -748,6 +814,8 @@ fn reverse_relation_field_defs( used: &mut HashSet, entity_count: &std::collections::HashMap, used_relation_enums: &mut HashSet, + module_paths: &HashMap>, + crate_prefix: &str, ) -> Vec { // First pass: collect all reverse relations let mut relations: Vec = Vec::new(); @@ -902,9 +970,9 @@ fn reverse_relation_field_defs( }; out.push(attr); + let entity_path = resolve_entity_module_path(&rel.target_entity, module_paths, crate_prefix); out.push(format!( - " pub {field_name}: {rust_type},", - rel.target_entity + " pub {field_name}: {rust_type}<{entity_path}::Entity>," )); } From 5adde9fd762ff5b4e29375b5649ea1eb30379d6d Mon Sep 17 00:00:00 2001 From: indianapoly Date: Tue, 31 Mar 2026 19:14:43 +0900 Subject: [PATCH 2/3] fix: lint error --- Cargo.lock | 20 ++++++++-------- crates/vespertide-cli/src/commands/export.rs | 23 ++++++++++--------- crates/vespertide-exporter/src/seaorm/mod.rs | 24 +++++++++++++------- 3 files changed, 38 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 31c2ddb4..6d08a8c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3726,7 +3726,7 @@ dependencies = [ [[package]] name = "vespertide" -version = "0.1.52" +version = "0.1.54" dependencies = [ "sea-orm", "tokio", @@ -3736,7 +3736,7 @@ dependencies = [ [[package]] name = "vespertide-cli" -version = "0.1.52" +version = "0.1.54" dependencies = [ "anyhow", "assert_cmd", @@ -3765,7 +3765,7 @@ dependencies = [ [[package]] name = "vespertide-config" -version = "0.1.52" +version = "0.1.54" dependencies = [ "clap", "schemars", @@ -3775,7 +3775,7 @@ dependencies = [ [[package]] name = "vespertide-core" -version = "0.1.52" +version = "0.1.54" dependencies = [ "rstest", "schemars", @@ -3787,7 +3787,7 @@ dependencies = [ [[package]] name = "vespertide-exporter" -version = "0.1.52" +version = "0.1.54" dependencies = [ "insta", "rstest", @@ -3799,7 +3799,7 @@ dependencies = [ [[package]] name = "vespertide-loader" -version = "0.1.52" +version = "0.1.54" dependencies = [ "anyhow", "rstest", @@ -3814,7 +3814,7 @@ dependencies = [ [[package]] name = "vespertide-macro" -version = "0.1.52" +version = "0.1.54" dependencies = [ "proc-macro2", "runtime-macros", @@ -3829,11 +3829,11 @@ dependencies = [ [[package]] name = "vespertide-naming" -version = "0.1.52" +version = "0.1.54" [[package]] name = "vespertide-planner" -version = "0.1.52" +version = "0.1.54" dependencies = [ "insta", "rstest", @@ -3844,7 +3844,7 @@ dependencies = [ [[package]] name = "vespertide-query" -version = "0.1.52" +version = "0.1.54" dependencies = [ "insta", "rstest", diff --git a/crates/vespertide-cli/src/commands/export.rs b/crates/vespertide-cli/src/commands/export.rs index 0b9f30ee..b0c4576b 100644 --- a/crates/vespertide-cli/src/commands/export.rs +++ b/crates/vespertide-cli/src/commands/export.rs @@ -84,7 +84,12 @@ pub async fn cmd_export(orm: OrmArg, export_dir: Option) -> Result<()> .map(|(table, rel_path)| { let code = match orm_kind { Orm::SeaOrm => seaorm_exporter - .render_entity_with_schema_and_paths(table, &all_tables, &module_paths, &crate_prefix) + .render_entity_with_schema_and_paths( + table, + &all_tables, + &module_paths, + &crate_prefix, + ) .map_err(|e| anyhow::anyhow!(e)), _ => render_entity_with_schema(orm_kind, table, &all_tables) .map_err(|e| anyhow::anyhow!(e)), @@ -137,14 +142,10 @@ pub async fn cmd_export(orm: OrmArg, export_dir: Option) -> Result<()> /// 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); + 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('/', "::"); + let module_path = after_src.trim_end_matches('/').replace('/', "::"); format!("crate::{module_path}") } else { String::new() @@ -161,10 +162,10 @@ fn rel_path_to_module_segments(rel_path: &Path) -> Vec { // Add directory components if let Some(parent) = rel_path.parent() { for component in parent.components() { - if let std::path::Component::Normal(name) = component { - if let Some(s) = name.to_str() { - segments.push(sanitize_filename(s).to_string()); - } + if let std::path::Component::Normal(name) = component + && let Some(s) = name.to_str() + { + segments.push(sanitize_filename(s).to_string()); } } } diff --git a/crates/vespertide-exporter/src/seaorm/mod.rs b/crates/vespertide-exporter/src/seaorm/mod.rs index a66d8169..b429e9bf 100644 --- a/crates/vespertide-exporter/src/seaorm/mod.rs +++ b/crates/vespertide-exporter/src/seaorm/mod.rs @@ -30,10 +30,10 @@ fn resolve_entity_module_path( module_paths: &HashMap>, crate_prefix: &str, ) -> String { - if !crate_prefix.is_empty() { - if let Some(to) = module_paths.get(target_table) { - return absolute_module_path(crate_prefix, to); - } + if !crate_prefix.is_empty() + && let Some(to) = module_paths.get(target_table) + { + return absolute_module_path(crate_prefix, to); } format!("super::{target_table}") } @@ -142,7 +142,8 @@ pub fn render_entity_with_config_and_paths( ) -> String { let primary_keys = primary_key_columns(table); let composite_pk = primary_keys.len() > 1; - let relation_fields = relation_field_defs_with_schema(table, schema, module_paths, crate_prefix); + let relation_fields = + relation_field_defs_with_schema(table, schema, module_paths, crate_prefix); // Build sets of columns with single-column unique constraints and indexes let unique_columns = single_column_unique_set(&table.constraints); @@ -502,7 +503,12 @@ fn resolve_fk_target<'a>( (ref_table, ref_columns.to_vec()) } -fn relation_field_defs_with_schema(table: &TableDef, schema: &[TableDef], module_paths: &HashMap>, crate_prefix: &str) -> Vec { +fn relation_field_defs_with_schema( + table: &TableDef, + schema: &[TableDef], + module_paths: &HashMap>, + crate_prefix: &str, +) -> Vec { let mut out = Vec::new(); let mut used = HashSet::new(); @@ -613,7 +619,8 @@ fn relation_field_defs_with_schema(table: &TableDef, schema: &[TableDef], module }; out.push(attr); - let entity_path = resolve_entity_module_path(resolved_table, module_paths, crate_prefix); + let entity_path = + resolve_entity_module_path(resolved_table, module_paths, crate_prefix); out.push(format!( " pub {field_name}: HasOne<{entity_path}::Entity>," )); @@ -970,7 +977,8 @@ fn reverse_relation_field_defs( }; out.push(attr); - let entity_path = resolve_entity_module_path(&rel.target_entity, module_paths, crate_prefix); + let entity_path = + resolve_entity_module_path(&rel.target_entity, module_paths, crate_prefix); out.push(format!( " pub {field_name}: {rust_type}<{entity_path}::Entity>," )); From 56d663c513610c66b4991a042a0212fcd6d4fcda Mon Sep 17 00:00:00 2001 From: indianapoly Date: Tue, 31 Mar 2026 19:26:08 +0900 Subject: [PATCH 3/3] Add testcase --- crates/vespertide-exporter/src/seaorm/mod.rs | 49 ++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/crates/vespertide-exporter/src/seaorm/mod.rs b/crates/vespertide-exporter/src/seaorm/mod.rs index b429e9bf..6cab74a1 100644 --- a/crates/vespertide-exporter/src/seaorm/mod.rs +++ b/crates/vespertide-exporter/src/seaorm/mod.rs @@ -1318,6 +1318,55 @@ fn to_snake_case(s: &str) -> String { result } +#[cfg(test)] +mod module_path_tests { + use super::*; + + #[test] + fn absolute_module_path_builds_correct_path() { + let result = absolute_module_path("crate::models", &["admin".into(), "admin".into()]); + assert_eq!(result, "crate::models::admin::admin"); + } + + #[test] + fn absolute_module_path_single_segment() { + let result = absolute_module_path("crate::models", &["user".into()]); + assert_eq!(result, "crate::models::user"); + } + + #[test] + fn absolute_module_path_deep_nesting() { + let result = absolute_module_path( + "crate::db::entities", + &["company".into(), "division".into(), "department".into()], + ); + assert_eq!(result, "crate::db::entities::company::division::department"); + } + + #[test] + fn resolve_entity_module_path_with_crate_prefix() { + let mut module_paths = HashMap::new(); + module_paths.insert("admin".into(), vec!["admin".into(), "admin".into()]); + let result = resolve_entity_module_path("admin", &module_paths, "crate::models"); + assert_eq!(result, "crate::models::admin::admin"); + } + + #[test] + fn resolve_entity_module_path_fallback_when_no_mapping() { + let module_paths = HashMap::new(); + let result = resolve_entity_module_path("user", &module_paths, "crate::models"); + assert_eq!(result, "super::user"); + } + + #[test] + fn resolve_entity_module_path_fallback_when_empty_prefix() { + let mut module_paths = HashMap::new(); + module_paths.insert("admin".into(), vec!["admin".into(), "admin".into()]); + let result = resolve_entity_module_path("admin", &module_paths, ""); + assert_eq!(result, "super::admin"); + } +} + #[cfg(test)] mod helper_tests { use super::*;