Skip to main content

drizzle_migrations/sqlite/
collection.rs

1//! `SQLite` DDL collection — typed access to schema entities.
2//!
3//! The generic [`EntityCollection<T>`] storage backbone lives in
4//! [`crate::collection`]; this file supplies the per-entity-type lookup
5//! helpers (`one`, `for_table`, `delete`) whose shape depends on each
6//! SQLite entity's identity (single-name for `Table`/`Index`; `(table,
7//! name)` for `Column`).
8
9use super::ddl::{
10    CheckConstraint, Column, ForeignKey, Index, PrimaryKey, SqliteEntity, Table, UniqueConstraint,
11    View,
12};
13use crate::collection::EntityCollection;
14use crate::traits::EntityKind;
15use std::borrow::Cow;
16use std::collections::{HashMap, HashSet};
17
18// =============================================================================
19// Per-entity-type lookup helpers
20// =============================================================================
21
22// Table-specific operations
23impl EntityCollection<Table> {
24    /// Find a table by name
25    #[must_use]
26    pub fn one(&self, name: &str) -> Option<&Table> {
27        self.entities.iter().find(|t| t.name == name)
28    }
29
30    /// Delete a table by name
31    pub fn delete(&mut self, name: &str) -> Option<Table> {
32        if let Some(pos) = self.entities.iter().position(|t| t.name == name) {
33            Some(self.entities.remove(pos))
34        } else {
35            None
36        }
37    }
38}
39
40// Column-specific operations
41impl EntityCollection<Column> {
42    /// Find a column by table and name
43    #[must_use]
44    pub fn one(&self, table: &str, name: &str) -> Option<&Column> {
45        self.entities
46            .iter()
47            .find(|c| c.table == table && c.name == name)
48    }
49
50    /// List columns for a table
51    #[must_use]
52    pub fn for_table(&self, table: &str) -> Vec<&Column> {
53        self.entities.iter().filter(|c| c.table == table).collect()
54    }
55
56    /// Delete a column by table and name
57    pub fn delete(&mut self, table: &str, name: &str) -> Option<Column> {
58        if let Some(pos) = self
59            .entities
60            .iter()
61            .position(|c| c.table == table && c.name == name)
62        {
63            Some(self.entities.remove(pos))
64        } else {
65            None
66        }
67    }
68}
69
70// Index-specific operations
71impl EntityCollection<Index> {
72    /// Find an index by name
73    #[must_use]
74    pub fn one(&self, name: &str) -> Option<&Index> {
75        self.entities.iter().find(|i| i.name == name)
76    }
77
78    /// List indexes for a table
79    #[must_use]
80    pub fn for_table(&self, table: &str) -> Vec<&Index> {
81        self.entities.iter().filter(|i| i.table == table).collect()
82    }
83}
84
85// ForeignKey-specific operations
86impl EntityCollection<ForeignKey> {
87    /// Find a foreign key by name
88    #[must_use]
89    pub fn one(&self, name: &str) -> Option<&ForeignKey> {
90        self.entities.iter().find(|f| f.name == name)
91    }
92
93    /// List foreign keys for a table
94    #[must_use]
95    pub fn for_table(&self, table: &str) -> Vec<&ForeignKey> {
96        self.entities.iter().filter(|f| f.table == table).collect()
97    }
98}
99
100// PrimaryKey-specific operations
101impl EntityCollection<PrimaryKey> {
102    /// Find a primary key by table
103    #[must_use]
104    pub fn for_table(&self, table: &str) -> Option<&PrimaryKey> {
105        self.entities.iter().find(|p| p.table == table)
106    }
107}
108
109// UniqueConstraint-specific operations
110impl EntityCollection<UniqueConstraint> {
111    /// Find by name
112    #[must_use]
113    pub fn one(&self, name: &str) -> Option<&UniqueConstraint> {
114        self.entities.iter().find(|u| u.name == name)
115    }
116
117    /// List for a table
118    #[must_use]
119    pub fn for_table(&self, table: &str) -> Vec<&UniqueConstraint> {
120        self.entities.iter().filter(|u| u.table == table).collect()
121    }
122}
123
124// CheckConstraint-specific operations
125impl EntityCollection<CheckConstraint> {
126    /// Find by name
127    #[must_use]
128    pub fn one(&self, name: &str) -> Option<&CheckConstraint> {
129        self.entities.iter().find(|c| c.name == name)
130    }
131
132    /// List for a table
133    #[must_use]
134    pub fn for_table(&self, table: &str) -> Vec<&CheckConstraint> {
135        self.entities.iter().filter(|c| c.table == table).collect()
136    }
137}
138
139// View-specific operations
140impl EntityCollection<View> {
141    /// Find a view by name
142    #[must_use]
143    pub fn one(&self, name: &str) -> Option<&View> {
144        self.entities.iter().find(|v| v.name == name)
145    }
146}
147
148// =============================================================================
149// SQLite DDL - Main Collection Type
150// =============================================================================
151
152/// `SQLite` DDL collection - stores all schema entities
153///
154/// This is the main type for working with DDL entities.
155/// It provides typed access to each entity type with collection operations.
156#[derive(Debug, Clone, Default)]
157pub struct SQLiteDDL {
158    pub tables: EntityCollection<Table>,
159    pub columns: EntityCollection<Column>,
160    pub indexes: EntityCollection<Index>,
161    pub fks: EntityCollection<ForeignKey>,
162    pub pks: EntityCollection<PrimaryKey>,
163    pub uniques: EntityCollection<UniqueConstraint>,
164    pub checks: EntityCollection<CheckConstraint>,
165    pub views: EntityCollection<View>,
166}
167
168impl SQLiteDDL {
169    /// Create a new empty DDL collection
170    #[must_use]
171    pub fn new() -> Self {
172        Self::default()
173    }
174
175    /// Create DDL from a list of entities
176    #[must_use]
177    pub fn from_entities(entities: Vec<SqliteEntity>) -> Self {
178        let mut ddl = Self::new();
179        for entity in entities {
180            ddl.push_entity(entity);
181        }
182        ddl
183    }
184
185    /// Push any entity type
186    pub fn push_entity(&mut self, entity: SqliteEntity) {
187        match entity {
188            SqliteEntity::Table(t) => self.tables.push(t),
189            SqliteEntity::Column(c) => self.columns.push(c),
190            SqliteEntity::Index(i) => self.indexes.push(i),
191            SqliteEntity::ForeignKey(f) => self.fks.push(f),
192            SqliteEntity::PrimaryKey(p) => self.pks.push(p),
193            SqliteEntity::UniqueConstraint(u) => self.uniques.push(u),
194            SqliteEntity::CheckConstraint(c) => self.checks.push(c),
195            SqliteEntity::View(v) => self.views.push(v),
196        };
197    }
198
199    /// Convert to entity array for snapshot serialization
200    #[must_use]
201    pub fn to_entities(&self) -> Vec<SqliteEntity> {
202        let mut entities = Vec::new();
203
204        // Tables first
205        for t in self.tables.list() {
206            entities.push(SqliteEntity::Table(t.clone()));
207        }
208        // Then columns
209        for c in self.columns.list() {
210            entities.push(SqliteEntity::Column(c.clone()));
211        }
212        // Then other entities
213        for i in self.indexes.list() {
214            entities.push(SqliteEntity::Index(i.clone()));
215        }
216        for f in self.fks.list() {
217            entities.push(SqliteEntity::ForeignKey(f.clone()));
218        }
219        for p in self.pks.list() {
220            entities.push(SqliteEntity::PrimaryKey(p.clone()));
221        }
222        for u in self.uniques.list() {
223            entities.push(SqliteEntity::UniqueConstraint(u.clone()));
224        }
225        for c in self.checks.list() {
226            entities.push(SqliteEntity::CheckConstraint(c.clone()));
227        }
228        for v in self.views.list() {
229            entities.push(SqliteEntity::View(v.clone()));
230        }
231
232        entities
233    }
234
235    /// Check if DDL is empty
236    #[must_use]
237    pub const fn is_empty(&self) -> bool {
238        self.tables.is_empty()
239            && self.columns.is_empty()
240            && self.indexes.is_empty()
241            && self.fks.is_empty()
242            && self.pks.is_empty()
243            && self.uniques.is_empty()
244            && self.checks.is_empty()
245            && self.views.is_empty()
246    }
247
248    /// Get all entities for a specific table
249    #[must_use]
250    pub fn table_entities<'a>(&'a self, table_name: &str) -> TableEntities<'a> {
251        TableEntities {
252            columns: self.columns.for_table(table_name),
253            indexes: self.indexes.for_table(table_name),
254            fks: self.fks.for_table(table_name),
255            pk: self.pks.for_table(table_name),
256            uniques: self.uniques.for_table(table_name),
257            checks: self.checks.for_table(table_name),
258        }
259    }
260}
261
262/// All entities belonging to a specific table
263pub struct TableEntities<'a> {
264    pub columns: Vec<&'a Column>,
265    pub indexes: Vec<&'a Index>,
266    pub fks: Vec<&'a ForeignKey>,
267    pub pk: Option<&'a PrimaryKey>,
268    pub uniques: Vec<&'a UniqueConstraint>,
269    pub checks: Vec<&'a CheckConstraint>,
270}
271
272// =============================================================================
273// Diff Types
274// =============================================================================
275
276// Re-export shared DiffType from traits module
277pub use crate::traits::DiffType;
278
279/// A diff statement for any entity
280#[derive(Debug, Clone)]
281pub struct EntityDiff {
282    pub diff_type: DiffType,
283    pub kind: EntityKind,
284    pub table: Option<String>,
285    pub name: String,
286    /// For alter: changed fields with (from, to) values
287    pub changes: HashMap<String, (String, String)>,
288    /// Original entity (for drop/alter)
289    pub left: Option<SqliteEntity>,
290    /// New entity (for create/alter)
291    pub right: Option<SqliteEntity>,
292}
293
294/// Compute diff between two DDL collections
295#[must_use]
296pub fn diff_ddl(left: &SQLiteDDL, right: &SQLiteDDL) -> Vec<EntityDiff> {
297    let mut diffs = Vec::new();
298
299    // Diff tables (no table_fn needed since these ARE tables)
300    diff_entity_type(
301        left.tables.list(),
302        right.tables.list(),
303        |t| t.name.to_string(),
304        |t| SqliteEntity::Table(t.clone()),
305        None,
306        EntityKind::Table,
307        &mut diffs,
308    );
309
310    // Diff columns - extract table name from column.
311    // Inline INTEGER PRIMARY KEY columns need context for the NOT NULL
312    // reconciliation (emitters skip NOT NULL, PRAGMA reports 0, snapshots say
313    // true), so collect them from both sides first.
314    let integer_pks: HashSet<(String, String)> = inline_integer_pk_columns(left)
315        .into_iter()
316        .chain(inline_integer_pk_columns(right))
317        .collect();
318    diff_entity_type_with(
319        left.columns.list(),
320        right.columns.list(),
321        |c| format!("{}:{}", c.table, c.name),
322        |c| SqliteEntity::Column(c.clone()),
323        Some(&|c: &Column| c.table.to_string()),
324        EntityKind::Column,
325        |l, r| columns_equivalent(l, r, &integer_pks),
326        &mut diffs,
327    );
328
329    // Diff indexes - extract table name from index
330    diff_entity_type(
331        left.indexes.list(),
332        right.indexes.list(),
333        |i| i.name.to_string(),
334        |i| SqliteEntity::Index(i.clone()),
335        Some(&|i: &Index| i.table.to_string()),
336        EntityKind::Index,
337        &mut diffs,
338    );
339
340    // Diff foreign keys - keyed structurally (table/columns/target), NOT by
341    // name: PRAGMA foreign_key_list cannot recover real FK names, so keying or
342    // comparing by name would guarantee drop/create churn on every push.
343    // Names are still carried on the entities and used for rendering.
344    diff_entity_type_with(
345        left.fks.list(),
346        right.fks.list(),
347        fk_structural_key,
348        |f| SqliteEntity::ForeignKey(f.clone()),
349        Some(&|f: &ForeignKey| f.table.to_string()),
350        EntityKind::ForeignKey,
351        foreign_keys_equivalent,
352        &mut diffs,
353    );
354
355    // Diff primary keys - keyed by table, compared by column set (names are
356    // synthesized during introspection and irrelevant for equivalence).
357    diff_entity_type_with(
358        left.pks.list(),
359        right.pks.list(),
360        |p| p.table.to_string(),
361        |p| SqliteEntity::PrimaryKey(p.clone()),
362        Some(&|p: &PrimaryKey| p.table.to_string()),
363        EntityKind::PrimaryKey,
364        primary_keys_equivalent,
365        &mut diffs,
366    );
367
368    // Diff unique constraints - extract table name from unique
369    diff_entity_type(
370        left.uniques.list(),
371        right.uniques.list(),
372        |u| u.name.to_string(),
373        |u| SqliteEntity::UniqueConstraint(u.clone()),
374        Some(&|u: &UniqueConstraint| u.table.to_string()),
375        EntityKind::UniqueConstraint,
376        &mut diffs,
377    );
378
379    // Diff check constraints - extract table name from check
380    diff_entity_type(
381        left.checks.list(),
382        right.checks.list(),
383        |c| c.name.to_string(),
384        |c| SqliteEntity::CheckConstraint(c.clone()),
385        Some(&|c: &CheckConstraint| c.table.to_string()),
386        EntityKind::CheckConstraint,
387        &mut diffs,
388    );
389
390    // Diff views (no table_fn needed since views are standalone)
391    diff_entity_type(
392        left.views.list(),
393        right.views.list(),
394        |v| v.name.to_string(),
395        |v| SqliteEntity::View(v.clone()),
396        None,
397        EntityKind::View,
398        &mut diffs,
399    );
400
401    diffs
402}
403
404/// Helper to diff a single entity type
405fn diff_entity_type<T: Clone + PartialEq>(
406    left: &[T],
407    right: &[T],
408    key_fn: impl Fn(&T) -> String,
409    to_entity: impl Fn(&T) -> SqliteEntity,
410    table_fn: Option<&dyn Fn(&T) -> String>,
411    kind: EntityKind,
412    diffs: &mut Vec<EntityDiff>,
413) {
414    diff_entity_type_with(
415        left,
416        right,
417        key_fn,
418        to_entity,
419        table_fn,
420        kind,
421        PartialEq::eq,
422        diffs,
423    );
424}
425
426#[allow(clippy::too_many_arguments)]
427fn diff_entity_type_with<T: Clone>(
428    left: &[T],
429    right: &[T],
430    key_fn: impl Fn(&T) -> String,
431    to_entity: impl Fn(&T) -> SqliteEntity,
432    table_fn: Option<&dyn Fn(&T) -> String>,
433    kind: EntityKind,
434    equivalent: impl Fn(&T, &T) -> bool,
435    diffs: &mut Vec<EntityDiff>,
436) {
437    let left_map: HashMap<String, &T> = left.iter().map(|e| (key_fn(e), e)).collect();
438    let right_map: HashMap<String, &T> = right.iter().map(|e| (key_fn(e), e)).collect();
439
440    // Find dropped (in left but not in right)
441    for left_entity in left {
442        let key = key_fn(left_entity);
443        if !right_map.contains_key(&key) {
444            diffs.push(EntityDiff {
445                diff_type: DiffType::Drop,
446                kind,
447                table: table_fn.map(|f| f(left_entity)),
448                name: key,
449                changes: HashMap::new(),
450                left: Some(to_entity(left_entity)),
451                right: None,
452            });
453        }
454    }
455
456    // Find created (in right but not in left)
457    for right_entity in right {
458        let key = key_fn(right_entity);
459        if !left_map.contains_key(&key) {
460            diffs.push(EntityDiff {
461                diff_type: DiffType::Create,
462                kind,
463                table: table_fn.map(|f| f(right_entity)),
464                name: key,
465                changes: HashMap::new(),
466                left: None,
467                right: Some(to_entity(right_entity)),
468            });
469        }
470    }
471
472    // Find altered (in both, but different)
473    for left_entity in left {
474        let key = key_fn(left_entity);
475        if let Some(right_entity) = right_map.get(&key)
476            && !equivalent(left_entity, right_entity)
477        {
478            diffs.push(EntityDiff {
479                diff_type: DiffType::Alter,
480                kind,
481                table: table_fn.map(|f| f(right_entity)),
482                name: key,
483                changes: HashMap::new(), // Field-level comparison available via left/right entities
484                left: Some(to_entity(left_entity)),
485                right: Some(to_entity(right_entity)),
486            });
487        }
488    }
489}
490
491// =============================================================================
492// Equivalence normalization
493//
494// Introspected DDL and macro/snapshot DDL systematically differ in ways that
495// don't change the rendered schema (ordinal positions, literal quoting styles,
496// synthesized constraint names, ...). These helpers normalize both sides
497// before comparing so that a push round-trip is a no-op. Rendering always
498// uses the original, un-normalized entities.
499// =============================================================================
500
501/// Strips one layer of outer balanced parentheses, if fully wrapped.
502fn strip_outer_parens(expr: &str) -> &str {
503    let expr = expr.trim();
504    let bytes = expr.as_bytes();
505    if bytes.len() < 2 || bytes[0] != b'(' || bytes[bytes.len() - 1] != b')' {
506        return expr;
507    }
508    let mut depth = 0i32;
509    for (i, ch) in expr.char_indices() {
510        match ch {
511            '(' => depth += 1,
512            ')' => {
513                depth -= 1;
514                if depth == 0 {
515                    if i == expr.len() - 1 {
516                        return expr[1..expr.len() - 1].trim();
517                    }
518                    return expr;
519                }
520            }
521            _ => {}
522        }
523    }
524    expr
525}
526
527/// Normalizes a DEFAULT literal for comparison: strips one paren layer, then
528/// one layer of matching quotes (`'x'` ≡ `"x"` ≡ `x`, with doubled-quote
529/// unescaping), then canonicalizes numeric literals (`0.0` ≡ `0`).
530fn normalize_default_literal(default: &str) -> String {
531    let s = strip_outer_parens(default);
532    let bytes = s.as_bytes();
533    let unquoted = if bytes.len() >= 2 && bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'' {
534        s[1..s.len() - 1].replace("''", "'")
535    } else if bytes.len() >= 2 && bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"' {
536        s[1..s.len() - 1].replace("\"\"", "\"")
537    } else {
538        s.to_string()
539    };
540
541    if let Ok(int) = unquoted.parse::<i128>() {
542        return int.to_string();
543    }
544    if let Ok(float) = unquoted.parse::<f64>()
545        && float.is_finite()
546    {
547        return float.to_string();
548    }
549    unquoted
550}
551
552/// Collects `(table, column)` pairs that render as inline `INTEGER PRIMARY
553/// KEY` (single-column PK entity, or a lone column-level PK flag).
554fn inline_integer_pk_columns(ddl: &SQLiteDDL) -> HashSet<(String, String)> {
555    let mut out = HashSet::new();
556
557    let mut candidates: Vec<(String, String)> = ddl
558        .pks
559        .list()
560        .iter()
561        .filter(|pk| pk.columns.len() == 1)
562        .map(|pk| (pk.table.to_string(), pk.columns[0].to_string()))
563        .collect();
564
565    // Column-level flags: only a single flag column per table renders inline.
566    let mut flag_pks: HashMap<String, Vec<String>> = HashMap::new();
567    for c in ddl.columns.list() {
568        if c.primary_key == Some(true) {
569            flag_pks
570                .entry(c.table.to_string())
571                .or_default()
572                .push(c.name.to_string());
573        }
574    }
575    for (table, cols) in flag_pks {
576        if let [col] = cols.as_slice() {
577            candidates.push((table, col.clone()));
578        }
579    }
580
581    for (table, col) in candidates {
582        let is_integer = ddl
583            .columns
584            .one(&table, &col)
585            .is_some_and(|c| c.sql_type.to_ascii_lowercase().starts_with("int"));
586        if is_integer {
587            out.insert((table, col));
588        }
589    }
590    out
591}
592
593fn columns_equivalent(
594    left: &Column,
595    right: &Column,
596    integer_pks: &HashSet<(String, String)>,
597) -> bool {
598    let normalize = |column: &Column| -> Column {
599        let mut c = column.clone();
600        c.sql_type = Cow::Owned(c.sql_type.to_ascii_lowercase());
601        // (a) ordinal position is introspection metadata, not schema shape
602        c.ordinal_position = None;
603        // Explicit `Some(false)` flags are equivalent to omitted flags
604        if c.primary_key == Some(false) {
605            c.primary_key = None;
606        }
607        if c.unique == Some(false) {
608            c.unique = None;
609        }
610        if c.autoincrement == Some(false) {
611            c.autoincrement = None;
612        }
613        // (b) inline INTEGER PRIMARY KEY: emitters skip NOT NULL and PRAGMA
614        // reports 0 while snapshots say true — both render identically, so
615        // pin not_null for comparison purposes.
616        if integer_pks.contains(&(c.table.to_string(), c.name.to_string())) {
617            c.not_null = true;
618        }
619        // (c) default literal quoting/numeric normalization
620        if let Some(default) = c.default.as_ref() {
621            c.default = Some(Cow::Owned(normalize_default_literal(default)));
622        }
623        // Generated expressions: macro producers store `(expr)`, introspection
624        // stores bare `expr` — strip one paren layer from both sides.
625        if let Some(generated) = c.generated.as_mut() {
626            generated.expression =
627                Cow::Owned(strip_outer_parens(&generated.expression).to_string());
628        }
629        c
630    };
631
632    normalize(left) == normalize(right)
633}
634
635fn normalize_fk_action(action: &Option<Cow<'static, str>>) -> Option<Cow<'static, str>> {
636    match action.as_deref() {
637        None => None,
638        Some(action) if action.eq_ignore_ascii_case("NO ACTION") => None,
639        Some(action) => Some(Cow::Owned(action.to_ascii_uppercase())),
640    }
641}
642
643/// Structural identity for a foreign key (used as the diff key): PRAGMA cannot
644/// recover FK constraint names, so identity is the (table, columns, target
645/// table, target columns) shape.
646fn fk_structural_key(fk: &ForeignKey) -> String {
647    let cols: Vec<&str> = fk.columns.iter().map(AsRef::as_ref).collect();
648    let cols_to: Vec<&str> = fk.columns_to.iter().map(AsRef::as_ref).collect();
649    format!(
650        "{}({})->{}({})",
651        fk.table,
652        cols.join(","),
653        fk.table_to,
654        cols_to.join(",")
655    )
656}
657
658fn foreign_keys_equivalent(left: &ForeignKey, right: &ForeignKey) -> bool {
659    let mut left = left.clone();
660    let mut right = right.clone();
661    left.on_delete = normalize_fk_action(&left.on_delete);
662    left.on_update = normalize_fk_action(&left.on_update);
663    right.on_delete = normalize_fk_action(&right.on_delete);
664    right.on_update = normalize_fk_action(&right.on_update);
665    // (e) names cannot be recovered from PRAGMA — equivalence is structural
666    left.name = Cow::Borrowed("");
667    right.name = Cow::Borrowed("");
668    left.name_explicit = false;
669    right.name_explicit = false;
670    left == right
671}
672
673/// (d) primary keys compare by column set (order-insensitive), not name.
674fn primary_keys_equivalent(left: &PrimaryKey, right: &PrimaryKey) -> bool {
675    if left.table != right.table {
676        return false;
677    }
678    let mut left_cols: Vec<&str> = left.columns.iter().map(AsRef::as_ref).collect();
679    let mut right_cols: Vec<&str> = right.columns.iter().map(AsRef::as_ref).collect();
680    left_cols.sort_unstable();
681    right_cols.sort_unstable();
682    left_cols == right_cols
683}
684
685#[cfg(test)]
686mod tests {
687    use super::*;
688
689    #[test]
690    fn test_ddl_collection_push() {
691        let mut ddl = SQLiteDDL::new();
692
693        ddl.tables.push(Table::new("users"));
694        ddl.columns.push(Column::new("users", "id", "integer"));
695        ddl.columns.push(Column::new("users", "name", "text"));
696
697        assert_eq!(ddl.tables.len(), 1);
698        assert_eq!(ddl.columns.len(), 2);
699        assert_eq!(ddl.columns.for_table("users").len(), 2);
700    }
701
702    #[test]
703    fn test_ddl_to_entities() {
704        let mut ddl = SQLiteDDL::new();
705        ddl.tables.push(Table::new("users"));
706        ddl.columns
707            .push(Column::new("users", "id", "integer").not_null());
708
709        let entities = ddl.to_entities();
710        assert_eq!(entities.len(), 2);
711    }
712
713    #[test]
714    fn test_diff_create() {
715        let left = SQLiteDDL::new();
716        let mut right = SQLiteDDL::new();
717        right.tables.push(Table::new("users"));
718
719        let diffs = diff_ddl(&left, &right);
720        assert_eq!(diffs.len(), 1);
721        assert_eq!(diffs[0].diff_type, DiffType::Create);
722        assert_eq!(diffs[0].kind, EntityKind::Table);
723    }
724
725    #[test]
726    fn test_diff_drop() {
727        let mut left = SQLiteDDL::new();
728        left.tables.push(Table::new("users"));
729        let right = SQLiteDDL::new();
730
731        let diffs = diff_ddl(&left, &right);
732        assert_eq!(diffs.len(), 1);
733        assert_eq!(diffs[0].diff_type, DiffType::Drop);
734    }
735
736    #[test]
737    fn ordinal_position_is_ignored_in_column_equivalence() {
738        let mut introspected = SQLiteDDL::new();
739        introspected.tables.push(Table::new("t"));
740        let mut col = Column::new("t", "name", "text").not_null();
741        col.ordinal_position = Some(3);
742        introspected.columns.push(col);
743
744        let mut snapshot = SQLiteDDL::new();
745        snapshot.tables.push(Table::new("t"));
746        snapshot
747            .columns
748            .push(Column::new("t", "name", "TEXT").not_null());
749
750        let diffs = diff_ddl(&introspected, &snapshot);
751        assert!(diffs.is_empty(), "unexpected diffs: {diffs:#?}");
752    }
753
754    #[test]
755    fn integer_pk_not_null_mismatch_is_reconciled() {
756        use crate::sqlite::ddl::PrimaryKey;
757
758        // Introspected: PRAGMA reports notnull = 0 for INTEGER PRIMARY KEY
759        let mut introspected = SQLiteDDL::new();
760        introspected.tables.push(Table::new("t"));
761        introspected.columns.push(Column::new("t", "id", "integer"));
762        introspected.pks.push(PrimaryKey::from_strings(
763            "t".to_string(),
764            "t_pk".to_string(),
765            vec!["id".to_string()],
766        ));
767
768        // Snapshot: macro marks PK fields NOT NULL
769        let mut snapshot = SQLiteDDL::new();
770        snapshot.tables.push(Table::new("t"));
771        snapshot
772            .columns
773            .push(Column::new("t", "id", "INTEGER").not_null());
774        snapshot.pks.push(PrimaryKey::from_strings(
775            "t".to_string(),
776            "t_pk".to_string(),
777            vec!["id".to_string()],
778        ));
779
780        let diffs = diff_ddl(&introspected, &snapshot);
781        assert!(diffs.is_empty(), "unexpected diffs: {diffs:#?}");
782    }
783
784    #[test]
785    fn default_literal_quoting_is_normalized() {
786        for (left_default, right_default) in [
787            ("'hello'", "hello"),
788            ("\"hello\"", "'hello'"),
789            ("'it''s'", "it's"),
790            ("0.0", "0"),
791            ("(42)", "42"),
792        ] {
793            let mut left = SQLiteDDL::new();
794            left.tables.push(Table::new("t"));
795            left.columns
796                .push(Column::new("t", "c", "text").default_value(left_default.to_string()));
797
798            let mut right = SQLiteDDL::new();
799            right.tables.push(Table::new("t"));
800            right
801                .columns
802                .push(Column::new("t", "c", "text").default_value(right_default.to_string()));
803
804            let diffs = diff_ddl(&left, &right);
805            assert!(
806                diffs.is_empty(),
807                "{left_default:?} vs {right_default:?} should be equivalent: {diffs:#?}"
808            );
809        }
810
811        // Different values must still diff.
812        let mut left = SQLiteDDL::new();
813        left.tables.push(Table::new("t"));
814        left.columns
815            .push(Column::new("t", "c", "text").default_value("'a'"));
816        let mut right = SQLiteDDL::new();
817        right.tables.push(Table::new("t"));
818        right
819            .columns
820            .push(Column::new("t", "c", "text").default_value("'b'"));
821        assert_eq!(diff_ddl(&left, &right).len(), 1);
822    }
823
824    #[test]
825    fn generated_expression_parens_are_normalized() {
826        use crate::sqlite::ddl::{Generated, GeneratedType};
827
828        let make = |expr: &str| {
829            let mut ddl = SQLiteDDL::new();
830            ddl.tables.push(Table::new("t"));
831            let mut col = Column::new("t", "g", "text");
832            col.generated = Some(Generated {
833                expression: expr.to_string().into(),
834                gen_type: GeneratedType::Virtual,
835            });
836            ddl.columns.push(col);
837            ddl
838        };
839
840        // Macro-produced `(expr)` vs introspected `expr`
841        let diffs = diff_ddl(&make("(length(name))"), &make("length(name)"));
842        assert!(diffs.is_empty(), "unexpected diffs: {diffs:#?}");
843
844        // Different expressions still diff
845        assert_eq!(
846            diff_ddl(&make("(length(name))"), &make("length(other)")).len(),
847            1
848        );
849    }
850
851    #[test]
852    fn primary_keys_compare_by_column_set_not_name() {
853        use crate::sqlite::ddl::PrimaryKey;
854
855        let make = |name: &str, cols: Vec<&str>| {
856            let mut ddl = SQLiteDDL::new();
857            ddl.tables.push(Table::new("t"));
858            ddl.columns
859                .push(Column::new("t", "a", "integer").not_null());
860            ddl.columns
861                .push(Column::new("t", "b", "integer").not_null());
862            ddl.pks.push(PrimaryKey::from_strings(
863                "t".to_string(),
864                name.to_string(),
865                cols.into_iter().map(str::to_string).collect(),
866            ));
867            ddl
868        };
869
870        // Same column set, different name and order: equivalent
871        let diffs = diff_ddl(
872            &make("t_pk", vec!["a", "b"]),
873            &make("custom", vec!["b", "a"]),
874        );
875        assert!(diffs.is_empty(), "unexpected diffs: {diffs:#?}");
876
877        // Different column set: alter
878        assert_eq!(
879            diff_ddl(&make("t_pk", vec!["a", "b"]), &make("t_pk", vec!["a"])).len(),
880            1
881        );
882    }
883
884    #[test]
885    fn foreign_keys_compare_structurally_not_by_name() {
886        let make = |name: &str| {
887            let mut ddl = SQLiteDDL::new();
888            ddl.tables.push(Table::new("child"));
889            ddl.tables.push(Table::new("parent"));
890            ddl.columns
891                .push(Column::new("child", "parent_id", "integer").not_null());
892            ddl.fks.push(ForeignKey::from_strings(
893                "child".to_string(),
894                name.to_string(),
895                vec!["parent_id".to_string()],
896                "parent".to_string(),
897                vec!["id".to_string()],
898            ));
899            ddl
900        };
901
902        // Same structure, different names (introspected names are synthesized):
903        // no churn — neither drop/create (structural key) nor alter (structural
904        // equivalence).
905        let diffs = diff_ddl(&make("fk_child_parent_id_parent_id_fk"), &make("my_fk"));
906        assert!(diffs.is_empty(), "unexpected diffs: {diffs:#?}");
907
908        // Different action: alter (single diff, not drop+create)
909        let mut with_cascade = make("a");
910        with_cascade.fks.list_mut()[0].on_delete = Some("CASCADE".into());
911        let diffs = diff_ddl(&make("b"), &with_cascade);
912        assert_eq!(diffs.len(), 1);
913        assert_eq!(diffs[0].diff_type, DiffType::Alter);
914    }
915
916    #[test]
917    fn introspected_types_and_no_action_fks_match_macro_snapshots() {
918        let mut introspected = SQLiteDDL::new();
919        introspected.tables.push(Table::new("child"));
920        introspected.tables.push(Table::new("parent"));
921        introspected
922            .columns
923            .push(Column::new("child", "id", "integer").not_null());
924        introspected
925            .columns
926            .push(Column::new("child", "parent_id", "integer").not_null());
927        introspected.fks.push(
928            ForeignKey::from_strings(
929                "child".to_string(),
930                "child_parent_id_fk".to_string(),
931                vec!["parent_id".to_string()],
932                "parent".to_string(),
933                vec!["id".to_string()],
934            )
935            .on_delete("NO ACTION")
936            .on_update("no action"),
937        );
938
939        let mut macro_snapshot = SQLiteDDL::new();
940        macro_snapshot.tables.push(Table::new("child"));
941        macro_snapshot.tables.push(Table::new("parent"));
942        macro_snapshot
943            .columns
944            .push(Column::new("child", "id", "INTEGER").not_null());
945        macro_snapshot
946            .columns
947            .push(Column::new("child", "parent_id", "INTEGER").not_null());
948        macro_snapshot.fks.push(ForeignKey::from_strings(
949            "child".to_string(),
950            "child_parent_id_fk".to_string(),
951            vec!["parent_id".to_string()],
952            "parent".to_string(),
953            vec!["id".to_string()],
954        ));
955
956        let diffs = diff_ddl(&introspected, &macro_snapshot);
957        assert!(diffs.is_empty(), "unexpected diffs: {diffs:#?}");
958    }
959}