From 09d08aba65d070b987f20edf172e6d4a1687e454 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 7 Jan 2026 19:56:36 +0900 Subject: [PATCH] Support inline reference --- .../changepack_log_5IwbwBiD2W1f_vy_JAnGq.json | 1 + Cargo.lock | 1 + crates/vespertide-core/Cargo.toml | 1 + .../vespertide-core/src/schema/foreign_key.rs | 15 ++ crates/vespertide-core/src/schema/table.rs | 133 ++++++++++++++++++ 5 files changed, 151 insertions(+) create mode 100644 .changepacks/changepack_log_5IwbwBiD2W1f_vy_JAnGq.json diff --git a/.changepacks/changepack_log_5IwbwBiD2W1f_vy_JAnGq.json b/.changepacks/changepack_log_5IwbwBiD2W1f_vy_JAnGq.json new file mode 100644 index 0000000..5c4894d --- /dev/null +++ b/.changepacks/changepack_log_5IwbwBiD2W1f_vy_JAnGq.json @@ -0,0 +1 @@ +{"changes":{"crates/vespertide/Cargo.toml":"Patch","crates/vespertide-core/Cargo.toml":"Patch","crates/vespertide-macro/Cargo.toml":"Patch","crates/vespertide-query/Cargo.toml":"Patch","crates/vespertide-planner/Cargo.toml":"Patch","crates/vespertide-config/Cargo.toml":"Patch","crates/vespertide-cli/Cargo.toml":"Patch","crates/vespertide-loader/Cargo.toml":"Patch","crates/vespertide-exporter/Cargo.toml":"Patch","crates/vespertide-naming/Cargo.toml":"Patch"},"note":"Support Reference","date":"2026-01-07T08:15:32.847134100Z"} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 49cb826..78804d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3043,6 +3043,7 @@ dependencies = [ "rstest", "schemars", "serde", + "serde_json", "thiserror 2.0.17", "vespertide-naming", ] diff --git a/crates/vespertide-core/Cargo.toml b/crates/vespertide-core/Cargo.toml index cc5f2fd..244f5b4 100644 --- a/crates/vespertide-core/Cargo.toml +++ b/crates/vespertide-core/Cargo.toml @@ -16,3 +16,4 @@ vespertide-naming = { workspace = true } [dev-dependencies] rstest = "0.26" +serde_json = "1" diff --git a/crates/vespertide-core/src/schema/foreign_key.rs b/crates/vespertide-core/src/schema/foreign_key.rs index b645b57..5586cd7 100644 --- a/crates/vespertide-core/src/schema/foreign_key.rs +++ b/crates/vespertide-core/src/schema/foreign_key.rs @@ -12,10 +12,25 @@ pub struct ForeignKeyDef { pub on_update: Option, } +/// Shorthand syntax for foreign key: { "references": "table.column", "on_delete": "cascade" } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub struct ReferenceSyntaxDef { + /// Reference in "table.column" format + pub references: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub on_delete: Option, + #[serde(skip_serializing_if = "Option::is_none")] + 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), + /// { "references": "table.column", "on_delete": "cascade" } + Reference(ReferenceSyntaxDef), + /// { "ref_table": "table", "ref_columns": ["column"], ... } Object(ForeignKeyDef), } diff --git a/crates/vespertide-core/src/schema/table.rs b/crates/vespertide-core/src/schema/table.rs index d449173..dd9a492 100644 --- a/crates/vespertide-core/src/schema/table.rs +++ b/crates/vespertide-core/src/schema/table.rs @@ -53,6 +53,7 @@ pub struct TableDef { #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, pub columns: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub constraints: Vec, } @@ -212,6 +213,22 @@ impl TableDef { } (parts[0].to_string(), vec![parts[1].to_string()], None, None) } + ForeignKeySyntax::Reference(ref_syntax) => { + // Parse { "references": "table.column", "on_delete": ... } format + let parts: Vec<&str> = ref_syntax.references.split('.').collect(); + if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() { + return Err(TableValidationError::InvalidForeignKeyFormat { + column_name: col.name.clone(), + value: ref_syntax.references.clone(), + }); + } + ( + parts[0].to_string(), + vec![parts[1].to_string()], + ref_syntax.on_delete.clone(), + ref_syntax.on_update.clone(), + ) + } ForeignKeySyntax::Object(fk_def) => ( fk_def.ref_table.clone(), fk_def.ref_columns.clone(), @@ -1579,4 +1596,120 @@ mod tests { assert!(error_msg.contains("invalid")); assert!(error_msg.contains("table.column")); } + + #[test] + fn normalize_inline_foreign_key_reference_syntax() { + // Test ForeignKeySyntax::Reference with { "references": "table.column", "on_delete": ... } + use crate::schema::foreign_key::ReferenceSyntaxDef; + + let mut user_id_col = col("user_id", ColumnType::Simple(SimpleColumnType::Integer)); + user_id_col.foreign_key = Some(ForeignKeySyntax::Reference(ReferenceSyntaxDef { + references: "users.id".into(), + on_delete: Some(ReferenceAction::Cascade), + on_update: None, + })); + + let table = TableDef { + name: "posts".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + user_id_col, + ], + constraints: 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: Some(ReferenceAction::Cascade), + on_update: None, + } if columns == &["user_id".to_string()] + && ref_table == "users" + && ref_columns == &["id".to_string()] + )); + } + + #[test] + fn normalize_inline_foreign_key_reference_syntax_invalid_format() { + // Test ForeignKeySyntax::Reference with invalid format + use crate::schema::foreign_key::ReferenceSyntaxDef; + + let mut user_id_col = col("user_id", ColumnType::Simple(SimpleColumnType::Integer)); + user_id_col.foreign_key = Some(ForeignKeySyntax::Reference(ReferenceSyntaxDef { + references: "invalid_no_dot".into(), + on_delete: None, + on_update: None, + })); + + let table = TableDef { + name: "posts".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + user_id_col, + ], + constraints: 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, "invalid_no_dot"); + } else { + panic!("Expected InvalidForeignKeyFormat error"); + } + } + + #[test] + fn deserialize_table_without_constraints() { + // Test that constraints field is optional in JSON deserialization + let json = r#"{ + "name": "users", + "columns": [ + { "name": "id", "type": "integer", "nullable": false } + ] + }"#; + + let table: TableDef = serde_json::from_str(json).unwrap(); + assert_eq!(table.name.as_str(), "users"); + assert!(table.constraints.is_empty()); + } + + #[test] + fn deserialize_foreign_key_reference_syntax() { + // Test JSON deserialization of new reference syntax + let json = r#"{ + "name": "posts", + "columns": [ + { "name": "id", "type": "integer", "nullable": false }, + { + "name": "user_id", + "type": "integer", + "nullable": false, + "foreign_key": { "references": "users.id", "on_delete": "cascade" } + } + ] + }"#; + + let table: TableDef = serde_json::from_str(json).unwrap(); + assert_eq!(table.columns.len(), 2); + + let user_id_col = &table.columns[1]; + assert!(user_id_col.foreign_key.is_some()); + + if let Some(ForeignKeySyntax::Reference(ref_syntax)) = &user_id_col.foreign_key { + assert_eq!(ref_syntax.references, "users.id"); + assert_eq!(ref_syntax.on_delete, Some(ReferenceAction::Cascade)); + } else { + panic!("Expected Reference syntax"); + } + } }