Skip to main content

siderite_migrations/
schema_editor.rs

1//! DDL generation for PostgreSQL, SQLite and MySQL.
2//!
3//! PostgreSQL and SQLite identifiers are double-quoted (embedded `"`
4//! doubled). SQLite `AlterField` / `RemoveField` / constraint changes rebuild
5//! the table (create new, copy, drop, rename); the migrator disables
6//! foreign keys on the connection before `BEGIN`. MySQL has its own dialect
7//! (backticks, `AUTO_INCREMENT`, `MODIFY COLUMN`, no transactional DDL); see
8//! the [`mysql`] module. Other backends return
9//! [`MigrationError::UnsupportedBackend`] before any I/O.
10
11pub mod mysql;
12
13use crate::error::MigrationError;
14use crate::operation::Operation;
15use crate::state::{
16    ConstraintState, DbDefault, FieldState, ForeignKeyState, ModelState, OnDelete, ProjectState,
17    SqlType, auto_index_name,
18};
19use siderite_orm::BackendKind;
20
21/// Quote `ident` with double quotes; embed a quote by doubling it.
22pub fn quote_ident(ident: &str) -> String {
23    let mut out = String::with_capacity(ident.len() + 2);
24    out.push('"');
25    for ch in ident.chars() {
26        if ch == '"' {
27            out.push('"');
28        }
29        out.push(ch);
30    }
31    out.push('"');
32    out
33}
34
35/// Quote `ident` for the SQL dialect of `kind`: backticks for MySQL, double
36/// quotes for everything else.
37pub fn quote_identifier(kind: BackendKind, ident: &str) -> String {
38    match kind {
39        BackendKind::MySql => mysql::quote_ident(ident),
40        _ => quote_ident(ident),
41    }
42}
43
44/// Quote a SQL string literal with single quotes.
45pub fn quote_string(value: &str) -> String {
46    let mut out = String::with_capacity(value.len() + 2);
47    out.push('\'');
48    for ch in value.chars() {
49        if ch == '\'' {
50            out.push('\'');
51        }
52        out.push(ch);
53    }
54    out.push('\'');
55    out
56}
57
58/// Render `operations` against `state`, applying each to a cloned state so
59/// later ops see earlier ones (needed for SQLite rebuilds).
60///
61/// # Errors
62/// [`MigrationError::UnsupportedBackend`] for anything other than PostgreSQL,
63/// SQLite or MySQL, or [`MigrationError::State`] when an op cannot be rendered.
64pub fn statements(
65    kind: BackendKind,
66    state: &ProjectState,
67    operations: &[Operation],
68) -> Result<Vec<String>, MigrationError> {
69    require_sql(kind)?;
70    let mut current = state.clone();
71    let mut out = Vec::new();
72    for op in operations {
73        out.extend(render(kind, op, &current)?);
74        op.apply_to_state(&mut current)?;
75    }
76    Ok(out)
77}
78
79/// Render a single operation against the current schema `state` (before the op).
80///
81/// # Errors
82/// See [`statements`].
83pub fn render(
84    kind: BackendKind,
85    op: &Operation,
86    state: &ProjectState,
87) -> Result<Vec<String>, MigrationError> {
88    require_sql(kind)?;
89    if kind == BackendKind::MySql {
90        return mysql::render(op, state);
91    }
92    match op {
93        Operation::CreateModel { model } => Ok(create_model_sql(kind, model)),
94        Operation::DeleteModel { name } => {
95            let model = state.require(name)?;
96            Ok(vec![format!("DROP TABLE {}", quote_ident(&model.table))])
97        }
98        Operation::RenameModel {
99            old_name, table, ..
100        } => {
101            let model = state.require(old_name)?;
102            if model.table == *table {
103                Ok(Vec::new())
104            } else {
105                Ok(vec![format!(
106                    "ALTER TABLE {} RENAME TO {}",
107                    quote_ident(&model.table),
108                    quote_ident(table)
109                )])
110            }
111        }
112        Operation::AddField { model, field } => add_field_sql(kind, state.require(model)?, field),
113        Operation::RemoveField { model, name } => {
114            remove_field_sql(kind, state.require(model)?, name)
115        }
116        Operation::AlterField { model, name, field } => {
117            alter_field_sql(kind, state.require(model)?, name, field)
118        }
119        Operation::RenameField {
120            model,
121            old_name,
122            new_name,
123        } => rename_field_sql(kind, state.require(model)?, old_name, new_name),
124        Operation::CreateIndex { model, index } => {
125            let m = state.require(model)?;
126            Ok(vec![create_index_sql(&m.table, index)])
127        }
128        Operation::DeleteIndex { model, name } => {
129            state.require(model)?;
130            Ok(vec![format!("DROP INDEX {}", quote_ident(name))])
131        }
132        Operation::AddConstraint { model, constraint } => {
133            add_constraint_sql(kind, state.require(model)?, constraint)
134        }
135        Operation::DeleteConstraint { model, name } => {
136            delete_constraint_sql(kind, state.require(model)?, name)
137        }
138        Operation::RunSQL { sql, .. } => Ok(vec![sql.clone()]),
139        Operation::RunRust { .. } => Ok(Vec::new()),
140    }
141}
142
143fn require_sql(kind: BackendKind) -> Result<(), MigrationError> {
144    match kind {
145        BackendKind::Postgres | BackendKind::Sqlite | BackendKind::MySql => Ok(()),
146        other => Err(MigrationError::UnsupportedBackend(other)),
147    }
148}
149
150fn create_model_sql(kind: BackendKind, model: &ModelState) -> Vec<String> {
151    let mut stmts = vec![create_table_sql(kind, model, &model.table)];
152    stmts.extend(index_sqls(model));
153    stmts
154}
155
156fn create_table_sql(kind: BackendKind, model: &ModelState, table: &str) -> String {
157    let mut parts: Vec<String> = model.fields.iter().map(|f| column_def(kind, f)).collect();
158    for constraint in &model.constraints {
159        parts.push(table_constraint_sql(constraint));
160    }
161    let body = parts.join(",\n  ");
162    format!("CREATE TABLE {} (\n  {}\n)", quote_ident(table), body)
163}
164
165fn table_constraint_sql(constraint: &ConstraintState) -> String {
166    match constraint {
167        ConstraintState::Unique { name, columns } => {
168            format!(
169                "CONSTRAINT {} UNIQUE ({})",
170                quote_ident(name),
171                columns
172                    .iter()
173                    .map(|c| quote_ident(c))
174                    .collect::<Vec<_>>()
175                    .join(", ")
176            )
177        }
178        ConstraintState::Check { name, sql } => {
179            format!("CONSTRAINT {} CHECK ({sql})", quote_ident(name))
180        }
181    }
182}
183
184fn column_def(kind: BackendKind, field: &FieldState) -> String {
185    let mut s = format!("{} {}", quote_ident(&field.column), type_sql(kind, field));
186    if field.auto && field.primary_key {
187        if let Some(fk) = &field.fk {
188            s.push(' ');
189            s.push_str(&references_sql(fk));
190        }
191        return s;
192    }
193    if !field.nullable {
194        s.push_str(" NOT NULL");
195    }
196    if let Some(default) = &field.default {
197        s.push_str(" DEFAULT ");
198        s.push_str(&default_sql(kind, default));
199    }
200    if field.unique && !field.primary_key {
201        s.push_str(" UNIQUE");
202    }
203    if field.primary_key {
204        s.push_str(" PRIMARY KEY");
205    }
206    if let Some(fk) = &field.fk {
207        s.push(' ');
208        s.push_str(&references_sql(fk));
209    }
210    s
211}
212
213fn type_sql(kind: BackendKind, field: &FieldState) -> String {
214    if field.auto && field.primary_key {
215        return match kind {
216            BackendKind::Sqlite => "INTEGER PRIMARY KEY AUTOINCREMENT".to_owned(),
217            BackendKind::Postgres => {
218                let base = match field.sql_type {
219                    SqlType::SmallInt | SqlType::Integer => "INTEGER",
220                    _ => "BIGINT",
221                };
222                format!("{base} GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY")
223            }
224            _ => sql_type_name(kind, field),
225        };
226    }
227    sql_type_name(kind, field)
228}
229
230fn sql_type_name(kind: BackendKind, field: &FieldState) -> String {
231    match (kind, field.sql_type) {
232        (_, SqlType::Decimal) => match (field.max_digits, field.decimal_places) {
233            (Some(p), Some(s)) => format!("NUMERIC({p},{s})"),
234            _ => "NUMERIC".to_owned(),
235        },
236        (_, SqlType::Text) => match field.max_length {
237            Some(n) => format!("VARCHAR({n})"),
238            None => "TEXT".to_owned(),
239        },
240        (_, SqlType::Duration) => "BIGINT".to_owned(),
241        (_, SqlType::IpAddr) => "TEXT".to_owned(),
242        (BackendKind::Postgres, SqlType::SmallInt) => "SMALLINT".to_owned(),
243        (BackendKind::Postgres, SqlType::Integer) => "INTEGER".to_owned(),
244        (BackendKind::Postgres, SqlType::BigInt) => "BIGINT".to_owned(),
245        (BackendKind::Postgres, SqlType::Real) => "REAL".to_owned(),
246        (BackendKind::Postgres, SqlType::Double) => "DOUBLE PRECISION".to_owned(),
247        (BackendKind::Postgres, SqlType::Bool) => "BOOLEAN".to_owned(),
248        (BackendKind::Postgres, SqlType::Binary) => "BYTEA".to_owned(),
249        (BackendKind::Postgres, SqlType::Date) => "DATE".to_owned(),
250        (BackendKind::Postgres, SqlType::Time) => "TIME".to_owned(),
251        (BackendKind::Postgres, SqlType::Timestamp) => "TIMESTAMPTZ".to_owned(),
252        (BackendKind::Postgres, SqlType::Uuid) => "UUID".to_owned(),
253        (BackendKind::Postgres, SqlType::Json) => "jsonb".to_owned(),
254        (BackendKind::Sqlite, SqlType::SmallInt | SqlType::Integer) => "INTEGER".to_owned(),
255        (BackendKind::Sqlite, SqlType::BigInt) => "BIGINT".to_owned(),
256        (BackendKind::Sqlite, SqlType::Real | SqlType::Double) => "REAL".to_owned(),
257        (BackendKind::Sqlite, SqlType::Bool) => "INTEGER".to_owned(),
258        (BackendKind::Sqlite, SqlType::Binary) => "BLOB".to_owned(),
259        (BackendKind::Sqlite, SqlType::Date | SqlType::Time | SqlType::Timestamp) => {
260            "TEXT".to_owned()
261        }
262        (BackendKind::Sqlite, SqlType::Uuid | SqlType::Json) => "TEXT".to_owned(),
263        _ => "TEXT".to_owned(),
264    }
265}
266
267fn default_sql(kind: BackendKind, default: &DbDefault) -> String {
268    match default {
269        DbDefault::Now => match kind {
270            BackendKind::Postgres => "CURRENT_TIMESTAMP".to_owned(),
271            BackendKind::Sqlite => {
272                // `%f` is SS.SSS, so `%H:%M:%f` yields `HH:MM:SS.sss`. RFC3339
273                // (used by `DateTime::from_value`) accepts the `Z` suffix.
274                "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))".to_owned()
275            }
276            _ => "CURRENT_TIMESTAMP".to_owned(),
277        },
278        DbDefault::Int(i) => i.to_string(),
279        DbDefault::Bool(true) => match kind {
280            BackendKind::Sqlite => "1".to_owned(),
281            _ => "TRUE".to_owned(),
282        },
283        DbDefault::Bool(false) => match kind {
284            BackendKind::Sqlite => "0".to_owned(),
285            _ => "FALSE".to_owned(),
286        },
287        DbDefault::Text(s) => quote_string(s),
288    }
289}
290
291fn references_sql(fk: &ForeignKeyState) -> String {
292    format!(
293        "REFERENCES {} ({}) ON DELETE {}",
294        quote_ident(&fk.target_table),
295        quote_ident(&fk.target_column),
296        on_delete_sql(fk.on_delete)
297    )
298}
299
300fn on_delete_sql(policy: OnDelete) -> &'static str {
301    match policy {
302        OnDelete::Cascade => "CASCADE",
303        OnDelete::Protect => "RESTRICT",
304        OnDelete::SetNull => "SET NULL",
305        OnDelete::SetDefault => "SET DEFAULT",
306        OnDelete::DoNothing => "NO ACTION",
307    }
308}
309
310fn index_sqls(model: &ModelState) -> Vec<String> {
311    let mut stmts = Vec::new();
312    let mut seen = std::collections::BTreeSet::new();
313    for field in &model.fields {
314        if field.index && !field.unique && !field.primary_key {
315            let name = auto_index_name(&model.table, &field.column);
316            if seen.insert(name.clone()) {
317                stmts.push(create_index_sql(
318                    &model.table,
319                    &crate::state::IndexState {
320                        name,
321                        columns: vec![field.column.clone()],
322                        unique: false,
323                    },
324                ));
325            }
326        }
327    }
328    for index in &model.indexes {
329        if seen.insert(index.name.clone()) {
330            stmts.push(create_index_sql(&model.table, index));
331        }
332    }
333    stmts
334}
335
336fn create_index_sql(table: &str, index: &crate::state::IndexState) -> String {
337    let unique = if index.unique { "UNIQUE " } else { "" };
338    format!(
339        "CREATE {unique}INDEX {} ON {} ({})",
340        quote_ident(&index.name),
341        quote_ident(table),
342        index
343            .columns
344            .iter()
345            .map(|c| quote_ident(c))
346            .collect::<Vec<_>>()
347            .join(", ")
348    )
349}
350
351fn add_field_sql(
352    kind: BackendKind,
353    model: &ModelState,
354    field: &FieldState,
355) -> Result<Vec<String>, MigrationError> {
356    if kind == BackendKind::Sqlite && (field.unique || field.primary_key) {
357        let mut new_model = model.clone();
358        new_model.fields.push(field.clone());
359        return Ok(sqlite_rebuild(model, &new_model));
360    }
361    let def = column_def(kind, field);
362    let mut stmts = vec![format!(
363        "ALTER TABLE {} ADD COLUMN {def}",
364        quote_ident(&model.table)
365    )];
366    if field.index && !field.unique && !field.primary_key {
367        stmts.push(create_index_sql(
368            &model.table,
369            &crate::state::IndexState {
370                name: auto_index_name(&model.table, &field.column),
371                columns: vec![field.column.clone()],
372                unique: false,
373            },
374        ));
375    }
376    Ok(stmts)
377}
378
379fn remove_field_sql(
380    kind: BackendKind,
381    model: &ModelState,
382    name: &str,
383) -> Result<Vec<String>, MigrationError> {
384    let field = model.field(name).ok_or_else(|| {
385        MigrationError::state(format!("field `{name}` not on model `{}`", model.name))
386    })?;
387    if kind == BackendKind::Sqlite {
388        let mut new_model = model.clone();
389        new_model.fields.retain(|f| f.name != name);
390        new_model
391            .indexes
392            .retain(|i| !i.columns.iter().any(|c| c == &field.column));
393        return Ok(sqlite_rebuild(model, &new_model));
394    }
395    Ok(vec![format!(
396        "ALTER TABLE {} DROP COLUMN {}",
397        quote_ident(&model.table),
398        quote_ident(&field.column)
399    )])
400}
401
402fn alter_field_sql(
403    kind: BackendKind,
404    model: &ModelState,
405    name: &str,
406    field: &FieldState,
407) -> Result<Vec<String>, MigrationError> {
408    let old = model.field(name).ok_or_else(|| {
409        MigrationError::state(format!("field `{name}` not on model `{}`", model.name))
410    })?;
411    if kind == BackendKind::Sqlite {
412        let mut new_model = model.clone();
413        if let Some(existing) = new_model.field_mut(name) {
414            *existing = field.clone();
415        }
416        // Pair by field identity: `old.column -> field.column` copies rows
417        // across a column rename; the map covers a field-name change too.
418        return Ok(sqlite_rebuild_mapped(
419            model,
420            &new_model,
421            &[(name, field.name.as_str())],
422        ));
423    }
424    let mut stmts = Vec::new();
425    let table = quote_ident(&model.table);
426    if old.column != field.column {
427        stmts.push(format!(
428            "ALTER TABLE {table} RENAME COLUMN {} TO {}",
429            quote_ident(&old.column),
430            quote_ident(&field.column)
431        ));
432    }
433    let col = quote_ident(&field.column);
434    if old.sql_type != field.sql_type
435        || old.max_length != field.max_length
436        || old.max_digits != field.max_digits
437        || old.decimal_places != field.decimal_places
438    {
439        // Postgres: when sql_type changes and the old column has a
440        // default, `ALTER COLUMN .. TYPE .. USING` fails ("default
441        // for column cannot be cast automatically"). Drop it first.
442        if kind == BackendKind::Postgres && old.sql_type != field.sql_type && old.default.is_some()
443        {
444            stmts.push(format!(
445                "ALTER TABLE {table} ALTER COLUMN {col} DROP DEFAULT"
446            ));
447        }
448        let new_type = sql_type_name(kind, field);
449        let mut stmt = format!("ALTER TABLE {table} ALTER COLUMN {col} TYPE {new_type}");
450        // Postgres has no implicit assignment cast for e.g. text -> integer,
451        // so spell out the cast. It fails loudly on bad data (fail-closed).
452        // Length/precision-only changes keep the cast implicit.
453        if kind == BackendKind::Postgres && old.sql_type != field.sql_type {
454            stmt.push_str(&format!(" USING {col}::{new_type}"));
455        }
456        stmts.push(stmt);
457        // After the TYPE change, ensure the new default is set (even when
458        // unchanged). The DROP DEFAULT above would otherwise clear it.
459        if kind == BackendKind::Postgres
460            && old.sql_type != field.sql_type
461            && let Some(d) = &field.default
462        {
463            stmts.push(format!(
464                "ALTER TABLE {table} ALTER COLUMN {col} SET DEFAULT {}",
465                default_sql(kind, d)
466            ));
467        }
468    }
469    if old.nullable != field.nullable {
470        if field.nullable {
471            stmts.push(format!(
472                "ALTER TABLE {table} ALTER COLUMN {col} DROP NOT NULL"
473            ));
474        } else {
475            stmts.push(format!(
476                "ALTER TABLE {table} ALTER COLUMN {col} SET NOT NULL"
477            ));
478        }
479    }
480    // Default changes when sql_type did not change (or non-Postgres).
481    // When Postgres sql_type changed, DROP/SET DEFAULT is handled above.
482    let pg_type_changed = kind == BackendKind::Postgres && old.sql_type != field.sql_type;
483    if !pg_type_changed && old.default != field.default {
484        match &field.default {
485            Some(d) => stmts.push(format!(
486                "ALTER TABLE {table} ALTER COLUMN {col} SET DEFAULT {}",
487                default_sql(kind, d)
488            )),
489            None => stmts.push(format!(
490                "ALTER TABLE {table} ALTER COLUMN {col} DROP DEFAULT"
491            )),
492        }
493    }
494    Ok(stmts)
495}
496
497fn rename_field_sql(
498    _kind: BackendKind,
499    model: &ModelState,
500    old_name: &str,
501    new_name: &str,
502) -> Result<Vec<String>, MigrationError> {
503    let field = model.field(old_name).ok_or_else(|| {
504        MigrationError::state(format!("field `{old_name}` not on model `{}`", model.name))
505    })?;
506    if field.column == old_name && old_name != new_name {
507        return Ok(vec![format!(
508            "ALTER TABLE {} RENAME COLUMN {} TO {}",
509            quote_ident(&model.table),
510            quote_ident(&field.column),
511            quote_ident(new_name)
512        )]);
513    }
514    Ok(Vec::new())
515}
516
517fn add_constraint_sql(
518    kind: BackendKind,
519    model: &ModelState,
520    constraint: &ConstraintState,
521) -> Result<Vec<String>, MigrationError> {
522    if kind == BackendKind::Sqlite {
523        let mut new_model = model.clone();
524        new_model.constraints.push(constraint.clone());
525        return Ok(sqlite_rebuild(model, &new_model));
526    }
527    Ok(vec![format!(
528        "ALTER TABLE {} ADD {}",
529        quote_ident(&model.table),
530        table_constraint_sql(constraint)
531    )])
532}
533
534fn delete_constraint_sql(
535    kind: BackendKind,
536    model: &ModelState,
537    name: &str,
538) -> Result<Vec<String>, MigrationError> {
539    if kind == BackendKind::Sqlite {
540        let mut new_model = model.clone();
541        new_model.constraints.retain(|c| c.name() != name);
542        return Ok(sqlite_rebuild(model, &new_model));
543    }
544    Ok(vec![format!(
545        "ALTER TABLE {} DROP CONSTRAINT {}",
546        quote_ident(&model.table),
547        quote_ident(name)
548    )])
549}
550
551/// One SQLite `CAST` that needs a pre-copy loss check: `sql` returns a row
552/// when some non-NULL value in `table`.`column` would change under the cast.
553pub struct SqliteCastCheck {
554    /// Table holding the old rows.
555    pub table: String,
556    /// Column whose values would change.
557    pub column: String,
558    /// `SELECT 1 ... LIMIT 1` probe; a non-empty result means lossy rows.
559    pub sql: String,
560}
561
562/// Columns of `op` whose SQLite rebuild `CAST` could lose data.
563///
564/// Only `AlterField` type changes need a probe. Same-type edits (length,
565/// nullability, defaults) copy verbatim, `anything -> TEXT/BLOB` stores every
566/// value, and `int -> REAL` compares equal (`5 IS 5.0`), so those skip.
567pub fn sqlite_lossy_cast_checks(state: &ProjectState, op: &Operation) -> Vec<SqliteCastCheck> {
568    let Operation::AlterField { model, name, field } = op else {
569        return Vec::new();
570    };
571    let Some(current) = state.model(model) else {
572        return Vec::new();
573    };
574    let Some(old) = current.field(name) else {
575        return Vec::new();
576    };
577    if !sqlite_cast_needs_check(old.sql_type, field.sql_type) {
578        return Vec::new();
579    }
580    let sql = format!(
581        "SELECT 1 AS siderite_lossy FROM {} WHERE {} IS NOT NULL AND CAST({} AS {}) IS NOT {} LIMIT 1",
582        quote_ident(&current.table),
583        quote_ident(&old.column),
584        quote_ident(&old.column),
585        sql_type_name(BackendKind::Sqlite, field),
586        quote_ident(&old.column),
587    );
588    vec![SqliteCastCheck {
589        table: current.table.clone(),
590        column: field.column.clone(),
591        sql,
592    }]
593}
594
595fn sqlite_cast_needs_check(old: SqlType, new: SqlType) -> bool {
596    if old == new {
597        return false;
598    }
599    // TEXT and BLOB hold any value; CAST there never loses data.
600    if is_sqlite_texty(new) || new == SqlType::Binary {
601        return false;
602    }
603    // Integer values compare equal as REAL (`5 IS 5.0`).
604    if is_sqlite_int(old) && is_sqlite_real(new) {
605        return false;
606    }
607    true
608}
609
610fn is_sqlite_texty(t: SqlType) -> bool {
611    matches!(
612        t,
613        SqlType::Text
614            | SqlType::Date
615            | SqlType::Time
616            | SqlType::Timestamp
617            | SqlType::Uuid
618            | SqlType::Json
619            | SqlType::IpAddr
620    )
621}
622
623fn is_sqlite_int(t: SqlType) -> bool {
624    matches!(
625        t,
626        SqlType::SmallInt | SqlType::Integer | SqlType::BigInt | SqlType::Bool | SqlType::Duration
627    )
628}
629
630fn is_sqlite_real(t: SqlType) -> bool {
631    matches!(t, SqlType::Real | SqlType::Double)
632}
633
634/// SQLite table-rebuild: create new, copy overlapping columns, drop, rename,
635/// recreate indexes.
636///
637/// `PRAGMA foreign_keys` is a no-op inside a transaction, so the migrator
638/// disables foreign keys on the connection *before* `BEGIN` rather than
639/// emitting that pragma here.
640///
641/// The copy pairs columns by field identity (old column -> new column), not
642/// by position. Fields present in both states copy `old.column` into
643/// `new.column`, so an `AlterField` column rename preserves its rows.
644/// Fields only in `new` (`AddField`) are skipped and default-filled; fields
645/// only in `old` (`RemoveField`) are dropped with the old table. A field
646/// rename (`RenameField`) changes the field name, so pass it in
647/// `field_renames` as `(old_name, new_name)` or the renamed column is
648/// treated as added and its rows are lost.
649fn sqlite_rebuild(old: &ModelState, new: &ModelState) -> Vec<String> {
650    sqlite_rebuild_mapped(old, new, &[])
651}
652
653/// [`sqlite_rebuild`] with explicit field renames for `RenameField` (and any
654/// `AlterField` that changes the field name).
655fn sqlite_rebuild_mapped(
656    old: &ModelState,
657    new: &ModelState,
658    field_renames: &[(&str, &str)],
659) -> Vec<String> {
660    let tmp = format!("{}__siderite_new", new.table);
661    let mut stmts = vec![create_table_sql(BackendKind::Sqlite, new, &tmp)];
662
663    let copied: Vec<(&FieldState, &FieldState)> = new
664        .fields
665        .iter()
666        .filter_map(|nf| {
667            if let Some(of) = old.field(&nf.name) {
668                return Some((of, nf));
669            }
670            // Renamed field: same logical value under a new name/column.
671            let old_name = field_renames
672                .iter()
673                .find(|(_, new_name)| *new_name == nf.name)
674                .map(|(old_name, _)| *old_name)?;
675            old.field(old_name).map(|of| (of, nf))
676        })
677        .collect();
678    if !copied.is_empty() {
679        let dest = copied
680            .iter()
681            .map(|(_, nf)| quote_ident(&nf.column))
682            .collect::<Vec<_>>()
683            .join(", ");
684        let src = copied
685            .iter()
686            .map(|(of, nf)| {
687                if of.sql_type == nf.sql_type {
688                    quote_ident(&of.column)
689                } else {
690                    format!(
691                        "CAST({} AS {})",
692                        quote_ident(&of.column),
693                        sql_type_name(BackendKind::Sqlite, nf)
694                    )
695                }
696            })
697            .collect::<Vec<_>>()
698            .join(", ");
699        stmts.push(format!(
700            "INSERT INTO {} ({dest}) SELECT {src} FROM {}",
701            quote_ident(&tmp),
702            quote_ident(&old.table)
703        ));
704    }
705
706    stmts.push(format!("DROP TABLE {}", quote_ident(&old.table)));
707    stmts.push(format!(
708        "ALTER TABLE {} RENAME TO {}",
709        quote_ident(&tmp),
710        quote_ident(&new.table)
711    ));
712    stmts.extend(index_sqls(new));
713    stmts
714}
715
716/// SQLite rebuild target for `op`: the table being dropped plus the column
717/// sets before/after and the index names the rebuild itself recreates.
718///
719/// `None` when `op` does not rebuild a table on SQLite (in-place alters,
720/// native renames, drops). The executor uses this to preserve triggers and
721/// hand-made indexes from `sqlite_master` across the drop/create cycle.
722pub struct SqliteRebuildTarget {
723    /// Table dropped and recreated by the rebuild.
724    pub table: String,
725    /// Column names before the op.
726    pub old_columns: Vec<String>,
727    /// Column names after the op.
728    pub new_columns: Vec<String>,
729    /// Index names the rebuild's own `CREATE INDEX` statements recreate.
730    pub recreated_indexes: std::collections::HashSet<String>,
731}
732
733/// Which table `op` rebuilds on SQLite, if any.
734///
735/// Mirrors the rebuild branches of `add_field_sql` / `remove_field_sql` /
736/// `alter_field_sql` / `add_constraint_sql` / `delete_constraint_sql`.
737pub fn sqlite_rebuild_target(state: &ProjectState, op: &Operation) -> Option<SqliteRebuildTarget> {
738    let (old, new) = match op {
739        Operation::AddField { model, field } if field.unique || field.primary_key => {
740            let old = state.model(model)?;
741            let mut new = old.clone();
742            new.fields.push(field.clone());
743            (old.clone(), new)
744        }
745        Operation::RemoveField { model, name } => {
746            let old = state.model(model)?;
747            let mut new = old.clone();
748            new.fields.retain(|f| f.name != *name);
749            // Mirror `remove_field_sql`: indexes on the dropped column are
750            // not recreated by the rebuild.
751            if let Some(dropped) = old.field(name).map(|f| f.column.clone()) {
752                new.indexes
753                    .retain(|i| !i.columns.iter().any(|c| c == &dropped));
754            }
755            (old.clone(), new)
756        }
757        Operation::AlterField { model, name, field } => {
758            let old = state.model(model)?;
759            let mut new = old.clone();
760            *new.field_mut(name)? = field.clone();
761            (old.clone(), new)
762        }
763        Operation::AddConstraint { model, constraint } => {
764            let old = state.model(model)?;
765            let mut new = old.clone();
766            new.constraints.push(constraint.clone());
767            (old.clone(), new)
768        }
769        Operation::DeleteConstraint { model, name } => {
770            let old = state.model(model)?;
771            let mut new = old.clone();
772            new.constraints.retain(|c| c.name() != *name);
773            (old.clone(), new)
774        }
775        _ => return None,
776    };
777    let table = old.table.clone();
778    let old_columns = old.fields.iter().map(|f| f.column.clone()).collect();
779    let new_columns = new.fields.iter().map(|f| f.column.clone()).collect();
780    Some(SqliteRebuildTarget {
781        table,
782        old_columns,
783        new_columns,
784        recreated_indexes: rebuilt_index_names(&new),
785    })
786}
787
788/// Index names `sqlite_rebuild` recreates for `new`.
789///
790/// Mirrors `index_sqls` so the executor can skip exactly those names when
791/// preserving extras (re-running one would fail with "already exists").
792fn rebuilt_index_names(new: &ModelState) -> std::collections::HashSet<String> {
793    let mut seen = std::collections::HashSet::new();
794    for field in &new.fields {
795        if field.index && !field.unique && !field.primary_key {
796            seen.insert(auto_index_name(&new.table, &field.column));
797        }
798    }
799    for index in &new.indexes {
800        seen.insert(index.name.clone());
801    }
802    seen
803}
804
805/// Whether `sql` references identifier `ident` (a column or table name).
806///
807/// Matches `"ident"`, `` `ident` `` and `[ident]` plus a bare `ident` word.
808/// Single-quoted string literals are skipped so `'val'` does not count as
809/// the column `val`. Case-insensitive, like SQLite identifiers.
810pub fn sqlite_sql_mentions_ident(sql: &str, ident: &str) -> bool {
811    if ident.is_empty() {
812        return false;
813    }
814    let lower_sql = sql.to_lowercase();
815    let lower = ident.to_lowercase();
816    for (open, close) in [("\"", "\""), ("`", "`"), ("[", "]")] {
817        if lower_sql.contains(&format!("{open}{lower}{close}")) {
818            return true;
819        }
820    }
821    let stripped = strip_single_quoted(&lower_sql);
822    stripped
823        .split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
824        .any(|token| token == lower)
825}
826
827/// Copy of `sql` with `'...'` string literals blanked (`''` escapes).
828fn strip_single_quoted(sql: &str) -> String {
829    let mut out = String::with_capacity(sql.len());
830    let mut chars = sql.chars().peekable();
831    while let Some(ch) = chars.next() {
832        if ch != '\'' {
833            out.push(ch);
834            continue;
835        }
836        // Skip to the closing quote, honouring `''` escapes.
837        loop {
838            match chars.next() {
839                None => break,
840                Some('\'') => {
841                    if chars.peek() == Some(&'\'') {
842                        chars.next();
843                    } else {
844                        break;
845                    }
846                }
847                Some(_) => {}
848            }
849        }
850        out.push(' ');
851    }
852    out
853}