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::{quote_ident, DbClient};
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/// Generate DDL statements from schema diffs.
849pub fn generate_ddl(diffs: &[SchemaDiff]) -> String {
850    let mut statements = Vec::new();
851
852    for d in diffs {
853        match d {
854            SchemaDiff::TableAdded(t) => {
855                let cols: Vec<String> = t
856                    .columns
857                    .iter()
858                    .map(|c| {
859                        let mut col = format!("    {} {}", quote_ident(&c.name), c.data_type);
860                        if !c.is_nullable {
861                            col.push_str(" NOT NULL");
862                        }
863                        if let Some(ref default) = c.default {
864                            col.push_str(&format!(" DEFAULT {}", default));
865                        }
866                        col
867                    })
868                    .collect();
869                statements.push(format!(
870                    "CREATE TABLE {} (\n{}\n);",
871                    quote_ident(&t.name),
872                    cols.join(",\n")
873                ));
874            }
875            SchemaDiff::TableDropped(name) => {
876                statements.push(format!(
877                    "DROP TABLE IF EXISTS {} CASCADE;",
878                    quote_ident(name)
879                ));
880            }
881            SchemaDiff::ColumnAdded { table, column } => {
882                let mut stmt = format!(
883                    "ALTER TABLE {} ADD COLUMN {} {}",
884                    quote_ident(table),
885                    quote_ident(&column.name),
886                    column.data_type
887                );
888                if !column.is_nullable {
889                    stmt.push_str(" NOT NULL");
890                }
891                if let Some(ref default) = column.default {
892                    stmt.push_str(&format!(" DEFAULT {}", default));
893                }
894                stmt.push(';');
895                statements.push(stmt);
896            }
897            SchemaDiff::ColumnDropped { table, column } => {
898                statements.push(format!(
899                    "ALTER TABLE {} DROP COLUMN {};",
900                    quote_ident(table),
901                    quote_ident(column)
902                ));
903            }
904            SchemaDiff::ColumnAltered {
905                table, column, to, ..
906            } => {
907                statements.push(format!(
908                    "ALTER TABLE {} ALTER COLUMN {} TYPE {};",
909                    quote_ident(table),
910                    quote_ident(column),
911                    to.data_type
912                ));
913                if to.is_nullable {
914                    statements.push(format!(
915                        "ALTER TABLE {} ALTER COLUMN {} DROP NOT NULL;",
916                        quote_ident(table),
917                        quote_ident(column)
918                    ));
919                } else {
920                    statements.push(format!(
921                        "ALTER TABLE {} ALTER COLUMN {} SET NOT NULL;",
922                        quote_ident(table),
923                        quote_ident(column)
924                    ));
925                }
926                match &to.default {
927                    Some(default) => {
928                        statements.push(format!(
929                            "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {};",
930                            quote_ident(table),
931                            quote_ident(column),
932                            default
933                        ));
934                    }
935                    None => {
936                        statements.push(format!(
937                            "ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT;",
938                            quote_ident(table),
939                            quote_ident(column)
940                        ));
941                    }
942                }
943            }
944            SchemaDiff::IndexAdded(idx) => {
945                statements.push(format!("{};", idx.definition));
946            }
947            SchemaDiff::IndexDropped { name, .. } => {
948                // PG: indexes are schema-scoped, no ON clause needed.
949                statements.push(format!("DROP INDEX IF EXISTS {};", quote_ident(name)));
950            }
951            SchemaDiff::ViewAdded(v) => {
952                let keyword = if v.is_materialized {
953                    "MATERIALIZED VIEW"
954                } else {
955                    "VIEW"
956                };
957                statements.push(format!(
958                    "CREATE {} {} AS {};",
959                    keyword,
960                    quote_ident(&v.name),
961                    v.definition.trim_end_matches(';').trim()
962                ));
963            }
964            SchemaDiff::ViewDropped(name) => {
965                statements.push(format!(
966                    "DROP VIEW IF EXISTS {} CASCADE;",
967                    quote_ident(name)
968                ));
969            }
970            SchemaDiff::ViewAltered { name, to, .. } => {
971                statements.push(format!(
972                    "CREATE OR REPLACE VIEW {} AS {};",
973                    quote_ident(name),
974                    to.trim_end_matches(';').trim()
975                ));
976            }
977            SchemaDiff::SequenceAdded(s) => {
978                statements.push(format!("CREATE SEQUENCE {};", quote_ident(&s.name)));
979            }
980            SchemaDiff::SequenceDropped(name) => {
981                statements.push(format!("DROP SEQUENCE IF EXISTS {};", quote_ident(name)));
982            }
983            SchemaDiff::FunctionAdded(func) => {
984                statements.push(format!("{};", func.definition.trim_end_matches(';')));
985            }
986            SchemaDiff::FunctionDropped(name) => {
987                statements.push(format!(
988                    "DROP FUNCTION IF EXISTS {} CASCADE;",
989                    quote_ident(name)
990                ));
991            }
992            SchemaDiff::FunctionAltered { name } => {
993                // For altered functions we'd need the full definition; leave a comment
994                statements.push(format!(
995                    "-- Function {} was altered; manual review needed",
996                    name
997                ));
998            }
999            SchemaDiff::EnumAdded(e) => {
1000                let values: Vec<String> = e.values.iter().map(|v| format!("'{}'", v)).collect();
1001                statements.push(format!(
1002                    "CREATE TYPE {} AS ENUM ({});",
1003                    quote_ident(&e.name),
1004                    values.join(", ")
1005                ));
1006            }
1007            SchemaDiff::EnumDropped(name) => {
1008                statements.push(format!(
1009                    "DROP TYPE IF EXISTS {} CASCADE;",
1010                    quote_ident(name)
1011                ));
1012            }
1013            SchemaDiff::ConstraintAdded(c) => {
1014                statements.push(format!(
1015                    "ALTER TABLE {} ADD CONSTRAINT {} {};",
1016                    quote_ident(&c.table_name),
1017                    quote_ident(&c.name),
1018                    c.definition
1019                ));
1020            }
1021            SchemaDiff::ConstraintDropped { table, name } => {
1022                statements.push(format!(
1023                    "ALTER TABLE {} DROP CONSTRAINT IF EXISTS {};",
1024                    quote_ident(table),
1025                    quote_ident(name)
1026                ));
1027            }
1028            SchemaDiff::TriggerAdded(t) => {
1029                statements.push(format!(
1030                    "-- Trigger {} on {} needs manual creation",
1031                    t.name, t.table_name
1032                ));
1033            }
1034            SchemaDiff::TriggerDropped { table, name } => {
1035                statements.push(format!(
1036                    "DROP TRIGGER IF EXISTS {} ON {};",
1037                    quote_ident(name),
1038                    quote_ident(table)
1039                ));
1040            }
1041            SchemaDiff::ExtensionAdded(name) => {
1042                statements.push(format!(
1043                    "CREATE EXTENSION IF NOT EXISTS {};",
1044                    quote_ident(name)
1045                ));
1046            }
1047            SchemaDiff::ExtensionDropped(name) => {
1048                statements.push(format!("DROP EXTENSION IF EXISTS {};", quote_ident(name)));
1049            }
1050        }
1051    }
1052
1053    statements.join("\n\n")
1054}
1055
1056/// Generate full DDL to recreate a schema from a snapshot.
1057pub fn to_ddl(snapshot: &SchemaSnapshot) -> String {
1058    let mut statements = Vec::new();
1059
1060    // Extensions first
1061    for ext in &snapshot.extensions {
1062        statements.push(format!(
1063            "CREATE EXTENSION IF NOT EXISTS {};",
1064            quote_ident(ext)
1065        ));
1066    }
1067
1068    // Enums before tables (types must exist for columns)
1069    for e in &snapshot.enums {
1070        let values: Vec<String> = e.values.iter().map(|v| format!("'{}'", v)).collect();
1071        statements.push(format!(
1072            "CREATE TYPE {} AS ENUM ({});",
1073            quote_ident(&e.name),
1074            values.join(", ")
1075        ));
1076    }
1077
1078    // Sequences
1079    for s in &snapshot.sequences {
1080        statements.push(format!("CREATE SEQUENCE {};", quote_ident(&s.name)));
1081    }
1082
1083    // Tables
1084    for t in &snapshot.tables {
1085        let cols: Vec<String> = t
1086            .columns
1087            .iter()
1088            .map(|c| {
1089                let mut col = format!("    {} {}", quote_ident(&c.name), c.data_type);
1090                if !c.is_nullable {
1091                    col.push_str(" NOT NULL");
1092                }
1093                if let Some(ref default) = c.default {
1094                    col.push_str(&format!(" DEFAULT {}", default));
1095                }
1096                col
1097            })
1098            .collect();
1099        statements.push(format!(
1100            "CREATE TABLE {} (\n{}\n);",
1101            quote_ident(&t.name),
1102            cols.join(",\n")
1103        ));
1104    }
1105
1106    // Constraints
1107    for c in &snapshot.constraints {
1108        statements.push(format!(
1109            "ALTER TABLE {} ADD CONSTRAINT {} {};",
1110            quote_ident(&c.table_name),
1111            quote_ident(&c.name),
1112            c.definition
1113        ));
1114    }
1115
1116    // Indexes
1117    for idx in &snapshot.indexes {
1118        statements.push(format!("{};", idx.definition));
1119    }
1120
1121    // Views
1122    for v in &snapshot.views {
1123        let keyword = if v.is_materialized {
1124            "MATERIALIZED VIEW"
1125        } else {
1126            "VIEW"
1127        };
1128        statements.push(format!(
1129            "CREATE {} {} AS {};",
1130            keyword,
1131            quote_ident(&v.name),
1132            v.definition.trim_end_matches(';').trim()
1133        ));
1134    }
1135
1136    // Functions
1137    for func in &snapshot.functions {
1138        statements.push(format!("{};", func.definition.trim_end_matches(';')));
1139    }
1140
1141    // Triggers
1142    for t in &snapshot.triggers {
1143        statements.push(format!(
1144            "-- Trigger {} on {}: {}",
1145            t.name, t.table_name, t.definition
1146        ));
1147    }
1148
1149    statements.join("\n\n")
1150}
1151
1152/// Generate MySQL-flavored DDL from a list of schema diffs.
1153///
1154/// Mirrors [`generate_ddl`] but emits MySQL syntax: backtick-quoted identifiers,
1155/// no `CASCADE` on DROPs (MySQL doesn't accept it), and skips diffs that
1156/// reference a table that's also being dropped (since MySQL has no `CASCADE`
1157/// and the dependent ALTER would fail with `Table doesn't exist`).
1158pub fn generate_ddl_mysql(diffs: &[SchemaDiff]) -> String {
1159    fn q(name: &str) -> String {
1160        format!("`{}`", name.replace('`', "``"))
1161    }
1162
1163    // First pass: collect the set of tables being dropped so we can skip
1164    // dependent diffs (constraints, indexes, triggers) that reference them.
1165    // PG uses `DROP TABLE ... CASCADE` to handle this transparently; MySQL
1166    // has no such cascade so we filter explicitly.
1167    let dropped_tables: std::collections::HashSet<&str> = diffs
1168        .iter()
1169        .filter_map(|d| match d {
1170            SchemaDiff::TableDropped(name) => Some(name.as_str()),
1171            _ => None,
1172        })
1173        .collect();
1174    let references_dropped_table = |t: &str| dropped_tables.contains(t);
1175
1176    // Order: emit dependent diffs (constraints/indexes/triggers) FIRST when
1177    // their table is NOT being dropped, then table-level changes, then
1178    // TableDropped last. This matches MySQL's typical migration order and
1179    // avoids dependency violations.
1180    let mut statements = Vec::new();
1181    for d in diffs {
1182        // Skip diffs whose parent table is going away in this same batch.
1183        // `DROP TABLE` on MySQL drops the table's indexes/constraints/triggers
1184        // along with it, and trying to drop them separately after the table is
1185        // gone fails with "Table doesn't exist".
1186        match d {
1187            SchemaDiff::ColumnAdded { table, .. }
1188            | SchemaDiff::ColumnDropped { table, .. }
1189            | SchemaDiff::ColumnAltered { table, .. }
1190            | SchemaDiff::ConstraintDropped { table, .. }
1191            | SchemaDiff::TriggerDropped { table, .. }
1192            | SchemaDiff::IndexDropped {
1193                table_name: table, ..
1194            } => {
1195                if references_dropped_table(table) {
1196                    continue;
1197                }
1198            }
1199            SchemaDiff::ConstraintAdded(c) if references_dropped_table(&c.table_name) => continue,
1200            SchemaDiff::TriggerAdded(t) if references_dropped_table(&t.table_name) => continue,
1201            SchemaDiff::IndexAdded(i) if references_dropped_table(&i.table_name) => continue,
1202            _ => {}
1203        }
1204        match d {
1205            SchemaDiff::TableAdded(t) => {
1206                let cols: Vec<String> = t
1207                    .columns
1208                    .iter()
1209                    .map(|c| {
1210                        let mut col = format!("    {} {}", q(&c.name), c.data_type);
1211                        if !c.is_nullable {
1212                            col.push_str(" NOT NULL");
1213                        }
1214                        if let Some(ref default) = c.default {
1215                            col.push_str(&format!(" DEFAULT {}", default));
1216                        }
1217                        col
1218                    })
1219                    .collect();
1220                statements.push(format!(
1221                    "CREATE TABLE {} (\n{}\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
1222                    q(&t.name),
1223                    cols.join(",\n")
1224                ));
1225            }
1226            SchemaDiff::TableDropped(name) => {
1227                statements.push(format!("DROP TABLE IF EXISTS {};", q(name)));
1228            }
1229            SchemaDiff::ColumnAdded { table, column } => {
1230                let mut stmt = format!(
1231                    "ALTER TABLE {} ADD COLUMN {} {}",
1232                    q(table),
1233                    q(&column.name),
1234                    column.data_type
1235                );
1236                if !column.is_nullable {
1237                    stmt.push_str(" NOT NULL");
1238                }
1239                if let Some(ref default) = column.default {
1240                    stmt.push_str(&format!(" DEFAULT {}", default));
1241                }
1242                stmt.push(';');
1243                statements.push(stmt);
1244            }
1245            SchemaDiff::ColumnDropped { table, column } => {
1246                statements.push(format!(
1247                    "ALTER TABLE {} DROP COLUMN {};",
1248                    q(table),
1249                    q(column)
1250                ));
1251            }
1252            SchemaDiff::ColumnAltered {
1253                table, column, to, ..
1254            } => {
1255                // MySQL collapses type+null+default into a single MODIFY COLUMN.
1256                let mut clause = format!(
1257                    "ALTER TABLE {} MODIFY COLUMN {} {}",
1258                    q(table),
1259                    q(column),
1260                    to.data_type
1261                );
1262                if !to.is_nullable {
1263                    clause.push_str(" NOT NULL");
1264                }
1265                if let Some(ref default) = to.default {
1266                    clause.push_str(&format!(" DEFAULT {}", default));
1267                }
1268                clause.push(';');
1269                statements.push(clause);
1270            }
1271            SchemaDiff::IndexAdded(idx) => {
1272                // idx.definition is already MySQL-shaped from introspect_mysql.
1273                statements.push(format!("{};", idx.definition.trim_end_matches(';')));
1274            }
1275            SchemaDiff::IndexDropped { name, table_name } => {
1276                // MySQL requires `DROP INDEX <name> ON <table>`.
1277                statements.push(format!("DROP INDEX {} ON {};", q(name), q(table_name)));
1278            }
1279            SchemaDiff::ViewAdded(v) => {
1280                statements.push(format!(
1281                    "CREATE VIEW {} AS {};",
1282                    q(&v.name),
1283                    v.definition.trim_end_matches(';').trim()
1284                ));
1285            }
1286            SchemaDiff::ViewDropped(name) => {
1287                statements.push(format!("DROP VIEW IF EXISTS {};", q(name)));
1288            }
1289            SchemaDiff::ViewAltered { name, to, .. } => {
1290                statements.push(format!(
1291                    "CREATE OR REPLACE VIEW {} AS {};",
1292                    q(name),
1293                    to.trim_end_matches(';').trim()
1294                ));
1295            }
1296            SchemaDiff::SequenceAdded(_) | SchemaDiff::SequenceDropped(_) => {
1297                // MySQL has no sequences; emit a comment.
1298                statements.push("-- (sequence diff omitted: MySQL has no sequences)".into());
1299            }
1300            SchemaDiff::FunctionAdded(func) => {
1301                statements.push(format!("{};", func.definition.trim_end_matches(';')));
1302            }
1303            SchemaDiff::FunctionDropped(name) => {
1304                statements.push(format!("DROP FUNCTION IF EXISTS {};", q(name)));
1305            }
1306            SchemaDiff::FunctionAltered { name } => {
1307                statements.push(format!(
1308                    "-- Function {} altered; manual review needed",
1309                    name
1310                ));
1311            }
1312            SchemaDiff::EnumAdded(_) | SchemaDiff::EnumDropped(_) => {
1313                statements
1314                    .push("-- (enum diff omitted: MySQL ENUM is a column-type modifier)".into());
1315            }
1316            SchemaDiff::ConstraintAdded(c) => {
1317                if c.definition.is_empty() {
1318                    statements.push(format!(
1319                        "-- ALTER TABLE {} ADD CONSTRAINT {} (definition unavailable)",
1320                        q(&c.table_name),
1321                        q(&c.name)
1322                    ));
1323                } else {
1324                    statements.push(format!(
1325                        "ALTER TABLE {} ADD CONSTRAINT {} {};",
1326                        q(&c.table_name),
1327                        q(&c.name),
1328                        c.definition
1329                    ));
1330                }
1331            }
1332            SchemaDiff::ConstraintDropped { table, name } => {
1333                statements.push(format!(
1334                    "ALTER TABLE {} DROP CONSTRAINT {};",
1335                    q(table),
1336                    q(name)
1337                ));
1338            }
1339            SchemaDiff::TriggerAdded(t) => {
1340                statements.push(format!(
1341                    "-- Trigger {} on {}: {}",
1342                    t.name, t.table_name, t.definition
1343                ));
1344            }
1345            SchemaDiff::TriggerDropped { table, name } => {
1346                statements.push(format!("DROP TRIGGER IF EXISTS {}.{};", q(table), q(name)));
1347            }
1348            SchemaDiff::ExtensionAdded(_) | SchemaDiff::ExtensionDropped(_) => {
1349                statements.push("-- (extension diff omitted: MySQL has no extensions)".into());
1350            }
1351        }
1352    }
1353    statements.join("\n\n")
1354}
1355
1356// ── MySQL schema introspection ───────────────────────────────────────────────
1357//
1358// Produces the same SchemaSnapshot shape as PG `introspect()` so `diff()`
1359// works on either dialect. Concepts that don't exist on MySQL (sequences,
1360// PG-style enums, extensions) come back as empty vectors. Materialized views
1361// don't exist on MySQL 8.0 so `is_materialized` is always false here.
1362
1363#[cfg(feature = "mysql")]
1364pub async fn introspect_mysql(client: &DbClient, schema: &str) -> Result<SchemaSnapshot> {
1365    use mysql_async::prelude::*;
1366    let pool = client.as_mysql()?;
1367    let mut conn = pool.get_conn().await?;
1368
1369    // Tables + columns (one row per column).
1370    let column_rows: Vec<(String, String, String, String, Option<String>, i32)> = conn
1371        .exec(
1372            "SELECT t.TABLE_NAME, c.COLUMN_NAME, c.DATA_TYPE, c.IS_NULLABLE, \
1373                    c.COLUMN_DEFAULT, c.ORDINAL_POSITION \
1374             FROM information_schema.TABLES t \
1375             JOIN information_schema.COLUMNS c \
1376               ON c.TABLE_SCHEMA = t.TABLE_SCHEMA AND c.TABLE_NAME = t.TABLE_NAME \
1377             WHERE t.TABLE_SCHEMA = ? AND t.TABLE_TYPE = 'BASE TABLE' \
1378             ORDER BY t.TABLE_NAME, c.ORDINAL_POSITION",
1379            (schema,),
1380        )
1381        .await?;
1382    let mut table_map: HashMap<String, Vec<ColumnDef>> = HashMap::new();
1383    for (table, col, dtype, nullable, default, ord) in column_rows {
1384        table_map.entry(table).or_default().push(ColumnDef {
1385            name: col,
1386            data_type: dtype,
1387            is_nullable: nullable == "YES",
1388            default,
1389            ordinal_position: ord,
1390        });
1391    }
1392    let mut tables: Vec<TableDef> = table_map
1393        .into_iter()
1394        .map(|(name, columns)| TableDef {
1395            schema: schema.to_string(),
1396            name,
1397            columns,
1398        })
1399        .collect();
1400    tables.sort_by(|a, b| a.name.cmp(&b.name));
1401
1402    // Views.
1403    let view_rows: Vec<(String, String)> = conn
1404        .exec(
1405            "SELECT TABLE_NAME, VIEW_DEFINITION FROM information_schema.VIEWS \
1406             WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME",
1407            (schema,),
1408        )
1409        .await?;
1410    let views: Vec<ViewDef> = view_rows
1411        .into_iter()
1412        .map(|(name, def)| ViewDef {
1413            schema: schema.to_string(),
1414            name,
1415            definition: def,
1416            is_materialized: false,
1417        })
1418        .collect();
1419
1420    // Indexes — group STATISTICS rows by (table, index_name). PRIMARY indexes
1421    // surface as primary-key constraints instead.
1422    let index_rows: Vec<(String, String, i32, String, i64)> = conn
1423        .exec(
1424            "SELECT TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX, COLUMN_NAME, NON_UNIQUE \
1425             FROM information_schema.STATISTICS \
1426             WHERE TABLE_SCHEMA = ? AND INDEX_NAME <> 'PRIMARY' \
1427             ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX",
1428            (schema,),
1429        )
1430        .await?;
1431    let mut idx_map: HashMap<(String, String), (Vec<String>, bool)> = HashMap::new();
1432    for (table, idx_name, _seq, col, non_unique) in index_rows {
1433        let entry = idx_map
1434            .entry((table, idx_name))
1435            .or_insert_with(|| (Vec::new(), non_unique == 0));
1436        entry.0.push(col);
1437    }
1438    let mut indexes: Vec<IndexDef> = idx_map
1439        .into_iter()
1440        .map(|((table, name), (cols, is_unique))| {
1441            let kw = if is_unique {
1442                "CREATE UNIQUE INDEX"
1443            } else {
1444                "CREATE INDEX"
1445            };
1446            let definition = format!(
1447                "{} `{}` ON `{}` ({})",
1448                kw,
1449                name,
1450                table,
1451                cols.iter()
1452                    .map(|c| format!("`{}`", c))
1453                    .collect::<Vec<_>>()
1454                    .join(", ")
1455            );
1456            IndexDef {
1457                schema: schema.to_string(),
1458                name,
1459                table_name: table,
1460                definition,
1461                is_unique,
1462            }
1463        })
1464        .collect();
1465    indexes.sort_by(|a, b| a.name.cmp(&b.name));
1466
1467    // Routines (procedures + functions). We store both via FunctionDef.
1468    let routine_rows: Vec<(String, String, String, String)> = conn
1469        .exec(
1470            "SELECT ROUTINE_NAME, \
1471                    COALESCE(DTD_IDENTIFIER, ''), \
1472                    COALESCE(EXTERNAL_LANGUAGE, ROUTINE_BODY), \
1473                    COALESCE(ROUTINE_DEFINITION, '') \
1474             FROM information_schema.ROUTINES \
1475             WHERE ROUTINE_SCHEMA = ? ORDER BY ROUTINE_NAME",
1476            (schema,),
1477        )
1478        .await?;
1479    let functions: Vec<FunctionDef> = routine_rows
1480        .into_iter()
1481        .map(|(name, return_type, language, definition)| FunctionDef {
1482            schema: schema.to_string(),
1483            name,
1484            arguments: String::new(),
1485            return_type,
1486            language,
1487            definition,
1488        })
1489        .collect();
1490
1491    // Constraints — PK / UNIQUE / FK. Definition is left empty for the diff
1492    // shape; the constraint type + name is the structural signal.
1493    let constraint_rows: Vec<(String, String, String)> = conn
1494        .exec(
1495            "SELECT TABLE_NAME, CONSTRAINT_NAME, CONSTRAINT_TYPE \
1496             FROM information_schema.TABLE_CONSTRAINTS \
1497             WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME, CONSTRAINT_NAME",
1498            (schema,),
1499        )
1500        .await?;
1501    let constraints: Vec<ConstraintDef> = constraint_rows
1502        .into_iter()
1503        .map(|(table, name, ctype)| ConstraintDef {
1504            schema: schema.to_string(),
1505            table_name: table,
1506            name,
1507            constraint_type: ctype,
1508            definition: String::new(),
1509        })
1510        .collect();
1511
1512    // Triggers.
1513    let trigger_rows: Vec<(String, String, String)> = conn
1514        .exec(
1515            "SELECT EVENT_OBJECT_TABLE, TRIGGER_NAME, ACTION_STATEMENT \
1516             FROM information_schema.TRIGGERS \
1517             WHERE TRIGGER_SCHEMA = ? ORDER BY EVENT_OBJECT_TABLE, TRIGGER_NAME",
1518            (schema,),
1519        )
1520        .await?;
1521    let triggers: Vec<TriggerDef> = trigger_rows
1522        .into_iter()
1523        .map(|(table_name, name, definition)| TriggerDef {
1524            schema: schema.to_string(),
1525            table_name,
1526            name,
1527            definition,
1528        })
1529        .collect();
1530
1531    Ok(SchemaSnapshot {
1532        tables,
1533        views,
1534        indexes,
1535        sequences: Vec::new(),
1536        functions,
1537        enums: Vec::new(),
1538        constraints,
1539        triggers,
1540        extensions: Vec::new(),
1541    })
1542}
1543
1544#[cfg(test)]
1545mod tests_generate_ddl_mysql {
1546    use super::*;
1547
1548    fn col(name: &str, ty: &str) -> ColumnDef {
1549        ColumnDef {
1550            name: name.into(),
1551            data_type: ty.into(),
1552            is_nullable: false,
1553            default: None,
1554            ordinal_position: 1,
1555        }
1556    }
1557
1558    #[test]
1559    fn drop_index_emits_on_clause() {
1560        let diffs = vec![SchemaDiff::IndexDropped {
1561            name: "idx_users_email".into(),
1562            table_name: "users".into(),
1563        }];
1564        let sql = generate_ddl_mysql(&diffs);
1565        assert!(sql.contains("DROP INDEX `idx_users_email` ON `users`"));
1566        // No PG-style "DROP INDEX IF EXISTS ...;" without ON clause.
1567        assert!(!sql.contains("DROP INDEX `idx_users_email`;"));
1568    }
1569
1570    #[test]
1571    fn dependent_diffs_filtered_when_parent_table_dropped() {
1572        // If we have TableDropped(t) AND ConstraintDropped/IndexDropped/
1573        // ColumnAltered/etc. referencing t, only the TableDropped should remain
1574        // in the output (others are implicit on MySQL).
1575        let diffs = vec![
1576            SchemaDiff::ConstraintDropped {
1577                table: "t".into(),
1578                name: "PRIMARY".into(),
1579            },
1580            SchemaDiff::IndexDropped {
1581                name: "idx_t_x".into(),
1582                table_name: "t".into(),
1583            },
1584            SchemaDiff::ColumnDropped {
1585                table: "t".into(),
1586                column: "x".into(),
1587            },
1588            SchemaDiff::TableDropped("t".into()),
1589        ];
1590        let sql = generate_ddl_mysql(&diffs);
1591        assert!(sql.contains("DROP TABLE IF EXISTS `t`;"));
1592        assert!(!sql.contains("DROP CONSTRAINT"));
1593        assert!(!sql.contains("DROP INDEX `idx_t_x`"));
1594        assert!(!sql.contains("DROP COLUMN"));
1595    }
1596
1597    #[test]
1598    fn table_added_uses_innodb_utf8mb4() {
1599        let diffs = vec![SchemaDiff::TableAdded(TableDef {
1600            schema: "db".into(),
1601            name: "t".into(),
1602            columns: vec![col("id", "int")],
1603        })];
1604        let sql = generate_ddl_mysql(&diffs);
1605        assert!(sql.contains("CREATE TABLE `t`"));
1606        assert!(sql.contains("ENGINE=InnoDB"));
1607        assert!(sql.contains("utf8mb4"));
1608    }
1609}