Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 29 additions & 11 deletions crates/oxyde-migrate/src/diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,35 @@ pub fn compute_diff(old: &Snapshot, new: &Snapshot) -> Result<Vec<MigrationOp>>
}
}

// 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) {
Expand All @@ -258,17 +287,6 @@ pub fn compute_diff(old: &Snapshot, new: &Snapshot) -> Result<Vec<MigrationOp>>
}
}

// 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
Expand Down
22 changes: 17 additions & 5 deletions crates/oxyde-migrate/src/sql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
let mut stmt = SeaIndex::create();
stmt.name(&index.name).table(Alias::new(table));

Expand All @@ -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 ────────────────────────────────────────────────────
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
67 changes: 66 additions & 1 deletion crates/oxyde-migrate/src/types.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -33,6 +33,46 @@ pub enum MigrateError {

pub type Result<T> = std::result::Result<T, MigrateError>;

fn normalize_optional_sql_fragment(value: Option<String>) -> Option<String> {
value
.map(|fragment| fragment.trim().to_string())
.filter(|fragment| !fragment.is_empty())
}

fn is_none_or_blank(value: &Option<String>) -> bool {
value
.as_deref()
.map(str::trim)
.map_or(true, |fragment| fragment.is_empty())
}

fn serialize_normalized_optional_sql_fragment<S>(
value: &Option<String>,
serializer: S,
) -> std::result::Result<S::Ok, S::Error>
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<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<String>::deserialize(deserializer)?;
Ok(normalize_optional_sql_fragment(value))
}

/// Field definition in schema
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FieldDef {
Expand Down Expand Up @@ -66,6 +106,31 @@ pub struct IndexDef {
pub fields: Vec<String>,
pub unique: bool,
pub method: Option<String>,
#[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<String>,
}

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
Expand Down
120 changes: 120 additions & 0 deletions crates/oxyde-migrate/tests/migration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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![],
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);

Expand All @@ -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 {
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ class Event(Model):

### Partial Index

Partial indexes are supported by PostgreSQL and SQLite migrations.

```python
class User(Model):
email: str
Expand Down
3 changes: 3 additions & 0 deletions python/oxyde/migrations/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
)

Expand Down
Loading
Loading