Skip to main content

waypoint_core/
schema.rs

1//! Schema introspection, diff, and DDL generation.
2//!
3//! Used by diff, drift, snapshot, and reversal commands. Introspection has
4//! a PostgreSQL implementation ([`introspect`]) and a MySQL implementation
5//! ([`introspect_mysql`]); [`introspect_db`] dispatches based on engine.
6//! [`diff`] is engine-agnostic — it consumes [`SchemaSnapshot`] regardless
7//! of which engine produced it. DDL generation comes in two flavours:
8//! [`generate_ddl`] for PostgreSQL and [`generate_ddl_mysql`] for MySQL
9//! (the latter omits CASCADE and filters dependent constraint/index diffs
10//! when their parent table is being dropped, since MySQL has no CASCADE).
11
12use std::collections::{HashMap, HashSet};
13
14use serde::Serialize;
15
16#[cfg(feature = "postgres")]
17use tokio_postgres::Client;
18
19use crate::db::{DbClient, quote_ident};
20use crate::dialect::DialectKind;
21use crate::error::Result;
22#[cfg(any(not(feature = "postgres"), not(feature = "mysql")))]
23use crate::error::WaypointError;
24
25/// Complete snapshot of a database schema.
26///
27/// Populated by [`introspect`] on PostgreSQL and [`introspect_mysql`] on
28/// MySQL. Concepts that don't apply to MySQL (sequences, PG-style enums,
29/// extensions) come back as empty vectors when produced by `introspect_mysql`.
30#[derive(Debug, Clone, Serialize, PartialEq)]
31pub struct SchemaSnapshot {
32    /// All base tables in the schema.
33    pub tables: Vec<TableDef>,
34    /// All views (regular and materialized) in the schema.
35    pub views: Vec<ViewDef>,
36    /// All indexes in the schema.
37    pub indexes: Vec<IndexDef>,
38    /// All sequences in the schema.
39    pub sequences: Vec<SequenceDef>,
40    /// All functions and procedures in the schema.
41    pub functions: Vec<FunctionDef>,
42    /// All enum types in the schema.
43    pub enums: Vec<EnumDef>,
44    /// All table constraints in the schema.
45    pub constraints: Vec<ConstraintDef>,
46    /// All triggers in the schema.
47    pub triggers: Vec<TriggerDef>,
48    /// Names of installed extensions (excluding plpgsql).
49    pub extensions: Vec<String>,
50}
51
52/// Definition of a database table.
53#[derive(Debug, Clone, Serialize, PartialEq)]
54pub struct TableDef {
55    /// Schema the table belongs to.
56    pub schema: String,
57    /// Name of the table.
58    pub name: String,
59    /// Columns belonging to this table.
60    pub columns: Vec<ColumnDef>,
61}
62
63/// Definition of a table column.
64#[derive(Debug, Clone, Serialize, PartialEq)]
65pub struct ColumnDef {
66    /// Name of the column.
67    pub name: String,
68    /// SQL data type of the column.
69    pub data_type: String,
70    /// Whether the column allows NULL values.
71    pub is_nullable: bool,
72    /// Default value expression, if any.
73    pub default: Option<String>,
74    /// Position of the column within its table (1-based).
75    pub ordinal_position: i32,
76}
77
78/// Definition of a database view.
79#[derive(Debug, Clone, Serialize, PartialEq)]
80pub struct ViewDef {
81    /// Schema the view belongs to.
82    pub schema: String,
83    /// Name of the view.
84    pub name: String,
85    /// SQL definition body of the view.
86    pub definition: String,
87    /// Whether this is a materialized view.
88    pub is_materialized: bool,
89}
90
91/// Definition of a database index.
92#[derive(Debug, Clone, Serialize, PartialEq)]
93pub struct IndexDef {
94    /// Schema the index belongs to.
95    pub schema: String,
96    /// Name of the index.
97    pub name: String,
98    /// Name of the table the index is built on.
99    pub table_name: String,
100    /// Full CREATE INDEX DDL statement.
101    pub definition: String,
102    /// Whether this is a unique index.
103    pub is_unique: bool,
104}
105
106/// Definition of a database sequence.
107#[derive(Debug, Clone, Serialize, PartialEq)]
108pub struct SequenceDef {
109    /// Schema the sequence belongs to.
110    pub schema: String,
111    /// Name of the sequence.
112    pub name: String,
113    /// Data type of the sequence (e.g. bigint).
114    pub data_type: String,
115}
116
117/// Definition of a database function or procedure.
118#[derive(Debug, Clone, Serialize, PartialEq)]
119pub struct FunctionDef {
120    /// Schema the function belongs to.
121    pub schema: String,
122    /// Name of the function.
123    pub name: String,
124    /// Function argument signature.
125    pub arguments: String,
126    /// Return type of the function.
127    pub return_type: String,
128    /// Implementation language (e.g. plpgsql, sql).
129    pub language: String,
130    /// Full function definition body.
131    pub definition: String,
132}
133
134/// Definition of a PostgreSQL enum type.
135#[derive(Debug, Clone, Serialize, PartialEq)]
136pub struct EnumDef {
137    /// Schema the enum belongs to.
138    pub schema: String,
139    /// Name of the enum type.
140    pub name: String,
141    /// Ordered list of enum label values.
142    pub values: Vec<String>,
143}
144
145/// Definition of a table constraint.
146#[derive(Debug, Clone, Serialize, PartialEq)]
147pub struct ConstraintDef {
148    /// Schema the constraint belongs to.
149    pub schema: String,
150    /// Name of the table the constraint is on.
151    pub table_name: String,
152    /// Name of the constraint.
153    pub name: String,
154    /// Type of constraint (e.g. PRIMARY KEY, UNIQUE, FOREIGN KEY, CHECK).
155    pub constraint_type: String,
156    /// Full constraint definition expression.
157    pub definition: String,
158}
159
160/// Definition of a database trigger.
161#[derive(Debug, Clone, Serialize, PartialEq)]
162pub struct TriggerDef {
163    /// Schema the trigger belongs to.
164    pub schema: String,
165    /// Name of the table the trigger is attached to.
166    pub table_name: String,
167    /// Name of the trigger.
168    pub name: String,
169    /// Action statement executed by the trigger.
170    pub definition: String,
171}
172
173/// Differences between two schema snapshots.
174#[derive(Debug, Clone, Serialize)]
175pub enum SchemaDiff {
176    /// A table was added in the target schema.
177    TableAdded(TableDef),
178    /// A table was dropped from the target schema.
179    TableDropped(String),
180    /// A column was added to an existing table.
181    ColumnAdded { table: String, column: ColumnDef },
182    /// A column was dropped from an existing table.
183    ColumnDropped { table: String, column: String },
184    /// A column definition was altered in an existing table.
185    ColumnAltered {
186        table: String,
187        column: String,
188        from: ColumnDef,
189        to: ColumnDef,
190    },
191    /// An index was added in the target schema.
192    IndexAdded(IndexDef),
193    /// An index was dropped from the target schema.
194    ///
195    /// Carries both the index name and the table it belongs to — MySQL's
196    /// `DROP INDEX` syntax requires the table (unlike PostgreSQL where
197    /// indexes are schema-scoped).
198    IndexDropped { name: String, table_name: String },
199    /// A view was added in the target schema.
200    ViewAdded(ViewDef),
201    /// A view was dropped from the target schema.
202    ViewDropped(String),
203    /// A view definition was altered.
204    ViewAltered {
205        name: String,
206        from: String,
207        to: String,
208    },
209    /// A sequence was added in the target schema.
210    SequenceAdded(SequenceDef),
211    /// A sequence was dropped from the target schema.
212    SequenceDropped(String),
213    /// A function was added in the target schema.
214    FunctionAdded(FunctionDef),
215    /// A function was dropped from the target schema.
216    FunctionDropped(String),
217    /// A function definition was altered.
218    FunctionAltered { name: String },
219    /// An enum type was added in the target schema.
220    EnumAdded(EnumDef),
221    /// An enum type was dropped from the target schema.
222    EnumDropped(String),
223    /// A constraint was added in the target schema.
224    ConstraintAdded(ConstraintDef),
225    /// A constraint was dropped from the target schema.
226    ConstraintDropped { table: String, name: String },
227    /// A trigger was added in the target schema.
228    TriggerAdded(TriggerDef),
229    /// A trigger was dropped from the target schema.
230    TriggerDropped { table: String, name: String },
231    /// A PostgreSQL extension was added.
232    ExtensionAdded(String),
233    /// A PostgreSQL extension was dropped.
234    ExtensionDropped(String),
235}
236
237impl std::fmt::Display for SchemaDiff {
238    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239        match self {
240            SchemaDiff::TableAdded(t) => write!(f, "+ TABLE {}", t.name),
241            SchemaDiff::TableDropped(n) => write!(f, "- TABLE {}", n),
242            SchemaDiff::ColumnAdded { table, column } => {
243                write!(
244                    f,
245                    "+ COLUMN {}.{} ({})",
246                    table, column.name, column.data_type
247                )
248            }
249            SchemaDiff::ColumnDropped { table, column } => {
250                write!(f, "- COLUMN {}.{}", table, column)
251            }
252            SchemaDiff::ColumnAltered { table, column, .. } => {
253                write!(f, "~ COLUMN {}.{}", table, column)
254            }
255            SchemaDiff::IndexAdded(idx) => write!(f, "+ INDEX {}", idx.name),
256            SchemaDiff::IndexDropped { name, table_name } => {
257                write!(f, "- INDEX {} ON {}", name, table_name)
258            }
259            SchemaDiff::ViewAdded(v) => write!(f, "+ VIEW {}", v.name),
260            SchemaDiff::ViewDropped(n) => write!(f, "- VIEW {}", n),
261            SchemaDiff::ViewAltered { name, .. } => write!(f, "~ VIEW {}", name),
262            SchemaDiff::SequenceAdded(s) => write!(f, "+ SEQUENCE {}", s.name),
263            SchemaDiff::SequenceDropped(n) => write!(f, "- SEQUENCE {}", n),
264            SchemaDiff::FunctionAdded(func) => write!(f, "+ FUNCTION {}", func.name),
265            SchemaDiff::FunctionDropped(n) => write!(f, "- FUNCTION {}", n),
266            SchemaDiff::FunctionAltered { name } => write!(f, "~ FUNCTION {}", name),
267            SchemaDiff::EnumAdded(e) => write!(f, "+ TYPE {} (enum)", e.name),
268            SchemaDiff::EnumDropped(n) => write!(f, "- TYPE {} (enum)", n),
269            SchemaDiff::ConstraintAdded(c) => {
270                write!(f, "+ CONSTRAINT {} ON {}", c.name, c.table_name)
271            }
272            SchemaDiff::ConstraintDropped { table, name } => {
273                write!(f, "- CONSTRAINT {} ON {}", name, table)
274            }
275            SchemaDiff::TriggerAdded(t) => write!(f, "+ TRIGGER {} ON {}", t.name, t.table_name),
276            SchemaDiff::TriggerDropped { table, name } => {
277                write!(f, "- TRIGGER {} ON {}", name, table)
278            }
279            SchemaDiff::ExtensionAdded(n) => write!(f, "+ EXTENSION {}", n),
280            SchemaDiff::ExtensionDropped(n) => write!(f, "- EXTENSION {}", n),
281        }
282    }
283}
284
285/// Introspect the current state of a schema (dialect-aware entry).
286pub async fn introspect_db(client: &DbClient, schema: &str) -> Result<SchemaSnapshot> {
287    match client.dialect_kind() {
288        #[cfg(feature = "postgres")]
289        DialectKind::Postgres => introspect(client.as_postgres()?, schema).await,
290        #[cfg(not(feature = "postgres"))]
291        DialectKind::Postgres => Err(WaypointError::ConfigError(
292            "PostgreSQL support is not compiled in".into(),
293        )),
294        #[cfg(feature = "mysql")]
295        DialectKind::Mysql => introspect_mysql(client, schema).await,
296        #[cfg(not(feature = "mysql"))]
297        DialectKind::Mysql => Err(WaypointError::ConfigError(
298            "MySQL support is not compiled in".into(),
299        )),
300    }
301}
302
303/// Introspect the current state of a PostgreSQL schema.
304#[cfg(feature = "postgres")]
305pub async fn introspect(client: &Client, schema: &str) -> Result<SchemaSnapshot> {
306    let (tables, views, indexes, sequences, functions, enums, constraints, triggers, extensions) =
307        tokio::try_join!(
308            introspect_tables(client, schema),
309            introspect_views(client, schema),
310            introspect_indexes(client, schema),
311            introspect_sequences(client, schema),
312            introspect_functions(client, schema),
313            introspect_enums(client, schema),
314            introspect_constraints(client, schema),
315            introspect_triggers(client, schema),
316            introspect_extensions(client),
317        )?;
318
319    Ok(SchemaSnapshot {
320        tables,
321        views,
322        indexes,
323        sequences,
324        functions,
325        enums,
326        constraints,
327        triggers,
328        extensions,
329    })
330}
331
332#[cfg(feature = "postgres")]
333async fn introspect_tables(client: &Client, schema: &str) -> Result<Vec<TableDef>> {
334    let rows = client
335        .query(
336            "SELECT t.table_name, c.column_name, c.data_type, c.is_nullable, c.column_default, c.ordinal_position
337             FROM information_schema.tables t
338             LEFT JOIN information_schema.columns c
339               ON t.table_schema = c.table_schema AND t.table_name = c.table_name
340             WHERE t.table_schema = $1 AND t.table_type = 'BASE TABLE'
341             ORDER BY t.table_name, c.ordinal_position",
342            &[&schema],
343        )
344        .await?;
345
346    let mut tables: Vec<TableDef> = Vec::new();
347    let mut current_table: Option<String> = None;
348    let mut columns: Vec<ColumnDef> = Vec::new();
349
350    for row in &rows {
351        let table_name: String = row.get(0);
352        let col_name: Option<String> = row.get(1);
353
354        if current_table.as_ref() != Some(&table_name) {
355            if let Some(prev_name) = current_table.take() {
356                tables.push(TableDef {
357                    schema: schema.to_string(),
358                    name: prev_name,
359                    columns: std::mem::take(&mut columns),
360                });
361            }
362            current_table = Some(table_name.clone());
363        }
364
365        if let Some(name) = col_name {
366            columns.push(ColumnDef {
367                name,
368                data_type: row.get(2),
369                is_nullable: row.get::<_, String>(3) == "YES",
370                default: row.get(4),
371                ordinal_position: row.get(5),
372            });
373        }
374    }
375
376    // Don't forget the last table
377    if let Some(name) = current_table {
378        tables.push(TableDef {
379            schema: schema.to_string(),
380            name,
381            columns,
382        });
383    }
384
385    Ok(tables)
386}
387
388#[cfg(feature = "postgres")]
389async fn introspect_views(client: &Client, schema: &str) -> Result<Vec<ViewDef>> {
390    // Regular views
391    let rows = client
392        .query(
393            "SELECT table_name, view_definition
394             FROM information_schema.views
395             WHERE table_schema = $1
396             ORDER BY table_name",
397            &[&schema],
398        )
399        .await?;
400
401    let mut views: Vec<ViewDef> = rows
402        .iter()
403        .map(|r| ViewDef {
404            schema: schema.to_string(),
405            name: r.get(0),
406            definition: r.get::<_, Option<String>>(1).unwrap_or_default(),
407            is_materialized: false,
408        })
409        .collect();
410
411    // Materialized views
412    let mat_rows = client
413        .query(
414            "SELECT c.relname, pg_get_viewdef(c.oid)
415             FROM pg_class c
416             JOIN pg_namespace n ON n.oid = c.relnamespace
417             WHERE n.nspname = $1 AND c.relkind = 'm'
418             ORDER BY c.relname",
419            &[&schema],
420        )
421        .await?;
422
423    for r in &mat_rows {
424        views.push(ViewDef {
425            schema: schema.to_string(),
426            name: r.get(0),
427            definition: r.get::<_, Option<String>>(1).unwrap_or_default(),
428            is_materialized: true,
429        });
430    }
431
432    Ok(views)
433}
434
435#[cfg(feature = "postgres")]
436async fn introspect_indexes(client: &Client, schema: &str) -> Result<Vec<IndexDef>> {
437    let rows = client
438        .query(
439            "SELECT indexname, tablename, indexdef
440             FROM pg_indexes
441             WHERE schemaname = $1
442             ORDER BY indexname",
443            &[&schema],
444        )
445        .await?;
446
447    Ok(rows
448        .iter()
449        .map(|r| {
450            let definition: String = r.get(2);
451            IndexDef {
452                schema: schema.to_string(),
453                name: r.get(0),
454                table_name: r.get(1),
455                is_unique: definition.to_uppercase().contains("UNIQUE"),
456                definition,
457            }
458        })
459        .collect())
460}
461
462#[cfg(feature = "postgres")]
463async fn introspect_sequences(client: &Client, schema: &str) -> Result<Vec<SequenceDef>> {
464    let rows = client
465        .query(
466            "SELECT sequence_name, data_type
467             FROM information_schema.sequences
468             WHERE sequence_schema = $1
469             ORDER BY sequence_name",
470            &[&schema],
471        )
472        .await?;
473
474    Ok(rows
475        .iter()
476        .map(|r| SequenceDef {
477            schema: schema.to_string(),
478            name: r.get(0),
479            data_type: r.get(1),
480        })
481        .collect())
482}
483
484#[cfg(feature = "postgres")]
485async fn introspect_functions(client: &Client, schema: &str) -> Result<Vec<FunctionDef>> {
486    let rows = client
487        .query(
488            "SELECT p.proname,
489                    pg_get_function_arguments(p.oid),
490                    pg_get_function_result(p.oid),
491                    l.lanname,
492                    pg_get_functiondef(p.oid)
493             FROM pg_proc p
494             JOIN pg_namespace n ON n.oid = p.pronamespace
495             JOIN pg_language l ON l.oid = p.prolang
496             WHERE n.nspname = $1
497               AND p.prokind IN ('f', 'p')
498             ORDER BY p.proname",
499            &[&schema],
500        )
501        .await?;
502
503    Ok(rows
504        .iter()
505        .map(|r| FunctionDef {
506            schema: schema.to_string(),
507            name: r.get(0),
508            arguments: r.get(1),
509            return_type: r.get::<_, Option<String>>(2).unwrap_or_default(),
510            language: r.get(3),
511            definition: r.get::<_, Option<String>>(4).unwrap_or_default(),
512        })
513        .collect())
514}
515
516#[cfg(feature = "postgres")]
517async fn introspect_enums(client: &Client, schema: &str) -> Result<Vec<EnumDef>> {
518    let rows = client
519        .query(
520            "SELECT t.typname, array_agg(e.enumlabel ORDER BY e.enumsortorder)::text[]
521             FROM pg_type t
522             JOIN pg_enum e ON e.enumtypid = t.oid
523             JOIN pg_namespace n ON n.oid = t.typnamespace
524             WHERE n.nspname = $1
525             GROUP BY t.typname
526             ORDER BY t.typname",
527            &[&schema],
528        )
529        .await?;
530
531    Ok(rows
532        .iter()
533        .map(|r| EnumDef {
534            schema: schema.to_string(),
535            name: r.get(0),
536            values: r.get(1),
537        })
538        .collect())
539}
540
541#[cfg(feature = "postgres")]
542async fn introspect_constraints(client: &Client, schema: &str) -> Result<Vec<ConstraintDef>> {
543    let rows = client
544        .query(
545            "SELECT tc.table_name, tc.constraint_name, tc.constraint_type,
546                    pg_get_constraintdef(c.oid)
547             FROM information_schema.table_constraints tc
548             JOIN pg_constraint c ON c.conname = tc.constraint_name
549             JOIN pg_namespace n ON n.oid = c.connamespace
550             WHERE tc.constraint_schema = $1 AND n.nspname = $1
551             ORDER BY tc.table_name, tc.constraint_name",
552            &[&schema],
553        )
554        .await?;
555
556    Ok(rows
557        .iter()
558        .map(|r| ConstraintDef {
559            schema: schema.to_string(),
560            table_name: r.get(0),
561            name: r.get(1),
562            constraint_type: r.get(2),
563            definition: r.get::<_, Option<String>>(3).unwrap_or_default(),
564        })
565        .collect())
566}
567
568#[cfg(feature = "postgres")]
569async fn introspect_triggers(client: &Client, schema: &str) -> Result<Vec<TriggerDef>> {
570    let rows = client
571        .query(
572            "SELECT event_object_table, trigger_name, action_statement
573             FROM information_schema.triggers
574             WHERE trigger_schema = $1
575             ORDER BY event_object_table, trigger_name",
576            &[&schema],
577        )
578        .await?;
579
580    Ok(rows
581        .iter()
582        .map(|r| TriggerDef {
583            schema: schema.to_string(),
584            table_name: r.get(0),
585            name: r.get(1),
586            definition: r.get(2),
587        })
588        .collect())
589}
590
591#[cfg(feature = "postgres")]
592async fn introspect_extensions(client: &Client) -> Result<Vec<String>> {
593    let rows = client
594        .query(
595            "SELECT extname FROM pg_extension WHERE extname != 'plpgsql' ORDER BY extname",
596            &[],
597        )
598        .await?;
599
600    Ok(rows.iter().map(|r| r.get(0)).collect())
601}
602
603/// Compare two schema snapshots and return the differences.
604pub fn diff(before: &SchemaSnapshot, after: &SchemaSnapshot) -> Vec<SchemaDiff> {
605    let mut diffs = Vec::new();
606
607    // Build lookup maps for O(1) access
608
609    // Tables - keyed by name, value is reference to TableDef
610    let before_tables: HashMap<&str, &TableDef> =
611        before.tables.iter().map(|t| (t.name.as_str(), t)).collect();
612    let after_tables: HashMap<&str, &TableDef> =
613        after.tables.iter().map(|t| (t.name.as_str(), t)).collect();
614
615    // Views - keyed by name, value is reference to ViewDef
616    let before_views: HashMap<&str, &ViewDef> =
617        before.views.iter().map(|v| (v.name.as_str(), v)).collect();
618    let after_views: HashMap<&str, &ViewDef> =
619        after.views.iter().map(|v| (v.name.as_str(), v)).collect();
620
621    // Indexes - existence check only, keyed by name
622    let before_indexes: HashSet<&str> = before.indexes.iter().map(|i| i.name.as_str()).collect();
623    let after_indexes: HashSet<&str> = after.indexes.iter().map(|i| i.name.as_str()).collect();
624
625    // Sequences - existence check only, keyed by name
626    let before_sequences: HashSet<&str> =
627        before.sequences.iter().map(|s| s.name.as_str()).collect();
628    let after_sequences: HashSet<&str> = after.sequences.iter().map(|s| s.name.as_str()).collect();
629
630    // Functions - keyed by name, value is reference to FunctionDef
631    let before_functions: HashMap<&str, &FunctionDef> = before
632        .functions
633        .iter()
634        .map(|f| (f.name.as_str(), f))
635        .collect();
636    let after_functions: HashMap<&str, &FunctionDef> = after
637        .functions
638        .iter()
639        .map(|f| (f.name.as_str(), f))
640        .collect();
641
642    // Enums - existence check only, keyed by name
643    let before_enums: HashSet<&str> = before.enums.iter().map(|e| e.name.as_str()).collect();
644    let after_enums: HashSet<&str> = after.enums.iter().map(|e| e.name.as_str()).collect();
645
646    // Constraints - compound key (table_name, name)
647    let before_constraints: HashSet<(&str, &str)> = before
648        .constraints
649        .iter()
650        .map(|c| (c.table_name.as_str(), c.name.as_str()))
651        .collect();
652    let after_constraints: HashSet<(&str, &str)> = after
653        .constraints
654        .iter()
655        .map(|c| (c.table_name.as_str(), c.name.as_str()))
656        .collect();
657
658    // Triggers - compound key (table_name, name)
659    let before_triggers: HashSet<(&str, &str)> = before
660        .triggers
661        .iter()
662        .map(|t| (t.table_name.as_str(), t.name.as_str()))
663        .collect();
664    let after_triggers: HashSet<(&str, &str)> = after
665        .triggers
666        .iter()
667        .map(|t| (t.table_name.as_str(), t.name.as_str()))
668        .collect();
669
670    // Extensions - existence check only
671    let before_extensions: HashSet<&str> = before.extensions.iter().map(|e| e.as_str()).collect();
672    let after_extensions: HashSet<&str> = after.extensions.iter().map(|e| e.as_str()).collect();
673
674    // Tables: check dropped/altered then added
675    for bt in &before.tables {
676        if let Some(at) = after_tables.get(bt.name.as_str()) {
677            diff_columns(&mut diffs, &bt.name, &bt.columns, &at.columns);
678        } else {
679            diffs.push(SchemaDiff::TableDropped(bt.name.clone()));
680        }
681    }
682    for at in &after.tables {
683        if !before_tables.contains_key(at.name.as_str()) {
684            diffs.push(SchemaDiff::TableAdded(at.clone()));
685        }
686    }
687
688    // Views: check dropped/altered then added
689    for bv in &before.views {
690        if let Some(av) = after_views.get(bv.name.as_str()) {
691            if bv.definition != av.definition {
692                diffs.push(SchemaDiff::ViewAltered {
693                    name: bv.name.clone(),
694                    from: bv.definition.clone(),
695                    to: av.definition.clone(),
696                });
697            }
698        } else {
699            diffs.push(SchemaDiff::ViewDropped(bv.name.clone()));
700        }
701    }
702    for av in &after.views {
703        if !before_views.contains_key(av.name.as_str()) {
704            diffs.push(SchemaDiff::ViewAdded(av.clone()));
705        }
706    }
707
708    // Indexes: check dropped then added
709    for bi in &before.indexes {
710        if !after_indexes.contains(bi.name.as_str()) {
711            diffs.push(SchemaDiff::IndexDropped {
712                name: bi.name.clone(),
713                table_name: bi.table_name.clone(),
714            });
715        }
716    }
717    for ai in &after.indexes {
718        if !before_indexes.contains(ai.name.as_str()) {
719            diffs.push(SchemaDiff::IndexAdded(ai.clone()));
720        }
721    }
722
723    // Sequences: check dropped then added
724    for bs in &before.sequences {
725        if !after_sequences.contains(bs.name.as_str()) {
726            diffs.push(SchemaDiff::SequenceDropped(bs.name.clone()));
727        }
728    }
729    for a_s in &after.sequences {
730        if !before_sequences.contains(a_s.name.as_str()) {
731            diffs.push(SchemaDiff::SequenceAdded(a_s.clone()));
732        }
733    }
734
735    // Functions: check dropped/altered then added
736    for bf in &before.functions {
737        if let Some(af) = after_functions.get(bf.name.as_str()) {
738            if bf.definition != af.definition {
739                diffs.push(SchemaDiff::FunctionAltered {
740                    name: bf.name.clone(),
741                });
742            }
743        } else {
744            diffs.push(SchemaDiff::FunctionDropped(bf.name.clone()));
745        }
746    }
747    for af in &after.functions {
748        if !before_functions.contains_key(af.name.as_str()) {
749            diffs.push(SchemaDiff::FunctionAdded(af.clone()));
750        }
751    }
752
753    // Enums: check dropped then added
754    for be in &before.enums {
755        if !after_enums.contains(be.name.as_str()) {
756            diffs.push(SchemaDiff::EnumDropped(be.name.clone()));
757        }
758    }
759    for ae in &after.enums {
760        if !before_enums.contains(ae.name.as_str()) {
761            diffs.push(SchemaDiff::EnumAdded(ae.clone()));
762        }
763    }
764
765    // Constraints: check dropped then added
766    for bc in &before.constraints {
767        if !after_constraints.contains(&(bc.table_name.as_str(), bc.name.as_str())) {
768            diffs.push(SchemaDiff::ConstraintDropped {
769                table: bc.table_name.clone(),
770                name: bc.name.clone(),
771            });
772        }
773    }
774    for ac in &after.constraints {
775        if !before_constraints.contains(&(ac.table_name.as_str(), ac.name.as_str())) {
776            diffs.push(SchemaDiff::ConstraintAdded(ac.clone()));
777        }
778    }
779
780    // Triggers: check dropped then added
781    for bt in &before.triggers {
782        if !after_triggers.contains(&(bt.table_name.as_str(), bt.name.as_str())) {
783            diffs.push(SchemaDiff::TriggerDropped {
784                table: bt.table_name.clone(),
785                name: bt.name.clone(),
786            });
787        }
788    }
789    for at in &after.triggers {
790        if !before_triggers.contains(&(at.table_name.as_str(), at.name.as_str())) {
791            diffs.push(SchemaDiff::TriggerAdded(at.clone()));
792        }
793    }
794
795    // Extensions: check dropped then added
796    for ext in &before.extensions {
797        if !after_extensions.contains(ext.as_str()) {
798            diffs.push(SchemaDiff::ExtensionDropped(ext.clone()));
799        }
800    }
801    for ext in &after.extensions {
802        if !before_extensions.contains(ext.as_str()) {
803            diffs.push(SchemaDiff::ExtensionAdded(ext.clone()));
804        }
805    }
806
807    diffs
808}
809
810fn diff_columns(
811    diffs: &mut Vec<SchemaDiff>,
812    table: &str,
813    before: &[ColumnDef],
814    after: &[ColumnDef],
815) {
816    let before_cols: HashMap<&str, &ColumnDef> =
817        before.iter().map(|c| (c.name.as_str(), c)).collect();
818    let after_cols: HashMap<&str, &ColumnDef> =
819        after.iter().map(|c| (c.name.as_str(), c)).collect();
820
821    for bc in before {
822        if let Some(ac) = after_cols.get(bc.name.as_str()) {
823            if bc != *ac {
824                diffs.push(SchemaDiff::ColumnAltered {
825                    table: table.to_string(),
826                    column: bc.name.clone(),
827                    from: bc.clone(),
828                    to: (*ac).clone(),
829                });
830            }
831        } else {
832            diffs.push(SchemaDiff::ColumnDropped {
833                table: table.to_string(),
834                column: bc.name.clone(),
835            });
836        }
837    }
838    for ac in after {
839        if !before_cols.contains_key(ac.name.as_str()) {
840            diffs.push(SchemaDiff::ColumnAdded {
841                table: table.to_string(),
842                column: ac.clone(),
843            });
844        }
845    }
846}
847
848/// Dependency rank for emitting a diff as DDL.
849///
850/// `generate_ddl` used to emit statements in whatever order `diff` produced
851/// them, which is not a runnable order. An auto-reversal for a dropped table
852/// came out as `CREATE TABLE orders (id integer DEFAULT nextval('orders_id_seq'
853/// …))` followed *later* by `CREATE SEQUENCE orders_id_seq`, so `undo` failed
854/// with `relation "orders_id_seq" does not exist` and the table never came
855/// back.
856///
857/// Creates run outwards from the things nothing depends on; drops run inwards.
858/// The sort is stable, so objects within a rank keep their diff order.
859fn ddl_rank(d: &SchemaDiff) -> u8 {
860    match d {
861        // Drops first, most dependent first.
862        SchemaDiff::TriggerDropped { .. } => 0,
863        SchemaDiff::ViewDropped(_) => 1,
864        SchemaDiff::FunctionDropped(_) => 2,
865        SchemaDiff::IndexDropped { .. } => 3,
866        SchemaDiff::ConstraintDropped { .. } => 4,
867        SchemaDiff::ColumnDropped { .. } => 5,
868        SchemaDiff::TableDropped(_) => 6,
869        SchemaDiff::SequenceDropped(_) => 7,
870        SchemaDiff::EnumDropped(_) => 8,
871        SchemaDiff::ExtensionDropped(_) => 9,
872
873        // Then creates, least dependent first.
874        SchemaDiff::ExtensionAdded(_) => 10,
875        SchemaDiff::EnumAdded(_) => 11,
876        SchemaDiff::SequenceAdded(_) => 12,
877        SchemaDiff::TableAdded(_) => 13,
878        SchemaDiff::ColumnAdded { .. } => 14,
879        SchemaDiff::ColumnAltered { .. } => 15,
880        SchemaDiff::ConstraintAdded(_) => 16,
881        SchemaDiff::IndexAdded(_) => 17,
882        SchemaDiff::ViewAdded(_) | SchemaDiff::ViewAltered { .. } => 18,
883        SchemaDiff::FunctionAdded(_) | SchemaDiff::FunctionAltered { .. } => 19,
884        SchemaDiff::TriggerAdded(_) => 20,
885    }
886}
887
888/// Order diffs into a sequence that can actually be executed, and drop indexes
889/// that a constraint in the same set will create anyway.
890///
891/// PostgreSQL builds a backing index for every `PRIMARY KEY` / `UNIQUE`
892/// constraint, and introspection sees both. Emitting both made the reversal
893/// fail on the second one with "relation already exists".
894fn order_diffs_for_ddl(diffs: &[SchemaDiff]) -> Vec<&SchemaDiff> {
895    let constraint_names: std::collections::HashSet<&str> = diffs
896        .iter()
897        .filter_map(|d| match d {
898            SchemaDiff::ConstraintAdded(c) => Some(c.name.as_str()),
899            _ => None,
900        })
901        .collect();
902
903    let mut ordered: Vec<&SchemaDiff> = diffs
904        .iter()
905        .filter(|d| match d {
906            SchemaDiff::IndexAdded(idx) => !constraint_names.contains(idx.name.as_str()),
907            _ => true,
908        })
909        .collect();
910    ordered.sort_by_key(|d| ddl_rank(d));
911    ordered
912}
913
914/// Generate DDL statements from schema diffs.
915pub fn generate_ddl(diffs: &[SchemaDiff]) -> String {
916    let mut statements = Vec::new();
917
918    for d in order_diffs_for_ddl(diffs) {
919        match d {
920            SchemaDiff::TableAdded(t) => {
921                let cols: Vec<String> = t
922                    .columns
923                    .iter()
924                    .map(|c| {
925                        let mut col = format!("    {} {}", quote_ident(&c.name), c.data_type);
926                        if !c.is_nullable {
927                            col.push_str(" NOT NULL");
928                        }
929                        if let Some(ref default) = c.default {
930                            col.push_str(&format!(" DEFAULT {}", default));
931                        }
932                        col
933                    })
934                    .collect();
935                statements.push(format!(
936                    "CREATE TABLE {} (\n{}\n);",
937                    quote_ident(&t.name),
938                    cols.join(",\n")
939                ));
940            }
941            SchemaDiff::TableDropped(name) => {
942                statements.push(format!(
943                    "DROP TABLE IF EXISTS {} CASCADE;",
944                    quote_ident(name)
945                ));
946            }
947            SchemaDiff::ColumnAdded { table, column } => {
948                let mut stmt = format!(
949                    "ALTER TABLE {} ADD COLUMN {} {}",
950                    quote_ident(table),
951                    quote_ident(&column.name),
952                    column.data_type
953                );
954                if !column.is_nullable {
955                    stmt.push_str(" NOT NULL");
956                }
957                if let Some(ref default) = column.default {
958                    stmt.push_str(&format!(" DEFAULT {}", default));
959                }
960                stmt.push(';');
961                statements.push(stmt);
962            }
963            SchemaDiff::ColumnDropped { table, column } => {
964                statements.push(format!(
965                    "ALTER TABLE {} DROP COLUMN {};",
966                    quote_ident(table),
967                    quote_ident(column)
968                ));
969            }
970            SchemaDiff::ColumnAltered {
971                table, column, to, ..
972            } => {
973                statements.push(format!(
974                    "ALTER TABLE {} ALTER COLUMN {} TYPE {};",
975                    quote_ident(table),
976                    quote_ident(column),
977                    to.data_type
978                ));
979                if to.is_nullable {
980                    statements.push(format!(
981                        "ALTER TABLE {} ALTER COLUMN {} DROP NOT NULL;",
982                        quote_ident(table),
983                        quote_ident(column)
984                    ));
985                } else {
986                    statements.push(format!(
987                        "ALTER TABLE {} ALTER COLUMN {} SET NOT NULL;",
988                        quote_ident(table),
989                        quote_ident(column)
990                    ));
991                }
992                match &to.default {
993                    Some(default) => {
994                        statements.push(format!(
995                            "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {};",
996                            quote_ident(table),
997                            quote_ident(column),
998                            default
999                        ));
1000                    }
1001                    None => {
1002                        statements.push(format!(
1003                            "ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT;",
1004                            quote_ident(table),
1005                            quote_ident(column)
1006                        ));
1007                    }
1008                }
1009            }
1010            SchemaDiff::IndexAdded(idx) => {
1011                statements.push(format!("{};", idx.definition));
1012            }
1013            SchemaDiff::IndexDropped { name, .. } => {
1014                // PG: indexes are schema-scoped, no ON clause needed.
1015                statements.push(format!("DROP INDEX IF EXISTS {};", quote_ident(name)));
1016            }
1017            SchemaDiff::ViewAdded(v) => {
1018                let keyword = if v.is_materialized {
1019                    "MATERIALIZED VIEW"
1020                } else {
1021                    "VIEW"
1022                };
1023                statements.push(format!(
1024                    "CREATE {} {} AS {};",
1025                    keyword,
1026                    quote_ident(&v.name),
1027                    v.definition.trim_end_matches(';').trim()
1028                ));
1029            }
1030            SchemaDiff::ViewDropped(name) => {
1031                statements.push(format!(
1032                    "DROP VIEW IF EXISTS {} CASCADE;",
1033                    quote_ident(name)
1034                ));
1035            }
1036            SchemaDiff::ViewAltered { name, to, .. } => {
1037                statements.push(format!(
1038                    "CREATE OR REPLACE VIEW {} AS {};",
1039                    quote_ident(name),
1040                    to.trim_end_matches(';').trim()
1041                ));
1042            }
1043            SchemaDiff::SequenceAdded(s) => {
1044                statements.push(format!("CREATE SEQUENCE {};", quote_ident(&s.name)));
1045            }
1046            SchemaDiff::SequenceDropped(name) => {
1047                statements.push(format!("DROP SEQUENCE IF EXISTS {};", quote_ident(name)));
1048            }
1049            SchemaDiff::FunctionAdded(func) => {
1050                statements.push(format!("{};", func.definition.trim_end_matches(';')));
1051            }
1052            SchemaDiff::FunctionDropped(name) => {
1053                statements.push(format!(
1054                    "DROP FUNCTION IF EXISTS {} CASCADE;",
1055                    quote_ident(name)
1056                ));
1057            }
1058            SchemaDiff::FunctionAltered { name } => {
1059                // For altered functions we'd need the full definition; leave a comment
1060                statements.push(format!(
1061                    "-- Function {} was altered; manual review needed",
1062                    name
1063                ));
1064            }
1065            SchemaDiff::EnumAdded(e) => {
1066                let values: Vec<String> = e
1067                    .values
1068                    .iter()
1069                    .map(|v| crate::db::quote_literal(v))
1070                    .collect();
1071                statements.push(format!(
1072                    "CREATE TYPE {} AS ENUM ({});",
1073                    quote_ident(&e.name),
1074                    values.join(", ")
1075                ));
1076            }
1077            SchemaDiff::EnumDropped(name) => {
1078                statements.push(format!(
1079                    "DROP TYPE IF EXISTS {} CASCADE;",
1080                    quote_ident(name)
1081                ));
1082            }
1083            SchemaDiff::ConstraintAdded(c) => {
1084                statements.push(format!(
1085                    "ALTER TABLE {} ADD CONSTRAINT {} {};",
1086                    quote_ident(&c.table_name),
1087                    quote_ident(&c.name),
1088                    c.definition
1089                ));
1090            }
1091            SchemaDiff::ConstraintDropped { table, name } => {
1092                statements.push(format!(
1093                    "ALTER TABLE {} DROP CONSTRAINT IF EXISTS {};",
1094                    quote_ident(table),
1095                    quote_ident(name)
1096                ));
1097            }
1098            SchemaDiff::TriggerAdded(t) => {
1099                statements.push(format!(
1100                    "-- Trigger {} on {} needs manual creation",
1101                    t.name, t.table_name
1102                ));
1103            }
1104            SchemaDiff::TriggerDropped { table, name } => {
1105                statements.push(format!(
1106                    "DROP TRIGGER IF EXISTS {} ON {};",
1107                    quote_ident(name),
1108                    quote_ident(table)
1109                ));
1110            }
1111            SchemaDiff::ExtensionAdded(name) => {
1112                statements.push(format!(
1113                    "CREATE EXTENSION IF NOT EXISTS {};",
1114                    quote_ident(name)
1115                ));
1116            }
1117            SchemaDiff::ExtensionDropped(name) => {
1118                statements.push(format!("DROP EXTENSION IF EXISTS {};", quote_ident(name)));
1119            }
1120        }
1121    }
1122
1123    statements.join("\n\n")
1124}
1125
1126/// Generate full DDL to recreate a schema from a snapshot.
1127pub fn to_ddl(snapshot: &SchemaSnapshot) -> String {
1128    let mut statements = Vec::new();
1129
1130    // Extensions first
1131    for ext in &snapshot.extensions {
1132        statements.push(format!(
1133            "CREATE EXTENSION IF NOT EXISTS {};",
1134            quote_ident(ext)
1135        ));
1136    }
1137
1138    // Enums before tables (types must exist for columns)
1139    for e in &snapshot.enums {
1140        let values: Vec<String> = e
1141            .values
1142            .iter()
1143            .map(|v| crate::db::quote_literal(v))
1144            .collect();
1145        statements.push(format!(
1146            "CREATE TYPE {} AS ENUM ({});",
1147            quote_ident(&e.name),
1148            values.join(", ")
1149        ));
1150    }
1151
1152    // Sequences
1153    for s in &snapshot.sequences {
1154        statements.push(format!("CREATE SEQUENCE {};", quote_ident(&s.name)));
1155    }
1156
1157    // Tables
1158    for t in &snapshot.tables {
1159        let cols: Vec<String> = t
1160            .columns
1161            .iter()
1162            .map(|c| {
1163                let mut col = format!("    {} {}", quote_ident(&c.name), c.data_type);
1164                if !c.is_nullable {
1165                    col.push_str(" NOT NULL");
1166                }
1167                if let Some(ref default) = c.default {
1168                    col.push_str(&format!(" DEFAULT {}", default));
1169                }
1170                col
1171            })
1172            .collect();
1173        statements.push(format!(
1174            "CREATE TABLE {} (\n{}\n);",
1175            quote_ident(&t.name),
1176            cols.join(",\n")
1177        ));
1178    }
1179
1180    // Constraints
1181    for c in &snapshot.constraints {
1182        statements.push(format!(
1183            "ALTER TABLE {} ADD CONSTRAINT {} {};",
1184            quote_ident(&c.table_name),
1185            quote_ident(&c.name),
1186            c.definition
1187        ));
1188    }
1189
1190    // Indexes
1191    for idx in &snapshot.indexes {
1192        statements.push(format!("{};", idx.definition));
1193    }
1194
1195    // Views
1196    for v in &snapshot.views {
1197        let keyword = if v.is_materialized {
1198            "MATERIALIZED VIEW"
1199        } else {
1200            "VIEW"
1201        };
1202        statements.push(format!(
1203            "CREATE {} {} AS {};",
1204            keyword,
1205            quote_ident(&v.name),
1206            v.definition.trim_end_matches(';').trim()
1207        ));
1208    }
1209
1210    // Functions
1211    for func in &snapshot.functions {
1212        statements.push(format!("{};", func.definition.trim_end_matches(';')));
1213    }
1214
1215    // Triggers
1216    for t in &snapshot.triggers {
1217        statements.push(format!(
1218            "-- Trigger {} on {}: {}",
1219            t.name, t.table_name, t.definition
1220        ));
1221    }
1222
1223    statements.join("\n\n")
1224}
1225
1226/// Generate MySQL-flavored DDL from a list of schema diffs.
1227///
1228/// Mirrors [`generate_ddl`] but emits MySQL syntax: backtick-quoted identifiers,
1229/// no `CASCADE` on DROPs (MySQL doesn't accept it), and skips diffs that
1230/// reference a table that's also being dropped (since MySQL has no `CASCADE`
1231/// and the dependent ALTER would fail with `Table doesn't exist`).
1232pub fn generate_ddl_mysql(diffs: &[SchemaDiff]) -> String {
1233    fn q(name: &str) -> String {
1234        format!("`{}`", name.replace('`', "``"))
1235    }
1236
1237    // First pass: collect the set of tables being dropped so we can skip
1238    // dependent diffs (constraints, indexes, triggers) that reference them.
1239    // PG uses `DROP TABLE ... CASCADE` to handle this transparently; MySQL
1240    // has no such cascade so we filter explicitly.
1241    let dropped_tables: std::collections::HashSet<&str> = diffs
1242        .iter()
1243        .filter_map(|d| match d {
1244            SchemaDiff::TableDropped(name) => Some(name.as_str()),
1245            _ => None,
1246        })
1247        .collect();
1248    let references_dropped_table = |t: &str| dropped_tables.contains(t);
1249
1250    // Order: emit dependent diffs (constraints/indexes/triggers) FIRST when
1251    // their table is NOT being dropped, then table-level changes, then
1252    // TableDropped last. This matches MySQL's typical migration order and
1253    // avoids dependency violations.
1254    let mut statements = Vec::new();
1255    for d in diffs {
1256        // Skip diffs whose parent table is going away in this same batch.
1257        // `DROP TABLE` on MySQL drops the table's indexes/constraints/triggers
1258        // along with it, and trying to drop them separately after the table is
1259        // gone fails with "Table doesn't exist".
1260        match d {
1261            SchemaDiff::ColumnAdded { table, .. }
1262            | SchemaDiff::ColumnDropped { table, .. }
1263            | SchemaDiff::ColumnAltered { table, .. }
1264            | SchemaDiff::ConstraintDropped { table, .. }
1265            | SchemaDiff::TriggerDropped { table, .. }
1266            | SchemaDiff::IndexDropped {
1267                table_name: table, ..
1268            } if references_dropped_table(table) => continue,
1269            SchemaDiff::ConstraintAdded(c) if references_dropped_table(&c.table_name) => continue,
1270            SchemaDiff::TriggerAdded(t) if references_dropped_table(&t.table_name) => continue,
1271            SchemaDiff::IndexAdded(i) if references_dropped_table(&i.table_name) => continue,
1272            _ => {}
1273        }
1274        match d {
1275            SchemaDiff::TableAdded(t) => {
1276                let cols: Vec<String> = t
1277                    .columns
1278                    .iter()
1279                    .map(|c| {
1280                        let mut col = format!("    {} {}", q(&c.name), c.data_type);
1281                        if !c.is_nullable {
1282                            col.push_str(" NOT NULL");
1283                        }
1284                        if let Some(ref default) = c.default {
1285                            col.push_str(&format!(" DEFAULT {}", default));
1286                        }
1287                        col
1288                    })
1289                    .collect();
1290                statements.push(format!(
1291                    "CREATE TABLE {} (\n{}\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
1292                    q(&t.name),
1293                    cols.join(",\n")
1294                ));
1295            }
1296            SchemaDiff::TableDropped(name) => {
1297                statements.push(format!("DROP TABLE IF EXISTS {};", q(name)));
1298            }
1299            SchemaDiff::ColumnAdded { table, column } => {
1300                let mut stmt = format!(
1301                    "ALTER TABLE {} ADD COLUMN {} {}",
1302                    q(table),
1303                    q(&column.name),
1304                    column.data_type
1305                );
1306                if !column.is_nullable {
1307                    stmt.push_str(" NOT NULL");
1308                }
1309                if let Some(ref default) = column.default {
1310                    stmt.push_str(&format!(" DEFAULT {}", default));
1311                }
1312                stmt.push(';');
1313                statements.push(stmt);
1314            }
1315            SchemaDiff::ColumnDropped { table, column } => {
1316                statements.push(format!(
1317                    "ALTER TABLE {} DROP COLUMN {};",
1318                    q(table),
1319                    q(column)
1320                ));
1321            }
1322            SchemaDiff::ColumnAltered {
1323                table, column, to, ..
1324            } => {
1325                // MySQL collapses type+null+default into a single MODIFY COLUMN.
1326                let mut clause = format!(
1327                    "ALTER TABLE {} MODIFY COLUMN {} {}",
1328                    q(table),
1329                    q(column),
1330                    to.data_type
1331                );
1332                if !to.is_nullable {
1333                    clause.push_str(" NOT NULL");
1334                }
1335                if let Some(ref default) = to.default {
1336                    clause.push_str(&format!(" DEFAULT {}", default));
1337                }
1338                clause.push(';');
1339                statements.push(clause);
1340            }
1341            SchemaDiff::IndexAdded(idx) => {
1342                // idx.definition is already MySQL-shaped from introspect_mysql.
1343                statements.push(format!("{};", idx.definition.trim_end_matches(';')));
1344            }
1345            SchemaDiff::IndexDropped { name, table_name } => {
1346                // MySQL requires `DROP INDEX <name> ON <table>`.
1347                statements.push(format!("DROP INDEX {} ON {};", q(name), q(table_name)));
1348            }
1349            SchemaDiff::ViewAdded(v) => {
1350                statements.push(format!(
1351                    "CREATE VIEW {} AS {};",
1352                    q(&v.name),
1353                    v.definition.trim_end_matches(';').trim()
1354                ));
1355            }
1356            SchemaDiff::ViewDropped(name) => {
1357                statements.push(format!("DROP VIEW IF EXISTS {};", q(name)));
1358            }
1359            SchemaDiff::ViewAltered { name, to, .. } => {
1360                statements.push(format!(
1361                    "CREATE OR REPLACE VIEW {} AS {};",
1362                    q(name),
1363                    to.trim_end_matches(';').trim()
1364                ));
1365            }
1366            SchemaDiff::SequenceAdded(_) | SchemaDiff::SequenceDropped(_) => {
1367                // MySQL has no sequences; emit a comment.
1368                statements.push("-- (sequence diff omitted: MySQL has no sequences)".into());
1369            }
1370            SchemaDiff::FunctionAdded(func) => {
1371                statements.push(format!("{};", func.definition.trim_end_matches(';')));
1372            }
1373            SchemaDiff::FunctionDropped(name) => {
1374                statements.push(format!("DROP FUNCTION IF EXISTS {};", q(name)));
1375            }
1376            SchemaDiff::FunctionAltered { name } => {
1377                statements.push(format!(
1378                    "-- Function {} altered; manual review needed",
1379                    name
1380                ));
1381            }
1382            SchemaDiff::EnumAdded(_) | SchemaDiff::EnumDropped(_) => {
1383                statements
1384                    .push("-- (enum diff omitted: MySQL ENUM is a column-type modifier)".into());
1385            }
1386            SchemaDiff::ConstraintAdded(c) => {
1387                if c.definition.is_empty() {
1388                    statements.push(format!(
1389                        "-- ALTER TABLE {} ADD CONSTRAINT {} (definition unavailable)",
1390                        q(&c.table_name),
1391                        q(&c.name)
1392                    ));
1393                } else {
1394                    statements.push(format!(
1395                        "ALTER TABLE {} ADD CONSTRAINT {} {};",
1396                        q(&c.table_name),
1397                        q(&c.name),
1398                        c.definition
1399                    ));
1400                }
1401            }
1402            SchemaDiff::ConstraintDropped { table, name } => {
1403                statements.push(format!(
1404                    "ALTER TABLE {} DROP CONSTRAINT {};",
1405                    q(table),
1406                    q(name)
1407                ));
1408            }
1409            SchemaDiff::TriggerAdded(t) => {
1410                statements.push(format!(
1411                    "-- Trigger {} on {}: {}",
1412                    t.name, t.table_name, t.definition
1413                ));
1414            }
1415            SchemaDiff::TriggerDropped { table, name } => {
1416                statements.push(format!("DROP TRIGGER IF EXISTS {}.{};", q(table), q(name)));
1417            }
1418            SchemaDiff::ExtensionAdded(_) | SchemaDiff::ExtensionDropped(_) => {
1419                statements.push("-- (extension diff omitted: MySQL has no extensions)".into());
1420            }
1421        }
1422    }
1423    statements.join("\n\n")
1424}
1425
1426// ── MySQL schema introspection ───────────────────────────────────────────────
1427//
1428// Produces the same SchemaSnapshot shape as PG `introspect()` so `diff()`
1429// works on either dialect. Concepts that don't exist on MySQL (sequences,
1430// PG-style enums, extensions) come back as empty vectors. Materialized views
1431// don't exist on MySQL 8.0 so `is_materialized` is always false here.
1432
1433#[cfg(feature = "mysql")]
1434pub async fn introspect_mysql(client: &DbClient, schema: &str) -> Result<SchemaSnapshot> {
1435    use mysql_async::prelude::*;
1436    let pool = client.as_mysql()?;
1437    let mut conn = pool.get_conn().await?;
1438
1439    // Tables + columns (one row per column).
1440    let column_rows: Vec<(String, String, String, String, Option<String>, i32)> = conn
1441        .exec(
1442            "SELECT t.TABLE_NAME, c.COLUMN_NAME, c.DATA_TYPE, c.IS_NULLABLE, \
1443                    c.COLUMN_DEFAULT, c.ORDINAL_POSITION \
1444             FROM information_schema.TABLES t \
1445             JOIN information_schema.COLUMNS c \
1446               ON c.TABLE_SCHEMA = t.TABLE_SCHEMA AND c.TABLE_NAME = t.TABLE_NAME \
1447             WHERE t.TABLE_SCHEMA = ? AND t.TABLE_TYPE = 'BASE TABLE' \
1448             ORDER BY t.TABLE_NAME, c.ORDINAL_POSITION",
1449            (schema,),
1450        )
1451        .await?;
1452    let mut table_map: HashMap<String, Vec<ColumnDef>> = HashMap::new();
1453    for (table, col, dtype, nullable, default, ord) in column_rows {
1454        table_map.entry(table).or_default().push(ColumnDef {
1455            name: col,
1456            data_type: dtype,
1457            is_nullable: nullable == "YES",
1458            default,
1459            ordinal_position: ord,
1460        });
1461    }
1462    let mut tables: Vec<TableDef> = table_map
1463        .into_iter()
1464        .map(|(name, columns)| TableDef {
1465            schema: schema.to_string(),
1466            name,
1467            columns,
1468        })
1469        .collect();
1470    tables.sort_by(|a, b| a.name.cmp(&b.name));
1471
1472    // Views.
1473    let view_rows: Vec<(String, String)> = conn
1474        .exec(
1475            "SELECT TABLE_NAME, VIEW_DEFINITION FROM information_schema.VIEWS \
1476             WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME",
1477            (schema,),
1478        )
1479        .await?;
1480    let views: Vec<ViewDef> = view_rows
1481        .into_iter()
1482        .map(|(name, def)| ViewDef {
1483            schema: schema.to_string(),
1484            name,
1485            definition: def,
1486            is_materialized: false,
1487        })
1488        .collect();
1489
1490    // Indexes — group STATISTICS rows by (table, index_name). PRIMARY indexes
1491    // surface as primary-key constraints instead.
1492    let index_rows: Vec<(String, String, i32, String, i64)> = conn
1493        .exec(
1494            "SELECT TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX, COLUMN_NAME, NON_UNIQUE \
1495             FROM information_schema.STATISTICS \
1496             WHERE TABLE_SCHEMA = ? AND INDEX_NAME <> 'PRIMARY' \
1497             ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX",
1498            (schema,),
1499        )
1500        .await?;
1501    let mut idx_map: HashMap<(String, String), (Vec<String>, bool)> = HashMap::new();
1502    for (table, idx_name, _seq, col, non_unique) in index_rows {
1503        let entry = idx_map
1504            .entry((table, idx_name))
1505            .or_insert_with(|| (Vec::new(), non_unique == 0));
1506        entry.0.push(col);
1507    }
1508    let mut indexes: Vec<IndexDef> = idx_map
1509        .into_iter()
1510        .map(|((table, name), (cols, is_unique))| {
1511            let kw = if is_unique {
1512                "CREATE UNIQUE INDEX"
1513            } else {
1514                "CREATE INDEX"
1515            };
1516            let definition = format!(
1517                "{} `{}` ON `{}` ({})",
1518                kw,
1519                name,
1520                table,
1521                cols.iter()
1522                    .map(|c| format!("`{}`", c))
1523                    .collect::<Vec<_>>()
1524                    .join(", ")
1525            );
1526            IndexDef {
1527                schema: schema.to_string(),
1528                name,
1529                table_name: table,
1530                definition,
1531                is_unique,
1532            }
1533        })
1534        .collect();
1535    indexes.sort_by(|a, b| a.name.cmp(&b.name));
1536
1537    // Routines (procedures + functions). We store both via FunctionDef.
1538    let routine_rows: Vec<(String, String, String, String)> = conn
1539        .exec(
1540            "SELECT ROUTINE_NAME, \
1541                    COALESCE(DTD_IDENTIFIER, ''), \
1542                    COALESCE(EXTERNAL_LANGUAGE, ROUTINE_BODY), \
1543                    COALESCE(ROUTINE_DEFINITION, '') \
1544             FROM information_schema.ROUTINES \
1545             WHERE ROUTINE_SCHEMA = ? ORDER BY ROUTINE_NAME",
1546            (schema,),
1547        )
1548        .await?;
1549    let functions: Vec<FunctionDef> = routine_rows
1550        .into_iter()
1551        .map(|(name, return_type, language, definition)| FunctionDef {
1552            schema: schema.to_string(),
1553            name,
1554            arguments: String::new(),
1555            return_type,
1556            language,
1557            definition,
1558        })
1559        .collect();
1560
1561    // Constraints — PK / UNIQUE / FK. Definition is left empty for the diff
1562    // shape; the constraint type + name is the structural signal.
1563    let constraint_rows: Vec<(String, String, String)> = conn
1564        .exec(
1565            "SELECT TABLE_NAME, CONSTRAINT_NAME, CONSTRAINT_TYPE \
1566             FROM information_schema.TABLE_CONSTRAINTS \
1567             WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME, CONSTRAINT_NAME",
1568            (schema,),
1569        )
1570        .await?;
1571    let constraints: Vec<ConstraintDef> = constraint_rows
1572        .into_iter()
1573        .map(|(table, name, ctype)| ConstraintDef {
1574            schema: schema.to_string(),
1575            table_name: table,
1576            name,
1577            constraint_type: ctype,
1578            definition: String::new(),
1579        })
1580        .collect();
1581
1582    // Triggers.
1583    let trigger_rows: Vec<(String, String, String)> = conn
1584        .exec(
1585            "SELECT EVENT_OBJECT_TABLE, TRIGGER_NAME, ACTION_STATEMENT \
1586             FROM information_schema.TRIGGERS \
1587             WHERE TRIGGER_SCHEMA = ? ORDER BY EVENT_OBJECT_TABLE, TRIGGER_NAME",
1588            (schema,),
1589        )
1590        .await?;
1591    let triggers: Vec<TriggerDef> = trigger_rows
1592        .into_iter()
1593        .map(|(table_name, name, definition)| TriggerDef {
1594            schema: schema.to_string(),
1595            table_name,
1596            name,
1597            definition,
1598        })
1599        .collect();
1600
1601    Ok(SchemaSnapshot {
1602        tables,
1603        views,
1604        indexes,
1605        sequences: Vec::new(),
1606        functions,
1607        enums: Vec::new(),
1608        constraints,
1609        triggers,
1610        extensions: Vec::new(),
1611    })
1612}
1613
1614#[cfg(test)]
1615mod tests_generate_ddl_mysql {
1616    use super::*;
1617
1618    fn col(name: &str, ty: &str) -> ColumnDef {
1619        ColumnDef {
1620            name: name.into(),
1621            data_type: ty.into(),
1622            is_nullable: false,
1623            default: None,
1624            ordinal_position: 1,
1625        }
1626    }
1627
1628    #[test]
1629    fn drop_index_emits_on_clause() {
1630        let diffs = vec![SchemaDiff::IndexDropped {
1631            name: "idx_users_email".into(),
1632            table_name: "users".into(),
1633        }];
1634        let sql = generate_ddl_mysql(&diffs);
1635        assert!(sql.contains("DROP INDEX `idx_users_email` ON `users`"));
1636        // No PG-style "DROP INDEX IF EXISTS ...;" without ON clause.
1637        assert!(!sql.contains("DROP INDEX `idx_users_email`;"));
1638    }
1639
1640    #[test]
1641    fn dependent_diffs_filtered_when_parent_table_dropped() {
1642        // If we have TableDropped(t) AND ConstraintDropped/IndexDropped/
1643        // ColumnAltered/etc. referencing t, only the TableDropped should remain
1644        // in the output (others are implicit on MySQL).
1645        let diffs = vec![
1646            SchemaDiff::ConstraintDropped {
1647                table: "t".into(),
1648                name: "PRIMARY".into(),
1649            },
1650            SchemaDiff::IndexDropped {
1651                name: "idx_t_x".into(),
1652                table_name: "t".into(),
1653            },
1654            SchemaDiff::ColumnDropped {
1655                table: "t".into(),
1656                column: "x".into(),
1657            },
1658            SchemaDiff::TableDropped("t".into()),
1659        ];
1660        let sql = generate_ddl_mysql(&diffs);
1661        assert!(sql.contains("DROP TABLE IF EXISTS `t`;"));
1662        assert!(!sql.contains("DROP CONSTRAINT"));
1663        assert!(!sql.contains("DROP INDEX `idx_t_x`"));
1664        assert!(!sql.contains("DROP COLUMN"));
1665    }
1666
1667    #[test]
1668    fn table_added_uses_innodb_utf8mb4() {
1669        let diffs = vec![SchemaDiff::TableAdded(TableDef {
1670            schema: "db".into(),
1671            name: "t".into(),
1672            columns: vec![col("id", "int")],
1673        })];
1674        let sql = generate_ddl_mysql(&diffs);
1675        assert!(sql.contains("CREATE TABLE `t`"));
1676        assert!(sql.contains("ENGINE=InnoDB"));
1677        assert!(sql.contains("utf8mb4"));
1678    }
1679}