diff --git a/crates/oxyde-migrate/src/diff.rs b/crates/oxyde-migrate/src/diff.rs index e0f19ac..e807ba1 100644 --- a/crates/oxyde-migrate/src/diff.rs +++ b/crates/oxyde-migrate/src/diff.rs @@ -248,6 +248,35 @@ pub fn compute_diff(old: &Snapshot, new: &Snapshot) -> Result> } } + // Find dropped and changed indexes + for old_idx in &old_table.indexes { + match new_table + .indexes + .iter() + .find(|idx| idx.name == old_idx.name) + { + Some(new_idx) if !new_idx.semantically_eq(old_idx) => { + ops.push(MigrationOp::DropIndex { + table: name.clone(), + name: old_idx.name.clone(), + index_def: Some(old_idx.clone()), + }); + ops.push(MigrationOp::CreateIndex { + table: name.clone(), + index: new_idx.clone(), + }); + } + None => { + ops.push(MigrationOp::DropIndex { + table: name.clone(), + name: old_idx.name.clone(), + index_def: Some(old_idx.clone()), + }); + } + _ => {} + } + } + // Find added indexes for new_idx in &new_table.indexes { if !old_table.indexes.iter().any(|idx| idx.name == new_idx.name) { @@ -258,17 +287,6 @@ pub fn compute_diff(old: &Snapshot, new: &Snapshot) -> Result> } } - // Find dropped indexes - for old_idx in &old_table.indexes { - if !new_table.indexes.iter().any(|idx| idx.name == old_idx.name) { - ops.push(MigrationOp::DropIndex { - table: name.clone(), - name: old_idx.name.clone(), - index_def: Some(old_idx.clone()), - }); - } - } - // Find added foreign keys for new_fk in &new_table.foreign_keys { if !old_table diff --git a/crates/oxyde-migrate/src/sql.rs b/crates/oxyde-migrate/src/sql.rs index e3b6bec..ccf37e4 100644 --- a/crates/oxyde-migrate/src/sql.rs +++ b/crates/oxyde-migrate/src/sql.rs @@ -256,7 +256,7 @@ fn mysql_column_def(field: &FieldDef) -> String { } /// Build CREATE INDEX SQL for an index on a table. -fn build_create_index(table: &str, index: &IndexDef, dialect: Dialect) -> String { +fn build_create_index(table: &str, index: &IndexDef, dialect: Dialect) -> Result { let mut stmt = SeaIndex::create(); stmt.name(&index.name).table(Alias::new(table)); @@ -275,7 +275,19 @@ fn build_create_index(table: &str, index: &IndexDef, dialect: Dialect) -> String } } - build_sql!(stmt, dialect) + let mut sql = build_sql!(stmt, dialect); + + if let Some(predicate) = index.normalized_where_clause() { + if dialect == Dialect::Mysql { + return Err(MigrateError::MigrationError( + "MySQL does not support partial indexes with WHERE predicates".to_string(), + )); + } + sql.push_str(" WHERE "); + sql.push_str(predicate); + } + + Ok(sql) } // ── SQLite table rebuild ──────────────────────────────────────────────────── @@ -353,7 +365,7 @@ fn sqlite_table_rebuild( // Recreate indexes for index in indexes { - stmts.push(build_create_index(table, index, Dialect::Sqlite)); + stmts.push(build_create_index(table, index, Dialect::Sqlite)?); } stmts.push("PRAGMA foreign_keys=ON".to_string()); @@ -393,7 +405,7 @@ impl MigrationOp { // Indexes (all dialects) for index in &table.indexes { - sql.push(build_create_index(&table.name, index, dialect)); + sql.push(build_create_index(&table.name, index, dialect)?); } // PG/MySQL: FK and CHECK as separate ALTER TABLE statements @@ -574,7 +586,7 @@ impl MigrationOp { }, MigrationOp::CreateIndex { table, index } => { - Ok(vec![build_create_index(table, index, dialect)]) + Ok(vec![build_create_index(table, index, dialect)?]) } MigrationOp::DropIndex { diff --git a/crates/oxyde-migrate/src/types.rs b/crates/oxyde-migrate/src/types.rs index bc46dca..41f79e1 100644 --- a/crates/oxyde-migrate/src/types.rs +++ b/crates/oxyde-migrate/src/types.rs @@ -1,6 +1,6 @@ //! Core data types for the migration system. -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::collections::HashMap; use thiserror::Error; @@ -33,6 +33,46 @@ pub enum MigrateError { pub type Result = std::result::Result; +fn normalize_optional_sql_fragment(value: Option) -> Option { + value + .map(|fragment| fragment.trim().to_string()) + .filter(|fragment| !fragment.is_empty()) +} + +fn is_none_or_blank(value: &Option) -> bool { + value + .as_deref() + .map(str::trim) + .map_or(true, |fragment| fragment.is_empty()) +} + +fn serialize_normalized_optional_sql_fragment( + value: &Option, + serializer: S, +) -> std::result::Result +where + S: Serializer, +{ + match value + .as_deref() + .map(str::trim) + .filter(|fragment| !fragment.is_empty()) + { + Some(fragment) => serializer.serialize_some(fragment), + None => serializer.serialize_none(), + } +} + +fn deserialize_normalized_optional_sql_fragment<'de, D>( + deserializer: D, +) -> std::result::Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + Ok(normalize_optional_sql_fragment(value)) +} + /// Field definition in schema #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct FieldDef { @@ -66,6 +106,31 @@ pub struct IndexDef { pub fields: Vec, pub unique: bool, pub method: Option, + #[serde( + default, + rename = "where", + skip_serializing_if = "is_none_or_blank", + serialize_with = "serialize_normalized_optional_sql_fragment", + deserialize_with = "deserialize_normalized_optional_sql_fragment" + )] + pub where_clause: Option, +} + +impl IndexDef { + pub fn normalized_where_clause(&self) -> Option<&str> { + self.where_clause + .as_deref() + .map(str::trim) + .filter(|fragment| !fragment.is_empty()) + } + + pub fn semantically_eq(&self, other: &Self) -> bool { + self.name == other.name + && self.fields == other.fields + && self.unique == other.unique + && self.method == other.method + && self.normalized_where_clause() == other.normalized_where_clause() + } } /// Foreign key definition diff --git a/crates/oxyde-migrate/tests/migration_tests.rs b/crates/oxyde-migrate/tests/migration_tests.rs index b7456c2..d59e8f7 100644 --- a/crates/oxyde-migrate/tests/migration_tests.rs +++ b/crates/oxyde-migrate/tests/migration_tests.rs @@ -47,6 +47,7 @@ fn sample_table() -> TableDef { fields: vec!["email".into()], unique: true, method: Some("btree".into()), + where_clause: None, }], foreign_keys: vec![], checks: vec![], @@ -312,6 +313,7 @@ fn test_dialect_specific_sql() { fields: vec!["name".into()], unique: false, method: None, + where_clause: None, }; let drop_idx_mysql = MigrationOp::DropIndex { table: "users".into(), @@ -462,6 +464,7 @@ fn test_sqlite_alter_column_with_schema_generates_rebuild() { fields: vec!["name".into()], unique: false, method: None, + where_clause: None, }]; let result = MigrationOp::AlterColumn { @@ -843,6 +846,7 @@ fn test_compute_diff_detects_index_changes() { fields: vec!["name".into()], unique: false, method: None, + where_clause: None, }); new.add_table(table); @@ -859,6 +863,55 @@ fn test_compute_diff_detects_index_changes() { assert!(drop_idx, "Should detect dropped index"); } +#[test] +fn test_compute_diff_detects_partial_index_predicate_change() { + let mut old = Snapshot::new(); + old.add_table(sample_table()); + + let mut new = Snapshot::new(); + let mut table = sample_table(); + table.indexes[0].where_clause = Some("deleted_at IS NULL".into()); + new.add_table(table); + + let ops = compute_diff(&old, &new).unwrap(); + + assert!( + matches!(ops.first(), Some(MigrationOp::DropIndex { name, .. }) if name == "users_email_idx") + ); + assert!( + matches!( + ops.get(1), + Some(MigrationOp::CreateIndex { index, .. }) + if index.name == "users_email_idx" + && index.where_clause.as_deref() == Some("deleted_at IS NULL") + ), + "predicate changes should rebuild the index, got {:?}", + ops + ); +} + +#[test] +fn test_compute_diff_ignores_partial_index_predicate_whitespace() { + let mut old = Snapshot::new(); + old.add_table(sample_table()); + + let mut new = Snapshot::new(); + let mut table = sample_table(); + table.indexes[0].where_clause = Some(" deleted_at IS NULL ".into()); + new.add_table(table); + + let mut old_table = old.tables.get_mut("users").unwrap().clone(); + old_table.indexes[0].where_clause = Some("deleted_at IS NULL".into()); + old.tables.insert("users".into(), old_table); + + let ops = compute_diff(&old, &new).unwrap(); + + assert!( + ops.is_empty(), + "whitespace-only predicate changes should not diff" + ); +} + #[test] fn test_drop_table_sql() { let sql = MigrationOp::DropTable { @@ -882,6 +935,7 @@ fn test_create_drop_index_sql() { fields: vec!["email".into()], unique: true, method: Some("btree".into()), + where_clause: None, }, } .to_sql(Dialect::Postgres) @@ -901,6 +955,7 @@ fn test_create_drop_index_sql() { fields: vec!["email".into()], unique: true, method: None, + where_clause: None, }), } .to_sql(Dialect::Postgres) @@ -911,6 +966,71 @@ fn test_create_drop_index_sql() { assert!(sql[0].contains("users_email_idx")); } +#[test] +fn test_partial_index_sql() { + let index = IndexDef { + name: "users_active_email_idx".into(), + fields: vec!["email".into()], + unique: true, + method: Some("btree".into()), + where_clause: Some("deleted_at IS NULL".into()), + }; + + for dialect in [Dialect::Postgres, Dialect::Sqlite] { + let sql = MigrationOp::CreateIndex { + table: "users".into(), + index: index.clone(), + } + .to_sql(dialect) + .unwrap(); + + assert_eq!(sql.len(), 1); + assert!(sql[0].contains("WHERE deleted_at IS NULL")); + } + + let err = MigrationOp::CreateIndex { + table: "users".into(), + index, + } + .to_sql(Dialect::Mysql) + .unwrap_err(); + + assert!(err + .to_string() + .contains("MySQL does not support partial indexes")); +} + +#[test] +fn test_partial_index_json_roundtrip_trims_predicate() { + let snapshot = Snapshot::from_json( + r#"{ + "version": 1, + "tables": { + "users": { + "name": "users", + "fields": [], + "indexes": [{ + "name": "users_active_email_idx", + "fields": ["email"], + "unique": true, + "method": "btree", + "where": " deleted_at IS NULL " + }], + "foreign_keys": [], + "checks": [], + "comment": null + } + } + }"#, + ) + .unwrap(); + + let json = snapshot.to_json().unwrap(); + + assert!(json.contains(r#""where": "deleted_at IS NULL""#)); + assert!(!json.contains(" deleted_at IS NULL ")); +} + #[test] fn test_rename_table_sql() { let sql = MigrationOp::RenameTable { diff --git a/docs/guide/models.md b/docs/guide/models.md index a7249c0..4a47740 100644 --- a/docs/guide/models.md +++ b/docs/guide/models.md @@ -144,6 +144,8 @@ class Event(Model): ### Partial Index +Partial indexes are supported by PostgreSQL and SQLite migrations. + ```python class User(Model): email: str diff --git a/python/oxyde/migrations/extract.py b/python/oxyde/migrations/extract.py index d02fe83..36ae806 100644 --- a/python/oxyde/migrations/extract.py +++ b/python/oxyde/migrations/extract.py @@ -249,6 +249,9 @@ def extract_current_schema(dialect: str = "sqlite") -> dict[str, Any]: "fields": list(index.fields), "unique": index.unique, "method": index.method, + "where": index.where.strip() or None + if isinstance(index.where, str) + else index.where, } ) diff --git a/python/oxyde/models/decorators.py b/python/oxyde/models/decorators.py index 84afadc..28bef7a 100644 --- a/python/oxyde/models/decorators.py +++ b/python/oxyde/models/decorators.py @@ -85,6 +85,8 @@ def __init__( self.method = method self.name = name self.unique = unique + if where is not None: + where = where.strip() or None self.where = where diff --git a/python/oxyde/tests/unit/test_migrations_detection.py b/python/oxyde/tests/unit/test_migrations_detection.py index 8bc1b27..8b813cd 100644 --- a/python/oxyde/tests/unit/test_migrations_detection.py +++ b/python/oxyde/tests/unit/test_migrations_detection.py @@ -9,6 +9,7 @@ from oxyde import Field, Model from oxyde.migrations.context import MigrationContext +from oxyde.migrations.extract import extract_current_schema from oxyde.migrations.generator import _operation_to_python from oxyde.migrations.replay import SchemaState from oxyde.models.registry import registered_tables @@ -113,6 +114,33 @@ class Meta: assert len(IndexedTable._db_meta.indexes) == 1 assert IndexedTable._db_meta.indexes[0].name == "idx_full_name" + def test_extract_partial_index_where_in_snapshot(self): + """Table-level partial index predicates must survive schema extraction.""" + from oxyde.models.decorators import Index + + class User(Model): + id: int | None = Field(default=None, db_pk=True) + email: str + deleted_at: datetime | None = Field(default=None) + + class Meta: + is_table = True + table_name = "users" + indexes = [ + Index( + fields=["email"], + name="idx_users_active_email", + unique=True, + where=" deleted_at IS NULL ", + ), + ] + + assert User._db_meta.indexes[0].where == "deleted_at IS NULL" + + snapshot = extract_current_schema(dialect="postgres") + [index] = snapshot["tables"]["users"]["indexes"] + assert index["where"] == "deleted_at IS NULL" + def test_extract_unique_together(self): """Test extracting unique_together constraints.""" diff --git a/python/oxyde/tests/unit/test_migrations_execution.py b/python/oxyde/tests/unit/test_migrations_execution.py index fd2f464..8ec375f 100644 --- a/python/oxyde/tests/unit/test_migrations_execution.py +++ b/python/oxyde/tests/unit/test_migrations_execution.py @@ -566,6 +566,7 @@ def test_rust_diff_drop_index_roundtrip(self): ALL_DIALECTS = ["postgres", "mysql", "sqlite"] NON_SQLITE = ["postgres", "mysql"] +PARTIAL_INDEX_DIALECTS = ["postgres", "sqlite"] def _field(name: str, **overrides) -> dict: @@ -659,6 +660,37 @@ def test_create_index(self, dialect): sqls = _render(ctx, dialect) assert any("CREATE" in s.upper() and "INDEX" in s.upper() and "idx_users_email" in s for s in sqls) + @pytest.mark.parametrize("dialect", PARTIAL_INDEX_DIALECTS) + def test_create_partial_index(self, dialect): + ctx = MigrationContext(mode="collect", dialect=dialect) + ctx.create_index( + "users", + { + "name": "idx_users_active_email", + "fields": ["email"], + "unique": True, + "method": None, + "where": " deleted_at IS NULL ", + }, + ) + sqls = _render(ctx, dialect) + assert any("WHERE deleted_at IS NULL" in s for s in sqls) + + def test_create_partial_index_mysql_rejected(self): + ctx = MigrationContext(mode="collect", dialect="mysql") + ctx.create_index( + "users", + { + "name": "idx_users_active_email", + "fields": ["email"], + "unique": True, + "method": None, + "where": "deleted_at IS NULL", + }, + ) + with pytest.raises(Exception, match="partial indexes"): + _render(ctx, "mysql") + @pytest.mark.parametrize("dialect", ALL_DIALECTS) def test_drop_index(self, dialect): ctx = MigrationContext(mode="collect", dialect=dialect) diff --git a/python/oxyde/tests/unit/test_migrations_pipeline.py b/python/oxyde/tests/unit/test_migrations_pipeline.py index 8b493fe..f537d09 100644 --- a/python/oxyde/tests/unit/test_migrations_pipeline.py +++ b/python/oxyde/tests/unit/test_migrations_pipeline.py @@ -30,6 +30,7 @@ ALL_DIALECTS = ["postgres", "mysql", "sqlite"] NON_SQLITE = ["postgres", "mysql"] +PARTIAL_INDEX_DIALECTS = ["postgres", "sqlite"] def _snapshot_from_models(models: list[type[Model]], dialect: str) -> dict: @@ -251,6 +252,45 @@ class Meta: ) assert any("idx_users_email" in s for s in down_sql) + @pytest.mark.parametrize("dialect", PARTIAL_INDEX_DIALECTS) + def test_create_partial_index_preserves_where(self, tmp_path, dialect): + from oxyde import Index + + class UserV1(Model): + id: int | None = Field(default=None, db_pk=True) + email: str + deleted_at: str | None = Field(default=None) + + class Meta: + is_table = True + table_name = "users" + + class UserV2(Model): + id: int | None = Field(default=None, db_pk=True) + email: str + deleted_at: str | None = Field(default=None) + + class Meta: + is_table = True + table_name = "users" + indexes = [ + Index( + fields=["email"], + name="idx_users_active_email", + unique=True, + where=" deleted_at IS NULL ", + ) + ] + + ops, up_sql, down_sql = _run_pipeline( + [UserV1], [UserV2], dialect, tmp_path, "add_active_email_index" + ) + + create_ops = [op for op in ops if op["type"] == "create_index"] + assert create_ops[0]["index"]["where"] == "deleted_at IS NULL" + assert any("WHERE deleted_at IS NULL" in s for s in up_sql) + assert any("idx_users_active_email" in s for s in down_sql) + @pytest.mark.parametrize("dialect", ALL_DIALECTS) def test_drop_index(self, tmp_path, dialect): from oxyde import Index