diff --git a/.changepacks/changepack_log_SrxUrZ9JN6_DBLxVs-EtU.json b/.changepacks/changepack_log_SrxUrZ9JN6_DBLxVs-EtU.json new file mode 100644 index 0000000..1053838 --- /dev/null +++ b/.changepacks/changepack_log_SrxUrZ9JN6_DBLxVs-EtU.json @@ -0,0 +1 @@ +{"changes":{"crates/vespertide-exporter/Cargo.toml":"Patch","crates/vespertide-macro/Cargo.toml":"Patch","crates/vespertide/Cargo.toml":"Patch","crates/vespertide-core/Cargo.toml":"Patch","crates/vespertide-planner/Cargo.toml":"Patch","crates/vespertide-config/Cargo.toml":"Patch","crates/vespertide-cli/Cargo.toml":"Patch","crates/vespertide-query/Cargo.toml":"Patch"},"note":"Implement exporter","date":"2025-12-11T14:41:13.513253700Z"} \ No newline at end of file diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 5079553..ba1e6fd 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -35,6 +35,8 @@ jobs: components: clippy, rustfmt - name: Build run: cargo check + - name: Lint + run: cargo clippy --all-targets --all-features -- -D warnings - name: Test run: | # rust coverage issue @@ -47,14 +49,14 @@ jobs: echo 'merge_derives = true' >> .rustfmt.toml echo 'use_small_heuristics = "Default"' >> .rustfmt.toml cargo fmt - cargo tarpaulin --out Lcov + cargo tarpaulin --out Lcov Stdout - name: Upload to codecov.io uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: true files: lcov.info - if: github.ref_name == 'main' + if: github.ref == 'refs/heads/main' # publish changepacks: @@ -75,4 +77,4 @@ jobs: with: publish: true env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} \ No newline at end of file + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock index d6d25c6..338a73c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -181,6 +181,18 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "windows-sys 0.59.0", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -193,6 +205,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.2" @@ -380,6 +398,18 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "insta" +version = "1.44.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5c943d4415edd8153251b6f197de5eb1640e56d84e8d9159bea190421c73698" +dependencies = [ + "console", + "once_cell", + "serde", + "similar", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -801,6 +831,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "slab" version = "0.4.11" @@ -913,14 +949,14 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "vespertide" -version = "0.1.1" +version = "0.1.2" dependencies = [ "vespertide-macro", ] [[package]] name = "vespertide-cli" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "chrono", @@ -934,13 +970,14 @@ dependencies = [ "tempfile", "vespertide-config", "vespertide-core", + "vespertide-exporter", "vespertide-planner", "vespertide-query", ] [[package]] name = "vespertide-config" -version = "0.1.1" +version = "0.1.2" dependencies = [ "clap", "serde", @@ -948,15 +985,25 @@ dependencies = [ [[package]] name = "vespertide-core" -version = "0.1.1" +version = "0.1.2" dependencies = [ "schemars", "serde", ] +[[package]] +name = "vespertide-exporter" +version = "0.1.2" +dependencies = [ + "insta", + "rstest", + "thiserror", + "vespertide-core", +] + [[package]] name = "vespertide-macro" -version = "0.1.1" +version = "0.1.2" dependencies = [ "thiserror", "vespertide-core", @@ -964,7 +1011,7 @@ dependencies = [ [[package]] name = "vespertide-planner" -version = "0.1.1" +version = "0.1.2" dependencies = [ "rstest", "thiserror", @@ -973,7 +1020,7 @@ dependencies = [ [[package]] name = "vespertide-query" -version = "0.1.1" +version = "0.1.2" dependencies = [ "rstest", "thiserror", diff --git a/Cargo.toml b/Cargo.toml index d32e9cb..5ba3a27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,3 +15,4 @@ vespertide-config = { path = "crates/vespertide-config", version = "0.1.2" } vespertide-macro = { path = "crates/vespertide-macro", version = "0.1.2" } vespertide-planner = { path = "crates/vespertide-planner", version = "0.1.2" } vespertide-query = { path = "crates/vespertide-query", version = "0.1.2" } +vespertide-exporter = { path = "crates/vespertide-exporter", version = "0.1.2" } diff --git a/crates/vespertide-cli/Cargo.toml b/crates/vespertide-cli/Cargo.toml index 890e482..08e7f03 100644 --- a/crates/vespertide-cli/Cargo.toml +++ b/crates/vespertide-cli/Cargo.toml @@ -21,6 +21,7 @@ vespertide-config = { workspace = true } vespertide-core = { workspace = true } vespertide-planner = { workspace = true } vespertide-query = { workspace = true } +vespertide-exporter = { workspace = true } [dev-dependencies] tempfile = "3" diff --git a/crates/vespertide-cli/src/commands/export.rs b/crates/vespertide-cli/src/commands/export.rs new file mode 100644 index 0000000..45295f4 --- /dev/null +++ b/crates/vespertide-cli/src/commands/export.rs @@ -0,0 +1,385 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result}; +use clap::ValueEnum; +use vespertide_config::VespertideConfig; +use vespertide_core::TableDef; +use vespertide_exporter::{Orm, render_entity}; + +use crate::utils::load_config; + +#[derive(Copy, Clone, Debug, ValueEnum)] +pub enum OrmArg { + Seaorm, + Sqlalchemy, + Sqlmodel, +} + +impl From for Orm { + fn from(value: OrmArg) -> Self { + match value { + OrmArg::Seaorm => Orm::SeaOrm, + OrmArg::Sqlalchemy => Orm::SqlAlchemy, + OrmArg::Sqlmodel => Orm::SqlModel, + } + } +} + +pub fn cmd_export(orm: OrmArg, export_dir: Option) -> Result<()> { + let config = load_config()?; + let models = load_models_recursive(config.models_dir()).context("load models recursively")?; + + let target_root = resolve_export_dir(export_dir, &config); + if !target_root.exists() { + fs::create_dir_all(&target_root) + .with_context(|| format!("create export dir {}", target_root.display()))?; + } + + let orm_kind: Orm = orm.into(); + + for (table, rel_path) in &models { + let code = render_entity(orm_kind, table).map_err(|e| anyhow::anyhow!(e))?; + let out_path = build_output_path(&target_root, rel_path, orm_kind); + if let Some(parent) = out_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("create parent dir {}", parent.display()))?; + } + fs::write(&out_path, code).with_context(|| format!("write {}", out_path.display()))?; + if matches!(orm_kind, Orm::SeaOrm) { + ensure_mod_chain(&target_root, rel_path) + .with_context(|| format!("ensure mod chain for {}", out_path.display()))?; + } + println!("Exported {} -> {}", table.name, out_path.display()); + } + + Ok(()) +} + +fn resolve_export_dir(export_dir: Option, config: &VespertideConfig) -> PathBuf { + if let Some(dir) = export_dir { + return dir; + } + // Prefer explicit model_export_dir from config, fallback to default inside config. + config.model_export_dir().to_path_buf() +} + +fn build_output_path(root: &Path, rel_path: &Path, orm: Orm) -> PathBuf { + let mut out = root.join(rel_path); + // swap extension based on ORM + let ext = match orm { + Orm::SeaOrm => "rs", + Orm::SqlAlchemy | Orm::SqlModel => "py", + }; + out.set_extension(ext); + out +} + +fn load_models_recursive(base: &Path) -> Result> { + let mut out = Vec::new(); + if !base.exists() { + return Ok(out); + } + walk_models(base, base, &mut out)?; + Ok(out) +} + +fn ensure_mod_chain(root: &Path, rel_path: &Path) -> Result<()> { + // Only needed for SeaORM (Rust) exports to wire modules. + let mut comps: Vec = rel_path + .with_extension("") + .components() + .filter_map(|c| c.as_os_str().to_str().map(|s| s.to_string())) + .collect(); + 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"); + if let Some(parent) = mod_path.parent() + && !parent.exists() + { + fs::create_dir_all(parent)?; + } + let mut content = if mod_path.exists() { + fs::read_to_string(&mod_path)? + } else { + String::new() + }; + let decl = format!("pub mod {};", child); + if !content.lines().any(|l| l.trim() == decl) { + if !content.is_empty() && !content.ends_with('\n') { + content.push('\n'); + } + content.push_str(&decl); + content.push('\n'); + fs::write(mod_path, content)?; + } + } + Ok(()) +} + +fn walk_models(root: &Path, current: &Path, acc: &mut Vec<(TableDef, PathBuf)>) -> Result<()> { + for entry in fs::read_dir(current).with_context(|| format!("read {}", current.display()))? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + walk_models(root, &path, acc)?; + continue; + } + let ext = path.extension().and_then(|s| s.to_str()); + if !matches!(ext, Some("json") | Some("yaml") | Some("yml")) { + continue; + } + let content = fs::read_to_string(&path) + .with_context(|| format!("read model file: {}", path.display()))?; + let table: TableDef = if ext == Some("json") { + serde_json::from_str(&content) + .with_context(|| format!("parse JSON model: {}", path.display()))? + } else { + serde_yaml::from_str(&content) + .with_context(|| format!("parse YAML model: {}", path.display()))? + }; + let rel = path.strip_prefix(root).unwrap_or(&path).to_path_buf(); + acc.push((table, rel)); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use std::fs; + use tempfile::tempdir; + use vespertide_core::{ColumnDef, ColumnType, TableConstraint}; + + struct CwdGuard { + original: PathBuf, + } + + impl CwdGuard { + fn new(dir: &PathBuf) -> Self { + let original = std::env::current_dir().unwrap(); + std::env::set_current_dir(dir).unwrap(); + Self { original } + } + } + + impl Drop for CwdGuard { + fn drop(&mut self) { + let _ = std::env::set_current_dir(&self.original); + } + } + + fn write_config() { + let cfg = VespertideConfig::default(); + let text = serde_json::to_string_pretty(&cfg).unwrap(); + fs::write("vespertide.json", text).unwrap(); + } + + fn write_model(path: &Path, table: &TableDef) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, serde_json::to_string_pretty(table).unwrap()).unwrap(); + } + + fn sample_table(name: &str) -> TableDef { + TableDef { + name: name.to_string(), + columns: vec![ColumnDef { + name: "id".into(), + r#type: ColumnType::Integer, + nullable: false, + default: None, + }], + constraints: vec![TableConstraint::PrimaryKey { + columns: vec!["id".into()], + }], + indexes: vec![], + } + } + + #[test] + #[serial] + fn export_writes_seaorm_files_to_default_dir() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + + let model = sample_table("users"); + write_model(Path::new("models/users.json"), &model); + + cmd_export(OrmArg::Seaorm, None).unwrap(); + + let out = PathBuf::from("src/models/users.rs"); + assert!(out.exists()); + let content = fs::read_to_string(out).unwrap(); + assert!(content.contains("#[sea_orm(table_name = \"users\")]")); + + // mod.rs wiring at root + let root_mod = PathBuf::from("src/models/mod.rs"); + assert!(root_mod.exists()); + let root_mod_content = fs::read_to_string(root_mod).unwrap(); + assert!(root_mod_content.contains("pub mod users;")); + } + + #[test] + #[serial] + fn export_respects_custom_output_dir() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + + let model = sample_table("posts"); + write_model(Path::new("models/blog/posts.json"), &model); + + let custom = PathBuf::from("out_dir"); + cmd_export(OrmArg::Seaorm, Some(custom.clone())).unwrap(); + + let out = custom.join("blog/posts.rs"); + assert!(out.exists()); + let content = fs::read_to_string(out).unwrap(); + assert!(content.contains("#[sea_orm(table_name = \"posts\")]")); + + // mod.rs wiring + let root_mod = custom.join("mod.rs"); + let blog_mod = custom.join("blog/mod.rs"); + assert!(root_mod.exists()); + assert!(blog_mod.exists()); + let root_mod_content = fs::read_to_string(root_mod).unwrap(); + let blog_mod_content = fs::read_to_string(blog_mod).unwrap(); + assert!(root_mod_content.contains("pub mod blog;")); + assert!(blog_mod_content.contains("pub mod posts;")); + } + + #[test] + #[serial] + fn export_with_sqlalchemy_sets_py_extension() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + + let model = sample_table("items"); + write_model(Path::new("models/items.json"), &model); + + cmd_export(OrmArg::Sqlalchemy, None).unwrap(); + + let out = PathBuf::from("src/models/items.py"); + assert!(out.exists()); + let content = fs::read_to_string(out).unwrap(); + assert!(content.contains("items")); + } + + #[test] + #[serial] + fn export_with_sqlmodel_sets_py_extension() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + + let model = sample_table("orders"); + write_model(Path::new("models/orders.json"), &model); + + cmd_export(OrmArg::Sqlmodel, None).unwrap(); + + let out = PathBuf::from("src/models/orders.py"); + assert!(out.exists()); + let content = fs::read_to_string(out).unwrap(); + assert!(content.contains("orders")); + } + + #[test] + #[serial] + fn load_models_recursive_returns_empty_when_absent() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + let models = load_models_recursive(Path::new("no_models")).unwrap(); + assert!(models.is_empty()); + } + + #[test] + #[serial] + fn load_models_recursive_ignores_non_model_files() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + + fs::create_dir_all("models").unwrap(); + fs::write("models/ignore.txt", "hello").unwrap(); + write_model(Path::new("models/valid.json"), &sample_table("valid")); + + let models = load_models_recursive(Path::new("models")).unwrap(); + assert_eq!(models.len(), 1); + assert_eq!(models[0].0.name, "valid"); + } + + #[test] + #[serial] + fn load_models_recursive_parses_yaml_branch() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + + fs::create_dir_all("models").unwrap(); + let table = sample_table("yaml_table"); + let yaml = serde_yaml::to_string(&table).unwrap(); + fs::write("models/yaml_table.yaml", yaml).unwrap(); + + let models = load_models_recursive(Path::new("models")).unwrap(); + assert_eq!(models.len(), 1); + assert_eq!(models[0].0.name, "yaml_table"); + } + + #[test] + #[serial] + fn ensure_mod_chain_adds_to_existing_file_without_trailing_newline() { + let tmp = tempdir().unwrap(); + let root = tmp.path().join("src/models"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("mod.rs"), "pub mod existing;").unwrap(); + + ensure_mod_chain(&root, Path::new("blog/posts.rs")).unwrap(); + + let root_mod = fs::read_to_string(root.join("mod.rs")).unwrap(); + let blog_mod = fs::read_to_string(root.join("blog/mod.rs")).unwrap(); + assert!(root_mod.contains("pub mod existing;")); + assert!(root_mod.contains("pub mod blog;")); + assert!(blog_mod.contains("pub mod posts;")); + // ensure newline appended if missing + assert!(root_mod.ends_with('\n')); + } + + #[test] + fn ensure_mod_chain_no_components_is_noop() { + let tmp = tempdir().unwrap(); + let root = tmp.path().join("src/models"); + fs::create_dir_all(&root).unwrap(); + // empty path should not error + assert!(ensure_mod_chain(&root, Path::new("")).is_ok()); + } + + #[test] + #[serial] + fn resolve_export_dir_prefers_override() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + let cfg = VespertideConfig::default(); + let override_dir = PathBuf::from("custom_out"); + let resolved = super::resolve_export_dir(Some(override_dir.clone()), &cfg); + assert_eq!(resolved, override_dir); + } + + #[test] + fn orm_arg_maps_to_enum() { + assert!(matches!(Orm::from(OrmArg::Seaorm), Orm::SeaOrm)); + assert!(matches!(Orm::from(OrmArg::Sqlalchemy), Orm::SqlAlchemy)); + assert!(matches!(Orm::from(OrmArg::Sqlmodel), Orm::SqlModel)); + } +} diff --git a/crates/vespertide-cli/src/commands/mod.rs b/crates/vespertide-cli/src/commands/mod.rs index affb043..c636880 100644 --- a/crates/vespertide-cli/src/commands/mod.rs +++ b/crates/vespertide-cli/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod diff; +pub mod export; pub mod init; pub mod log; pub mod new; @@ -7,6 +8,7 @@ pub mod sql; pub mod status; pub use diff::cmd_diff; +pub use export::cmd_export; pub use init::cmd_init; pub use log::cmd_log; pub use new::cmd_new; diff --git a/crates/vespertide-cli/src/commands/new.rs b/crates/vespertide-cli/src/commands/new.rs index 55e12aa..86a0b06 100644 --- a/crates/vespertide-cli/src/commands/new.rs +++ b/crates/vespertide-cli/src/commands/new.rs @@ -76,6 +76,19 @@ fn write_json_with_schema( Ok(()) } +fn write_yaml(path: &std::path::Path, table: &TableDef, schema_url: &str) -> Result<()> { + let mut value = serde_yaml::to_value(table).context("serialize table to yaml value")?; + if let serde_yaml::Value::Mapping(ref mut map) = value { + map.insert( + serde_yaml::Value::String("$schema".to_string()), + serde_yaml::Value::String(schema_url.to_string()), + ); + } + let text = serde_yaml::to_string(&value).context("serialize yaml with schema")?; + fs::write(path, text).with_context(|| format!("write file: {}", path.display()))?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -102,8 +115,10 @@ mod tests { } fn write_config(model_format: FileFormat) { - let mut cfg = VespertideConfig::default(); - cfg.model_format = model_format; + let cfg = VespertideConfig { + model_format, + ..VespertideConfig::default() + }; let text = serde_json::to_string_pretty(&cfg).unwrap(); std::fs::write("vespertide.json", text).unwrap(); } @@ -140,8 +155,10 @@ mod tests { cmd_new("orders".into(), None).unwrap(); - let mut cfg = VespertideConfig::default(); - cfg.model_format = FileFormat::Yaml; + let cfg = VespertideConfig { + model_format: FileFormat::Yaml, + ..VespertideConfig::default() + }; let path = cfg.models_dir().join("orders.yaml"); assert!(path.exists()); @@ -149,7 +166,7 @@ mod tests { let value: serde_yaml::Value = serde_yaml::from_str(&text).unwrap(); let schema = value .as_mapping() - .and_then(|m| m.get(&serde_yaml::Value::String("$schema".into()))) + .and_then(|m| m.get(serde_yaml::Value::String("$schema".into()))) .and_then(|v| v.as_str()); assert_eq!(schema, Some(expected_schema.as_str())); } @@ -164,8 +181,10 @@ mod tests { cmd_new("products".into(), None).unwrap(); - let mut cfg = VespertideConfig::default(); - cfg.model_format = FileFormat::Yml; + let cfg = VespertideConfig { + model_format: FileFormat::Yml, + ..VespertideConfig::default() + }; let path = cfg.models_dir().join("products.yml"); assert!(path.exists()); @@ -173,7 +192,7 @@ mod tests { let value: serde_yaml::Value = serde_yaml::from_str(&text).unwrap(); let schema = value .as_mapping() - .and_then(|m| m.get(&serde_yaml::Value::String("$schema".into()))) + .and_then(|m| m.get(serde_yaml::Value::String("$schema".into()))) .and_then(|v| v.as_str()); assert_eq!(schema, Some(expected_schema.as_str())); } @@ -196,15 +215,3 @@ mod tests { assert!(msg.contains("users.json")); } } -fn write_yaml(path: &std::path::Path, table: &TableDef, schema_url: &str) -> Result<()> { - let mut value = serde_yaml::to_value(table).context("serialize table to yaml value")?; - if let serde_yaml::Value::Mapping(ref mut map) = value { - map.insert( - serde_yaml::Value::String("$schema".to_string()), - serde_yaml::Value::String(schema_url.to_string()), - ); - } - let text = serde_yaml::to_string(&value).context("serialize yaml with schema")?; - fs::write(path, text).with_context(|| format!("write file: {}", path.display()))?; - Ok(()) -} diff --git a/crates/vespertide-cli/src/commands/sql.rs b/crates/vespertide-cli/src/commands/sql.rs index a07338d..a7fe1c0 100644 --- a/crates/vespertide-cli/src/commands/sql.rs +++ b/crates/vespertide-cli/src/commands/sql.rs @@ -164,7 +164,9 @@ mod tests { nullable: false, default: None, }], - constraints: vec![TableConstraint::PrimaryKey(vec!["id".into()])], + constraints: vec![TableConstraint::PrimaryKey { + columns: vec!["id".into()], + }], }], }; assert!(emit_sql(&plan).is_ok()); diff --git a/crates/vespertide-cli/src/main.rs b/crates/vespertide-cli/src/main.rs index 086dab2..1a0c5d2 100644 --- a/crates/vespertide-cli/src/main.rs +++ b/crates/vespertide-cli/src/main.rs @@ -3,7 +3,10 @@ use clap::{CommandFactory, Parser, Subcommand}; mod commands; mod utils; -use commands::{cmd_diff, cmd_init, cmd_log, cmd_new, cmd_revision, cmd_sql, cmd_status}; +use crate::commands::export::OrmArg; +use commands::{ + cmd_diff, cmd_export, cmd_init, cmd_log, cmd_new, cmd_revision, cmd_sql, cmd_status, +}; use vespertide_config::FileFormat; /// vespertide command-line interface. @@ -39,6 +42,15 @@ enum Commands { }, /// Initialize vespertide.json with defaults. Init, + /// Export models into ORM-specific code. + Export { + /// Target ORM for export. + #[arg(short = 'o', long = "orm", value_enum, default_value = "seaorm")] + orm: OrmArg, + /// Output directory (defaults to config modelsDir or src/models). + #[arg(short = 'd', long = "export-dir")] + export_dir: Option, + }, } fn main() -> Result<()> { @@ -51,6 +63,7 @@ fn main() -> Result<()> { Some(Commands::Status) => cmd_status(), Some(Commands::Revision { message }) => cmd_revision(message), Some(Commands::Init) => cmd_init(), + Some(Commands::Export { orm, export_dir }) => cmd_export(orm, export_dir), None => { // No subcommand: show help and exit successfully. Cli::command().print_help()?; diff --git a/crates/vespertide-config/src/config.rs b/crates/vespertide-config/src/config.rs index 193cc26..e0dd48c 100644 --- a/crates/vespertide-config/src/config.rs +++ b/crates/vespertide-config/src/config.rs @@ -24,6 +24,13 @@ pub struct VespertideConfig { pub migration_format: FileFormat, #[serde(default = "default_migration_filename_pattern")] pub migration_filename_pattern: String, + /// Output directory for generated ORM models. + #[serde(default = "default_model_export_dir")] + pub model_export_dir: PathBuf, +} + +fn default_model_export_dir() -> PathBuf { + PathBuf::from("src/models") } impl Default for VespertideConfig { @@ -36,6 +43,7 @@ impl Default for VespertideConfig { model_format: FileFormat::Json, migration_format: FileFormat::Json, migration_filename_pattern: default_migration_filename_pattern(), + model_export_dir: default_model_export_dir(), } } } @@ -75,4 +83,9 @@ impl VespertideConfig { pub fn migration_filename_pattern(&self) -> &str { &self.migration_filename_pattern } + + /// Output directory for generated ORM models. + pub fn model_export_dir(&self) -> &Path { + &self.model_export_dir + } } diff --git a/crates/vespertide-core/src/schema/constraint.rs b/crates/vespertide-core/src/schema/constraint.rs index cdda9cb..e779d4c 100644 --- a/crates/vespertide-core/src/schema/constraint.rs +++ b/crates/vespertide-core/src/schema/constraint.rs @@ -6,7 +6,9 @@ use crate::schema::{ReferenceAction, names::ColumnName, names::TableName}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case", tag = "type")] pub enum TableConstraint { - PrimaryKey(Vec), + PrimaryKey { + columns: Vec, + }, Unique { name: Option, columns: Vec, diff --git a/crates/vespertide-exporter/Cargo.toml b/crates/vespertide-exporter/Cargo.toml new file mode 100644 index 0000000..b3367a5 --- /dev/null +++ b/crates/vespertide-exporter/Cargo.toml @@ -0,0 +1,17 @@ + [package] + name = "vespertide-exporter" + version = "0.1.2" + edition.workspace = true + license.workspace = true + repository.workspace = true + homepage.workspace = true + documentation.workspace = true + description = "Export vespertide table definitions into ORM-specific models" + + [dependencies] + vespertide-core = { workspace = true } +thiserror = "2" + +[dev-dependencies] +rstest = "0.26" +insta = { version = "1.44", features = ["yaml"] } diff --git a/crates/vespertide-exporter/src/lib.rs b/crates/vespertide-exporter/src/lib.rs new file mode 100644 index 0000000..4b0fd78 --- /dev/null +++ b/crates/vespertide-exporter/src/lib.rs @@ -0,0 +1,12 @@ +//! Helpers to convert `TableDef` models into ORM-specific representations +//! such as SeaORM, SQLAlchemy, and SQLModel. + +pub mod orm; +pub mod seaorm; +pub mod sqlalchemy; +pub mod sqlmodel; + +pub use orm::{Orm, OrmExporter, render_entity}; +pub use seaorm::{SeaOrmExporter, render_entity as render_seaorm_entity}; +pub use sqlalchemy::SqlAlchemyExporter; +pub use sqlmodel::SqlModelExporter; diff --git a/crates/vespertide-exporter/src/orm.rs b/crates/vespertide-exporter/src/orm.rs new file mode 100644 index 0000000..16cdac2 --- /dev/null +++ b/crates/vespertide-exporter/src/orm.rs @@ -0,0 +1,25 @@ +use vespertide_core::TableDef; + +use crate::{seaorm::SeaOrmExporter, sqlalchemy::SqlAlchemyExporter, sqlmodel::SqlModelExporter}; + +/// Supported ORM targets. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Orm { + SeaOrm, + SqlAlchemy, + SqlModel, +} + +/// Standardized exporter interface for all supported ORMs. +pub trait OrmExporter { + fn render_entity(&self, table: &TableDef) -> Result; +} + +/// Render a single table definition for the selected ORM. +pub fn render_entity(orm: Orm, table: &TableDef) -> Result { + match orm { + Orm::SeaOrm => SeaOrmExporter.render_entity(table), + Orm::SqlAlchemy => SqlAlchemyExporter.render_entity(table), + Orm::SqlModel => SqlModelExporter.render_entity(table), + } +} diff --git a/crates/vespertide-exporter/src/seaorm/mod.rs b/crates/vespertide-exporter/src/seaorm/mod.rs new file mode 100644 index 0000000..9d75d99 --- /dev/null +++ b/crates/vespertide-exporter/src/seaorm/mod.rs @@ -0,0 +1,252 @@ +use std::collections::HashSet; + +use crate::orm::OrmExporter; +use vespertide_core::{ColumnDef, ColumnType, IndexDef, TableConstraint, TableDef}; + +pub struct SeaOrmExporter; + +impl OrmExporter for SeaOrmExporter { + fn render_entity(&self, table: &TableDef) -> Result { + Ok(render_entity(table)) + } +} + +/// Render a single table into SeaORM entity code. +/// +/// Follows the official entity format: +/// +pub fn render_entity(table: &TableDef) -> String { + let primary_keys = primary_key_columns(table); + let composite_pk = primary_keys.len() > 1; + let indexes = &table.indexes; + let relation_fields = relation_field_defs(table); + + let mut lines: Vec = Vec::new(); + lines.push("use sea_orm::entity::prelude::*;".into()); + lines.push(String::new()); + lines.push("#[sea_orm::model]".into()); + lines.push("#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]".into()); + lines.push(format!("#[sea_orm(table_name = \"{}\")]", table.name)); + lines.push("pub struct Model {".into()); + + for column in &table.columns { + render_column(&mut lines, column, &primary_keys, composite_pk); + } + for field in relation_fields { + lines.push(field); + } + + lines.push("}".into()); + + // Indexes (relations expressed as belongs_to fields above) + lines.push(String::new()); + render_indexes(&mut lines, indexes); + + lines.push("impl ActiveModelBehavior for ActiveModel {}".into()); + + lines.push(String::new()); + + lines.join("\n") +} + +fn render_column( + lines: &mut Vec, + column: &ColumnDef, + primary_keys: &HashSet, + composite_pk: bool, +) { + if primary_keys.contains(&column.name) { + if composite_pk { + lines.push(" #[sea_orm(primary_key, auto_increment = false)]".into()); + } else { + lines.push(" #[sea_orm(primary_key)]".into()); + } + } + + let field_name = sanitize_field_name(&column.name); + let ty = rust_type(&column.r#type, column.nullable); + lines.push(format!(" pub {}: {},", field_name, ty)); +} + +fn primary_key_columns(table: &TableDef) -> HashSet { + let mut keys = HashSet::new(); + for constraint in &table.constraints { + if let TableConstraint::PrimaryKey { columns } = constraint { + for col in columns { + keys.insert(col.clone()); + } + } + } + keys +} + +fn relation_field_defs(table: &TableDef) -> Vec { + let mut out = Vec::new(); + let mut used = HashSet::new(); + for constraint in &table.constraints { + if let TableConstraint::ForeignKey { + columns, + ref_table, + ref_columns, + .. + } = constraint + { + let base = sanitize_field_name(ref_table); + let field_name = unique_name(&base, &mut used); + let from = fk_attr_value(columns); + let to = fk_attr_value(ref_columns); + out.push(format!( + " #[sea_orm(belongs_to, from = \"{from}\", to = \"{to}\")]" + )); + out.push(format!( + " pub {field_name}: HasOne," + )); + } + } + out +} + +fn fk_attr_value(cols: &[String]) -> String { + if cols.len() == 1 { + cols[0].clone() + } else { + format!("({})", cols.join(", ")) + } +} + +fn render_indexes(lines: &mut Vec, indexes: &[IndexDef]) { + if indexes.is_empty() { + return; + } + lines.push(String::new()); + lines.push("// Index definitions (SeaORM uses Statement builders externally)".into()); + for idx in indexes { + let cols = idx.columns.join(", "); + lines.push(format!( + "// {} on [{}] unique={}", + idx.name, cols, idx.unique + )); + } +} + +fn rust_type(column_type: &ColumnType, nullable: bool) -> String { + let base = match column_type { + ColumnType::Integer => "i32".to_string(), + ColumnType::BigInt => "i64".to_string(), + ColumnType::Text => "String".to_string(), + ColumnType::Boolean => "bool".to_string(), + ColumnType::Timestamp => "DateTimeWithTimeZone".to_string(), + ColumnType::Custom(custom) => custom.clone(), + }; + + if nullable { + format!("Option<{}>", base) + } else { + base + } +} + +fn sanitize_field_name(name: &str) -> String { + let mut result = String::new(); + + for (idx, ch) in name.chars().enumerate() { + if (ch.is_ascii_alphanumeric() && (idx > 0 || ch.is_ascii_alphabetic())) || ch == '_' { + result.push(ch); + } else if idx == 0 && ch.is_ascii_digit() { + result.push('_'); + result.push(ch); + } else { + result.push('_'); + } + } + + if result.is_empty() { + "_col".into() + } else { + result + } +} + +fn unique_name(base: &str, used: &mut HashSet) -> String { + let mut name = base.to_string(); + let mut i = 1; + while used.contains(&name) { + name = format!("{base}_{i}"); + i += 1; + } + used.insert(name.clone()); + name +} + +#[cfg(test)] +mod tests { + use super::*; + use insta::{assert_snapshot, with_settings}; + use rstest::rstest; + + #[rstest] + #[case("basic_single_pk", TableDef { + name: "users".into(), + columns: vec![ + ColumnDef { name: "id".into(), r#type: ColumnType::Integer, nullable: false, default: None }, + ColumnDef { name: "display_name".into(), r#type: ColumnType::Text, nullable: true, default: None }, + ], + constraints: vec![TableConstraint::PrimaryKey { columns: vec!["id".into()] }], + indexes: vec![], + })] + #[case("composite_pk", TableDef { + name: "accounts".into(), + columns: vec![ + ColumnDef { name: "id".into(), r#type: ColumnType::Integer, nullable: false, default: None }, + ColumnDef { name: "tenant_id".into(), r#type: ColumnType::BigInt, nullable: false, default: None }, + ], + constraints: vec![TableConstraint::PrimaryKey { columns: vec!["id".into(), "tenant_id".into()] }], + indexes: vec![], + })] + #[case("fk_single", TableDef { + name: "posts".into(), + columns: vec![ + ColumnDef { name: "id".into(), r#type: ColumnType::Integer, nullable: false, default: None }, + ColumnDef { name: "user_id".into(), r#type: ColumnType::Integer, nullable: false, default: None }, + ColumnDef { name: "title".into(), r#type: ColumnType::Text, nullable: true, default: None }, + ], + constraints: vec![ + TableConstraint::PrimaryKey { columns: vec!["id".into()] }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + ], + indexes: vec![], + })] + #[case("fk_composite", TableDef { + name: "invoices".into(), + columns: vec![ + ColumnDef { name: "id".into(), r#type: ColumnType::Integer, nullable: false, default: None }, + ColumnDef { name: "customer_id".into(), r#type: ColumnType::Integer, nullable: false, default: None }, + ColumnDef { name: "customer_tenant_id".into(), r#type: ColumnType::Integer, nullable: false, default: None }, + ], + constraints: vec![ + TableConstraint::PrimaryKey { columns: vec!["id".into()] }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["customer_id".into(), "customer_tenant_id".into()], + ref_table: "customers".into(), + ref_columns: vec!["id".into(), "tenant_id".into()], + on_delete: None, + on_update: None, + }, + ], + indexes: vec![], + })] + fn render_entity_snapshots(#[case] name: &str, #[case] table: TableDef) { + let rendered = render_entity(&table); + with_settings!({ snapshot_suffix => format!("params_{}", name) }, { + assert_snapshot!(rendered); + }); + } +} diff --git a/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_snapshots@params_basic_single_pk.snap b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_snapshots@params_basic_single_pk.snap new file mode 100644 index 0000000..ec6d11e --- /dev/null +++ b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_snapshots@params_basic_single_pk.snap @@ -0,0 +1,16 @@ +--- +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 = "users")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub display_name: Option, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_snapshots@params_composite_pk.snap b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_snapshots@params_composite_pk.snap new file mode 100644 index 0000000..caee645 --- /dev/null +++ b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_snapshots@params_composite_pk.snap @@ -0,0 +1,17 @@ +--- +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 = "accounts")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: i32, + #[sea_orm(primary_key, auto_increment = false)] + pub tenant_id: i64, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_snapshots@params_fk_composite.snap b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_snapshots@params_fk_composite.snap new file mode 100644 index 0000000..7373a9a --- /dev/null +++ b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_snapshots@params_fk_composite.snap @@ -0,0 +1,19 @@ +--- +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 = "invoices")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub customer_id: i32, + pub customer_tenant_id: i32, + #[sea_orm(belongs_to, from = "(customer_id, customer_tenant_id)", to = "(id, tenant_id)")] + pub customers: HasOne, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_snapshots@params_fk_single.snap b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_snapshots@params_fk_single.snap new file mode 100644 index 0000000..0286f45 --- /dev/null +++ b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_snapshots@params_fk_single.snap @@ -0,0 +1,19 @@ +--- +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 = "posts")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub user_id: i32, + pub title: Option, + #[sea_orm(belongs_to, from = "user_id", to = "id")] + pub users: HasOne, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/vespertide-exporter/src/sqlalchemy/mod.rs b/crates/vespertide-exporter/src/sqlalchemy/mod.rs new file mode 100644 index 0000000..4d5c438 --- /dev/null +++ b/crates/vespertide-exporter/src/sqlalchemy/mod.rs @@ -0,0 +1,11 @@ +use crate::orm::OrmExporter; +use vespertide_core::TableDef; + +pub struct SqlAlchemyExporter; + +impl OrmExporter for SqlAlchemyExporter { + fn render_entity(&self, table: &TableDef) -> Result { + // Placeholder: replace with real SQLAlchemy generation + Ok(format!("# SQLAlchemy model placeholder for {}", table.name)) + } +} diff --git a/crates/vespertide-exporter/src/sqlmodel/mod.rs b/crates/vespertide-exporter/src/sqlmodel/mod.rs new file mode 100644 index 0000000..c35c84a --- /dev/null +++ b/crates/vespertide-exporter/src/sqlmodel/mod.rs @@ -0,0 +1,11 @@ +use crate::orm::OrmExporter; +use vespertide_core::TableDef; + +pub struct SqlModelExporter; + +impl OrmExporter for SqlModelExporter { + fn render_entity(&self, table: &TableDef) -> Result { + // Placeholder: replace with real SQLModel generation + Ok(format!("# SQLModel placeholder for {}", table.name)) + } +} diff --git a/crates/vespertide-planner/src/apply.rs b/crates/vespertide-planner/src/apply.rs index a1ce1e2..7ed9982 100644 --- a/crates/vespertide-planner/src/apply.rs +++ b/crates/vespertide-planner/src/apply.rs @@ -138,8 +138,8 @@ pub fn apply_action( fn rename_column_in_constraints(constraints: &mut [TableConstraint], from: &str, to: &str) { for constraint in constraints { match constraint { - TableConstraint::PrimaryKey(cols) => { - for c in cols.iter_mut() { + TableConstraint::PrimaryKey { columns } => { + for c in columns.iter_mut() { if c == from { *c = to.to_string(); } @@ -185,9 +185,9 @@ fn rename_column_in_indexes(indexes: &mut [IndexDef], from: &str, to: &str) { fn drop_column_from_constraints(constraints: &mut Vec, column: &str) { constraints.retain_mut(|c| match c { - TableConstraint::PrimaryKey(cols) => { - cols.retain(|c| c != column); - !cols.is_empty() + TableConstraint::PrimaryKey { columns } => { + columns.retain(|c| c != column); + !columns.is_empty() } TableConstraint::Unique { columns, .. } => { columns.retain(|c| c != column); @@ -375,7 +375,7 @@ mod tests { col("ref_id", ColumnType::Integer) ], vec![ - TableConstraint::PrimaryKey(vec!["id".into()]), + TableConstraint::PrimaryKey{columns: vec!["id".into()] }, TableConstraint::Unique { name: Some("u_old".into()), columns: vec!["old".into()], @@ -419,7 +419,7 @@ mod tests { col("new_col", ColumnType::Boolean) ], vec![ - TableConstraint::PrimaryKey(vec!["id".into()]), + TableConstraint::PrimaryKey{columns: vec!["id".into()] }, TableConstraint::Unique { name: Some("u_old".into()), columns: vec!["old".into()], @@ -448,7 +448,7 @@ mod tests { "users", vec![col("id", ColumnType::Integer), col("old", ColumnType::Text)], vec![ - TableConstraint::PrimaryKey(vec!["id".into()]), + TableConstraint::PrimaryKey{columns: vec!["id".into()] }, TableConstraint::Unique { name: Some("u_old".into()), columns: vec!["old".into()], @@ -476,7 +476,7 @@ mod tests { "users", vec![col("id", ColumnType::Integer)], vec![ - TableConstraint::PrimaryKey(vec!["id".into()]), + TableConstraint::PrimaryKey{columns: vec!["id".into()] }, TableConstraint::Check { name: None, expr: "old IS NOT NULL".into(), @@ -543,7 +543,7 @@ mod tests { #[rstest] #[case( vec![ - TableConstraint::PrimaryKey(vec!["id".into(), "old".into()]), + TableConstraint::PrimaryKey{columns: vec!["id".into(), "old".into()] }, TableConstraint::Unique { name: None, columns: vec!["old".into(), "keep".into()], @@ -565,7 +565,7 @@ mod tests { "old", "new", vec![ - TableConstraint::PrimaryKey(vec!["id".into(), "new".into()]), + TableConstraint::PrimaryKey{columns: vec!["id".into(), "new".into()] }, TableConstraint::Unique { name: None, columns: vec!["new".into(), "keep".into()], @@ -587,7 +587,7 @@ mod tests { )] #[case( vec![ - TableConstraint::PrimaryKey(vec!["id".into()]), + TableConstraint::PrimaryKey{columns: vec!["id".into()] }, TableConstraint::Check { name: None, expr: "id > 0".into(), @@ -597,7 +597,7 @@ mod tests { "missing", "new", vec![ - TableConstraint::PrimaryKey(vec!["id".into()]), + TableConstraint::PrimaryKey{columns: vec!["id".into()] }, TableConstraint::Check { name: None, expr: "id > 0".into(), diff --git a/crates/vespertide-planner/src/schema.rs b/crates/vespertide-planner/src/schema.rs index 78174af..386a209 100644 --- a/crates/vespertide-planner/src/schema.rs +++ b/crates/vespertide-planner/src/schema.rs @@ -52,13 +52,13 @@ mod tests { actions: vec![MigrationAction::CreateTable { table: "users".into(), columns: vec![col("id", ColumnType::Integer)], - constraints: vec![TableConstraint::PrimaryKey(vec!["id".into()])], + constraints: vec![TableConstraint::PrimaryKey{columns: vec!["id".into()] }], }], }], table( "users", vec![col("id", ColumnType::Integer)], - vec![TableConstraint::PrimaryKey(vec!["id".into()])], + vec![TableConstraint::PrimaryKey{columns: vec!["id".into()] }], vec![], ) )] @@ -71,7 +71,7 @@ mod tests { actions: vec![MigrationAction::CreateTable { table: "users".into(), columns: vec![col("id", ColumnType::Integer)], - constraints: vec![TableConstraint::PrimaryKey(vec!["id".into()])], + constraints: vec![TableConstraint::PrimaryKey{columns: vec!["id".into()] }], }], }, MigrationPlan { @@ -91,7 +91,7 @@ mod tests { col("id", ColumnType::Integer), col("name", ColumnType::Text), ], - vec![TableConstraint::PrimaryKey(vec!["id".into()])], + vec![TableConstraint::PrimaryKey{columns: vec!["id".into()] }], vec![], ) )] @@ -104,7 +104,7 @@ mod tests { actions: vec![MigrationAction::CreateTable { table: "users".into(), columns: vec![col("id", ColumnType::Integer)], - constraints: vec![TableConstraint::PrimaryKey(vec!["id".into()])], + constraints: vec![TableConstraint::PrimaryKey{columns: vec!["id".into()] }], }], }, MigrationPlan { @@ -137,7 +137,7 @@ mod tests { col("id", ColumnType::Integer), col("name", ColumnType::Text), ], - vec![TableConstraint::PrimaryKey(vec!["id".into()])], + vec![TableConstraint::PrimaryKey{columns: vec!["id".into()] }], vec![IndexDef { name: "idx_users_name".into(), columns: vec!["name".into()], diff --git a/crates/vespertide-planner/src/validate.rs b/crates/vespertide-planner/src/validate.rs index 759c9aa..3d06bc1 100644 --- a/crates/vespertide-planner/src/validate.rs +++ b/crates/vespertide-planner/src/validate.rs @@ -64,7 +64,7 @@ fn validate_constraint( table_map: &std::collections::HashMap<&str, HashSet<&str>>, ) -> Result<(), PlannerError> { match constraint { - TableConstraint::PrimaryKey(columns) => { + TableConstraint::PrimaryKey { columns } => { if columns.is_empty() { return Err(PlannerError::EmptyConstraintColumns( table_name.to_string(), @@ -196,6 +196,29 @@ fn validate_index( Ok(()) } +/// Validate a migration plan for correctness. +/// Checks for: +/// - AddColumn actions with NOT NULL columns without default must have fill_with +pub fn validate_migration_plan(plan: &MigrationPlan) -> Result<(), PlannerError> { + for action in &plan.actions { + if let MigrationAction::AddColumn { + table, + column, + fill_with, + } = action + { + // If column is NOT NULL and has no default, fill_with is required + if !column.nullable && column.default.is_none() && fill_with.is_none() { + return Err(PlannerError::MissingFillWith( + table.clone(), + column.name.clone(), + )); + } + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -254,7 +277,7 @@ mod tests { vec![table( "users", vec![col("id", ColumnType::Integer)], - vec![TableConstraint::PrimaryKey(vec!["id".into()])], + vec![TableConstraint::PrimaryKey{columns: vec!["id".into()] }], vec![], )], None @@ -325,7 +348,7 @@ mod tests { table( "posts", vec![col("id", ColumnType::Integer)], - vec![TableConstraint::PrimaryKey(vec!["id".into()])], + vec![TableConstraint::PrimaryKey{columns: vec!["id".into()] }], vec![], ), table( @@ -361,7 +384,7 @@ mod tests { vec![table( "users", vec![col("id", ColumnType::Integer)], - vec![TableConstraint::PrimaryKey(vec!["nonexistent".into()])], + vec![TableConstraint::PrimaryKey{columns: vec!["nonexistent".into()] }], vec![], )], Some(is_constraint_column as fn(&PlannerError) -> bool) @@ -394,7 +417,7 @@ mod tests { vec![table( "users", vec![col("id", ColumnType::Integer)], - vec![TableConstraint::PrimaryKey(vec![])], + vec![TableConstraint::PrimaryKey{columns: vec![] }], vec![], )], Some(is_empty_columns as fn(&PlannerError) -> bool) @@ -618,26 +641,3 @@ mod tests { assert!(result.is_ok()); } } - -/// Validate a migration plan for correctness. -/// Checks for: -/// - AddColumn actions with NOT NULL columns without default must have fill_with -pub fn validate_migration_plan(plan: &MigrationPlan) -> Result<(), PlannerError> { - for action in &plan.actions { - if let MigrationAction::AddColumn { - table, - column, - fill_with, - } = action - { - // If column is NOT NULL and has no default, fill_with is required - if !column.nullable && column.default.is_none() && fill_with.is_none() { - return Err(PlannerError::MissingFillWith( - table.clone(), - column.name.clone(), - )); - } - } - } - Ok(()) -} diff --git a/crates/vespertide-query/src/sql.rs b/crates/vespertide-query/src/sql.rs index a0e4b03..d367c73 100644 --- a/crates/vespertide-query/src/sql.rs +++ b/crates/vespertide-query/src/sql.rs @@ -234,8 +234,8 @@ fn table_constraint_sql( binds: &mut Vec, ) -> Result { Ok(match constraint { - TableConstraint::PrimaryKey(cols) => { - let placeholders = cols + TableConstraint::PrimaryKey { columns } => { + let placeholders = columns .iter() .map(|c| bind(binds, c)) .collect::>() @@ -384,7 +384,7 @@ mod tests { col("id", ColumnType::Integer), col("name", ColumnType::Text), ], - constraints: vec![TableConstraint::PrimaryKey(vec!["id".into()])], + constraints: vec![TableConstraint::PrimaryKey{columns: vec!["id".into()] }], }, vec![( "CREATE TABLE $1 ($2 INTEGER, $3 TEXT, PRIMARY KEY ($4));".to_string(), @@ -590,7 +590,7 @@ mod tests { #[case::simple( "users", vec![col("id", ColumnType::Integer), col("name", ColumnType::Text)], - vec![TableConstraint::PrimaryKey(vec!["id".into()])], + vec![TableConstraint::PrimaryKey{columns: vec!["id".into()] }], ( "CREATE TABLE $1 ($2 INTEGER, $3 TEXT, PRIMARY KEY ($4));".to_string(), vec!["users".to_string(), "id".to_string(), "name".to_string(), "id".to_string()], @@ -600,7 +600,7 @@ mod tests { "users", vec![col("id", ColumnType::Integer), col("email", ColumnType::Text)], vec![ - TableConstraint::PrimaryKey(vec!["id".into()]), + TableConstraint::PrimaryKey{columns: vec!["id".into()] }, TableConstraint::Unique { name: Some("unique_email".into()), columns: vec!["email".into()], @@ -673,11 +673,11 @@ mod tests { #[rstest] #[case::primary_key_single( - TableConstraint::PrimaryKey(vec!["id".into()]), + TableConstraint::PrimaryKey{columns: vec!["id".into()] }, ("PRIMARY KEY ($1)".to_string(), vec!["id".to_string()]) )] #[case::primary_key_multiple( - TableConstraint::PrimaryKey(vec!["id".into(), "version".into()]), + TableConstraint::PrimaryKey{columns: vec!["id".into(), "version".into()] }, ("PRIMARY KEY ($1, $2)".to_string(), vec!["id".to_string(), "version".to_string()]) )] #[case::unique_without_name( diff --git a/crates/vespertide-schema-gen/src/main.rs b/crates/vespertide-schema-gen/src/main.rs index fffb60b..e1f69e5 100644 --- a/crates/vespertide-schema-gen/src/main.rs +++ b/crates/vespertide-schema-gen/src/main.rs @@ -124,7 +124,7 @@ mod tests { let out = temp_dir.path(); // Create directory first - fs::create_dir_all(&out).unwrap(); + fs::create_dir_all(out).unwrap(); assert!(out.exists()); // Should still work diff --git a/examples/app/models/post/post.json b/examples/app/models/post/post.json new file mode 100644 index 0000000..f1d1857 --- /dev/null +++ b/examples/app/models/post/post.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/model.schema.json", + "columns": [{ + "name": "title", + "type": "Integer", + "nullable": false + }, { + "name": "content", + "type": "Text", + "nullable": false + }, { + "name": "created_at", + "type": "Timestamp", + "nullable": false + }, { + "name": "updated_at", + "type": "Timestamp", + "nullable": true + }, { + "name": "user_id", + "type": "Integer", + "nullable": false + }], + "constraints": [{ + "type": "foreign_key", + "columns": ["user_id"], + "ref_table": "user", + "ref_columns": ["id"] + }, { + "type": "primary_key", + "columns": ["id"] + }], + "indexes": [], + "name": "post" +} \ No newline at end of file diff --git a/examples/app/src/models/mod.rs b/examples/app/src/models/mod.rs new file mode 100644 index 0000000..6f1b986 --- /dev/null +++ b/examples/app/src/models/mod.rs @@ -0,0 +1,2 @@ +pub mod post; +pub mod user; diff --git a/examples/app/src/models/post/mod.rs b/examples/app/src/models/post/mod.rs new file mode 100644 index 0000000..e8b6291 --- /dev/null +++ b/examples/app/src/models/post/mod.rs @@ -0,0 +1 @@ +pub mod post; diff --git a/examples/app/src/models/post/post.rs b/examples/app/src/models/post/post.rs new file mode 100644 index 0000000..39cf82c --- /dev/null +++ b/examples/app/src/models/post/post.rs @@ -0,0 +1,16 @@ +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "post")] +pub struct Model { + pub title: i32, + pub content: String, + pub created_at: DateTimeWithTimeZone, + pub updated_at: Option, + pub user_id: i32, + #[sea_orm(belongs_to, from = "user_id", to = "id")] + pub user: HasOne, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/examples/app/src/models/user.rs b/examples/app/src/models/user.rs new file mode 100644 index 0000000..d910adf --- /dev/null +++ b/examples/app/src/models/user.rs @@ -0,0 +1,10 @@ +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "user")] +pub struct Model { + pub aa: i32, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/schemas/migration.schema.json b/schemas/migration.schema.json index b20fdfb..d4046df 100644 --- a/schemas/migration.schema.json +++ b/schemas/migration.schema.json @@ -326,16 +326,20 @@ { "type": "object", "properties": { + "columns": { + "type": "array", + "items": { + "type": "string" + } + }, "type": { "type": "string", "const": "primary_key" } }, - "items": { - "type": "string" - }, "required": [ - "type" + "type", + "columns" ] }, { diff --git a/schemas/model.schema.json b/schemas/model.schema.json index 10301b2..c396a91 100644 --- a/schemas/model.schema.json +++ b/schemas/model.schema.json @@ -120,16 +120,20 @@ { "type": "object", "properties": { + "columns": { + "type": "array", + "items": { + "type": "string" + } + }, "type": { "type": "string", "const": "primary_key" } }, - "items": { - "type": "string" - }, "required": [ - "type" + "type", + "columns" ] }, {