Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changepacks/changepack_log_HAyt50CG5kz_DTEfgfnGb.json
Original file line number Diff line number Diff line change
@@ -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"}
20 changes: 10 additions & 10 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

69 changes: 68 additions & 1 deletion crates/vespertide-cli/src/commands/export.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
Expand Down Expand Up @@ -61,6 +62,19 @@ pub async fn cmd_export(orm: OrmArg, export_dir: Option<PathBuf>) -> Result<()>
// Extract all tables for schema context (used for FK chain resolution)
let all_tables: Vec<TableDef> = 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<String, Vec<String>> = 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());

Expand All @@ -70,7 +84,12 @@ pub async fn cmd_export(orm: OrmArg, export_dir: Option<PathBuf>) -> 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)),
Expand Down Expand Up @@ -117,6 +136,54 @@ pub async fn cmd_export(orm: OrmArg, export_dir: Option<PathBuf>) -> 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<String> {
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).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<PathBuf>, config: &VespertideConfig) -> PathBuf {
if let Some(dir) = export_dir {
return dir;
Expand Down
137 changes: 131 additions & 6 deletions crates/vespertide-exporter/src/seaorm/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};

use crate::orm::OrmExporter;
use vespertide_config::SeaOrmConfig;
Expand All @@ -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<String, Vec<String>>,
crate_prefix: &str,
) -> String {
if !crate_prefix.is_empty()
&& 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.
Expand Down Expand Up @@ -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<String, Vec<String>>,
crate_prefix: &str,
) -> Result<String, String> {
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.
Expand All @@ -76,10 +126,24 @@ 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<String, Vec<String>>,
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);
Expand Down Expand Up @@ -439,7 +503,12 @@ fn resolve_fk_target<'a>(
(ref_table, ref_columns.to_vec())
}

fn relation_field_defs_with_schema(table: &TableDef, schema: &[TableDef]) -> Vec<String> {
fn relation_field_defs_with_schema(
table: &TableDef,
schema: &[TableDef],
module_paths: &HashMap<String, Vec<String>>,
crate_prefix: &str,
) -> Vec<String> {
let mut out = Vec::new();
let mut used = HashSet::new();

Expand Down Expand Up @@ -550,8 +619,10 @@ 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<super::{resolved_table}::Entity>,"
" pub {field_name}: HasOne<{entity_path}::Entity>,"
));
}
}
Expand All @@ -563,6 +634,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);

Expand Down Expand Up @@ -748,6 +821,8 @@ fn reverse_relation_field_defs(
used: &mut HashSet<String>,
entity_count: &std::collections::HashMap<String, usize>,
used_relation_enums: &mut HashSet<String>,
module_paths: &HashMap<String, Vec<String>>,
crate_prefix: &str,
) -> Vec<String> {
// First pass: collect all reverse relations
let mut relations: Vec<ReverseRelation> = Vec::new();
Expand Down Expand Up @@ -902,9 +977,10 @@ 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}<super::{}::Entity>,",
rel.target_entity
" pub {field_name}: {rust_type}<{entity_path}::Entity>,"
));
}

Expand Down Expand Up @@ -1242,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::*;
Expand Down
Loading