From a3ca118c63ae01221442fa0e596cd423df8363b5 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 14 Dec 2025 23:39:19 +0900 Subject: [PATCH 01/11] Add new col, action conv --- Cargo.lock | 16 +- crates/vespertide-core/src/action.rs | 14 +- crates/vespertide-core/src/schema/column.rs | 14 ++ crates/vespertide-exporter/src/seaorm/mod.rs | 26 ++- crates/vespertide-planner/src/diff.rs | 16 +- crates/vespertide-query/src/sql.rs | 4 + examples/app/migrations/0001_create_user.json | 2 +- examples/app/migrations/0002_create_post.json | 193 ------------------ schemas/migration.schema.json | 70 +++++-- schemas/model.schema.json | 46 ++++- 10 files changed, 169 insertions(+), 232 deletions(-) delete mode 100644 examples/app/migrations/0002_create_post.json diff --git a/Cargo.lock b/Cargo.lock index 83ee82db..892a40af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2831,7 +2831,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vespertide" -version = "0.1.5" +version = "0.1.6" dependencies = [ "vespertide-core", "vespertide-macro", @@ -2839,7 +2839,7 @@ dependencies = [ [[package]] name = "vespertide-cli" -version = "0.1.5" +version = "0.1.6" dependencies = [ "anyhow", "chrono", @@ -2860,7 +2860,7 @@ dependencies = [ [[package]] name = "vespertide-config" -version = "0.1.5" +version = "0.1.6" dependencies = [ "clap", "serde", @@ -2868,7 +2868,7 @@ dependencies = [ [[package]] name = "vespertide-core" -version = "0.1.5" +version = "0.1.6" dependencies = [ "schemars", "serde", @@ -2877,7 +2877,7 @@ dependencies = [ [[package]] name = "vespertide-exporter" -version = "0.1.5" +version = "0.1.6" dependencies = [ "insta", "rstest", @@ -2887,7 +2887,7 @@ dependencies = [ [[package]] name = "vespertide-macro" -version = "0.1.5" +version = "0.1.6" dependencies = [ "proc-macro2", "quote", @@ -2902,7 +2902,7 @@ dependencies = [ [[package]] name = "vespertide-planner" -version = "0.1.5" +version = "0.1.6" dependencies = [ "rstest", "thiserror", @@ -2911,7 +2911,7 @@ dependencies = [ [[package]] name = "vespertide-query" -version = "0.1.5" +version = "0.1.6" dependencies = [ "rstest", "thiserror", diff --git a/crates/vespertide-core/src/action.rs b/crates/vespertide-core/src/action.rs index 934523f3..0107f109 100644 --- a/crates/vespertide-core/src/action.rs +++ b/crates/vespertide-core/src/action.rs @@ -15,56 +15,44 @@ pub struct MigrationPlan { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(tag = "type")] +#[serde(tag = "type", rename_all = "snake_case")] pub enum MigrationAction { - #[serde(rename_all = "snake_case")] CreateTable { table: TableName, columns: Vec, constraints: Vec, }, - #[serde(rename_all = "snake_case")] DeleteTable { table: TableName }, - #[serde(rename_all = "snake_case")] AddColumn { table: TableName, column: ColumnDef, /// Optional fill value to backfill existing rows when adding NOT NULL without default. fill_with: Option, }, - #[serde(rename_all = "snake_case")] RenameColumn { table: TableName, from: ColumnName, to: ColumnName, }, - #[serde(rename_all = "snake_case")] DeleteColumn { table: TableName, column: ColumnName, }, - #[serde(rename_all = "snake_case")] ModifyColumnType { table: TableName, column: ColumnName, new_type: ColumnType, }, - #[serde(rename_all = "snake_case")] AddIndex { table: TableName, index: IndexDef }, - #[serde(rename_all = "snake_case")] RemoveIndex { table: TableName, name: IndexName }, - #[serde(rename_all = "snake_case")] AddConstraint { table: TableName, constraint: TableConstraint, }, - #[serde(rename_all = "snake_case")] RemoveConstraint { table: TableName, constraint: TableConstraint, }, - #[serde(rename_all = "snake_case")] RenameTable { from: TableName, to: TableName }, - #[serde(rename_all = "snake_case")] RawSql { sql: String }, } diff --git a/crates/vespertide-core/src/schema/column.rs b/crates/vespertide-core/src/schema/column.rs index d5d49198..c57bfb4c 100644 --- a/crates/vespertide-core/src/schema/column.rs +++ b/crates/vespertide-core/src/schema/column.rs @@ -40,6 +40,7 @@ impl ColumnType { SimpleColumnType::Time => "TIME".into(), SimpleColumnType::Timestamp => "TIMESTAMP".into(), SimpleColumnType::Timestamptz => "TIMESTAMPTZ".into(), + SimpleColumnType::Interval => "INTERVAL".into(), SimpleColumnType::Bytea => "BYTEA".into(), SimpleColumnType::Uuid => "UUID".into(), SimpleColumnType::Json => "JSON".into(), @@ -47,9 +48,12 @@ impl ColumnType { SimpleColumnType::Inet => "INET".into(), SimpleColumnType::Cidr => "CIDR".into(), SimpleColumnType::Macaddr => "MACADDR".into(), + SimpleColumnType::Xml => "XML".into(), }, ColumnType::Complex(ty) => match ty { ComplexColumnType::Varchar { length } => format!("VARCHAR({})", length), + ComplexColumnType::Numeric { precision, scale } => format!("NUMERIC({}, {})", precision, scale), + ComplexColumnType::Char { length } => format!("CHAR({})", length), ComplexColumnType::Custom { custom_type } => custom_type.clone(), }, } @@ -70,14 +74,18 @@ impl ColumnType { SimpleColumnType::Time => "Time".to_string(), SimpleColumnType::Timestamp => "DateTime".to_string(), SimpleColumnType::Timestamptz => "DateTimeWithTimeZone".to_string(), + SimpleColumnType::Interval => "String".to_string(), SimpleColumnType::Bytea => "Vec".to_string(), SimpleColumnType::Uuid => "Uuid".to_string(), SimpleColumnType::Json | SimpleColumnType::Jsonb => "Json".to_string(), SimpleColumnType::Inet | SimpleColumnType::Cidr => "String".to_string(), SimpleColumnType::Macaddr => "String".to_string(), + SimpleColumnType::Xml => "String".to_string(), }, ColumnType::Complex(ty) => match ty { ComplexColumnType::Varchar { .. } => "String".to_string(), + ComplexColumnType::Numeric { .. } => "Decimal".to_string(), + ComplexColumnType::Char { .. } => "String".to_string(), ComplexColumnType::Custom { .. } => "String".to_string(), // Default for custom types }, }; @@ -110,6 +118,7 @@ pub enum SimpleColumnType { Time, Timestamp, Timestamptz, + Interval, // Binary type Bytea, @@ -125,12 +134,17 @@ pub enum SimpleColumnType { Inet, Cidr, Macaddr, + + // XML type + Xml, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case", tag = "kind")] pub enum ComplexColumnType { Varchar { length: u32 }, + Numeric { precision: u32, scale: u32 }, + Char { length: u32 }, Custom { custom_type: String }, } diff --git a/crates/vespertide-exporter/src/seaorm/mod.rs b/crates/vespertide-exporter/src/seaorm/mod.rs index a9b4a0db..8ecbbbb1 100644 --- a/crates/vespertide-exporter/src/seaorm/mod.rs +++ b/crates/vespertide-exporter/src/seaorm/mod.rs @@ -197,7 +197,7 @@ mod helper_tests { #[test] fn test_rust_type() { - use vespertide_core::{ColumnType, SimpleColumnType}; + use vespertide_core::{ColumnType, SimpleColumnType, ComplexColumnType}; // Numeric types assert_eq!( ColumnType::Simple(SimpleColumnType::SmallInt).to_rust_type(false), @@ -313,6 +313,28 @@ mod helper_tests { ColumnType::Simple(SimpleColumnType::Macaddr).to_rust_type(false), "String" ); + + // Interval type + assert_eq!( + ColumnType::Simple(SimpleColumnType::Interval).to_rust_type(false), + "String" + ); + + // XML type + assert_eq!( + ColumnType::Simple(SimpleColumnType::Xml).to_rust_type(false), + "String" + ); + + // Complex types + assert_eq!( + ColumnType::Complex(ComplexColumnType::Numeric { precision: 10, scale: 2 }).to_rust_type(false), + "Decimal" + ); + assert_eq!( + ColumnType::Complex(ComplexColumnType::Char { length: 10 }).to_rust_type(false), + "String" + ); } #[test] @@ -341,7 +363,7 @@ mod tests { use super::*; use insta::{assert_snapshot, with_settings}; use rstest::rstest; - use vespertide_core::{ColumnType, SimpleColumnType}; + use vespertide_core::{ColumnType, SimpleColumnType, ComplexColumnType}; #[rstest] #[case("basic_single_pk", TableDef { diff --git a/crates/vespertide-planner/src/diff.rs b/crates/vespertide-planner/src/diff.rs index 4c6326fe..0c916040 100644 --- a/crates/vespertide-planner/src/diff.rs +++ b/crates/vespertide-planner/src/diff.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use vespertide_core::{MigrationAction, MigrationPlan, TableDef}; +use vespertide_core::{MigrationAction, MigrationPlan, TableConstraint, TableDef}; use crate::error::PlannerError; @@ -95,6 +95,20 @@ pub fn diff_schemas(from: &[TableDef], to: &[TableDef]) -> Result Date: Sun, 14 Dec 2025 23:48:19 +0900 Subject: [PATCH 02/11] Fix convert logic --- crates/vespertide-cli/src/commands/export.rs | 75 +++++++++++++++++-- crates/vespertide-exporter/src/seaorm/mod.rs | 24 ++++++ ...der_entity_snapshots@params_inline_pk.snap | 16 ++++ examples/app/models/user copy.json | 48 ++++++++++++ examples/app/src/models/mod.rs | 1 + examples/app/src/models/post/post.rs | 10 +-- examples/app/src/models/user.rs | 5 +- examples/app/src/models/user_copy.rs | 17 +++++ 8 files changed, 182 insertions(+), 14 deletions(-) create mode 100644 crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_snapshots@params_inline_pk.snap create mode 100644 examples/app/models/user copy.json create mode 100644 examples/app/src/models/user_copy.rs diff --git a/crates/vespertide-cli/src/commands/export.rs b/crates/vespertide-cli/src/commands/export.rs index 5e058f80..6ff2ff70 100644 --- a/crates/vespertide-cli/src/commands/export.rs +++ b/crates/vespertide-cli/src/commands/export.rs @@ -67,16 +67,52 @@ fn resolve_export_dir(export_dir: Option, config: &VespertideConfig) -> } 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); + // Sanitize file name: replace spaces with underscores + let mut out = root.to_path_buf(); + + // Reconstruct path with sanitized file name + for component in rel_path.components() { + if let std::path::Component::Normal(name) = component { + out.push(name); + } else { + out.push(component.as_os_str()); + } + } + + // Sanitize the file name (last component) + if let Some(file_name) = out.file_name().and_then(|n| n.to_str()) { + // Remove extension, sanitize, then add new extension + let (stem, _ext) = if let Some(dot_idx) = file_name.rfind('.') { + file_name.split_at(dot_idx) + } else { + (file_name, "") + }; + + let sanitized = sanitize_filename(stem); + let ext = match orm { + Orm::SeaOrm => "rs", + Orm::SqlAlchemy | Orm::SqlModel => "py", + }; + out.set_file_name(format!("{}.{}", sanitized, ext)); + } + out } +fn sanitize_filename(name: &str) -> String { + name.chars() + .map(|ch| { + if ch.is_alphanumeric() || ch == '_' || ch == '-' { + ch + } else if ch == ' ' { + '_' + } else { + '_' + } + }) + .collect::() +} + fn load_models_recursive(base: &Path) -> Result> { let mut out = Vec::new(); if !base.exists() { @@ -91,7 +127,7 @@ fn ensure_mod_chain(root: &Path, rel_path: &Path) -> Result<()> { let mut comps: Vec = rel_path .with_extension("") .components() - .filter_map(|c| c.as_os_str().to_str().map(|s| s.to_string())) + .filter_map(|c| c.as_os_str().to_str().map(|s| sanitize_filename(s).to_string())) .collect(); if comps.is_empty() { return Ok(()); @@ -387,4 +423,27 @@ mod tests { assert!(matches!(Orm::from(OrmArg::Sqlalchemy), Orm::SqlAlchemy)); assert!(matches!(Orm::from(OrmArg::Sqlmodel), Orm::SqlModel)); } + + #[test] + fn test_sanitize_filename() { + assert_eq!(sanitize_filename("normal_name"), "normal_name"); + assert_eq!(sanitize_filename("user copy"), "user_copy"); + assert_eq!(sanitize_filename("user copy"), "user__copy"); + assert_eq!(sanitize_filename("user-copy"), "user-copy"); + assert_eq!(sanitize_filename("user.copy"), "user_copy"); + assert_eq!(sanitize_filename("user copy.json"), "user_copy_json"); + } + + #[test] + fn build_output_path_sanitizes_spaces() { + use std::path::Path; + let root = Path::new("src/models"); + let rel_path = Path::new("user copy.json"); + let out = build_output_path(root, rel_path, Orm::SeaOrm); + assert_eq!(out, Path::new("src/models/user_copy.rs")); + + let rel_path2 = Path::new("blog/post name.yaml"); + let out2 = build_output_path(root, rel_path2, Orm::SeaOrm); + assert_eq!(out2, Path::new("src/models/blog/post_name.rs")); + } } diff --git a/crates/vespertide-exporter/src/seaorm/mod.rs b/crates/vespertide-exporter/src/seaorm/mod.rs index 8ecbbbb1..3541ac46 100644 --- a/crates/vespertide-exporter/src/seaorm/mod.rs +++ b/crates/vespertide-exporter/src/seaorm/mod.rs @@ -70,6 +70,8 @@ fn render_column( fn primary_key_columns(table: &TableDef) -> HashSet { let mut keys = HashSet::new(); + + // First, check table-level constraints for constraint in &table.constraints { if let TableConstraint::PrimaryKey { columns } = constraint { for col in columns { @@ -77,6 +79,15 @@ fn primary_key_columns(table: &TableDef) -> HashSet { } } } + + // Then, check inline primary_key on columns + // This handles cases where primary_key is defined inline but not yet normalized + for column in &table.columns { + if column.primary_key == Some(true) { + keys.insert(column.name.clone()); + } + } + keys } @@ -343,6 +354,10 @@ mod helper_tests { assert_eq!(sanitize_field_name("123name"), "_123name"); assert_eq!(sanitize_field_name("name-with-dash"), "name_with_dash"); assert_eq!(sanitize_field_name("name.with.dot"), "name_with_dot"); + assert_eq!(sanitize_field_name("name with space"), "name_with_space"); + assert_eq!(sanitize_field_name("name with multiple spaces"), "name__with__multiple__spaces"); + assert_eq!(sanitize_field_name(" name_with_leading_space"), "_name_with_leading_space"); + assert_eq!(sanitize_field_name("name_with_trailing_space "), "name_with_trailing_space_"); assert_eq!(sanitize_field_name(""), "_col"); assert_eq!(sanitize_field_name("a"), "a"); } @@ -424,6 +439,15 @@ mod tests { ], indexes: vec![], })] + #[case("inline_pk", TableDef { + name: "users".into(), + columns: vec![ + ColumnDef { name: "id".into(), r#type: ColumnType::Simple(SimpleColumnType::Uuid), nullable: false, default: Some("gen_random_uuid()".into()), comment: None, primary_key: Some(true), unique: None, index: None, foreign_key: None }, + ColumnDef { name: "email".into(), r#type: ColumnType::Simple(SimpleColumnType::Text), nullable: false, default: None, comment: None, primary_key: None, unique: Some(vespertide_core::StrOrBoolOrArray::Bool(true)), index: None, foreign_key: None }, + ], + constraints: vec![], + indexes: vec![], + })] fn render_entity_snapshots(#[case] name: &str, #[case] table: TableDef) { let rendered = render_entity(&table); with_settings!({ snapshot_suffix => format!("params_{}", name) }, { diff --git a/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_snapshots@params_inline_pk.snap b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_snapshots@params_inline_pk.snap new file mode 100644 index 00000000..86b1fdec --- /dev/null +++ b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_snapshots@params_inline_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: Uuid, + pub email: String, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/examples/app/models/user copy.json b/examples/app/models/user copy.json new file mode 100644 index 00000000..7221fb1a --- /dev/null +++ b/examples/app/models/user copy.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/model.schema.json", + "name": "user", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "primary_key": true + }, + { + "name": "email", + "type": { "kind": "varchar", "length": 255 }, + "nullable": false, + "unique": true, + "index": true + }, + { + "name": "password", + "type": { "kind": "varchar", "length": 255 }, + "nullable": false + }, + { + "name": "name", + "type": { "kind": "varchar", "length": 100 }, + "nullable": false + }, + { + "name": "profile_image", + "type": "text", + "nullable": true + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": true + } + ], + "constraints": [], + "indexes": [] +} diff --git a/examples/app/src/models/mod.rs b/examples/app/src/models/mod.rs index 6f1b986e..8d4c7f6f 100644 --- a/examples/app/src/models/mod.rs +++ b/examples/app/src/models/mod.rs @@ -1,2 +1,3 @@ pub mod post; +pub mod user_copy; pub mod user; diff --git a/examples/app/src/models/post/post.rs b/examples/app/src/models/post/post.rs index 39cf82ca..52aef456 100644 --- a/examples/app/src/models/post/post.rs +++ b/examples/app/src/models/post/post.rs @@ -4,13 +4,13 @@ use sea_orm::entity::prelude::*; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "post")] pub struct Model { - pub title: i32, + #[sea_orm(primary_key)] + pub id: i32, + pub title: String, pub content: String, - pub created_at: DateTimeWithTimeZone, - pub updated_at: Option, + pub created_at: DateTime, + 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 index d910adff..07b4fc3d 100644 --- a/examples/app/src/models/user.rs +++ b/examples/app/src/models/user.rs @@ -4,7 +4,10 @@ use sea_orm::entity::prelude::*; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "user")] pub struct Model { - pub aa: i32, + #[sea_orm(primary_key)] + pub id: i32, + pub name: String, + pub email: String, } impl ActiveModelBehavior for ActiveModel {} diff --git a/examples/app/src/models/user_copy.rs b/examples/app/src/models/user_copy.rs new file mode 100644 index 00000000..f023549b --- /dev/null +++ b/examples/app/src/models/user_copy.rs @@ -0,0 +1,17 @@ +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "user")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: Uuid, + pub email: String, + pub password: String, + pub name: String, + pub profile_image: Option, + pub created_at: DateTimeWithTimeZone, + pub updated_at: Option, +} + +impl ActiveModelBehavior for ActiveModel {} From 655498faa6b1432b7f58b1276f642adcb24df483 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 14 Dec 2025 23:48:34 +0900 Subject: [PATCH 03/11] Fix convert logic --- .changepacks/changepack_log_h3wS_OvJO-t6o_K_bYFid.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changepacks/changepack_log_h3wS_OvJO-t6o_K_bYFid.json diff --git a/.changepacks/changepack_log_h3wS_OvJO-t6o_K_bYFid.json b/.changepacks/changepack_log_h3wS_OvJO-t6o_K_bYFid.json new file mode 100644 index 00000000..9b52c36d --- /dev/null +++ b/.changepacks/changepack_log_h3wS_OvJO-t6o_K_bYFid.json @@ -0,0 +1 @@ +{"changes":{"crates/vespertide/Cargo.toml":"Patch","crates/vespertide-core/Cargo.toml":"Patch","crates/vespertide-exporter/Cargo.toml":"Patch","crates/vespertide-config/Cargo.toml":"Patch","crates/vespertide-planner/Cargo.toml":"Patch","crates/vespertide-query/Cargo.toml":"Patch","crates/vespertide-cli/Cargo.toml":"Patch","crates/vespertide-macro/Cargo.toml":"Patch"},"note":"Fix exporting logic","date":"2025-12-14T14:48:31.944130300Z"} \ No newline at end of file From 58e1c18ce1ca7acadabd1201a98c8c97ab1b76a6 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 15 Dec 2025 00:19:45 +0900 Subject: [PATCH 04/11] Implement checking fk issue, add auto increment --- crates/vespertide-cli/src/commands/diff.rs | 4 +- crates/vespertide-cli/src/commands/export.rs | 1 + crates/vespertide-cli/src/commands/sql.rs | 1 + crates/vespertide-cli/src/utils.rs | 18 +- crates/vespertide-core/src/schema/column.rs | 6 +- .../vespertide-core/src/schema/constraint.rs | 2 + .../vespertide-core/src/schema/foreign_key.rs | 8 + crates/vespertide-core/src/schema/mod.rs | 2 + .../vespertide-core/src/schema/primary_key.rs | 19 ++ crates/vespertide-core/src/schema/table.rs | 300 ++++++++++++++++-- crates/vespertide-exporter/src/seaorm/mod.rs | 29 +- crates/vespertide-planner/src/apply.rs | 24 +- crates/vespertide-planner/src/diff.rs | 43 ++- crates/vespertide-planner/src/schema.rs | 12 +- crates/vespertide-planner/src/validate.rs | 10 +- crates/vespertide-query/src/sql.rs | 14 +- 16 files changed, 407 insertions(+), 86 deletions(-) create mode 100644 crates/vespertide-core/src/schema/primary_key.rs diff --git a/crates/vespertide-cli/src/commands/diff.rs b/crates/vespertide-cli/src/commands/diff.rs index c31f33f8..324e9328 100644 --- a/crates/vespertide-cli/src/commands/diff.rs +++ b/crates/vespertide-cli/src/commands/diff.rs @@ -146,7 +146,7 @@ fn format_action(action: &MigrationAction) -> String { fn format_constraint_type(constraint: &vespertide_core::TableConstraint) -> String { match constraint { - vespertide_core::TableConstraint::PrimaryKey { columns } => { + vespertide_core::TableConstraint::PrimaryKey { columns, .. } => { format!("PRIMARY KEY ({})", columns.join(", ")) } vespertide_core::TableConstraint::Unique { name, columns } => { @@ -311,6 +311,7 @@ mod tests { MigrationAction::AddConstraint { table: "users".into(), constraint: vespertide_core::TableConstraint::PrimaryKey { + auto_increment: false, columns: vec!["id".into()], }, }, @@ -354,6 +355,7 @@ mod tests { MigrationAction::RemoveConstraint { table: "users".into(), constraint: vespertide_core::TableConstraint::PrimaryKey { + auto_increment: false, columns: vec!["id".into()], }, }, diff --git a/crates/vespertide-cli/src/commands/export.rs b/crates/vespertide-cli/src/commands/export.rs index 6ff2ff70..162bbfe5 100644 --- a/crates/vespertide-cli/src/commands/export.rs +++ b/crates/vespertide-cli/src/commands/export.rs @@ -240,6 +240,7 @@ mod tests { foreign_key: None, }], constraints: vec![TableConstraint::PrimaryKey { + auto_increment: false, columns: vec!["id".into()], }], indexes: vec![], diff --git a/crates/vespertide-cli/src/commands/sql.rs b/crates/vespertide-cli/src/commands/sql.rs index b660d81c..cc4b22a2 100644 --- a/crates/vespertide-cli/src/commands/sql.rs +++ b/crates/vespertide-cli/src/commands/sql.rs @@ -176,6 +176,7 @@ mod tests { foreign_key: None, }], constraints: vec![TableConstraint::PrimaryKey { + auto_increment: false, columns: vec!["id".into()], }], }], diff --git a/crates/vespertide-cli/src/utils.rs b/crates/vespertide-cli/src/utils.rs index f27db586..accd61ed 100644 --- a/crates/vespertide-cli/src/utils.rs +++ b/crates/vespertide-cli/src/utils.rs @@ -29,12 +29,24 @@ pub fn load_models(config: &VespertideConfig) -> Result> { let mut tables = Vec::new(); load_models_recursive(models_dir, &mut tables)?; + // Normalize tables to convert inline constraints (primary_key, foreign_key, etc.) to table-level constraints + // This must happen before validation so that foreign key references can be checked + let normalized_tables: Vec = tables + .into_iter() + .map(|t| { + t.normalize().map_err(|e| { + anyhow::anyhow!("Failed to normalize table '{}': {}", t.name, e) + }) + }) + .collect::, _>>()?; + // Validate schema integrity before returning - if !tables.is_empty() { - validate_schema(&tables).map_err(|e| anyhow::anyhow!("schema validation failed: {}", e))?; + if !normalized_tables.is_empty() { + validate_schema(&normalized_tables) + .map_err(|e| anyhow::anyhow!("schema validation failed: {}", e))?; } - Ok(tables) + Ok(normalized_tables) } /// Recursively walk directory and load model files. diff --git a/crates/vespertide-core/src/schema/column.rs b/crates/vespertide-core/src/schema/column.rs index c57bfb4c..df40eda1 100644 --- a/crates/vespertide-core/src/schema/column.rs +++ b/crates/vespertide-core/src/schema/column.rs @@ -1,7 +1,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::schema::{foreign_key::ForeignKeyDef, names::ColumnName, str_or_bool::StrOrBoolOrArray}; +use crate::schema::{foreign_key::ForeignKeySyntax, names::ColumnName, primary_key::PrimaryKeySyntax, str_or_bool::StrOrBoolOrArray}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] @@ -11,10 +11,10 @@ pub struct ColumnDef { pub nullable: bool, pub default: Option, pub comment: Option, - pub primary_key: Option, + pub primary_key: Option, pub unique: Option, pub index: Option, - pub foreign_key: Option, + pub foreign_key: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] diff --git a/crates/vespertide-core/src/schema/constraint.rs b/crates/vespertide-core/src/schema/constraint.rs index 83151e34..7cd0d655 100644 --- a/crates/vespertide-core/src/schema/constraint.rs +++ b/crates/vespertide-core/src/schema/constraint.rs @@ -10,6 +10,8 @@ use crate::schema::{ #[serde(rename_all = "snake_case", tag = "type")] pub enum TableConstraint { PrimaryKey { + #[serde(default)] + auto_increment: bool, columns: Vec, }, Unique { diff --git a/crates/vespertide-core/src/schema/foreign_key.rs b/crates/vespertide-core/src/schema/foreign_key.rs index 18e963f9..b645b575 100644 --- a/crates/vespertide-core/src/schema/foreign_key.rs +++ b/crates/vespertide-core/src/schema/foreign_key.rs @@ -11,3 +11,11 @@ pub struct ForeignKeyDef { pub on_delete: Option, pub on_update: Option, } + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case", untagged)] +pub enum ForeignKeySyntax { + /// table.column + String(String), + Object(ForeignKeyDef), +} diff --git a/crates/vespertide-core/src/schema/mod.rs b/crates/vespertide-core/src/schema/mod.rs index a6f918b7..b66813a1 100644 --- a/crates/vespertide-core/src/schema/mod.rs +++ b/crates/vespertide-core/src/schema/mod.rs @@ -2,11 +2,13 @@ pub mod column; pub mod constraint; pub mod foreign_key; pub mod index; +pub mod primary_key; pub mod names; pub mod reference; pub mod str_or_bool; pub mod table; +pub use primary_key::PrimaryKeyDef; pub use column::{ColumnDef, ColumnType, ComplexColumnType, SimpleColumnType}; pub use constraint::TableConstraint; pub use index::IndexDef; diff --git a/crates/vespertide-core/src/schema/primary_key.rs b/crates/vespertide-core/src/schema/primary_key.rs new file mode 100644 index 00000000..755302fd --- /dev/null +++ b/crates/vespertide-core/src/schema/primary_key.rs @@ -0,0 +1,19 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::schema::names::ColumnName; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub struct PrimaryKeyDef { + #[serde(default)] + pub auto_increment: bool, + pub columns: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case", untagged)] +pub enum PrimaryKeySyntax { + Bool(bool), + Object(PrimaryKeyDef), +} diff --git a/crates/vespertide-core/src/schema/table.rs b/crates/vespertide-core/src/schema/table.rs index 6556639f..480f55c7 100644 --- a/crates/vespertide-core/src/schema/table.rs +++ b/crates/vespertide-core/src/schema/table.rs @@ -4,8 +4,9 @@ use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use crate::schema::{ - StrOrBoolOrArray, column::ColumnDef, constraint::TableConstraint, index::IndexDef, - names::TableName, + StrOrBoolOrArray, column::ColumnDef, constraint::TableConstraint, + foreign_key::ForeignKeySyntax, index::IndexDef, names::TableName, + primary_key::PrimaryKeySyntax, }; #[derive(Debug, Clone, PartialEq, Eq)] @@ -14,6 +15,10 @@ pub enum TableValidationError { index_name: String, column_name: String, }, + InvalidForeignKeyFormat { + column_name: String, + value: String, + }, } impl std::fmt::Display for TableValidationError { @@ -29,6 +34,13 @@ impl std::fmt::Display for TableValidationError { index_name, column_name ) } + TableValidationError::InvalidForeignKeyFormat { column_name, value } => { + write!( + f, + "Invalid foreign key format '{}' on column '{}': expected 'table.column' format", + value, column_name + ) + } } } } @@ -56,13 +68,26 @@ impl TableDef { let mut constraints = self.constraints.clone(); let mut indexes = self.indexes.clone(); - // Collect columns with inline primary_key - let pk_columns: Vec = self - .columns - .iter() - .filter(|c| c.primary_key == Some(true)) - .map(|c| c.name.clone()) - .collect(); + // Collect columns with inline primary_key and check for auto_increment + let mut pk_columns: Vec = Vec::new(); + let mut pk_auto_increment = false; + + for col in &self.columns { + if let Some(ref pk) = col.primary_key { + match pk { + PrimaryKeySyntax::Bool(true) => { + pk_columns.push(col.name.clone()); + } + PrimaryKeySyntax::Bool(false) => {} + PrimaryKeySyntax::Object(pk_def) => { + pk_columns.push(col.name.clone()); + if pk_def.auto_increment { + pk_auto_increment = true; + } + } + } + } + } // Add primary key constraint if any columns have inline pk and no existing pk constraint if !pk_columns.is_empty() { @@ -71,6 +96,7 @@ impl TableDef { .any(|c| matches!(c, TableConstraint::PrimaryKey { .. })); if !has_pk { constraints.push(TableConstraint::PrimaryKey { + auto_increment: pk_auto_increment, columns: pk_columns, }); } @@ -158,7 +184,28 @@ impl TableDef { } // Handle inline foreign_key - if let Some(ref fk) = col.foreign_key { + if let Some(ref fk_syntax) = col.foreign_key { + // Convert ForeignKeySyntax to ForeignKeyDef + let (ref_table, ref_columns, on_delete, on_update) = match fk_syntax { + ForeignKeySyntax::String(s) => { + // Parse "table.column" format + let parts: Vec<&str> = s.split('.').collect(); + if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() { + return Err(TableValidationError::InvalidForeignKeyFormat { + column_name: col.name.clone(), + value: s.clone(), + }); + } + (parts[0].to_string(), vec![parts[1].to_string()], None, None) + } + ForeignKeySyntax::Object(fk_def) => ( + fk_def.ref_table.clone(), + fk_def.ref_columns.clone(), + fk_def.on_delete.clone(), + fk_def.on_update.clone(), + ), + }; + // Check if this foreign key already exists let exists = constraints.iter().any(|c| { if let TableConstraint::ForeignKey { columns, .. } = c { @@ -172,10 +219,10 @@ impl TableDef { constraints.push(TableConstraint::ForeignKey { name: None, columns: vec![col.name.clone()], - ref_table: fk.ref_table.clone(), - ref_columns: fk.ref_columns.clone(), - on_delete: fk.on_delete.clone(), - on_update: fk.on_update.clone(), + ref_table, + ref_columns, + on_delete, + on_update, }); } } @@ -326,7 +373,8 @@ impl TableDef { mod tests { use super::*; use crate::schema::column::{ColumnType, SimpleColumnType}; - use crate::schema::foreign_key::ForeignKeyDef; + use crate::schema::foreign_key::{ForeignKeyDef, ForeignKeySyntax}; + use crate::schema::primary_key::PrimaryKeySyntax; use crate::schema::reference::ReferenceAction; use crate::schema::str_or_bool::StrOrBoolOrArray; @@ -347,7 +395,7 @@ mod tests { #[test] fn normalize_inline_primary_key() { let mut id_col = col("id", ColumnType::Simple(SimpleColumnType::Integer)); - id_col.primary_key = Some(true); + id_col.primary_key = Some(PrimaryKeySyntax::Bool(true)); let table = TableDef { name: "users".into(), @@ -363,17 +411,17 @@ mod tests { assert_eq!(normalized.constraints.len(), 1); assert!(matches!( &normalized.constraints[0], - TableConstraint::PrimaryKey { columns } if columns == &["id".to_string()] + TableConstraint::PrimaryKey { columns, .. } if columns == &["id".to_string()] )); } #[test] fn normalize_multiple_inline_primary_keys() { let mut id_col = col("id", ColumnType::Simple(SimpleColumnType::Integer)); - id_col.primary_key = Some(true); + id_col.primary_key = Some(PrimaryKeySyntax::Bool(true)); let mut tenant_col = col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)); - tenant_col.primary_key = Some(true); + tenant_col.primary_key = Some(PrimaryKeySyntax::Bool(true)); let table = TableDef { name: "users".into(), @@ -386,19 +434,20 @@ mod tests { assert_eq!(normalized.constraints.len(), 1); assert!(matches!( &normalized.constraints[0], - TableConstraint::PrimaryKey { columns } if columns == &["id".to_string(), "tenant_id".to_string()] + TableConstraint::PrimaryKey { columns, .. } if columns == &["id".to_string(), "tenant_id".to_string()] )); } #[test] fn normalize_does_not_duplicate_existing_pk() { let mut id_col = col("id", ColumnType::Simple(SimpleColumnType::Integer)); - id_col.primary_key = Some(true); + id_col.primary_key = Some(PrimaryKeySyntax::Bool(true)); let table = TableDef { name: "users".into(), columns: vec![id_col], constraints: vec![TableConstraint::PrimaryKey { + auto_increment: false, columns: vec!["id".into()], }], indexes: vec![], @@ -500,12 +549,12 @@ mod tests { #[test] fn normalize_inline_foreign_key() { let mut user_id_col = col("user_id", ColumnType::Simple(SimpleColumnType::Integer)); - user_id_col.foreign_key = Some(ForeignKeyDef { + user_id_col.foreign_key = Some(ForeignKeySyntax::Object(ForeignKeyDef { ref_table: "users".into(), ref_columns: vec!["id".into()], on_delete: Some(ReferenceAction::Cascade), on_update: None, - }); + })); let table = TableDef { name: "posts".into(), @@ -537,7 +586,7 @@ mod tests { #[test] fn normalize_all_inline_constraints() { let mut id_col = col("id", ColumnType::Simple(SimpleColumnType::Integer)); - id_col.primary_key = Some(true); + id_col.primary_key = Some(PrimaryKeySyntax::Bool(true)); let mut email_col = col("email", ColumnType::Simple(SimpleColumnType::Text)); email_col.unique = Some(StrOrBoolOrArray::Bool(true)); @@ -546,12 +595,12 @@ mod tests { name_col.index = Some(StrOrBoolOrArray::Bool(true)); let mut user_id_col = col("org_id", ColumnType::Simple(SimpleColumnType::Integer)); - user_id_col.foreign_key = Some(ForeignKeyDef { + user_id_col.foreign_key = Some(ForeignKeySyntax::Object(ForeignKeyDef { ref_table: "orgs".into(), ref_columns: vec!["id".into()], on_delete: None, on_update: None, - }); + })); let table = TableDef { name: "users".into(), @@ -868,12 +917,12 @@ mod tests { fn normalize_inline_foreign_key_already_exists() { // Test that existing foreign key constraint is not duplicated let mut user_id_col = col("user_id", ColumnType::Simple(SimpleColumnType::Integer)); - user_id_col.foreign_key = Some(ForeignKeyDef { + user_id_col.foreign_key = Some(ForeignKeySyntax::Object(ForeignKeyDef { ref_table: "users".into(), ref_columns: vec!["id".into()], on_delete: None, on_update: None, - }); + })); let table = TableDef { name: "posts".into(), @@ -1031,6 +1080,7 @@ mod tests { constraints: vec![ // Add a PrimaryKey constraint (different type) - should not match TableConstraint::PrimaryKey { + auto_increment: false, columns: vec!["id".into()], }, ], @@ -1057,6 +1107,7 @@ mod tests { constraints: vec![ // Add a PrimaryKey constraint (different type) - should not match TableConstraint::PrimaryKey { + auto_increment: false, columns: vec!["id".into()], }, ], @@ -1104,4 +1155,197 @@ mod tests { panic!("Expected DuplicateIndexColumn error"); } } + + #[test] + fn normalize_inline_foreign_key_string_syntax() { + // Test ForeignKeySyntax::String with valid "table.column" format + let mut user_id_col = col("user_id", ColumnType::Simple(SimpleColumnType::Integer)); + user_id_col.foreign_key = Some(ForeignKeySyntax::String("users.id".into())); + + let table = TableDef { + name: "posts".into(), + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + user_id_col, + ], + constraints: vec![], + indexes: vec![], + }; + + let normalized = table.normalize().unwrap(); + assert_eq!(normalized.constraints.len(), 1); + assert!(matches!( + &normalized.constraints[0], + TableConstraint::ForeignKey { + name: None, + columns, + ref_table, + ref_columns, + on_delete: None, + on_update: None, + } if columns == &["user_id".to_string()] + && ref_table == "users" + && ref_columns == &["id".to_string()] + )); + } + + #[test] + fn normalize_inline_foreign_key_invalid_format_no_dot() { + // Test ForeignKeySyntax::String with invalid format (no dot) + let mut user_id_col = col("user_id", ColumnType::Simple(SimpleColumnType::Integer)); + user_id_col.foreign_key = Some(ForeignKeySyntax::String("usersid".into())); + + let table = TableDef { + name: "posts".into(), + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + user_id_col, + ], + constraints: vec![], + indexes: vec![], + }; + + let result = table.normalize(); + assert!(result.is_err()); + if let Err(TableValidationError::InvalidForeignKeyFormat { + column_name, + value, + }) = result + { + assert_eq!(column_name, "user_id"); + assert_eq!(value, "usersid"); + } else { + panic!("Expected InvalidForeignKeyFormat error"); + } + } + + #[test] + fn normalize_inline_foreign_key_invalid_format_empty_table() { + // Test ForeignKeySyntax::String with empty table part + let mut user_id_col = col("user_id", ColumnType::Simple(SimpleColumnType::Integer)); + user_id_col.foreign_key = Some(ForeignKeySyntax::String(".id".into())); + + let table = TableDef { + name: "posts".into(), + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + user_id_col, + ], + constraints: vec![], + indexes: vec![], + }; + + let result = table.normalize(); + assert!(result.is_err()); + if let Err(TableValidationError::InvalidForeignKeyFormat { + column_name, + value, + }) = result + { + assert_eq!(column_name, "user_id"); + assert_eq!(value, ".id"); + } else { + panic!("Expected InvalidForeignKeyFormat error"); + } + } + + #[test] + fn normalize_inline_foreign_key_invalid_format_empty_column() { + // Test ForeignKeySyntax::String with empty column part + let mut user_id_col = col("user_id", ColumnType::Simple(SimpleColumnType::Integer)); + user_id_col.foreign_key = Some(ForeignKeySyntax::String("users.".into())); + + let table = TableDef { + name: "posts".into(), + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + user_id_col, + ], + constraints: vec![], + indexes: vec![], + }; + + let result = table.normalize(); + assert!(result.is_err()); + if let Err(TableValidationError::InvalidForeignKeyFormat { + column_name, + value, + }) = result + { + assert_eq!(column_name, "user_id"); + assert_eq!(value, "users."); + } else { + panic!("Expected InvalidForeignKeyFormat error"); + } + } + + #[test] + fn normalize_inline_foreign_key_invalid_format_too_many_parts() { + // Test ForeignKeySyntax::String with too many parts + let mut user_id_col = col("user_id", ColumnType::Simple(SimpleColumnType::Integer)); + user_id_col.foreign_key = Some(ForeignKeySyntax::String("schema.users.id".into())); + + let table = TableDef { + name: "posts".into(), + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + user_id_col, + ], + constraints: vec![], + indexes: vec![], + }; + + let result = table.normalize(); + assert!(result.is_err()); + if let Err(TableValidationError::InvalidForeignKeyFormat { + column_name, + value, + }) = result + { + assert_eq!(column_name, "user_id"); + assert_eq!(value, "schema.users.id"); + } else { + panic!("Expected InvalidForeignKeyFormat error"); + } + } + + #[test] + fn normalize_inline_primary_key_with_auto_increment() { + use crate::schema::primary_key::PrimaryKeyDef; + + let mut id_col = col("id", ColumnType::Simple(SimpleColumnType::Integer)); + id_col.primary_key = Some(PrimaryKeySyntax::Object(PrimaryKeyDef { + auto_increment: true, + columns: vec![], // columns is ignored for inline definition + })); + + let table = TableDef { + name: "users".into(), + columns: vec![ + id_col, + col("name", ColumnType::Simple(SimpleColumnType::Text)), + ], + constraints: vec![], + indexes: vec![], + }; + + let normalized = table.normalize().unwrap(); + assert_eq!(normalized.constraints.len(), 1); + assert!(matches!( + &normalized.constraints[0], + TableConstraint::PrimaryKey { auto_increment: true, columns } if columns == &["id".to_string()] + )); + } + + #[test] + fn test_invalid_foreign_key_format_error_display() { + let error = TableValidationError::InvalidForeignKeyFormat { + column_name: "user_id".into(), + value: "invalid".into(), + }; + let error_msg = format!("{}", error); + assert!(error_msg.contains("user_id")); + assert!(error_msg.contains("invalid")); + assert!(error_msg.contains("table.column")); + } } diff --git a/crates/vespertide-exporter/src/seaorm/mod.rs b/crates/vespertide-exporter/src/seaorm/mod.rs index 3541ac46..9607d309 100644 --- a/crates/vespertide-exporter/src/seaorm/mod.rs +++ b/crates/vespertide-exporter/src/seaorm/mod.rs @@ -69,25 +69,29 @@ fn render_column( } fn primary_key_columns(table: &TableDef) -> HashSet { + use vespertide_core::schema::primary_key::PrimaryKeySyntax; let mut keys = HashSet::new(); - + // First, check table-level constraints for constraint in &table.constraints { - if let TableConstraint::PrimaryKey { columns } = constraint { + if let TableConstraint::PrimaryKey { columns, .. } = constraint { for col in columns { keys.insert(col.clone()); } } } - + // Then, check inline primary_key on columns // This handles cases where primary_key is defined inline but not yet normalized for column in &table.columns { - if column.primary_key == Some(true) { - keys.insert(column.name.clone()); + match &column.primary_key { + Some(PrimaryKeySyntax::Bool(true)) | Some(PrimaryKeySyntax::Object(_)) => { + keys.insert(column.name.clone()); + } + _ => {} } } - + keys } @@ -378,7 +382,8 @@ mod tests { use super::*; use insta::{assert_snapshot, with_settings}; use rstest::rstest; - use vespertide_core::{ColumnType, SimpleColumnType, ComplexColumnType}; + use vespertide_core::{ColumnType, SimpleColumnType}; + use vespertide_core::schema::primary_key::PrimaryKeySyntax; #[rstest] #[case("basic_single_pk", TableDef { @@ -387,7 +392,7 @@ mod tests { ColumnDef { name: "id".into(), r#type: ColumnType::Simple(SimpleColumnType::Integer), nullable: false, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, ColumnDef { name: "display_name".into(), r#type: ColumnType::Simple(SimpleColumnType::Text), nullable: true, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, ], - constraints: vec![TableConstraint::PrimaryKey { columns: vec!["id".into()] }], + constraints: vec![TableConstraint::PrimaryKey { auto_increment: false, columns: vec!["id".into()] }], indexes: vec![], })] #[case("composite_pk", TableDef { @@ -396,7 +401,7 @@ mod tests { ColumnDef { name: "id".into(), r#type: ColumnType::Simple(SimpleColumnType::Integer), nullable: false, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, ColumnDef { name: "tenant_id".into(), r#type: ColumnType::Simple(SimpleColumnType::BigInt), nullable: false, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, ], - constraints: vec![TableConstraint::PrimaryKey { columns: vec!["id".into(), "tenant_id".into()] }], + constraints: vec![TableConstraint::PrimaryKey { auto_increment: false, columns: vec!["id".into(), "tenant_id".into()] }], indexes: vec![], })] #[case("fk_single", TableDef { @@ -407,7 +412,7 @@ mod tests { ColumnDef { name: "title".into(), r#type: ColumnType::Simple(SimpleColumnType::Text), nullable: true, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, ], constraints: vec![ - TableConstraint::PrimaryKey { columns: vec!["id".into()] }, + TableConstraint::PrimaryKey { auto_increment: false, columns: vec!["id".into()] }, TableConstraint::ForeignKey { name: None, columns: vec!["user_id".into()], @@ -427,7 +432,7 @@ mod tests { ColumnDef { name: "customer_tenant_id".into(), r#type: ColumnType::Simple(SimpleColumnType::Integer), nullable: false, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, ], constraints: vec![ - TableConstraint::PrimaryKey { columns: vec!["id".into()] }, + TableConstraint::PrimaryKey { auto_increment: false, columns: vec!["id".into()] }, TableConstraint::ForeignKey { name: None, columns: vec!["customer_id".into(), "customer_tenant_id".into()], @@ -442,7 +447,7 @@ mod tests { #[case("inline_pk", TableDef { name: "users".into(), columns: vec![ - ColumnDef { name: "id".into(), r#type: ColumnType::Simple(SimpleColumnType::Uuid), nullable: false, default: Some("gen_random_uuid()".into()), comment: None, primary_key: Some(true), unique: None, index: None, foreign_key: None }, + ColumnDef { name: "id".into(), r#type: ColumnType::Simple(SimpleColumnType::Uuid), nullable: false, default: Some("gen_random_uuid()".into()), comment: None, primary_key: Some(PrimaryKeySyntax::Bool(true)), unique: None, index: None, foreign_key: None }, ColumnDef { name: "email".into(), r#type: ColumnType::Simple(SimpleColumnType::Text), nullable: false, default: None, comment: None, primary_key: None, unique: Some(vespertide_core::StrOrBoolOrArray::Bool(true)), index: None, foreign_key: None }, ], constraints: vec![], diff --git a/crates/vespertide-planner/src/apply.rs b/crates/vespertide-planner/src/apply.rs index 769a6bac..9b22d574 100644 --- a/crates/vespertide-planner/src/apply.rs +++ b/crates/vespertide-planner/src/apply.rs @@ -155,7 +155,7 @@ pub fn apply_action( fn rename_column_in_constraints(constraints: &mut [TableConstraint], from: &str, to: &str) { for constraint in constraints { match constraint { - TableConstraint::PrimaryKey { columns } => { + TableConstraint::PrimaryKey { columns, .. } => { for c in columns.iter_mut() { if c == from { *c = to.to_string(); @@ -202,7 +202,7 @@ 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 { columns } => { + TableConstraint::PrimaryKey { columns, .. } => { columns.retain(|c| c != column); !columns.is_empty() } @@ -397,7 +397,7 @@ mod tests { col("ref_id", ColumnType::Simple(SimpleColumnType::Integer)) ], vec![ - TableConstraint::PrimaryKey{columns: vec!["id".into()] }, + TableConstraint::PrimaryKey{ auto_increment: false, columns: vec!["id".into()] }, TableConstraint::Unique { name: Some("u_old".into()), columns: vec!["old".into()], @@ -441,7 +441,7 @@ mod tests { col("new_col", ColumnType::Simple(SimpleColumnType::Boolean)) ], vec![ - TableConstraint::PrimaryKey{columns: vec!["id".into()] }, + TableConstraint::PrimaryKey{ auto_increment: false, columns: vec!["id".into()] }, TableConstraint::Unique { name: Some("u_old".into()), columns: vec!["old".into()], @@ -470,7 +470,7 @@ mod tests { "users", vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), col("old", ColumnType::Simple(SimpleColumnType::Text))], vec![ - TableConstraint::PrimaryKey{columns: vec!["id".into()] }, + TableConstraint::PrimaryKey{ auto_increment: false, columns: vec!["id".into()] }, TableConstraint::Unique { name: Some("u_old".into()), columns: vec!["old".into()], @@ -498,7 +498,7 @@ mod tests { "users", vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], vec![ - TableConstraint::PrimaryKey{columns: vec!["id".into()] }, + TableConstraint::PrimaryKey{ auto_increment: false, columns: vec!["id".into()] }, TableConstraint::Check { name: None, expr: "old IS NOT NULL".into(), @@ -559,6 +559,7 @@ mod tests { actions: vec![MigrationAction::AddConstraint { table: "users".into(), constraint: TableConstraint::PrimaryKey { + auto_increment: false, columns: vec!["id".into()], }, }], @@ -566,6 +567,7 @@ mod tests { "users", vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], vec![TableConstraint::PrimaryKey { + auto_increment: false, columns: vec!["id".into()], }], vec![], @@ -576,6 +578,7 @@ mod tests { "users", vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], vec![TableConstraint::PrimaryKey { + auto_increment: false, columns: vec!["id".into()], }], vec![], @@ -583,6 +586,7 @@ mod tests { actions: vec![MigrationAction::RemoveConstraint { table: "users".into(), constraint: TableConstraint::PrimaryKey { + auto_increment: false, columns: vec!["id".into()], }, }], @@ -606,7 +610,7 @@ mod tests { #[rstest] #[case( vec![ - TableConstraint::PrimaryKey{columns: vec!["id".into(), "old".into()] }, + TableConstraint::PrimaryKey{ auto_increment: false, columns: vec!["id".into(), "old".into()] }, TableConstraint::Unique { name: None, columns: vec!["old".into(), "keep".into()], @@ -628,7 +632,7 @@ mod tests { "old", "new", vec![ - TableConstraint::PrimaryKey{columns: vec!["id".into(), "new".into()] }, + TableConstraint::PrimaryKey{ auto_increment: false, columns: vec!["id".into(), "new".into()] }, TableConstraint::Unique { name: None, columns: vec!["new".into(), "keep".into()], @@ -650,7 +654,7 @@ mod tests { )] #[case( vec![ - TableConstraint::PrimaryKey{columns: vec!["id".into()] }, + TableConstraint::PrimaryKey{ auto_increment: false, columns: vec!["id".into()] }, TableConstraint::Check { name: None, expr: "id > 0".into(), @@ -660,7 +664,7 @@ mod tests { "missing", "new", vec![ - TableConstraint::PrimaryKey{columns: vec!["id".into()] }, + TableConstraint::PrimaryKey{ auto_increment: false, columns: vec!["id".into()] }, TableConstraint::Check { name: None, expr: "id > 0".into(), diff --git a/crates/vespertide-planner/src/diff.rs b/crates/vespertide-planner/src/diff.rs index 0c916040..c9e70eaf 100644 --- a/crates/vespertide-planner/src/diff.rs +++ b/crates/vespertide-planner/src/diff.rs @@ -96,16 +96,33 @@ pub fn diff_schemas(from: &[TableDef], to: &[TableDef]) -> Result { + let parts: Vec<&str> = s.split('.').collect(); + if parts.len() == 2 { + (parts[0].to_string(), vec![parts[1].to_string()], None, None) + } else { + continue; // Skip invalid foreign key format + } + } + ForeignKeySyntax::Object(fk) => ( + fk.ref_table.clone(), + fk.ref_columns.clone(), + fk.on_delete.clone(), + fk.on_update.clone(), + ), + }; actions.push(MigrationAction::AddConstraint { table: (*name).to_string(), constraint: TableConstraint::ForeignKey { name: None, columns: vec![def.name.clone()], - ref_table: fk.ref_table.clone(), - ref_columns: fk.ref_columns.clone(), - on_delete: fk.on_delete.clone(), - on_update: fk.on_update.clone(), + ref_table, + ref_columns, + on_delete, + on_update, }, }); } @@ -396,6 +413,8 @@ mod tests { use super::*; use vespertide_core::schema::foreign_key::ForeignKeyDef; use vespertide_core::{StrOrBoolOrArray, TableConstraint}; + use vespertide_core::schema::primary_key::PrimaryKeySyntax; + use vespertide_core::schema::foreign_key::ForeignKeySyntax; fn col_with_pk(name: &str, ty: ColumnType) -> ColumnDef { ColumnDef { @@ -404,7 +423,7 @@ mod tests { nullable: false, default: None, comment: None, - primary_key: Some(true), + primary_key: Some(PrimaryKeySyntax::Bool(true)), unique: None, index: None, foreign_key: None, @@ -449,12 +468,12 @@ mod tests { primary_key: None, unique: None, index: None, - foreign_key: Some(ForeignKeyDef { + foreign_key: Some(ForeignKeySyntax::Object(ForeignKeyDef { ref_table: ref_table.to_string(), ref_columns: vec![ref_col.to_string()], on_delete: None, on_update: None, - }), + })), } } @@ -479,7 +498,7 @@ mod tests { assert_eq!(constraints.len(), 1); assert!(matches!( &constraints[0], - TableConstraint::PrimaryKey { columns } if columns == &["id".to_string()] + TableConstraint::PrimaryKey { columns, .. } if columns == &["id".to_string()] )); } else { panic!("Expected CreateTable action"); @@ -618,7 +637,7 @@ mod tests { #[test] fn create_table_with_all_inline_constraints() { let mut id_col = col("id", ColumnType::Simple(SimpleColumnType::Integer)); - id_col.primary_key = Some(true); + id_col.primary_key = Some(PrimaryKeySyntax::Bool(true)); id_col.nullable = false; let mut email_col = col("email", ColumnType::Simple(SimpleColumnType::Text)); @@ -628,12 +647,12 @@ mod tests { name_col.index = Some(StrOrBoolOrArray::Bool(true)); let mut org_id_col = col("org_id", ColumnType::Simple(SimpleColumnType::Integer)); - org_id_col.foreign_key = Some(ForeignKeyDef { + org_id_col.foreign_key = Some(ForeignKeySyntax::Object(ForeignKeyDef { ref_table: "orgs".into(), ref_columns: vec!["id".into()], on_delete: None, on_update: None, - }); + })); let plan = diff_schemas( &[], diff --git a/crates/vespertide-planner/src/schema.rs b/crates/vespertide-planner/src/schema.rs index c167675a..f02082d6 100644 --- a/crates/vespertide-planner/src/schema.rs +++ b/crates/vespertide-planner/src/schema.rs @@ -59,13 +59,13 @@ mod tests { actions: vec![MigrationAction::CreateTable { table: "users".into(), columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], - constraints: vec![TableConstraint::PrimaryKey{columns: vec!["id".into()] }], + constraints: vec![TableConstraint::PrimaryKey{ auto_increment: false, columns: vec!["id".into()] }], }], }], table( "users", vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], - vec![TableConstraint::PrimaryKey{columns: vec!["id".into()] }], + vec![TableConstraint::PrimaryKey{ auto_increment: false, columns: vec!["id".into()] }], vec![], ) )] @@ -78,7 +78,7 @@ mod tests { actions: vec![MigrationAction::CreateTable { table: "users".into(), columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], - constraints: vec![TableConstraint::PrimaryKey{columns: vec!["id".into()] }], + constraints: vec![TableConstraint::PrimaryKey{ auto_increment: false, columns: vec!["id".into()] }], }], }, MigrationPlan { @@ -98,7 +98,7 @@ mod tests { col("id", ColumnType::Simple(SimpleColumnType::Integer)), col("name", ColumnType::Simple(SimpleColumnType::Text)), ], - vec![TableConstraint::PrimaryKey{columns: vec!["id".into()] }], + vec![TableConstraint::PrimaryKey{ auto_increment: false, columns: vec!["id".into()] }], vec![], ) )] @@ -111,7 +111,7 @@ mod tests { actions: vec![MigrationAction::CreateTable { table: "users".into(), columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], - constraints: vec![TableConstraint::PrimaryKey{columns: vec!["id".into()] }], + constraints: vec![TableConstraint::PrimaryKey{ auto_increment: false, columns: vec!["id".into()] }], }], }, MigrationPlan { @@ -144,7 +144,7 @@ mod tests { col("id", ColumnType::Simple(SimpleColumnType::Integer)), col("name", ColumnType::Simple(SimpleColumnType::Text)), ], - vec![TableConstraint::PrimaryKey{columns: vec!["id".into()] }], + vec![TableConstraint::PrimaryKey{ auto_increment: false, 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 48b87dfa..72c81eec 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(), @@ -282,7 +282,7 @@ mod tests { vec![table( "users", vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], - vec![TableConstraint::PrimaryKey{columns: vec!["id".into()] }], + vec![TableConstraint::PrimaryKey{ auto_increment: false, columns: vec!["id".into()] }], vec![], )], None @@ -353,7 +353,7 @@ mod tests { table( "posts", vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], - vec![TableConstraint::PrimaryKey{columns: vec!["id".into()] }], + vec![TableConstraint::PrimaryKey{ auto_increment: false, columns: vec!["id".into()] }], vec![], ), table( @@ -389,7 +389,7 @@ mod tests { vec![table( "users", vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], - vec![TableConstraint::PrimaryKey{columns: vec!["nonexistent".into()] }], + vec![TableConstraint::PrimaryKey{ auto_increment: false, columns: vec!["nonexistent".into()] }], vec![], )], Some(is_constraint_column as fn(&PlannerError) -> bool) @@ -422,7 +422,7 @@ mod tests { vec![table( "users", vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], - vec![TableConstraint::PrimaryKey{columns: vec![] }], + vec![TableConstraint::PrimaryKey{ auto_increment: false, columns: vec![] }], vec![], )], Some(is_empty_columns as fn(&PlannerError) -> bool) diff --git a/crates/vespertide-query/src/sql.rs b/crates/vespertide-query/src/sql.rs index 2093156a..066ba877 100644 --- a/crates/vespertide-query/src/sql.rs +++ b/crates/vespertide-query/src/sql.rs @@ -285,7 +285,7 @@ fn table_constraint_sql( binds: &mut Vec, ) -> Result { Ok(match constraint { - TableConstraint::PrimaryKey { columns } => { + TableConstraint::PrimaryKey { columns, .. } => { let placeholders = columns .iter() .map(|c| bind(binds, c)) @@ -441,7 +441,7 @@ mod tests { col("id", ColumnType::Simple(SimpleColumnType::Integer)), col("name", ColumnType::Simple(SimpleColumnType::Text)), ], - constraints: vec![TableConstraint::PrimaryKey{columns: vec!["id".into()] }], + constraints: vec![TableConstraint::PrimaryKey{ auto_increment: false, columns: vec!["id".into()] }], }, vec![( "CREATE TABLE $1 ($2 INTEGER, $3 TEXT, PRIMARY KEY ($4));".to_string(), @@ -630,6 +630,7 @@ mod tests { MigrationAction::AddConstraint { table: "users".into(), constraint: TableConstraint::PrimaryKey { + auto_increment: false, columns: vec!["id".into()], }, }, @@ -655,6 +656,7 @@ mod tests { MigrationAction::RemoveConstraint { table: "users".into(), constraint: TableConstraint::PrimaryKey { + auto_increment: false, columns: vec!["id".into()], }, }, @@ -760,7 +762,7 @@ mod tests { #[case::simple( "users", vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), col("name", ColumnType::Simple(SimpleColumnType::Text))], - vec![TableConstraint::PrimaryKey{columns: vec!["id".into()] }], + vec![TableConstraint::PrimaryKey{ auto_increment: false, 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()], @@ -770,7 +772,7 @@ mod tests { "users", vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), col("email", ColumnType::Simple(SimpleColumnType::Text))], vec![ - TableConstraint::PrimaryKey{columns: vec!["id".into()] }, + TableConstraint::PrimaryKey{ auto_increment: false, columns: vec!["id".into()] }, TableConstraint::Unique { name: Some("unique_email".into()), columns: vec!["email".into()], @@ -848,11 +850,11 @@ mod tests { #[rstest] #[case::primary_key_single( - TableConstraint::PrimaryKey{columns: vec!["id".into()] }, + TableConstraint::PrimaryKey{ auto_increment: false, columns: vec!["id".into()] }, ("PRIMARY KEY ($1)".to_string(), vec!["id".to_string()]) )] #[case::primary_key_multiple( - TableConstraint::PrimaryKey{columns: vec!["id".into(), "version".into()] }, + TableConstraint::PrimaryKey{ auto_increment: false, columns: vec!["id".into(), "version".into()] }, ("PRIMARY KEY ($1, $2)".to_string(), vec!["id".to_string(), "version".to_string()]) )] #[case::unique_without_name( From 416e82c4ec620c548a303b427c2a75df15ccdd2f Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 15 Dec 2025 00:20:30 +0900 Subject: [PATCH 05/11] Implement checking fk issue, add auto increment --- .../changepack_log_PFe8JgOcrIeEzleBAGPSC.json | 1 + .../changepack_log_PtMoTb6mIuZ84njX_hTSY.json | 1 + schemas/migration.schema.json | 55 +++++++++++++++++-- schemas/model.schema.json | 55 +++++++++++++++++-- 4 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 .changepacks/changepack_log_PFe8JgOcrIeEzleBAGPSC.json create mode 100644 .changepacks/changepack_log_PtMoTb6mIuZ84njX_hTSY.json diff --git a/.changepacks/changepack_log_PFe8JgOcrIeEzleBAGPSC.json b/.changepacks/changepack_log_PFe8JgOcrIeEzleBAGPSC.json new file mode 100644 index 00000000..150e0bcc --- /dev/null +++ b/.changepacks/changepack_log_PFe8JgOcrIeEzleBAGPSC.json @@ -0,0 +1 @@ +{"changes":{"crates/vespertide-exporter/Cargo.toml":"Patch","crates/vespertide-config/Cargo.toml":"Patch","crates/vespertide-macro/Cargo.toml":"Patch","crates/vespertide-query/Cargo.toml":"Patch","crates/vespertide-planner/Cargo.toml":"Patch","crates/vespertide-core/Cargo.toml":"Patch","crates/vespertide-cli/Cargo.toml":"Patch","crates/vespertide/Cargo.toml":"Patch"},"note":"Add auto increment","date":"2025-12-14T15:20:16.940927400Z"} \ No newline at end of file diff --git a/.changepacks/changepack_log_PtMoTb6mIuZ84njX_hTSY.json b/.changepacks/changepack_log_PtMoTb6mIuZ84njX_hTSY.json new file mode 100644 index 00000000..ea607e4e --- /dev/null +++ b/.changepacks/changepack_log_PtMoTb6mIuZ84njX_hTSY.json @@ -0,0 +1 @@ +{"changes":{"crates/vespertide-exporter/Cargo.toml":"Patch","crates/vespertide/Cargo.toml":"Patch","crates/vespertide-cli/Cargo.toml":"Patch","crates/vespertide-planner/Cargo.toml":"Patch","crates/vespertide-query/Cargo.toml":"Patch","crates/vespertide-core/Cargo.toml":"Patch","crates/vespertide-config/Cargo.toml":"Patch","crates/vespertide-macro/Cargo.toml":"Patch"},"note":"Add fk validation","date":"2025-12-14T15:20:26.765917400Z"} \ No newline at end of file diff --git a/schemas/migration.schema.json b/schemas/migration.schema.json index efd3baf7..e26f5915 100644 --- a/schemas/migration.schema.json +++ b/schemas/migration.schema.json @@ -51,7 +51,7 @@ "foreign_key": { "anyOf": [ { - "$ref": "#/$defs/ForeignKeyDef" + "$ref": "#/$defs/ForeignKeySyntax" }, { "type": "null" @@ -75,9 +75,13 @@ "type": "boolean" }, "primary_key": { - "type": [ - "boolean", - "null" + "anyOf": [ + { + "$ref": "#/$defs/PrimaryKeySyntax" + }, + { + "type": "null" + } ] }, "type": { @@ -228,6 +232,17 @@ "ref_columns" ] }, + "ForeignKeySyntax": { + "anyOf": [ + { + "description": "table.column", + "type": "string" + }, + { + "$ref": "#/$defs/ForeignKeyDef" + } + ] + }, "IndexDef": { "type": "object", "properties": { @@ -511,6 +526,34 @@ } ] }, + "PrimaryKeyDef": { + "type": "object", + "properties": { + "auto_increment": { + "type": "boolean", + "default": false + }, + "columns": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "columns" + ] + }, + "PrimaryKeySyntax": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/$defs/PrimaryKeyDef" + } + ] + }, "ReferenceAction": { "type": "string", "enum": [ @@ -567,6 +610,10 @@ { "type": "object", "properties": { + "auto_increment": { + "type": "boolean", + "default": false + }, "columns": { "type": "array", "items": { diff --git a/schemas/model.schema.json b/schemas/model.schema.json index 3a8ba25a..ddf8a3d3 100644 --- a/schemas/model.schema.json +++ b/schemas/model.schema.json @@ -50,7 +50,7 @@ "foreign_key": { "anyOf": [ { - "$ref": "#/$defs/ForeignKeyDef" + "$ref": "#/$defs/ForeignKeySyntax" }, { "type": "null" @@ -74,9 +74,13 @@ "type": "boolean" }, "primary_key": { - "type": [ - "boolean", - "null" + "anyOf": [ + { + "$ref": "#/$defs/PrimaryKeySyntax" + }, + { + "type": "null" + } ] }, "type": { @@ -227,6 +231,17 @@ "ref_columns" ] }, + "ForeignKeySyntax": { + "anyOf": [ + { + "description": "table.column", + "type": "string" + }, + { + "$ref": "#/$defs/ForeignKeyDef" + } + ] + }, "IndexDef": { "type": "object", "properties": { @@ -249,6 +264,34 @@ "unique" ] }, + "PrimaryKeyDef": { + "type": "object", + "properties": { + "auto_increment": { + "type": "boolean", + "default": false + }, + "columns": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "columns" + ] + }, + "PrimaryKeySyntax": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/$defs/PrimaryKeyDef" + } + ] + }, "ReferenceAction": { "type": "string", "enum": [ @@ -305,6 +348,10 @@ { "type": "object", "properties": { + "auto_increment": { + "type": "boolean", + "default": false + }, "columns": { "type": "array", "items": { From 93fcb14027bf790327695fee843206d2f45b5f11 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 15 Dec 2025 00:39:36 +0900 Subject: [PATCH 06/11] Add testcase --- Cargo.lock | 9 +- crates/vespertide-cli/src/commands/export.rs | 31 ++++- crates/vespertide-cli/src/utils.rs | 39 +++++- crates/vespertide-core/Cargo.toml | 3 + crates/vespertide-core/src/schema/column.rs | 131 +++++++++++++++++++ crates/vespertide-planner/src/diff.rs | 35 +---- examples/app/Cargo.toml | 2 +- 7 files changed, 211 insertions(+), 39 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 892a40af..0f6a7091 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1900,9 +1900,9 @@ dependencies = [ [[package]] name = "sea-orm" -version = "2.0.0-rc.20" +version = "2.0.0-rc.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "880cc0a64f1ee0320d7d6cd2b9290c27e89750669cfc5b6afdce1654755a2983" +checksum = "83b0bd6374d233e1553becb8786e22665d958641a1d72d5fdb52b9c07d2ce8d8" dependencies = [ "async-stream", "async-trait", @@ -1932,9 +1932,9 @@ dependencies = [ [[package]] name = "sea-orm-macros" -version = "2.0.0-rc.20" +version = "2.0.0-rc.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8aae91cb86cf389da96119df71967ffb1b3caf1687303def77336f8dc9d33e78" +checksum = "c295fd7665874275dd6926efe45efdecd0e54b98b5c177f95970fe4af6b213c9" dependencies = [ "heck 0.5.0", "pluralizer", @@ -2870,6 +2870,7 @@ dependencies = [ name = "vespertide-core" version = "0.1.6" dependencies = [ + "rstest", "schemars", "serde", "thiserror", diff --git a/crates/vespertide-cli/src/commands/export.rs b/crates/vespertide-cli/src/commands/export.rs index 162bbfe5..e9034c72 100644 --- a/crates/vespertide-cli/src/commands/export.rs +++ b/crates/vespertide-cli/src/commands/export.rs @@ -442,9 +442,38 @@ mod tests { let rel_path = Path::new("user copy.json"); let out = build_output_path(root, rel_path, Orm::SeaOrm); assert_eq!(out, Path::new("src/models/user_copy.rs")); - + let rel_path2 = Path::new("blog/post name.yaml"); let out2 = build_output_path(root, rel_path2, Orm::SeaOrm); assert_eq!(out2, Path::new("src/models/blog/post_name.rs")); } + + #[test] + fn build_output_path_handles_file_without_extension() { + use std::path::Path; + let root = Path::new("src/models"); + // File without extension - covers line 88 (else branch) + let rel_path = Path::new("users"); + let out = build_output_path(root, rel_path, Orm::SeaOrm); + assert_eq!(out, Path::new("src/models/users.rs")); + + let out_py = build_output_path(root, rel_path, Orm::SqlAlchemy); + assert_eq!(out_py, Path::new("src/models/users.py")); + } + + #[test] + fn build_output_path_handles_special_path_components() { + use std::path::Path; + let root = Path::new("src/models"); + // Path with CurDir component (.) - covers line 78 (non-Normal component branch) + let rel_path = Path::new("./blog/posts.json"); + let out = build_output_path(root, rel_path, Orm::SeaOrm); + // The . component gets pushed via the else branch + assert!(out.to_string_lossy().contains("posts")); + + // Path with ParentDir component (..) + let rel_path2 = Path::new("../other/items.yaml"); + let out2 = build_output_path(root, rel_path2, Orm::SeaOrm); + assert!(out2.to_string_lossy().contains("items")); + } } diff --git a/crates/vespertide-cli/src/utils.rs b/crates/vespertide-cli/src/utils.rs index accd61ed..45ceb142 100644 --- a/crates/vespertide-cli/src/utils.rs +++ b/crates/vespertide-cli/src/utils.rs @@ -230,7 +230,9 @@ mod tests { use serial_test::serial; use std::fs; use tempfile::tempdir; - use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; + use vespertide_core::{ + schema::foreign_key::ForeignKeySyntax, ColumnDef, ColumnType, SimpleColumnType, + }; struct CwdGuard { original: PathBuf, @@ -388,4 +390,39 @@ mod tests { let name = migration_filename_with_format_and_pattern(version, comment, format, pattern); assert_eq!(name, expected); } + + #[test] + #[serial] + fn load_models_fails_on_invalid_fk_format() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + + fs::create_dir_all("models").unwrap(); + + // Create a model with invalid FK string format (missing dot separator) + let table = TableDef { + name: "orders".into(), + columns: vec![ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + // Invalid FK format: should be "table.column" but missing the dot + foreign_key: Some(ForeignKeySyntax::String("invalid_format".into())), + }], + constraints: vec![], + indexes: vec![], + }; + fs::write("models/orders.json", serde_json::to_string_pretty(&table).unwrap()).unwrap(); + + let result = load_models(&VespertideConfig::default()); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("Failed to normalize table 'orders'")); + } } diff --git a/crates/vespertide-core/Cargo.toml b/crates/vespertide-core/Cargo.toml index af544052..cc44cc6f 100644 --- a/crates/vespertide-core/Cargo.toml +++ b/crates/vespertide-core/Cargo.toml @@ -12,3 +12,6 @@ description = "Data models for tables, columns, constraints, indexes, and migrat serde = { version = "1", features = ["derive"] } schemars = { version = "1.1" } thiserror = "2" + +[dev-dependencies] +rstest = "0.26" diff --git a/crates/vespertide-core/src/schema/column.rs b/crates/vespertide-core/src/schema/column.rs index df40eda1..9f4d3a51 100644 --- a/crates/vespertide-core/src/schema/column.rs +++ b/crates/vespertide-core/src/schema/column.rs @@ -159,3 +159,134 @@ impl From for ColumnType { ColumnType::Complex(ty) } } + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + #[case(SimpleColumnType::SmallInt, "SMALLINT")] + #[case(SimpleColumnType::Integer, "INTEGER")] + #[case(SimpleColumnType::BigInt, "BIGINT")] + #[case(SimpleColumnType::Real, "REAL")] + #[case(SimpleColumnType::DoublePrecision, "DOUBLE PRECISION")] + #[case(SimpleColumnType::Text, "TEXT")] + #[case(SimpleColumnType::Boolean, "BOOLEAN")] + #[case(SimpleColumnType::Date, "DATE")] + #[case(SimpleColumnType::Time, "TIME")] + #[case(SimpleColumnType::Timestamp, "TIMESTAMP")] + #[case(SimpleColumnType::Timestamptz, "TIMESTAMPTZ")] + #[case(SimpleColumnType::Interval, "INTERVAL")] + #[case(SimpleColumnType::Bytea, "BYTEA")] + #[case(SimpleColumnType::Uuid, "UUID")] + #[case(SimpleColumnType::Json, "JSON")] + #[case(SimpleColumnType::Jsonb, "JSONB")] + #[case(SimpleColumnType::Inet, "INET")] + #[case(SimpleColumnType::Cidr, "CIDR")] + #[case(SimpleColumnType::Macaddr, "MACADDR")] + #[case(SimpleColumnType::Xml, "XML")] + fn test_simple_column_type_to_sql(#[case] column_type: SimpleColumnType, #[case] expected: &str) { + assert_eq!(ColumnType::Simple(column_type).to_sql(), expected); + } + + #[rstest] + #[case(ComplexColumnType::Varchar { length: 255 }, "VARCHAR(255)")] + #[case(ComplexColumnType::Varchar { length: 50 }, "VARCHAR(50)")] + #[case(ComplexColumnType::Varchar { length: 1 }, "VARCHAR(1)")] + #[case(ComplexColumnType::Numeric { precision: 10, scale: 2 }, "NUMERIC(10, 2)")] + #[case(ComplexColumnType::Numeric { precision: 5, scale: 0 }, "NUMERIC(5, 0)")] + #[case(ComplexColumnType::Numeric { precision: 18, scale: 4 }, "NUMERIC(18, 4)")] + #[case(ComplexColumnType::Char { length: 10 }, "CHAR(10)")] + #[case(ComplexColumnType::Char { length: 1 }, "CHAR(1)")] + #[case(ComplexColumnType::Char { length: 255 }, "CHAR(255)")] + #[case(ComplexColumnType::Custom { custom_type: "MONEY".into() }, "MONEY")] + #[case(ComplexColumnType::Custom { custom_type: "JSONB".into() }, "JSONB")] + #[case(ComplexColumnType::Custom { custom_type: "CUSTOM_TYPE".into() }, "CUSTOM_TYPE")] + fn test_complex_column_type_to_sql(#[case] column_type: ComplexColumnType, #[case] expected: &str) { + assert_eq!(ColumnType::Complex(column_type).to_sql(), expected); + } + + #[rstest] + #[case(SimpleColumnType::SmallInt, "i16")] + #[case(SimpleColumnType::Integer, "i32")] + #[case(SimpleColumnType::BigInt, "i64")] + #[case(SimpleColumnType::Real, "f32")] + #[case(SimpleColumnType::DoublePrecision, "f64")] + #[case(SimpleColumnType::Text, "String")] + #[case(SimpleColumnType::Boolean, "bool")] + #[case(SimpleColumnType::Date, "Date")] + #[case(SimpleColumnType::Time, "Time")] + #[case(SimpleColumnType::Timestamp, "DateTime")] + #[case(SimpleColumnType::Timestamptz, "DateTimeWithTimeZone")] + #[case(SimpleColumnType::Interval, "String")] + #[case(SimpleColumnType::Bytea, "Vec")] + #[case(SimpleColumnType::Uuid, "Uuid")] + #[case(SimpleColumnType::Json, "Json")] + #[case(SimpleColumnType::Jsonb, "Json")] + #[case(SimpleColumnType::Inet, "String")] + #[case(SimpleColumnType::Cidr, "String")] + #[case(SimpleColumnType::Macaddr, "String")] + #[case(SimpleColumnType::Xml, "String")] + fn test_simple_column_type_to_rust_type_not_nullable(#[case] column_type: SimpleColumnType, #[case] expected: &str) { + assert_eq!(ColumnType::Simple(column_type).to_rust_type(false), expected); + } + + #[rstest] + #[case(SimpleColumnType::SmallInt, "Option")] + #[case(SimpleColumnType::Integer, "Option")] + #[case(SimpleColumnType::BigInt, "Option")] + #[case(SimpleColumnType::Real, "Option")] + #[case(SimpleColumnType::DoublePrecision, "Option")] + #[case(SimpleColumnType::Text, "Option")] + #[case(SimpleColumnType::Boolean, "Option")] + #[case(SimpleColumnType::Date, "Option")] + #[case(SimpleColumnType::Time, "Option