1use 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#[derive(Debug, Clone, Serialize, PartialEq)]
31pub struct SchemaSnapshot {
32 pub tables: Vec<TableDef>,
34 pub views: Vec<ViewDef>,
36 pub indexes: Vec<IndexDef>,
38 pub sequences: Vec<SequenceDef>,
40 pub functions: Vec<FunctionDef>,
42 pub enums: Vec<EnumDef>,
44 pub constraints: Vec<ConstraintDef>,
46 pub triggers: Vec<TriggerDef>,
48 pub extensions: Vec<String>,
50}
51
52#[derive(Debug, Clone, Serialize, PartialEq)]
54pub struct TableDef {
55 pub schema: String,
57 pub name: String,
59 pub columns: Vec<ColumnDef>,
61}
62
63#[derive(Debug, Clone, Serialize, PartialEq)]
65pub struct ColumnDef {
66 pub name: String,
68 pub data_type: String,
70 pub is_nullable: bool,
72 pub default: Option<String>,
74 pub ordinal_position: i32,
76}
77
78#[derive(Debug, Clone, Serialize, PartialEq)]
80pub struct ViewDef {
81 pub schema: String,
83 pub name: String,
85 pub definition: String,
87 pub is_materialized: bool,
89}
90
91#[derive(Debug, Clone, Serialize, PartialEq)]
93pub struct IndexDef {
94 pub schema: String,
96 pub name: String,
98 pub table_name: String,
100 pub definition: String,
102 pub is_unique: bool,
104}
105
106#[derive(Debug, Clone, Serialize, PartialEq)]
108pub struct SequenceDef {
109 pub schema: String,
111 pub name: String,
113 pub data_type: String,
115}
116
117#[derive(Debug, Clone, Serialize, PartialEq)]
119pub struct FunctionDef {
120 pub schema: String,
122 pub name: String,
124 pub arguments: String,
126 pub return_type: String,
128 pub language: String,
130 pub definition: String,
132}
133
134#[derive(Debug, Clone, Serialize, PartialEq)]
136pub struct EnumDef {
137 pub schema: String,
139 pub name: String,
141 pub values: Vec<String>,
143}
144
145#[derive(Debug, Clone, Serialize, PartialEq)]
147pub struct ConstraintDef {
148 pub schema: String,
150 pub table_name: String,
152 pub name: String,
154 pub constraint_type: String,
156 pub definition: String,
158}
159
160#[derive(Debug, Clone, Serialize, PartialEq)]
162pub struct TriggerDef {
163 pub schema: String,
165 pub table_name: String,
167 pub name: String,
169 pub definition: String,
171}
172
173#[derive(Debug, Clone, Serialize)]
175pub enum SchemaDiff {
176 TableAdded(TableDef),
178 TableDropped(String),
180 ColumnAdded { table: String, column: ColumnDef },
182 ColumnDropped { table: String, column: String },
184 ColumnAltered {
186 table: String,
187 column: String,
188 from: ColumnDef,
189 to: ColumnDef,
190 },
191 IndexAdded(IndexDef),
193 IndexDropped { name: String, table_name: String },
199 ViewAdded(ViewDef),
201 ViewDropped(String),
203 ViewAltered {
205 name: String,
206 from: String,
207 to: String,
208 },
209 SequenceAdded(SequenceDef),
211 SequenceDropped(String),
213 FunctionAdded(FunctionDef),
215 FunctionDropped(String),
217 FunctionAltered { name: String },
219 EnumAdded(EnumDef),
221 EnumDropped(String),
223 ConstraintAdded(ConstraintDef),
225 ConstraintDropped { table: String, name: String },
227 TriggerAdded(TriggerDef),
229 TriggerDropped { table: String, name: String },
231 ExtensionAdded(String),
233 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
285pub 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#[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 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 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 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
603pub fn diff(before: &SchemaSnapshot, after: &SchemaSnapshot) -> Vec<SchemaDiff> {
605 let mut diffs = Vec::new();
606
607 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
848pub 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 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 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
1056pub fn to_ddl(snapshot: &SchemaSnapshot) -> String {
1058 let mut statements = Vec::new();
1059
1060 for ext in &snapshot.extensions {
1062 statements.push(format!(
1063 "CREATE EXTENSION IF NOT EXISTS {};",
1064 quote_ident(ext)
1065 ));
1066 }
1067
1068 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 for s in &snapshot.sequences {
1080 statements.push(format!("CREATE SEQUENCE {};", quote_ident(&s.name)));
1081 }
1082
1083 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 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 for idx in &snapshot.indexes {
1118 statements.push(format!("{};", idx.definition));
1119 }
1120
1121 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 for func in &snapshot.functions {
1138 statements.push(format!("{};", func.definition.trim_end_matches(';')));
1139 }
1140
1141 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
1152pub fn generate_ddl_mysql(diffs: &[SchemaDiff]) -> String {
1159 fn q(name: &str) -> String {
1160 format!("`{}`", name.replace('`', "``"))
1161 }
1162
1163 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 let mut statements = Vec::new();
1181 for d in diffs {
1182 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 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 statements.push(format!("{};", idx.definition.trim_end_matches(';')));
1274 }
1275 SchemaDiff::IndexDropped { name, table_name } => {
1276 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 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#[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 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 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 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 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 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 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 assert!(!sql.contains("DROP INDEX `idx_users_email`;"));
1568 }
1569
1570 #[test]
1571 fn dependent_diffs_filtered_when_parent_table_dropped() {
1572 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}