diff --git a/.gitignore b/.gitignore index a1fb61b..c742731 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target local.db +settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..735c763 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,135 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +Vespertide is a Rust workspace for defining database schemas in JSON/YAML and generating migration plans and SQL from model diffs. It enables declarative schema management by comparing the current model state against a baseline reconstructed from applied migrations. + +## Build and Test Commands + +```bash +# Build the entire workspace +cargo build + +# Run all tests +cargo test + +# Run tests for a specific crate +cargo test -p vespertide-core +cargo test -p vespertide-planner + +# Format code +cargo fmt + +# Lint (important: use all targets and features) +cargo clippy --all-targets --all-features + +# Regenerate JSON schemas +cargo run -p vespertide-schema-gen -- --out schemas + +# Run CLI commands (use -p vespertide-cli) +cargo run -p vespertide-cli -- init +cargo run -p vespertide-cli -- new user +cargo run -p vespertide-cli -- diff +cargo run -p vespertide-cli -- sql +cargo run -p vespertide-cli -- revision -m "message" +cargo run -p vespertide-cli -- status +cargo run -p vespertide-cli -- log +``` + +## Architecture + +### Core Data Flow + +1. **Schema Definition**: Users define tables in JSON files (`TableDef`) in the `models/` directory +2. **Baseline Reconstruction**: Applied migrations are replayed to rebuild the baseline schema +3. **Diffing**: Current models are compared against the baseline to compute changes +4. **Planning**: Changes are converted into a `MigrationPlan` with versioned actions +5. **SQL Generation**: Migration actions are translated into PostgreSQL SQL statements + +### Crate Responsibilities + +- **vespertide-core**: Data structures (`TableDef`, `ColumnDef`, `MigrationAction`, `MigrationPlan`, constraints, indexes) +- **vespertide-planner**: + - `schema_from_plans()`: Replays applied migrations to reconstruct baseline schema + - `diff_schemas()`: Compares two schemas and generates migration actions + - `plan_next_migration()`: Combines baseline reconstruction + diffing to create the next migration + - `apply_action()`: Applies a single migration action to a schema (used during replay) + - `validate_*()`: Validates schemas and migration plans +- **vespertide-query**: Converts `MigrationAction` → PostgreSQL SQL with bind parameters +- **vespertide-config**: Manages `vespertide.json` (models/migrations directories, naming case preferences) +- **vespertide-cli**: Command-line interface implementation +- **vespertide-exporter**: Exports schemas to other formats (e.g., SeaORM entities) +- **vespertide-schema-gen**: Generates JSON Schema files for validation +- **vespertide-macro**: Placeholder for future runtime migration executor + +### Key Architectural Patterns + +**Migration Replay Pattern**: The planner doesn't store a "current database state" - it reconstructs it by replaying all applied migrations in order. This ensures the baseline is always derivable from the migration history. + +**Declarative Diffing**: Users declare the desired end state in model files. The diff engine compares this against the reconstructed baseline to compute necessary changes. + +**Action-Based Migrations**: All changes are expressed as typed `MigrationAction` enums (CreateTable, AddColumn, ModifyColumnType, etc.) rather than raw SQL. SQL generation happens in a separate layer. + +## Important Implementation Details + +### ColumnDef Structure +When creating `ColumnDef` instances in tests or code, you must initialize ALL fields including the newer inline constraint fields: + +```rust +ColumnDef { + name: "id".into(), + r#type: ColumnType::Integer, + nullable: false, + default: None, + comment: None, + primary_key: None, // Inline PK declaration + unique: None, // Inline unique constraint + index: None, // Inline index creation + foreign_key: None, // Inline FK definition +} +``` + +These inline fields (added recently) allow constraints to be defined directly on columns in addition to table-level `TableConstraint` definitions. + +### Foreign Key Definition +Foreign keys can be defined inline on columns via the `foreign_key` field: + +```rust +pub struct ForeignKeyDef { + pub ref_table: TableName, + pub ref_columns: Vec, + pub on_delete: Option, + pub on_update: Option, +} +``` + +### Migration Plan Validation +- Non-nullable columns added to existing tables require either a `default` value or a `fill_with` backfill expression +- Schemas are validated for constraint consistency before diffing +- The planner validates that column/table names follow the configured naming case + +### SQL Generation Target +All SQL generation currently targets **PostgreSQL only**. When modifying the query builder, ensure PostgreSQL compatibility. + +### JSON Schema Generation +The `vespertide-schema-gen` crate uses `schemars` to generate JSON Schemas from the Rust types. After modifying core data structures, regenerate schemas with: +```bash +cargo run -p vespertide-schema-gen -- --out schemas +``` + +Schema base URL can be overridden via `VESP_SCHEMA_BASE_URL` environment variable. + +## Testing Patterns + +- Tests use helper functions like `col()` and `table()` to reduce boilerplate +- Use `rstest` for parameterized tests (common in planner/query crates) +- Use `serial_test::serial` for tests that modify the filesystem or working directory +- Snapshot testing with `insta` is used in the exporter crate + +## Limitations + +- YAML loading is not implemented (templates can be generated but not parsed) +- Runtime migration executor (`run_migrations`) in `vespertide-macro` is not implemented +- Only PostgreSQL SQL generation is supported diff --git a/Cargo.lock b/Cargo.lock index 3ca0896..6818127 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2831,7 +2831,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vespertide" -version = "0.1.2" +version = "0.1.3" dependencies = [ "vespertide-core", "vespertide-macro", @@ -2839,7 +2839,7 @@ dependencies = [ [[package]] name = "vespertide-cli" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "chrono", @@ -2860,7 +2860,7 @@ dependencies = [ [[package]] name = "vespertide-config" -version = "0.1.2" +version = "0.1.3" dependencies = [ "clap", "serde", @@ -2868,7 +2868,7 @@ dependencies = [ [[package]] name = "vespertide-core" -version = "0.1.2" +version = "0.1.3" dependencies = [ "schemars", "serde", @@ -2877,7 +2877,7 @@ dependencies = [ [[package]] name = "vespertide-exporter" -version = "0.1.2" +version = "0.1.3" dependencies = [ "insta", "rstest", @@ -2887,7 +2887,7 @@ dependencies = [ [[package]] name = "vespertide-macro" -version = "0.1.2" +version = "0.1.3" dependencies = [ "proc-macro2", "quote", @@ -2902,7 +2902,7 @@ dependencies = [ [[package]] name = "vespertide-planner" -version = "0.1.2" +version = "0.1.3" dependencies = [ "rstest", "thiserror", @@ -2911,7 +2911,7 @@ dependencies = [ [[package]] name = "vespertide-query" -version = "0.1.2" +version = "0.1.3" dependencies = [ "rstest", "thiserror", diff --git a/crates/vespertide-cli/src/commands/diff.rs b/crates/vespertide-cli/src/commands/diff.rs index c0d6a3f..e787785 100644 --- a/crates/vespertide-cli/src/commands/diff.rs +++ b/crates/vespertide-cli/src/commands/diff.rs @@ -123,6 +123,58 @@ fn format_action(action: &MigrationAction) -> String { sql.bright_cyan() ) } + MigrationAction::AddConstraint { table, constraint } => { + format!( + "{} {} {} {}", + "Add constraint:".bright_green(), + format_constraint_type(constraint).bright_cyan().bold(), + "on".bright_white(), + table.bright_cyan() + ) + } + MigrationAction::RemoveConstraint { table, constraint } => { + format!( + "{} {} {} {}", + "Remove constraint:".bright_red(), + format_constraint_type(constraint).bright_cyan().bold(), + "from".bright_white(), + table.bright_cyan() + ) + } + } +} + +fn format_constraint_type(constraint: &vespertide_core::TableConstraint) -> String { + match constraint { + vespertide_core::TableConstraint::PrimaryKey { columns } => { + format!("PRIMARY KEY ({})", columns.join(", ")) + } + vespertide_core::TableConstraint::Unique { name, columns } => { + if let Some(n) = name { + format!("{} UNIQUE ({})", n, columns.join(", ")) + } else { + format!("UNIQUE ({})", columns.join(", ")) + } + } + vespertide_core::TableConstraint::ForeignKey { + name, + columns, + ref_table, + .. + } => { + if let Some(n) = name { + format!("{} FK ({}) -> {}", n, columns.join(", "), ref_table) + } else { + format!("FK ({}) -> {}", columns.join(", "), ref_table) + } + } + vespertide_core::TableConstraint::Check { name, expr } => { + if let Some(n) = name { + format!("{} CHECK ({})", n, expr) + } else { + format!("CHECK ({})", expr) + } + } } } @@ -172,6 +224,11 @@ mod tests { r#type: ColumnType::Integer, nullable: false, default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, }], constraints: vec![], indexes: vec![], @@ -197,6 +254,11 @@ mod tests { r#type: ColumnType::Text, nullable: true, default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, }, fill_with: None, }, @@ -245,6 +307,92 @@ mod tests { MigrationAction::RawSql { sql: "SELECT 1".into() }, format!("{} {}", "Execute raw SQL:".bright_yellow(), "SELECT 1".bright_cyan()) )] + #[case( + MigrationAction::AddConstraint { + table: "users".into(), + constraint: vespertide_core::TableConstraint::PrimaryKey { + columns: vec!["id".into()], + }, + }, + format!("{} {} {} {}", "Add constraint:".bright_green(), "PRIMARY KEY (id)".bright_cyan().bold(), "on".bright_white(), "users".bright_cyan()) + )] + #[case( + MigrationAction::AddConstraint { + table: "users".into(), + constraint: vespertide_core::TableConstraint::Unique { + name: Some("unique_email".into()), + columns: vec!["email".into()], + }, + }, + format!("{} {} {} {}", "Add constraint:".bright_green(), "unique_email UNIQUE (email)".bright_cyan().bold(), "on".bright_white(), "users".bright_cyan()) + )] + #[case( + MigrationAction::AddConstraint { + table: "posts".into(), + constraint: vespertide_core::TableConstraint::ForeignKey { + name: Some("fk_user".into()), + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }, + format!("{} {} {} {}", "Add constraint:".bright_green(), "fk_user FK (user_id) -> users".bright_cyan().bold(), "on".bright_white(), "posts".bright_cyan()) + )] + #[case( + MigrationAction::AddConstraint { + table: "users".into(), + constraint: vespertide_core::TableConstraint::Check { + name: Some("check_age".into()), + expr: "age > 0".into(), + }, + }, + format!("{} {} {} {}", "Add constraint:".bright_green(), "check_age CHECK (age > 0)".bright_cyan().bold(), "on".bright_white(), "users".bright_cyan()) + )] + #[case( + MigrationAction::RemoveConstraint { + table: "users".into(), + constraint: vespertide_core::TableConstraint::PrimaryKey { + columns: vec!["id".into()], + }, + }, + format!("{} {} {} {}", "Remove constraint:".bright_red(), "PRIMARY KEY (id)".bright_cyan().bold(), "from".bright_white(), "users".bright_cyan()) + )] + #[case( + MigrationAction::RemoveConstraint { + table: "users".into(), + constraint: vespertide_core::TableConstraint::Unique { + name: None, + columns: vec!["email".into()], + }, + }, + format!("{} {} {} {}", "Remove constraint:".bright_red(), "UNIQUE (email)".bright_cyan().bold(), "from".bright_white(), "users".bright_cyan()) + )] + #[case( + MigrationAction::RemoveConstraint { + table: "posts".into(), + constraint: vespertide_core::TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }, + format!("{} {} {} {}", "Remove constraint:".bright_red(), "FK (user_id) -> users".bright_cyan().bold(), "from".bright_white(), "posts".bright_cyan()) + )] + #[case( + MigrationAction::RemoveConstraint { + table: "users".into(), + constraint: vespertide_core::TableConstraint::Check { + name: None, + expr: "age > 0".into(), + }, + }, + format!("{} {} {} {}", "Remove constraint:".bright_red(), "CHECK (age > 0)".bright_cyan().bold(), "from".bright_white(), "users".bright_cyan()) + )] #[serial] fn format_action_cases(#[case] action: MigrationAction, #[case] expected: String) { assert_eq!(format_action(&action), expected); diff --git a/crates/vespertide-cli/src/commands/export.rs b/crates/vespertide-cli/src/commands/export.rs index 45295f4..d116631 100644 --- a/crates/vespertide-cli/src/commands/export.rs +++ b/crates/vespertide-cli/src/commands/export.rs @@ -197,6 +197,11 @@ mod tests { r#type: ColumnType::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()], diff --git a/crates/vespertide-cli/src/commands/revision.rs b/crates/vespertide-cli/src/commands/revision.rs index 1833159..239b0f7 100644 --- a/crates/vespertide-cli/src/commands/revision.rs +++ b/crates/vespertide-cli/src/commands/revision.rs @@ -168,6 +168,11 @@ mod tests { r#type: ColumnType::Integer, nullable: false, default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, }], constraints: vec![], indexes: vec![], diff --git a/crates/vespertide-cli/src/commands/sql.rs b/crates/vespertide-cli/src/commands/sql.rs index a7fe1c0..2fbeb4d 100644 --- a/crates/vespertide-cli/src/commands/sql.rs +++ b/crates/vespertide-cli/src/commands/sql.rs @@ -117,6 +117,11 @@ mod tests { r#type: ColumnType::Integer, nullable: false, default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, }], constraints: vec![], indexes: vec![], @@ -163,6 +168,11 @@ mod tests { r#type: ColumnType::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()], diff --git a/crates/vespertide-cli/src/commands/status.rs b/crates/vespertide-cli/src/commands/status.rs index e35523a..fbebec7 100644 --- a/crates/vespertide-cli/src/commands/status.rs +++ b/crates/vespertide-cli/src/commands/status.rs @@ -180,6 +180,11 @@ mod tests { r#type: ColumnType::Integer, nullable: false, default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, }], constraints: vec![], indexes: vec![], @@ -201,6 +206,11 @@ mod tests { r#type: ColumnType::Integer, nullable: false, default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, }], constraints: vec![], }], diff --git a/crates/vespertide-cli/src/utils.rs b/crates/vespertide-cli/src/utils.rs index 0c98bfe..3479814 100644 --- a/crates/vespertide-cli/src/utils.rs +++ b/crates/vespertide-cli/src/utils.rs @@ -1,5 +1,5 @@ use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use vespertide_config::{FileFormat, VespertideConfig}; @@ -19,7 +19,7 @@ pub fn load_config() -> Result { Ok(config) } -/// Load all model definitions from the models directory. +/// Load all model definitions from the models directory (recursively). pub fn load_models(config: &VespertideConfig) -> Result> { let models_dir = config.models_dir(); if !models_dir.exists() { @@ -27,14 +27,34 @@ pub fn load_models(config: &VespertideConfig) -> Result> { } let mut tables = Vec::new(); - let entries = fs::read_dir(models_dir).context("read models directory")?; + load_models_recursive(models_dir, &mut tables)?; + + // Validate schema integrity before returning + if !tables.is_empty() { + validate_schema(&tables).map_err(|e| anyhow::anyhow!("schema validation failed: {}", e))?; + } + + Ok(tables) +} + +/// Recursively walk directory and load model files. +fn load_models_recursive(dir: &Path, tables: &mut Vec) -> Result<()> { + let entries = + fs::read_dir(dir).with_context(|| format!("read models directory: {}", dir.display()))?; for entry in entries { let entry = entry.context("read directory entry")?; let path = entry.path(); + + if path.is_dir() { + // Recursively process subdirectories + load_models_recursive(&path, tables)?; + continue; + } + if path.is_file() { let ext = path.extension().and_then(|s| s.to_str()); - if ext == Some("json") || ext == Some("yaml") || ext == Some("yml") { + if matches!(ext, Some("json") | Some("yaml") | Some("yml")) { let content = fs::read_to_string(&path) .with_context(|| format!("read model file: {}", path.display()))?; @@ -51,12 +71,7 @@ pub fn load_models(config: &VespertideConfig) -> Result> { } } - // Validate schema integrity before returning - if !tables.is_empty() { - validate_schema(&tables).map_err(|e| anyhow::anyhow!("schema validation failed: {}", e))?; - } - - Ok(tables) + Ok(()) } /// Load all migration plans from the migrations directory, sorted by version. @@ -199,6 +214,7 @@ fn render_migration_name(pattern: &str, version: u32, sanitized_comment: &str) - #[cfg(test)] mod tests { use super::*; + use rstest::rstest; use serial_test::serial; use std::fs; use tempfile::tempdir; @@ -252,6 +268,11 @@ mod tests { r#type: ColumnType::Integer, nullable: false, default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, }], constraints: vec![], indexes: vec![], @@ -263,6 +284,40 @@ mod tests { assert_eq!(models[0].name, "users"); } + #[test] + #[serial] + fn load_models_recursive_processes_subdirectories() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + + fs::create_dir_all("models/subdir").unwrap(); + + // Create model in subdirectory + let table = TableDef { + name: "subtable".into(), + columns: vec![ColumnDef { + name: "id".into(), + r#type: ColumnType::Integer, + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }], + constraints: vec![], + indexes: vec![], + }; + let content = serde_json::to_string_pretty(&table).unwrap(); + fs::write("models/subdir/subtable.json", content).unwrap(); + + let models = load_models(&VespertideConfig::default()).unwrap(); + assert_eq!(models.len(), 1); + assert_eq!(models[0].name, "subtable"); + } + #[test] #[serial] fn load_migrations_reads_yaml_and_sorts() { @@ -300,33 +355,19 @@ mod tests { assert_eq!(plans[1].version, 2); } - #[test] - fn migration_filename_respects_format_and_sanitizes_comment() { - let name = migration_filename_with_format_and_pattern( - 5, - Some("Hello! World"), - FileFormat::Yml, - "%04v_%m", - ); - assert_eq!(name, "0005_hello__world.yml"); - } - - #[test] - fn migration_filename_handles_zero_width_and_trim() { - // width 0 falls back to default version and trailing separators are trimmed - let name = migration_filename_with_format_and_pattern(3, None, FileFormat::Json, "%0v__"); - assert_eq!(name, "0003.json"); - } - - #[test] - fn migration_filename_replaces_version_directly() { - let name = migration_filename_with_format_and_pattern(12, None, FileFormat::Json, "%v"); - assert_eq!(name, "0012.json"); - } - - #[test] - fn migration_filename_uses_default_when_comment_only_and_empty() { - let name = migration_filename_with_format_and_pattern(7, None, FileFormat::Json, "%m"); - assert_eq!(name, "0007.json"); + #[rstest] + #[case(5, Some("Hello! World"), FileFormat::Yml, "%04v_%m", "0005_hello__world.yml")] + #[case(3, None, FileFormat::Json, "%0v__", "0003.json")] // width 0 falls back to default version and trailing separators are trimmed + #[case(12, None, FileFormat::Json, "%v", "0012.json")] + #[case(7, None, FileFormat::Json, "%m", "0007.json")] // uses default when comment only and empty + fn migration_filename_with_format_and_pattern_tests( + #[case] version: u32, + #[case] comment: Option<&str>, + #[case] format: FileFormat, + #[case] pattern: &str, + #[case] expected: &str, + ) { + let name = migration_filename_with_format_and_pattern(version, comment, format, pattern); + assert_eq!(name, expected); } } diff --git a/crates/vespertide-core/src/action.rs b/crates/vespertide-core/src/action.rs index 210ee0b..934523f 100644 --- a/crates/vespertide-core/src/action.rs +++ b/crates/vespertide-core/src/action.rs @@ -54,6 +54,16 @@ pub enum MigrationAction { #[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/lib.rs b/crates/vespertide-core/src/lib.rs index 548dd28..3b1ec5f 100644 --- a/crates/vespertide-core/src/lib.rs +++ b/crates/vespertide-core/src/lib.rs @@ -5,6 +5,6 @@ pub mod schema; pub use action::{MigrationAction, MigrationPlan}; pub use migration::{MigrationError, MigrationOptions}; pub use schema::{ - ColumnDef, ColumnName, ColumnType, IndexDef, IndexName, ReferenceAction, TableConstraint, - TableDef, TableName, + ColumnDef, ColumnName, ColumnType, IndexDef, IndexName, ReferenceAction, StrOrBoolOrArray, + TableConstraint, TableDef, TableName, TableValidationError, }; diff --git a/crates/vespertide-core/src/schema/column.rs b/crates/vespertide-core/src/schema/column.rs index bbb3a66..fd79547 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::names::ColumnName; +use crate::schema::{foreign_key::ForeignKeyDef, names::ColumnName, str_or_bool::StrOrBoolOrArray}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] @@ -10,6 +10,11 @@ pub struct ColumnDef { pub r#type: ColumnType, pub nullable: bool, pub default: Option, + pub comment: Option, + pub primary_key: Option, + pub unique: Option, + pub index: 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 e779d4c..83151e3 100644 --- a/crates/vespertide-core/src/schema/constraint.rs +++ b/crates/vespertide-core/src/schema/constraint.rs @@ -1,7 +1,10 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::schema::{ReferenceAction, names::ColumnName, names::TableName}; +use crate::schema::{ + ReferenceAction, + names::{ColumnName, TableName}, +}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case", tag = "type")] diff --git a/crates/vespertide-core/src/schema/foreign_key.rs b/crates/vespertide-core/src/schema/foreign_key.rs new file mode 100644 index 0000000..18e963f --- /dev/null +++ b/crates/vespertide-core/src/schema/foreign_key.rs @@ -0,0 +1,13 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::schema::{names::ColumnName, names::TableName, reference::ReferenceAction}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub struct ForeignKeyDef { + pub ref_table: TableName, + pub ref_columns: Vec, + pub on_delete: Option, + pub on_update: Option, +} diff --git a/crates/vespertide-core/src/schema/mod.rs b/crates/vespertide-core/src/schema/mod.rs index e960622..2a3104e 100644 --- a/crates/vespertide-core/src/schema/mod.rs +++ b/crates/vespertide-core/src/schema/mod.rs @@ -1,8 +1,10 @@ pub mod column; pub mod constraint; +pub mod foreign_key; pub mod index; pub mod names; pub mod reference; +pub mod str_or_bool; pub mod table; pub use column::{ColumnDef, ColumnType}; @@ -10,4 +12,5 @@ pub use constraint::TableConstraint; pub use index::IndexDef; pub use names::{ColumnName, IndexName, TableName}; pub use reference::ReferenceAction; -pub use table::TableDef; +pub use str_or_bool::StrOrBoolOrArray; +pub use table::{TableDef, TableValidationError}; diff --git a/crates/vespertide-core/src/schema/str_or_bool.rs b/crates/vespertide-core/src/schema/str_or_bool.rs new file mode 100644 index 0000000..d545453 --- /dev/null +++ b/crates/vespertide-core/src/schema/str_or_bool.rs @@ -0,0 +1,10 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case", untagged)] +pub enum StrOrBoolOrArray { + Str(String), + Array(Vec), + Bool(bool), +} diff --git a/crates/vespertide-core/src/schema/table.rs b/crates/vespertide-core/src/schema/table.rs index fadbeac..9e7fa50 100644 --- a/crates/vespertide-core/src/schema/table.rs +++ b/crates/vespertide-core/src/schema/table.rs @@ -1,10 +1,36 @@ use schemars::JsonSchema; + use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; use crate::schema::{ - column::ColumnDef, constraint::TableConstraint, index::IndexDef, names::TableName, + StrOrBoolOrArray, column::ColumnDef, constraint::TableConstraint, index::IndexDef, names::TableName, }; +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TableValidationError { + DuplicateIndexColumn { + index_name: String, + column_name: String, + }, +} + +impl std::fmt::Display for TableValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TableValidationError::DuplicateIndexColumn { index_name, column_name } => { + write!( + f, + "Duplicate index '{}' on column '{}': the same index name cannot be applied to the same column multiple times", + index_name, column_name + ) + } + } + } +} + +impl std::error::Error for TableValidationError {} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub struct TableDef { @@ -13,3 +39,943 @@ pub struct TableDef { pub constraints: Vec, pub indexes: Vec, } + +impl TableDef { + /// Normalizes inline column constraints (primary_key, unique, index, foreign_key) + /// into table-level constraints and indexes. + /// Returns a new TableDef with all inline constraints converted to table-level. + /// + /// # Errors + /// + /// Returns an error if the same index name is applied to the same column multiple times. + pub fn normalize(&self) -> Result { + 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(); + + // Add primary key constraint if any columns have inline pk and no existing pk constraint + if !pk_columns.is_empty() { + let has_pk = constraints + .iter() + .any(|c| matches!(c, TableConstraint::PrimaryKey { .. })); + if !has_pk { + constraints.push(TableConstraint::PrimaryKey { + columns: pk_columns, + }); + } + } + + // Process inline unique and index for each column + for col in &self.columns { + // Handle inline unique + if let Some(ref unique_val) = col.unique { + match unique_val { + StrOrBoolOrArray::Str(name) => { + let constraint_name = Some(name.clone()); + + // Check if this unique constraint already exists + let exists = constraints.iter().any(|c| { + if let TableConstraint::Unique { name: c_name, columns } = c { + c_name.as_ref() == Some(name) && columns.len() == 1 && columns[0] == col.name + } else { + false + } + }); + + if !exists { + constraints.push(TableConstraint::Unique { + name: constraint_name, + columns: vec![col.name.clone()], + }); + } + } + StrOrBoolOrArray::Bool(true) => { + let exists = constraints.iter().any(|c| { + if let TableConstraint::Unique { name: None, columns } = c { + columns.len() == 1 && columns[0] == col.name + } else { + false + } + }); + + if !exists { + constraints.push(TableConstraint::Unique { + name: None, + columns: vec![col.name.clone()], + }); + } + } + StrOrBoolOrArray::Bool(false) => continue, + StrOrBoolOrArray::Array(names) => { + // Array format: each element is a constraint name + // This column will be part of all these named constraints + for constraint_name in names { + // Check if constraint with this name already exists + if let Some(existing) = constraints.iter_mut().find(|c| { + if let TableConstraint::Unique { name: Some(n), .. } = c { + n == constraint_name + } else { + false + } + }) { + // Add this column to existing composite constraint + if let TableConstraint::Unique { columns, .. } = existing && !columns.contains(&col.name) { + columns.push(col.name.clone()); + } + } else { + // Create new constraint with this column + constraints.push(TableConstraint::Unique { + name: Some(constraint_name.clone()), + columns: vec![col.name.clone()], + }); + } + } + } + } + } + + // Handle inline foreign_key + if let Some(ref fk) = col.foreign_key { + // Check if this foreign key already exists + let exists = constraints.iter().any(|c| { + if let TableConstraint::ForeignKey { columns, .. } = c { + columns.len() == 1 && columns[0] == col.name + } else { + false + } + }); + + if !exists { + 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(), + }); + } + } + } + + // Group columns by index name to create composite indexes + // Use a HashMap to group, but preserve column order by tracking first occurrence + let mut index_groups: HashMap> = HashMap::new(); + let mut index_order: Vec = Vec::new(); // Preserve order of first occurrence + // Track which columns are already in each index from inline definitions to detect duplicates + // Only track inline definitions, not existing table-level indexes (they can be extended) + let mut inline_index_column_tracker: HashMap> = HashMap::new(); + + for col in &self.columns { + if let Some(ref index_val) = col.index { + match index_val { + StrOrBoolOrArray::Str(name) => { + // Named index - group by name + let index_name = name.clone(); + + // Check for duplicate - only check inline definitions, not existing table-level indexes + if let Some(columns) = inline_index_column_tracker.get(name.as_str()) && columns.contains(col.name.as_str()) { + return Err(TableValidationError::DuplicateIndexColumn { + index_name: name.clone(), + column_name: col.name.clone(), + }); + } + + if !index_groups.contains_key(&index_name) { + index_order.push(index_name.clone()); + } + + index_groups + .entry(index_name.clone()) + .or_default() + .push(col.name.clone()); + + inline_index_column_tracker + .entry(index_name) + .or_default() + .insert(col.name.clone()); + } + StrOrBoolOrArray::Bool(true) => { + // Auto-generated index name + let index_name = format!("idx_{}_{}", self.name, col.name); + + // Check for duplicate (auto-generated names are unique per column, so this shouldn't happen) + // But we check anyway for consistency - only check inline definitions + if let Some(columns) = inline_index_column_tracker.get(index_name.as_str()) && columns.contains(col.name.as_str()) { + return Err(TableValidationError::DuplicateIndexColumn { + index_name: index_name.clone(), + column_name: col.name.clone(), + }); + } + + if !index_groups.contains_key(&index_name) { + index_order.push(index_name.clone()); + } + + index_groups + .entry(index_name.clone()) + .or_default() + .push(col.name.clone()); + + inline_index_column_tracker + .entry(index_name) + .or_default() + .insert(col.name.clone()); + } + StrOrBoolOrArray::Bool(false) => continue, + StrOrBoolOrArray::Array(names) => { + // Array format: each element is an index name + // This column will be part of all these named indexes + // Check for duplicates within the array + let mut seen_in_array = HashSet::new(); + for index_name in names { + // Check for duplicate within the same array + if seen_in_array.contains(index_name.as_str()) { + return Err(TableValidationError::DuplicateIndexColumn { + index_name: index_name.clone(), + column_name: col.name.clone(), + }); + } + seen_in_array.insert(index_name.clone()); + + // Check for duplicate across different inline definitions + // Only check inline definitions, not existing table-level indexes + if let Some(columns) = inline_index_column_tracker.get(index_name.as_str()) &&columns.contains(col.name.as_str()) { + return Err(TableValidationError::DuplicateIndexColumn { + index_name: index_name.clone(), + column_name: col.name.clone(), + }); + } + + if !index_groups.contains_key(index_name.as_str()) { + index_order.push(index_name.clone()); + } + + index_groups + .entry(index_name.clone()) + .or_default() + .push(col.name.clone()); + + inline_index_column_tracker + .entry(index_name.clone()) + .or_default() + .insert(col.name.clone()); + } + } + } + } + } + + // Create indexes from grouped columns in order + for index_name in index_order { + let columns = index_groups.get(&index_name).unwrap().clone(); + + // Check if this index already exists (by name only, not by column match) + // Multiple indexes can have the same columns but different names + let exists = indexes + .iter() + .any(|i| i.name == index_name); + + if !exists { + indexes.push(IndexDef { + name: index_name, + columns, + unique: false, + }); + } + } + + Ok(TableDef { + name: self.name.clone(), + columns: self.columns.clone(), + constraints, + indexes, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::schema::column::ColumnType; + use crate::schema::foreign_key::ForeignKeyDef; + use crate::schema::reference::ReferenceAction; + use crate::schema::str_or_bool::StrOrBoolOrArray; + + fn col(name: &str, ty: ColumnType) -> ColumnDef { + ColumnDef { + name: name.to_string(), + r#type: ty, + nullable: true, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + } + } + + #[test] + fn normalize_inline_primary_key() { + let mut id_col = col("id", ColumnType::Integer); + id_col.primary_key = Some(true); + + let table = TableDef { + name: "users".into(), + columns: vec![id_col, col("name", ColumnType::Text)], + constraints: vec![], + indexes: vec![], + }; + + let normalized = table.normalize().unwrap(); + assert_eq!(normalized.constraints.len(), 1); + assert!(matches!( + &normalized.constraints[0], + TableConstraint::PrimaryKey { columns } if columns == &["id".to_string()] + )); + } + + #[test] + fn normalize_multiple_inline_primary_keys() { + let mut id_col = col("id", ColumnType::Integer); + id_col.primary_key = Some(true); + + let mut tenant_col = col("tenant_id", ColumnType::Integer); + tenant_col.primary_key = Some(true); + + let table = TableDef { + name: "users".into(), + columns: vec![id_col, tenant_col], + constraints: vec![], + indexes: vec![], + }; + + let normalized = table.normalize().unwrap(); + assert_eq!(normalized.constraints.len(), 1); + assert!(matches!( + &normalized.constraints[0], + 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::Integer); + id_col.primary_key = Some(true); + + let table = TableDef { + name: "users".into(), + columns: vec![id_col], + constraints: vec![TableConstraint::PrimaryKey { + columns: vec!["id".into()], + }], + indexes: vec![], + }; + + let normalized = table.normalize().unwrap(); + assert_eq!(normalized.constraints.len(), 1); + } + + #[test] + fn normalize_inline_unique_bool() { + let mut email_col = col("email", ColumnType::Text); + email_col.unique = Some(StrOrBoolOrArray::Bool(true)); + + let table = TableDef { + name: "users".into(), + columns: vec![col("id", ColumnType::Integer), email_col], + constraints: vec![], + indexes: vec![], + }; + + let normalized = table.normalize().unwrap(); + assert_eq!(normalized.constraints.len(), 1); + assert!(matches!( + &normalized.constraints[0], + TableConstraint::Unique { name: None, columns } if columns == &["email".to_string()] + )); + } + + #[test] + fn normalize_inline_unique_with_name() { + let mut email_col = col("email", ColumnType::Text); + email_col.unique = Some(StrOrBoolOrArray::Str("uq_users_email".into())); + + let table = TableDef { + name: "users".into(), + columns: vec![col("id", ColumnType::Integer), email_col], + constraints: vec![], + indexes: vec![], + }; + + let normalized = table.normalize().unwrap(); + assert_eq!(normalized.constraints.len(), 1); + assert!(matches!( + &normalized.constraints[0], + TableConstraint::Unique { name: Some(n), columns } + if n == "uq_users_email" && columns == &["email".to_string()] + )); + } + + #[test] + fn normalize_inline_index_bool() { + let mut name_col = col("name", ColumnType::Text); + name_col.index = Some(StrOrBoolOrArray::Bool(true)); + + let table = TableDef { + name: "users".into(), + columns: vec![col("id", ColumnType::Integer), name_col], + constraints: vec![], + indexes: vec![], + }; + + let normalized = table.normalize().unwrap(); + assert_eq!(normalized.indexes.len(), 1); + assert_eq!(normalized.indexes[0].name, "idx_users_name"); + assert_eq!(normalized.indexes[0].columns, vec!["name".to_string()]); + assert!(!normalized.indexes[0].unique); + } + + #[test] + fn normalize_inline_index_with_name() { + let mut name_col = col("name", ColumnType::Text); + name_col.index = Some(StrOrBoolOrArray::Str("custom_idx_name".into())); + + let table = TableDef { + name: "users".into(), + columns: vec![col("id", ColumnType::Integer), name_col], + constraints: vec![], + indexes: vec![], + }; + + let normalized = table.normalize().unwrap(); + assert_eq!(normalized.indexes.len(), 1); + assert_eq!(normalized.indexes[0].name, "custom_idx_name"); + } + + #[test] + fn normalize_inline_foreign_key() { + let mut user_id_col = col("user_id", ColumnType::Integer); + user_id_col.foreign_key = Some(ForeignKeyDef { + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: Some(ReferenceAction::Cascade), + on_update: None, + }); + + let table = TableDef { + name: "posts".into(), + columns: vec![col("id", ColumnType::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: Some(ReferenceAction::Cascade), + on_update: None, + } if columns == &["user_id".to_string()] + && ref_table == "users" + && ref_columns == &["id".to_string()] + )); + } + + #[test] + fn normalize_all_inline_constraints() { + let mut id_col = col("id", ColumnType::Integer); + id_col.primary_key = Some(true); + + let mut email_col = col("email", ColumnType::Text); + email_col.unique = Some(StrOrBoolOrArray::Bool(true)); + + let mut name_col = col("name", ColumnType::Text); + name_col.index = Some(StrOrBoolOrArray::Bool(true)); + + let mut user_id_col = col("org_id", ColumnType::Integer); + user_id_col.foreign_key = Some(ForeignKeyDef { + ref_table: "orgs".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }); + + let table = TableDef { + name: "users".into(), + columns: vec![id_col, email_col, name_col, user_id_col], + constraints: vec![], + indexes: vec![], + }; + + let normalized = table.normalize().unwrap(); + // Should have: PrimaryKey, Unique, ForeignKey + assert_eq!(normalized.constraints.len(), 3); + // Should have: 1 index + assert_eq!(normalized.indexes.len(), 1); + } + + #[test] + fn normalize_composite_index_from_string_name() { + let mut updated_at_col = col("updated_at", ColumnType::Timestamp); + updated_at_col.index = Some(StrOrBoolOrArray::Str("tuple".into())); + + let mut user_id_col = col("user_id", ColumnType::Integer); + user_id_col.index = Some(StrOrBoolOrArray::Str("tuple".into())); + + let table = TableDef { + name: "post".into(), + columns: vec![col("id", ColumnType::Integer), updated_at_col, user_id_col], + constraints: vec![], + indexes: vec![], + }; + + let normalized = table.normalize().unwrap(); + assert_eq!(normalized.indexes.len(), 1); + assert_eq!(normalized.indexes[0].name, "tuple"); + assert_eq!( + normalized.indexes[0].columns, + vec!["updated_at".to_string(), "user_id".to_string()] + ); + assert!(!normalized.indexes[0].unique); + } + + #[test] + fn normalize_multiple_different_indexes() { + let mut col1 = col("col1", ColumnType::Text); + col1.index = Some(StrOrBoolOrArray::Str("idx_a".into())); + + let mut col2 = col("col2", ColumnType::Text); + col2.index = Some(StrOrBoolOrArray::Str("idx_a".into())); + + let mut col3 = col("col3", ColumnType::Text); + col3.index = Some(StrOrBoolOrArray::Str("idx_b".into())); + + let mut col4 = col("col4", ColumnType::Text); + col4.index = Some(StrOrBoolOrArray::Bool(true)); + + let table = TableDef { + name: "test".into(), + columns: vec![col("id", ColumnType::Integer), col1, col2, col3, col4], + constraints: vec![], + indexes: vec![], + }; + + let normalized = table.normalize().unwrap(); + assert_eq!(normalized.indexes.len(), 3); + + // Check idx_a composite index + let idx_a = normalized + .indexes + .iter() + .find(|i| i.name == "idx_a") + .unwrap(); + assert_eq!(idx_a.columns, vec!["col1".to_string(), "col2".to_string()]); + + // Check idx_b single column index + let idx_b = normalized + .indexes + .iter() + .find(|i| i.name == "idx_b") + .unwrap(); + assert_eq!(idx_b.columns, vec!["col3".to_string()]); + + // Check auto-generated index for col4 + let idx_col4 = normalized + .indexes + .iter() + .find(|i| i.name == "idx_test_col4") + .unwrap(); + assert_eq!(idx_col4.columns, vec!["col4".to_string()]); + } + + #[test] + fn normalize_false_values_are_ignored() { + let mut email_col = col("email", ColumnType::Text); + email_col.unique = Some(StrOrBoolOrArray::Bool(false)); + email_col.index = Some(StrOrBoolOrArray::Bool(false)); + + let table = TableDef { + name: "users".into(), + columns: vec![col("id", ColumnType::Integer), email_col], + constraints: vec![], + indexes: vec![], + }; + + let normalized = table.normalize().unwrap(); + assert_eq!(normalized.constraints.len(), 0); + assert_eq!(normalized.indexes.len(), 0); + } + + #[test] + fn normalize_multiple_indexes_from_same_array() { + // Multiple columns with same array of index names should create multiple composite indexes + let mut updated_at_col = col("updated_at", ColumnType::Timestamp); + updated_at_col.index = Some(StrOrBoolOrArray::Array(vec!["tuple".into(), "tuple2".into()])); + + let mut user_id_col = col("user_id", ColumnType::Integer); + user_id_col.index = Some(StrOrBoolOrArray::Array(vec!["tuple".into(), "tuple2".into()])); + + let table = TableDef { + name: "post".into(), + columns: vec![col("id", ColumnType::Integer), updated_at_col, user_id_col], + constraints: vec![], + indexes: vec![], + }; + + let normalized = table.normalize().unwrap(); + // Should have: tuple (composite: updated_at, user_id), tuple2 (composite: updated_at, user_id) + assert_eq!(normalized.indexes.len(), 2); + + let tuple_idx = normalized.indexes.iter().find(|i| i.name == "tuple").unwrap(); + let mut sorted_cols = tuple_idx.columns.clone(); + sorted_cols.sort(); + assert_eq!(sorted_cols, vec!["updated_at".to_string(), "user_id".to_string()]); + + let tuple2_idx = normalized.indexes.iter().find(|i| i.name == "tuple2").unwrap(); + let mut sorted_cols2 = tuple2_idx.columns.clone(); + sorted_cols2.sort(); + assert_eq!(sorted_cols2, vec!["updated_at".to_string(), "user_id".to_string()]); + } + + #[test] + fn normalize_inline_unique_with_array_existing_constraint() { + // Test Array format where constraint already exists - should add column to existing + let mut col1 = col("col1", ColumnType::Text); + col1.unique = Some(StrOrBoolOrArray::Array(vec!["uq_group".into()])); + + let mut col2 = col("col2", ColumnType::Text); + col2.unique = Some(StrOrBoolOrArray::Array(vec!["uq_group".into()])); + + let table = TableDef { + name: "test".into(), + columns: vec![col("id", ColumnType::Integer), col1, col2], + constraints: vec![], + indexes: vec![], + }; + + let normalized = table.normalize().unwrap(); + assert_eq!(normalized.constraints.len(), 1); + let unique_constraint = &normalized.constraints[0]; + assert!(matches!( + unique_constraint, + TableConstraint::Unique { name: Some(n), columns: _ } + if n == "uq_group" + )); + if let TableConstraint::Unique { columns, .. } = unique_constraint { + let mut sorted_cols = columns.clone(); + sorted_cols.sort(); + assert_eq!(sorted_cols, vec!["col1".to_string(), "col2".to_string()]); + } + } + + #[test] + fn normalize_inline_unique_with_array_column_already_in_constraint() { + // Test Array format where column is already in constraint - should not duplicate + let mut col1 = col("col1", ColumnType::Text); + col1.unique = Some(StrOrBoolOrArray::Array(vec!["uq_group".into()])); + + let table = TableDef { + name: "test".into(), + columns: vec![col("id", ColumnType::Integer), col1.clone()], + constraints: vec![], + indexes: vec![], + }; + + let normalized1 = table.normalize().unwrap(); + assert_eq!(normalized1.constraints.len(), 1); + + // Add same column again - should not create duplicate + let table2 = TableDef { + name: "test".into(), + columns: vec![col("id", ColumnType::Integer), col1], + constraints: normalized1.constraints.clone(), + indexes: vec![], + }; + + let normalized2 = table2.normalize().unwrap(); + assert_eq!(normalized2.constraints.len(), 1); + if let TableConstraint::Unique { columns, .. } = &normalized2.constraints[0] { + assert_eq!(columns.len(), 1); + assert_eq!(columns[0], "col1"); + } + } + + #[test] + fn normalize_inline_unique_str_already_exists() { + // Test that existing unique constraint with same name and column is not duplicated + let mut email_col = col("email", ColumnType::Text); + email_col.unique = Some(StrOrBoolOrArray::Str("uq_email".into())); + + let table = TableDef { + name: "users".into(), + columns: vec![col("id", ColumnType::Integer), email_col], + constraints: vec![TableConstraint::Unique { + name: Some("uq_email".into()), + columns: vec!["email".into()], + }], + indexes: vec![], + }; + + let normalized = table.normalize().unwrap(); + // Should not duplicate the constraint + let unique_constraints: Vec<_> = normalized + .constraints + .iter() + .filter(|c| matches!(c, TableConstraint::Unique { .. })) + .collect(); + assert_eq!(unique_constraints.len(), 1); + } + + #[test] + fn normalize_inline_unique_bool_already_exists() { + // Test that existing unnamed unique constraint with same column is not duplicated + let mut email_col = col("email", ColumnType::Text); + email_col.unique = Some(StrOrBoolOrArray::Bool(true)); + + let table = TableDef { + name: "users".into(), + columns: vec![col("id", ColumnType::Integer), email_col], + constraints: vec![TableConstraint::Unique { + name: None, + columns: vec!["email".into()], + }], + indexes: vec![], + }; + + let normalized = table.normalize().unwrap(); + // Should not duplicate the constraint + let unique_constraints: Vec<_> = normalized + .constraints + .iter() + .filter(|c| matches!(c, TableConstraint::Unique { .. })) + .collect(); + assert_eq!(unique_constraints.len(), 1); + } + + #[test] + 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::Integer); + user_id_col.foreign_key = Some(ForeignKeyDef { + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }); + + let table = TableDef { + name: "posts".into(), + columns: vec![col("id", ColumnType::Integer), user_id_col], + constraints: vec![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![], + }; + + let normalized = table.normalize().unwrap(); + // Should not duplicate the foreign key + let fk_constraints: Vec<_> = normalized + .constraints + .iter() + .filter(|c| matches!(c, TableConstraint::ForeignKey { .. })) + .collect(); + assert_eq!(fk_constraints.len(), 1); + } + + #[test] + fn normalize_duplicate_index_same_column_str() { + // Same index name applied to the same column multiple times should error + // This tests inline index duplicate, not table-level index + let mut col1 = col("col1", ColumnType::Text); + col1.index = Some(StrOrBoolOrArray::Str("idx1".into())); + + let table = TableDef { + name: "test".into(), + columns: vec![ + col("id", ColumnType::Integer), + col1.clone(), + { + // Same column with same index name again + let mut c = col1.clone(); + c.index = Some(StrOrBoolOrArray::Str("idx1".into())); + c + }, + ], + constraints: vec![], + indexes: vec![], + }; + + let result = table.normalize(); + assert!(result.is_err()); + if let Err(TableValidationError::DuplicateIndexColumn { index_name, column_name }) = result { + assert_eq!(index_name, "idx1"); + assert_eq!(column_name, "col1"); + } else { + panic!("Expected DuplicateIndexColumn error"); + } + } + + #[test] + fn normalize_duplicate_index_same_column_array() { + // Same index name in array applied to the same column should error + let mut col1 = col("col1", ColumnType::Text); + col1.index = Some(StrOrBoolOrArray::Array(vec!["idx1".into(), "idx1".into()])); + + let table = TableDef { + name: "test".into(), + columns: vec![col("id", ColumnType::Integer), col1], + constraints: vec![], + indexes: vec![], + }; + + let result = table.normalize(); + assert!(result.is_err()); + if let Err(TableValidationError::DuplicateIndexColumn { index_name, column_name }) = result { + assert_eq!(index_name, "idx1"); + assert_eq!(column_name, "col1"); + } else { + panic!("Expected DuplicateIndexColumn error"); + } + } + + #[test] + fn normalize_duplicate_index_same_column_multiple_definitions() { + // Same index name applied to the same column in different ways should error + let mut col1 = col("col1", ColumnType::Text); + col1.index = Some(StrOrBoolOrArray::Str("idx1".into())); + + let table = TableDef { + name: "test".into(), + columns: vec![ + col("id", ColumnType::Integer), + col1.clone(), + { + let mut c = col1.clone(); + c.index = Some(StrOrBoolOrArray::Array(vec!["idx1".into()])); + c + }, + ], + constraints: vec![], + indexes: vec![], + }; + + let result = table.normalize(); + assert!(result.is_err()); + if let Err(TableValidationError::DuplicateIndexColumn { index_name, column_name }) = result { + assert_eq!(index_name, "idx1"); + assert_eq!(column_name, "col1"); + } else { + panic!("Expected DuplicateIndexColumn error"); + } + } + + #[test] + fn test_table_validation_error_display() { + let error = TableValidationError::DuplicateIndexColumn { + index_name: "idx_test".into(), + column_name: "col1".into(), + }; + let error_msg = format!("{}", error); + assert!(error_msg.contains("idx_test")); + assert!(error_msg.contains("col1")); + assert!(error_msg.contains("Duplicate index")); + } + + #[test] + fn normalize_inline_unique_str_with_different_constraint_type() { + // Test that other constraint types don't match in the exists check + let mut email_col = col("email", ColumnType::Text); + email_col.unique = Some(StrOrBoolOrArray::Str("uq_email".into())); + + let table = TableDef { + name: "users".into(), + columns: vec![col("id", ColumnType::Integer), email_col], + constraints: vec![ + // Add a PrimaryKey constraint (different type) - should not match + TableConstraint::PrimaryKey { + columns: vec!["id".into()], + }, + ], + indexes: vec![], + }; + + let normalized = table.normalize().unwrap(); + // Should have: PrimaryKey (existing) + Unique (new) + assert_eq!(normalized.constraints.len(), 2); + } + + #[test] + fn normalize_inline_unique_array_with_different_constraint_type() { + // Test that other constraint types don't match in the exists check for Array case + let mut col1 = col("col1", ColumnType::Text); + col1.unique = Some(StrOrBoolOrArray::Array(vec!["uq_group".into()])); + + let table = TableDef { + name: "test".into(), + columns: vec![col("id", ColumnType::Integer), col1], + constraints: vec![ + // Add a PrimaryKey constraint (different type) - should not match + TableConstraint::PrimaryKey { + columns: vec!["id".into()], + }, + ], + indexes: vec![], + }; + + let normalized = table.normalize().unwrap(); + // Should have: PrimaryKey (existing) + Unique (new) + assert_eq!(normalized.constraints.len(), 2); + } + + #[test] + fn normalize_duplicate_index_bool_true_same_column() { + // Test that Bool(true) with duplicate on same column errors + let mut col1 = col("col1", ColumnType::Text); + col1.index = Some(StrOrBoolOrArray::Bool(true)); + + let table = TableDef { + name: "test".into(), + columns: vec![ + col("id", ColumnType::Integer), + col1.clone(), + { + // Same column with Bool(true) again + let mut c = col1.clone(); + c.index = Some(StrOrBoolOrArray::Bool(true)); + c + }, + ], + constraints: vec![], + indexes: vec![], + }; + + let result = table.normalize(); + assert!(result.is_err()); + if let Err(TableValidationError::DuplicateIndexColumn { index_name, column_name }) = result { + assert!(index_name.contains("idx_test")); + assert!(index_name.contains("col1")); + assert_eq!(column_name, "col1"); + } else { + panic!("Expected DuplicateIndexColumn error"); + } + } +} diff --git a/crates/vespertide-exporter/src/seaorm/mod.rs b/crates/vespertide-exporter/src/seaorm/mod.rs index 9d75d99..c7a542a 100644 --- a/crates/vespertide-exporter/src/seaorm/mod.rs +++ b/crates/vespertide-exporter/src/seaorm/mod.rs @@ -178,6 +178,77 @@ fn unique_name(base: &str, used: &mut HashSet) -> String { name } +#[cfg(test)] +mod helper_tests { + use super::*; + use vespertide_core::{ColumnType, IndexDef}; + + #[test] + fn test_render_indexes() { + let mut lines = Vec::new(); + let indexes = vec![ + IndexDef { + name: "idx_users_email".into(), + columns: vec!["email".into()], + unique: false, + }, + IndexDef { + name: "idx_users_name_email".into(), + columns: vec!["name".into(), "email".into()], + unique: true, + }, + ]; + render_indexes(&mut lines, &indexes); + assert!(!lines.is_empty()); + assert!(lines.iter().any(|l| l.contains("idx_users_email"))); + assert!(lines.iter().any(|l| l.contains("idx_users_name_email"))); + } + + #[test] + fn test_render_indexes_empty() { + let mut lines = Vec::new(); + render_indexes(&mut lines, &[]); + // Should not add anything when indexes are empty + assert_eq!(lines.len(), 0); + } + + #[test] + fn test_rust_type() { + assert_eq!(rust_type(&ColumnType::Integer, false), "i32"); + assert_eq!(rust_type(&ColumnType::Integer, true), "Option"); + assert_eq!(rust_type(&ColumnType::BigInt, false), "i64"); + assert_eq!(rust_type(&ColumnType::BigInt, true), "Option"); + assert_eq!(rust_type(&ColumnType::Text, false), "String"); + assert_eq!(rust_type(&ColumnType::Text, true), "Option"); + assert_eq!(rust_type(&ColumnType::Boolean, false), "bool"); + assert_eq!(rust_type(&ColumnType::Boolean, true), "Option"); + assert_eq!(rust_type(&ColumnType::Timestamp, false), "DateTimeWithTimeZone"); + assert_eq!(rust_type(&ColumnType::Timestamp, true), "Option"); + assert_eq!(rust_type(&ColumnType::Custom("MyType".into()), false), "MyType"); + assert_eq!(rust_type(&ColumnType::Custom("MyType".into()), true), "Option"); + } + + #[test] + fn test_sanitize_field_name() { + assert_eq!(sanitize_field_name("normal_name"), "normal_name"); + 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(""), "_col"); + assert_eq!(sanitize_field_name("a"), "a"); + } + + #[test] + fn test_unique_name() { + let mut used = std::collections::HashSet::new(); + assert_eq!(unique_name("test", &mut used), "test"); + assert_eq!(unique_name("test", &mut used), "test_1"); + assert_eq!(unique_name("test", &mut used), "test_2"); + assert_eq!(unique_name("other", &mut used), "other"); + assert_eq!(unique_name("other", &mut used), "other_1"); + } +} + #[cfg(test)] mod tests { use super::*; @@ -188,8 +259,8 @@ mod tests { #[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 }, + ColumnDef { name: "id".into(), r#type: ColumnType::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::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()] }], indexes: vec![], @@ -197,8 +268,8 @@ mod tests { #[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 }, + ColumnDef { name: "id".into(), r#type: ColumnType::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::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()] }], indexes: vec![], @@ -206,9 +277,9 @@ mod tests { #[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 }, + ColumnDef { name: "id".into(), r#type: ColumnType::Integer, nullable: false, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, + ColumnDef { name: "user_id".into(), r#type: ColumnType::Integer, nullable: false, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, + ColumnDef { name: "title".into(), r#type: ColumnType::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()] }, @@ -226,9 +297,9 @@ mod tests { #[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 }, + ColumnDef { name: "id".into(), r#type: ColumnType::Integer, nullable: false, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, + ColumnDef { name: "customer_id".into(), r#type: ColumnType::Integer, nullable: false, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, + ColumnDef { name: "customer_tenant_id".into(), r#type: ColumnType::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()] }, diff --git a/crates/vespertide-planner/src/apply.rs b/crates/vespertide-planner/src/apply.rs index a1a5ff9..e20bf2a 100644 --- a/crates/vespertide-planner/src/apply.rs +++ b/crates/vespertide-planner/src/apply.rs @@ -133,6 +133,22 @@ pub fn apply_action( } } MigrationAction::RawSql { .. } => Ok(()), // Does not mutate in-memory schema; allowed as side-effect-only + MigrationAction::AddConstraint { table, constraint } => { + let tbl = schema + .iter_mut() + .find(|t| t.name == *table) + .ok_or_else(|| PlannerError::TableNotFound(table.clone()))?; + tbl.constraints.push(constraint.clone()); + Ok(()) + } + MigrationAction::RemoveConstraint { table, constraint } => { + let tbl = schema + .iter_mut() + .find(|t| t.name == *table) + .ok_or_else(|| PlannerError::TableNotFound(table.clone()))?; + tbl.constraints.retain(|c| c != constraint); + Ok(()) + } } } @@ -223,6 +239,11 @@ mod tests { r#type: ty, nullable: true, default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, } } @@ -533,6 +554,47 @@ mod tests { vec![], )], })] + #[case(SuccessCase { + initial: vec![table("users", vec![col("id", ColumnType::Integer)], vec![], vec![])], + actions: vec![MigrationAction::AddConstraint { + table: "users".into(), + constraint: TableConstraint::PrimaryKey { + columns: vec!["id".into()], + }, + }], + expected: vec![table( + "users", + vec![col("id", ColumnType::Integer)], + vec![TableConstraint::PrimaryKey { + columns: vec!["id".into()], + }], + vec![], + )], + })] + #[case(SuccessCase { + initial: vec![table( + "users", + vec![col("id", ColumnType::Integer)], + vec![TableConstraint::PrimaryKey { + columns: vec!["id".into()], + }], + vec![], + )], + actions: vec![MigrationAction::RemoveConstraint { + table: "users".into(), + constraint: TableConstraint::PrimaryKey { + columns: vec!["id".into()], + }, + }], + expected: vec![table("users", vec![col("id", ColumnType::Integer)], vec![], vec![])], + })] + #[case(SuccessCase { + initial: vec![table("users", vec![col("id", ColumnType::Integer)], vec![], vec![])], + actions: vec![MigrationAction::RawSql { + sql: "SELECT 1;".to_string(), + }], + expected: vec![table("users", vec![col("id", ColumnType::Integer)], vec![], vec![])], + })] fn apply_action_success_cases(#[case] case: SuccessCase) { let mut schema = case.initial; for action in case.actions { diff --git a/crates/vespertide-planner/src/diff.rs b/crates/vespertide-planner/src/diff.rs index 036213b..10d664e 100644 --- a/crates/vespertide-planner/src/diff.rs +++ b/crates/vespertide-planner/src/diff.rs @@ -5,11 +5,34 @@ use vespertide_core::{MigrationAction, MigrationPlan, TableDef}; use crate::error::PlannerError; /// Diff two schema snapshots into a migration plan. +/// Both schemas are normalized to convert inline column constraints +/// (primary_key, unique, index, foreign_key) to table-level constraints. pub fn diff_schemas(from: &[TableDef], to: &[TableDef]) -> Result { let mut actions: Vec = Vec::new(); - let from_map: HashMap<_, _> = from.iter().map(|t| (t.name.as_str(), t)).collect(); - let to_map: HashMap<_, _> = to.iter().map(|t| (t.name.as_str(), t)).collect(); + // Normalize both schemas to ensure inline constraints are converted to table-level + let from_normalized: Vec = from + .iter() + .map(|t| { + t.normalize().map_err(|e| { + PlannerError::TableValidation(format!("Failed to normalize table '{}': {}", t.name, e)) + }) + }) + .collect::, _>>()?; + let to_normalized: Vec = to + .iter() + .map(|t| { + t.normalize().map_err(|e| { + PlannerError::TableValidation(format!("Failed to normalize table '{}': {}", t.name, e)) + }) + }) + .collect::, _>>()?; + + let from_map: HashMap<_, _> = from_normalized + .iter() + .map(|t| (t.name.as_str(), t)) + .collect(); + let to_map: HashMap<_, _> = to_normalized.iter().map(|t| (t.name.as_str(), t)).collect(); // Drop tables that disappeared. for name in from_map.keys() { @@ -97,8 +120,26 @@ pub fn diff_schemas(from: &[TableDef], to: &[TableDef]) -> Result ColumnDef { + ColumnDef { + name: name.to_string(), + r#type: ty, + nullable: false, + default: None, + comment: None, + primary_key: Some(true), + unique: None, + index: None, + foreign_key: None, + } + } + + fn col_with_unique(name: &str, ty: ColumnType) -> ColumnDef { + ColumnDef { + name: name.to_string(), + r#type: ty, + nullable: true, + default: None, + comment: None, + primary_key: None, + unique: Some(StrOrBoolOrArray::Bool(true)), + index: None, + foreign_key: None, + } + } + + fn col_with_index(name: &str, ty: ColumnType) -> ColumnDef { + ColumnDef { + name: name.to_string(), + r#type: ty, + nullable: true, + default: None, + comment: None, + primary_key: None, + unique: None, + index: Some(StrOrBoolOrArray::Bool(true)), + foreign_key: None, + } + } + + fn col_with_fk(name: &str, ty: ColumnType, ref_table: &str, ref_col: &str) -> ColumnDef { + ColumnDef { + name: name.to_string(), + r#type: ty, + nullable: true, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: Some(ForeignKeyDef { + ref_table: ref_table.to_string(), + ref_columns: vec![ref_col.to_string()], + on_delete: None, + on_update: None, + }), + } + } + + #[test] + fn create_table_with_inline_pk() { + let plan = diff_schemas( + &[], + &[table( + "users", + vec![ + col_with_pk("id", ColumnType::Integer), + col("name", ColumnType::Text), + ], + vec![], + vec![], + )], + ) + .unwrap(); + + assert_eq!(plan.actions.len(), 1); + if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] { + assert_eq!(constraints.len(), 1); + assert!(matches!( + &constraints[0], + TableConstraint::PrimaryKey { columns } if columns == &["id".to_string()] + )); + } else { + panic!("Expected CreateTable action"); + } + } + + #[test] + fn create_table_with_inline_unique() { + let plan = diff_schemas( + &[], + &[table( + "users", + vec![ + col("id", ColumnType::Integer), + col_with_unique("email", ColumnType::Text), + ], + vec![], + vec![], + )], + ) + .unwrap(); + + assert_eq!(plan.actions.len(), 1); + if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] { + assert_eq!(constraints.len(), 1); + assert!(matches!( + &constraints[0], + TableConstraint::Unique { name: None, columns } if columns == &["email".to_string()] + )); + } else { + panic!("Expected CreateTable action"); + } + } + + #[test] + fn create_table_with_inline_index() { + let plan = diff_schemas( + &[], + &[table( + "users", + vec![ + col("id", ColumnType::Integer), + col_with_index("name", ColumnType::Text), + ], + vec![], + vec![], + )], + ) + .unwrap(); + + // Should have CreateTable + AddIndex + assert_eq!(plan.actions.len(), 2); + assert!(matches!( + &plan.actions[0], + MigrationAction::CreateTable { .. } + )); + if let MigrationAction::AddIndex { index, .. } = &plan.actions[1] { + assert_eq!(index.name, "idx_users_name"); + assert_eq!(index.columns, vec!["name".to_string()]); + } else { + panic!("Expected AddIndex action"); + } + } + + #[test] + fn create_table_with_inline_fk() { + let plan = diff_schemas( + &[], + &[table( + "posts", + vec![ + col("id", ColumnType::Integer), + col_with_fk("user_id", ColumnType::Integer, "users", "id"), + ], + vec![], + vec![], + )], + ) + .unwrap(); + + assert_eq!(plan.actions.len(), 1); + if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] { + assert_eq!(constraints.len(), 1); + assert!(matches!( + &constraints[0], + TableConstraint::ForeignKey { columns, ref_table, ref_columns, .. } + if columns == &["user_id".to_string()] + && ref_table == "users" + && ref_columns == &["id".to_string()] + )); + } else { + panic!("Expected CreateTable action"); + } + } + + #[test] + fn add_index_via_inline_constraint() { + // Existing table without index -> table with inline index + let plan = diff_schemas( + &[table( + "users", + vec![ + col("id", ColumnType::Integer), + col("name", ColumnType::Text), + ], + vec![], + vec![], + )], + &[table( + "users", + vec![ + col("id", ColumnType::Integer), + col_with_index("name", ColumnType::Text), + ], + vec![], + vec![], + )], + ) + .unwrap(); + + assert_eq!(plan.actions.len(), 1); + if let MigrationAction::AddIndex { table, index } = &plan.actions[0] { + assert_eq!(table, "users"); + assert_eq!(index.name, "idx_users_name"); + assert_eq!(index.columns, vec!["name".to_string()]); + } else { + panic!("Expected AddIndex action, got {:?}", plan.actions[0]); + } + } + + #[test] + fn create_table_with_all_inline_constraints() { + let mut id_col = col("id", ColumnType::Integer); + id_col.primary_key = Some(true); + id_col.nullable = false; + + let mut email_col = col("email", ColumnType::Text); + email_col.unique = Some(StrOrBoolOrArray::Bool(true)); + + let mut name_col = col("name", ColumnType::Text); + name_col.index = Some(StrOrBoolOrArray::Bool(true)); + + let mut org_id_col = col("org_id", ColumnType::Integer); + org_id_col.foreign_key = Some(ForeignKeyDef { + ref_table: "orgs".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }); + + let plan = diff_schemas( + &[], + &[table( + "users", + vec![id_col, email_col, name_col, org_id_col], + vec![], + vec![], + )], + ) + .unwrap(); + + // Should have CreateTable + AddIndex + assert_eq!(plan.actions.len(), 2); + + if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] { + // Should have: PrimaryKey, Unique, ForeignKey (3 constraints) + assert_eq!(constraints.len(), 3); + } else { + panic!("Expected CreateTable action"); + } + + // Check for AddIndex action + assert!(matches!(&plan.actions[1], MigrationAction::AddIndex { .. })); + } + + #[test] + fn add_constraint_to_existing_table() { + // Add a unique constraint to an existing table + let from_schema = vec![table( + "users", + vec![col("id", ColumnType::Integer), col("email", ColumnType::Text)], + vec![], + vec![], + )]; + + let to_schema = vec![table( + "users", + vec![col("id", ColumnType::Integer), col("email", ColumnType::Text)], + vec![vespertide_core::TableConstraint::Unique { + name: Some("uq_users_email".into()), + columns: vec!["email".into()], + }], + vec![], + )]; + + let plan = diff_schemas(&from_schema, &to_schema).unwrap(); + assert_eq!(plan.actions.len(), 1); + if let MigrationAction::AddConstraint { table, constraint } = &plan.actions[0] { + assert_eq!(table, "users"); + assert!(matches!( + constraint, + vespertide_core::TableConstraint::Unique { name: Some(n), columns } + if n == "uq_users_email" && columns == &vec!["email".to_string()] + )); + } else { + panic!("Expected AddConstraint action, got {:?}", plan.actions[0]); + } + } + + #[test] + fn remove_constraint_from_existing_table() { + // Remove a unique constraint from an existing table + let from_schema = vec![table( + "users", + vec![col("id", ColumnType::Integer), col("email", ColumnType::Text)], + vec![vespertide_core::TableConstraint::Unique { + name: Some("uq_users_email".into()), + columns: vec!["email".into()], + }], + vec![], + )]; + + let to_schema = vec![table( + "users", + vec![col("id", ColumnType::Integer), col("email", ColumnType::Text)], + vec![], + vec![], + )]; + + let plan = diff_schemas(&from_schema, &to_schema).unwrap(); + assert_eq!(plan.actions.len(), 1); + if let MigrationAction::RemoveConstraint { table, constraint } = &plan.actions[0] { + assert_eq!(table, "users"); + assert!(matches!( + constraint, + vespertide_core::TableConstraint::Unique { name: Some(n), columns } + if n == "uq_users_email" && columns == &vec!["email".to_string()] + )); + } else { + panic!("Expected RemoveConstraint action, got {:?}", plan.actions[0]); + } + } + + #[test] + fn diff_schemas_with_normalize_error() { + // Test that normalize errors are properly propagated + let mut col1 = col("col1", ColumnType::Text); + col1.index = Some(StrOrBoolOrArray::Str("idx1".into())); + + let table = TableDef { + name: "test".into(), + columns: vec![ + col("id", ColumnType::Integer), + col1.clone(), + { + // Same column with same index name - should error + let mut c = col1.clone(); + c.index = Some(StrOrBoolOrArray::Str("idx1".into())); + c + }, + ], + constraints: vec![], + indexes: vec![], + }; + + let result = diff_schemas(&[], &[table]); + assert!(result.is_err()); + if let Err(PlannerError::TableValidation(msg)) = result { + assert!(msg.contains("Failed to normalize table")); + assert!(msg.contains("Duplicate index")); + } else { + panic!("Expected TableValidation error, got {:?}", result); + } + } + + #[test] + fn diff_schemas_with_normalize_error_in_from_schema() { + // Test that normalize errors in 'from' schema are properly propagated + let mut col1 = col("col1", ColumnType::Text); + col1.index = Some(StrOrBoolOrArray::Str("idx1".into())); + + let table = TableDef { + name: "test".into(), + columns: vec![ + col("id", ColumnType::Integer), + col1.clone(), + { + // Same column with same index name - should error + let mut c = col1.clone(); + c.index = Some(StrOrBoolOrArray::Str("idx1".into())); + c + }, + ], + constraints: vec![], + indexes: vec![], + }; + + // 'from' schema has the invalid table + let result = diff_schemas(&[table], &[]); + assert!(result.is_err()); + if let Err(PlannerError::TableValidation(msg)) = result { + assert!(msg.contains("Failed to normalize table")); + assert!(msg.contains("Duplicate index")); + } else { + panic!("Expected TableValidation error, got {:?}", result); + } + } + } } diff --git a/crates/vespertide-planner/src/error.rs b/crates/vespertide-planner/src/error.rs index fe841b9..896a52a 100644 --- a/crates/vespertide-planner/src/error.rs +++ b/crates/vespertide-planner/src/error.rs @@ -26,4 +26,6 @@ pub enum PlannerError { EmptyConstraintColumns(String, String), #[error("AddColumn requires fill_with when column is NOT NULL without default: {0}.{1}")] MissingFillWith(String, String), + #[error("table validation error: {0}")] + TableValidation(String), } diff --git a/crates/vespertide-planner/src/plan.rs b/crates/vespertide-planner/src/plan.rs index 985d424..a12c7d9 100644 --- a/crates/vespertide-planner/src/plan.rs +++ b/crates/vespertide-planner/src/plan.rs @@ -35,6 +35,11 @@ mod tests { r#type: ty, nullable: true, default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, } } diff --git a/crates/vespertide-planner/src/schema.rs b/crates/vespertide-planner/src/schema.rs index 386a209..5752eba 100644 --- a/crates/vespertide-planner/src/schema.rs +++ b/crates/vespertide-planner/src/schema.rs @@ -26,6 +26,11 @@ mod tests { r#type: ty, nullable: true, default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, } } diff --git a/crates/vespertide-planner/src/validate.rs b/crates/vespertide-planner/src/validate.rs index 3d06bc1..5bb2742 100644 --- a/crates/vespertide-planner/src/validate.rs +++ b/crates/vespertide-planner/src/validate.rs @@ -231,6 +231,11 @@ mod tests { r#type: ty, nullable: true, default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, } } @@ -553,6 +558,11 @@ mod tests { r#type: ColumnType::Text, nullable: false, default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, }, fill_with: None, }], @@ -584,6 +594,11 @@ mod tests { r#type: ColumnType::Text, nullable: false, default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, }, fill_with: Some("default@example.com".into()), }], @@ -608,6 +623,11 @@ mod tests { r#type: ColumnType::Text, nullable: true, default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, }, fill_with: None, }], @@ -632,6 +652,11 @@ mod tests { r#type: ColumnType::Text, nullable: false, default: Some("default@example.com".into()), + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, }, fill_with: None, }], diff --git a/crates/vespertide-query/src/builder.rs b/crates/vespertide-query/src/builder.rs index 235afc1..d799361 100644 --- a/crates/vespertide-query/src/builder.rs +++ b/crates/vespertide-query/src/builder.rs @@ -23,6 +23,11 @@ mod tests { r#type: ty, nullable: true, default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, } } diff --git a/crates/vespertide-query/src/error.rs b/crates/vespertide-query/src/error.rs index 889ba5c..77472ab 100644 --- a/crates/vespertide-query/src/error.rs +++ b/crates/vespertide-query/src/error.rs @@ -4,4 +4,6 @@ use thiserror::Error; pub enum QueryError { #[error("unsupported table constraint")] UnsupportedConstraint, + #[error("{0}")] + Other(String), } diff --git a/crates/vespertide-query/src/sql.rs b/crates/vespertide-query/src/sql.rs index f35b8f3..f62330d 100644 --- a/crates/vespertide-query/src/sql.rs +++ b/crates/vespertide-query/src/sql.rs @@ -187,6 +187,64 @@ pub fn build_action_queries(action: &MigrationAction) -> Result, sql: sql.to_string(), binds: Vec::new(), }]), + MigrationAction::AddConstraint { table, constraint } => { + let mut binds = Vec::new(); + let t = bind(&mut binds, table); + let constraint_sql = table_constraint_sql(constraint, &mut binds)?; + Ok(vec![BuiltQuery { + sql: format!("ALTER TABLE {t} ADD {constraint_sql};"), + binds, + }]) + } + MigrationAction::RemoveConstraint { table, constraint } => { + let mut binds = Vec::new(); + let t = bind(&mut binds, table); + // For removing constraints, we need to handle each type differently + let drop_sql = match constraint { + TableConstraint::PrimaryKey { .. } => { + // PostgreSQL syntax for dropping primary key + format!("ALTER TABLE {t} DROP CONSTRAINT {t}_pkey;") + } + TableConstraint::Unique { name, columns } => { + if let Some(n) = name { + let nm = bind(&mut binds, n); + format!("ALTER TABLE {t} DROP CONSTRAINT {nm};") + } else { + // Generate default constraint name for unnamed unique + let cols = columns.join("_"); + let constraint_name = bind(&mut binds, format!("{}_{}_key", table, cols)); + format!("ALTER TABLE {t} DROP CONSTRAINT {constraint_name};") + } + } + TableConstraint::ForeignKey { name, columns, .. } => { + if let Some(n) = name { + let nm = bind(&mut binds, n); + format!("ALTER TABLE {t} DROP CONSTRAINT {nm};") + } else { + // Generate default constraint name for unnamed foreign key + let cols = columns.join("_"); + let constraint_name = bind(&mut binds, format!("{}_{}_fkey", table, cols)); + format!("ALTER TABLE {t} DROP CONSTRAINT {constraint_name};") + } + } + TableConstraint::Check { name, .. } => { + if let Some(n) = name { + let nm = bind(&mut binds, n); + format!("ALTER TABLE {t} DROP CONSTRAINT {nm};") + } else { + // Check constraints without names are problematic to drop + // Return an error or use a placeholder + return Err(QueryError::Other( + "Cannot drop unnamed CHECK constraint".to_string(), + )); + } + } + }; + Ok(vec![BuiltQuery { + sql: drop_sql, + binds, + }]) + } } } @@ -353,6 +411,11 @@ mod tests { r#type: ty, nullable: true, default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, } } @@ -567,6 +630,119 @@ mod tests { vec!["old_users".to_string(), "new_users".to_string()], )] )] + #[case::raw_sql( + MigrationAction::RawSql { + sql: "SELECT 1;".to_string(), + }, + vec![("SELECT 1;".to_string(), vec![])] + )] + #[case::add_constraint_primary_key( + MigrationAction::AddConstraint { + table: "users".into(), + constraint: TableConstraint::PrimaryKey { + columns: vec!["id".into()], + }, + }, + vec![( + "ALTER TABLE $1 ADD PRIMARY KEY ($2);".to_string(), + vec!["users".to_string(), "id".to_string()], + )] + )] + #[case::add_constraint_unique( + MigrationAction::AddConstraint { + table: "users".into(), + constraint: TableConstraint::Unique { + name: Some("unique_email".into()), + columns: vec!["email".into()], + }, + }, + vec![( + "ALTER TABLE $1 ADD CONSTRAINT $2 UNIQUE ($3);".to_string(), + vec!["users".to_string(), "unique_email".to_string(), "email".to_string()], + )] + )] + #[case::remove_constraint_primary_key( + MigrationAction::RemoveConstraint { + table: "users".into(), + constraint: TableConstraint::PrimaryKey { + columns: vec!["id".into()], + }, + }, + vec![("ALTER TABLE $1 DROP CONSTRAINT $1_pkey;".to_string(), vec!["users".to_string()])] + )] + #[case::remove_constraint_unique_named( + MigrationAction::RemoveConstraint { + table: "users".into(), + constraint: TableConstraint::Unique { + name: Some("unique_email".into()), + columns: vec!["email".into()], + }, + }, + vec![( + "ALTER TABLE $1 DROP CONSTRAINT $2;".to_string(), + vec!["users".to_string(), "unique_email".to_string()], + )] + )] + #[case::remove_constraint_unique_unnamed( + MigrationAction::RemoveConstraint { + table: "users".into(), + constraint: TableConstraint::Unique { + name: None, + columns: vec!["email".into()], + }, + }, + vec![( + "ALTER TABLE $1 DROP CONSTRAINT $2;".to_string(), + vec!["users".to_string(), "users_email_key".to_string()], + )] + )] + #[case::remove_constraint_foreign_key_named( + MigrationAction::RemoveConstraint { + table: "posts".into(), + constraint: TableConstraint::ForeignKey { + name: Some("fk_user".into()), + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }, + vec![( + "ALTER TABLE $1 DROP CONSTRAINT $2;".to_string(), + vec!["posts".to_string(), "fk_user".to_string()], + )] + )] + #[case::remove_constraint_foreign_key_unnamed( + MigrationAction::RemoveConstraint { + table: "posts".into(), + constraint: TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }, + vec![( + "ALTER TABLE $1 DROP CONSTRAINT $2;".to_string(), + vec!["posts".to_string(), "posts_user_id_fkey".to_string()], + )] + )] + #[case::remove_constraint_check_named( + MigrationAction::RemoveConstraint { + table: "users".into(), + constraint: TableConstraint::Check { + name: Some("check_age".into()), + expr: "age > 0".into(), + }, + }, + vec![( + "ALTER TABLE $1 DROP CONSTRAINT $2;".to_string(), + vec!["users".to_string(), "check_age".to_string()], + )] + )] fn test_build_action_queries( #[case] action: MigrationAction, #[case] expected: Vec<(String, Vec)>, @@ -833,4 +1009,23 @@ mod tests { let mut binds = Vec::new(); assert_eq!(reference_action_sql(&action, &mut binds), expected); } + + #[test] + fn test_remove_constraint_check_unnamed_error() { + let action = MigrationAction::RemoveConstraint { + table: "users".into(), + constraint: TableConstraint::Check { + name: None, + expr: "age > 0".into(), + }, + }; + let result = build_action_queries(&action); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("Cannot drop unnamed CHECK constraint") + ); + } } diff --git a/examples/app/migrations/0002_create_post.json b/examples/app/migrations/0002_create_post.json new file mode 100644 index 0000000..ffc7f59 --- /dev/null +++ b/examples/app/migrations/0002_create_post.json @@ -0,0 +1,193 @@ +{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/migration.schema.json", + "actions": [ + { + "column": "aa", + "table": "user", + "type": "DeleteColumn" + }, + { + "column": { + "comment": null, + "default": null, + "foreign_key": null, + "index": null, + "name": "id", + "nullable": false, + "primary_key": true, + "type": "Integer", + "unique": null + }, + "fill_with": "1", + "table": "user", + "type": "AddColumn" + }, + { + "column": { + "comment": null, + "default": null, + "foreign_key": null, + "index": null, + "name": "name", + "nullable": false, + "primary_key": null, + "type": "Text", + "unique": null + }, + "fill_with": "John Doe", + "table": "user", + "type": "AddColumn" + }, + { + "column": { + "comment": null, + "default": null, + "foreign_key": null, + "index": null, + "name": "email", + "nullable": false, + "primary_key": null, + "type": "Text", + "unique": true + }, + "fill_with": "john.doe@example.com", + "table": "user", + "type": "AddColumn" + }, + { + "constraint": { + "columns": [ + "id" + ], + "type": "primary_key" + }, + "table": "user", + "type": "AddConstraint" + }, + { + "constraint": { + "columns": [ + "email" + ], + "name": null, + "type": "unique" + }, + "table": "user", + "type": "AddConstraint" + }, + { + "columns": [ + { + "comment": null, + "default": null, + "foreign_key": null, + "index": null, + "name": "id", + "nullable": false, + "primary_key": true, + "type": "Integer", + "unique": null + }, + { + "comment": null, + "default": null, + "foreign_key": null, + "index": null, + "name": "title", + "nullable": false, + "primary_key": null, + "type": "Text", + "unique": null + }, + { + "comment": null, + "default": null, + "foreign_key": null, + "index": null, + "name": "content", + "nullable": false, + "primary_key": null, + "type": "Text", + "unique": null + }, + { + "comment": null, + "default": null, + "foreign_key": null, + "index": null, + "name": "created_at", + "nullable": false, + "primary_key": null, + "type": "Timestamp", + "unique": null + }, + { + "comment": null, + "default": null, + "foreign_key": null, + "index": null, + "name": "updated_at", + "nullable": true, + "primary_key": null, + "type": "Timestamp", + "unique": null + }, + { + "comment": null, + "default": null, + "foreign_key": { + "on_delete": null, + "on_update": null, + "ref_columns": [ + "id" + ], + "ref_table": "user" + }, + "index": true, + "name": "user_id", + "nullable": false, + "primary_key": null, + "type": "Integer", + "unique": null + } + ], + "constraints": [ + { + "columns": [ + "id" + ], + "type": "primary_key" + }, + { + "columns": [ + "user_id" + ], + "name": null, + "on_delete": null, + "on_update": null, + "ref_columns": [ + "id" + ], + "ref_table": "user", + "type": "foreign_key" + } + ], + "table": "post", + "type": "CreateTable" + }, + { + "index": { + "columns": [ + "user_id" + ], + "name": "idx_post_user_id", + "unique": false + }, + "table": "post", + "type": "AddIndex" + } + ], + "comment": "Create post", + "created_at": "2025-12-12T18:17:30Z", + "version": 2 +} \ No newline at end of file diff --git a/examples/app/models/post/post.json b/examples/app/models/post/post.json index f1d1857..657805b 100644 --- a/examples/app/models/post/post.json +++ b/examples/app/models/post/post.json @@ -1,8 +1,14 @@ { "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/model.schema.json", + "name": "post", "columns": [{ - "name": "title", + "name": "id", "type": "Integer", + "nullable": false, + "primary_key": true + }, { + "name": "title", + "type": "Text", "nullable": false }, { "name": "content", @@ -15,21 +21,18 @@ }, { "name": "updated_at", "type": "Timestamp", - "nullable": true + "nullable": true, + "index": ["tuple","tuple2"] }, { "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"] + "nullable": false, + "foreign_key": { + "ref_table": "user", + "ref_columns": ["id"] + }, + "index": ["tuple","tuple2"] }], - "indexes": [], - "name": "post" + "constraints": [], + "indexes": [] } \ No newline at end of file diff --git a/examples/app/models/user.json b/examples/app/models/user.json index e6a3f88..2714de6 100644 --- a/examples/app/models/user.json +++ b/examples/app/models/user.json @@ -1,11 +1,21 @@ { - "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/model.schema.json", + "$schema": "../../../schemas/model.schema.json", + "name": "user", "columns": [{ - "name": "aa", + "name": "id", "type": "Integer", + "nullable": false, + "primary_key": true + }, { + "name": "name", + "type": "Text", "nullable": false + }, { + "name": "email", + "type": "Text", + "nullable": false, + "unique": true }], "constraints": [], - "indexes": [], - "name": "user" + "indexes": [] } \ No newline at end of file diff --git a/schemas/migration.schema.json b/schemas/migration.schema.json index f2d4b2a..8efcf55 100644 --- a/schemas/migration.schema.json +++ b/schemas/migration.schema.json @@ -36,20 +36,62 @@ "ColumnDef": { "type": "object", "properties": { + "comment": { + "type": [ + "string", + "null" + ] + }, "default": { "type": [ "string", "null" ] }, + "foreign_key": { + "anyOf": [ + { + "$ref": "#/$defs/ForeignKeyDef" + }, + { + "type": "null" + } + ] + }, + "index": { + "anyOf": [ + { + "$ref": "#/$defs/StrOrBoolOrArray" + }, + { + "type": "null" + } + ] + }, "name": { "type": "string" }, "nullable": { "type": "boolean" }, + "primary_key": { + "type": [ + "boolean", + "null" + ] + }, "type": { "$ref": "#/$defs/ColumnType" + }, + "unique": { + "anyOf": [ + { + "$ref": "#/$defs/StrOrBoolOrArray" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -84,6 +126,44 @@ } ] }, + "ForeignKeyDef": { + "type": "object", + "properties": { + "on_delete": { + "anyOf": [ + { + "$ref": "#/$defs/ReferenceAction" + }, + { + "type": "null" + } + ] + }, + "on_update": { + "anyOf": [ + { + "$ref": "#/$defs/ReferenceAction" + }, + { + "type": "null" + } + ] + }, + "ref_columns": { + "type": "array", + "items": { + "type": "string" + } + }, + "ref_table": { + "type": "string" + } + }, + "required": [ + "ref_table", + "ref_columns" + ] + }, "IndexDef": { "type": "object", "properties": { @@ -108,22 +188,6 @@ }, "MigrationAction": { "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "RawSql" - }, - "sql": { - "type": "string" - } - }, - "required": [ - "type", - "sql" - ] - }, { "type": "object", "properties": { @@ -305,6 +369,46 @@ "name" ] }, + { + "type": "object", + "properties": { + "constraint": { + "$ref": "#/$defs/TableConstraint" + }, + "table": { + "type": "string" + }, + "type": { + "type": "string", + "const": "AddConstraint" + } + }, + "required": [ + "type", + "table", + "constraint" + ] + }, + { + "type": "object", + "properties": { + "constraint": { + "$ref": "#/$defs/TableConstraint" + }, + "table": { + "type": "string" + }, + "type": { + "type": "string", + "const": "RemoveConstraint" + } + }, + "required": [ + "type", + "table", + "constraint" + ] + }, { "type": "object", "properties": { @@ -324,6 +428,22 @@ "from", "to" ] + }, + { + "type": "object", + "properties": { + "sql": { + "type": "string" + }, + "type": { + "type": "string", + "const": "RawSql" + } + }, + "required": [ + "type", + "sql" + ] } ] }, @@ -337,6 +457,22 @@ "NoAction" ] }, + "StrOrBoolOrArray": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "boolean" + } + ] + }, "TableConstraint": { "oneOf": [ { diff --git a/schemas/model.schema.json b/schemas/model.schema.json index c396a91..526e602 100644 --- a/schemas/model.schema.json +++ b/schemas/model.schema.json @@ -35,20 +35,62 @@ "ColumnDef": { "type": "object", "properties": { + "comment": { + "type": [ + "string", + "null" + ] + }, "default": { "type": [ "string", "null" ] }, + "foreign_key": { + "anyOf": [ + { + "$ref": "#/$defs/ForeignKeyDef" + }, + { + "type": "null" + } + ] + }, + "index": { + "anyOf": [ + { + "$ref": "#/$defs/StrOrBoolOrArray" + }, + { + "type": "null" + } + ] + }, "name": { "type": "string" }, "nullable": { "type": "boolean" }, + "primary_key": { + "type": [ + "boolean", + "null" + ] + }, "type": { "$ref": "#/$defs/ColumnType" + }, + "unique": { + "anyOf": [ + { + "$ref": "#/$defs/StrOrBoolOrArray" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -83,6 +125,44 @@ } ] }, + "ForeignKeyDef": { + "type": "object", + "properties": { + "on_delete": { + "anyOf": [ + { + "$ref": "#/$defs/ReferenceAction" + }, + { + "type": "null" + } + ] + }, + "on_update": { + "anyOf": [ + { + "$ref": "#/$defs/ReferenceAction" + }, + { + "type": "null" + } + ] + }, + "ref_columns": { + "type": "array", + "items": { + "type": "string" + } + }, + "ref_table": { + "type": "string" + } + }, + "required": [ + "ref_table", + "ref_columns" + ] + }, "IndexDef": { "type": "object", "properties": { @@ -115,6 +195,22 @@ "NoAction" ] }, + "StrOrBoolOrArray": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "boolean" + } + ] + }, "TableConstraint": { "oneOf": [ {