vespertide_planner/
diff.rs

1use std::collections::HashMap;
2
3use vespertide_core::{MigrationAction, MigrationPlan, TableDef};
4
5use crate::error::PlannerError;
6
7/// Diff two schema snapshots into a migration plan.
8/// Both schemas are normalized to convert inline column constraints
9/// (primary_key, unique, index, foreign_key) to table-level constraints.
10pub fn diff_schemas(from: &[TableDef], to: &[TableDef]) -> Result<MigrationPlan, PlannerError> {
11    let mut actions: Vec<MigrationAction> = Vec::new();
12
13    // Normalize both schemas to ensure inline constraints are converted to table-level
14    let from_normalized: Vec<TableDef> = from
15        .iter()
16        .map(|t| {
17            t.normalize().map_err(|e| {
18                PlannerError::TableValidation(format!(
19                    "Failed to normalize table '{}': {}",
20                    t.name, e
21                ))
22            })
23        })
24        .collect::<Result<Vec<_>, _>>()?;
25    let to_normalized: Vec<TableDef> = to
26        .iter()
27        .map(|t| {
28            t.normalize().map_err(|e| {
29                PlannerError::TableValidation(format!(
30                    "Failed to normalize table '{}': {}",
31                    t.name, e
32                ))
33            })
34        })
35        .collect::<Result<Vec<_>, _>>()?;
36
37    let from_map: HashMap<_, _> = from_normalized
38        .iter()
39        .map(|t| (t.name.as_str(), t))
40        .collect();
41    let to_map: HashMap<_, _> = to_normalized.iter().map(|t| (t.name.as_str(), t)).collect();
42
43    // Drop tables that disappeared.
44    for name in from_map.keys() {
45        if !to_map.contains_key(name) {
46            actions.push(MigrationAction::DeleteTable {
47                table: (*name).to_string(),
48            });
49        }
50    }
51
52    // Update existing tables and their indexes/columns.
53    for (name, to_tbl) in &to_map {
54        if let Some(from_tbl) = from_map.get(name) {
55            // Columns
56            let from_cols: HashMap<_, _> = from_tbl
57                .columns
58                .iter()
59                .map(|c| (c.name.as_str(), c))
60                .collect();
61            let to_cols: HashMap<_, _> = to_tbl
62                .columns
63                .iter()
64                .map(|c| (c.name.as_str(), c))
65                .collect();
66
67            // Deleted columns
68            for col in from_cols.keys() {
69                if !to_cols.contains_key(col) {
70                    actions.push(MigrationAction::DeleteColumn {
71                        table: (*name).to_string(),
72                        column: (*col).to_string(),
73                    });
74                }
75            }
76
77            // Modified columns
78            for (col, to_def) in &to_cols {
79                if let Some(from_def) = from_cols.get(col)
80                    && from_def.r#type != to_def.r#type
81                {
82                    actions.push(MigrationAction::ModifyColumnType {
83                        table: (*name).to_string(),
84                        column: (*col).to_string(),
85                        new_type: to_def.r#type.clone(),
86                    });
87                }
88            }
89
90            // Added columns
91            // Note: Inline foreign keys are already converted to TableConstraint::ForeignKey
92            // by normalize(), so they will be handled in the constraint diff below.
93            for (col, def) in &to_cols {
94                if !from_cols.contains_key(col) {
95                    actions.push(MigrationAction::AddColumn {
96                        table: (*name).to_string(),
97                        column: (*def).clone(),
98                        fill_with: None,
99                    });
100                }
101            }
102
103            // Indexes
104            let from_indexes: HashMap<_, _> = from_tbl
105                .indexes
106                .iter()
107                .map(|i| (i.name.as_str(), i))
108                .collect();
109            let to_indexes: HashMap<_, _> = to_tbl
110                .indexes
111                .iter()
112                .map(|i| (i.name.as_str(), i))
113                .collect();
114
115            for idx in from_indexes.keys() {
116                if !to_indexes.contains_key(idx) {
117                    actions.push(MigrationAction::RemoveIndex {
118                        table: (*name).to_string(),
119                        name: (*idx).to_string(),
120                    });
121                }
122            }
123            for (idx, def) in &to_indexes {
124                if !from_indexes.contains_key(idx) {
125                    actions.push(MigrationAction::AddIndex {
126                        table: (*name).to_string(),
127                        index: (*def).clone(),
128                    });
129                }
130            }
131
132            // Constraints - compare and detect additions/removals
133            for from_constraint in &from_tbl.constraints {
134                if !to_tbl.constraints.contains(from_constraint) {
135                    actions.push(MigrationAction::RemoveConstraint {
136                        table: (*name).to_string(),
137                        constraint: from_constraint.clone(),
138                    });
139                }
140            }
141            for to_constraint in &to_tbl.constraints {
142                if !from_tbl.constraints.contains(to_constraint) {
143                    actions.push(MigrationAction::AddConstraint {
144                        table: (*name).to_string(),
145                        constraint: to_constraint.clone(),
146                    });
147                }
148            }
149        }
150    }
151
152    // Create new tables (and their indexes).
153    for (name, tbl) in &to_map {
154        if !from_map.contains_key(name) {
155            actions.push(MigrationAction::CreateTable {
156                table: tbl.name.clone(),
157                columns: tbl.columns.clone(),
158                constraints: tbl.constraints.clone(),
159            });
160            for idx in &tbl.indexes {
161                actions.push(MigrationAction::AddIndex {
162                    table: tbl.name.clone(),
163                    index: idx.clone(),
164                });
165            }
166        }
167    }
168
169    Ok(MigrationPlan {
170        comment: None,
171        created_at: None,
172        version: 0,
173        actions,
174    })
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use rstest::rstest;
181    use vespertide_core::{ColumnDef, ColumnType, IndexDef, MigrationAction, SimpleColumnType};
182
183    fn col(name: &str, ty: ColumnType) -> ColumnDef {
184        ColumnDef {
185            name: name.to_string(),
186            r#type: ty,
187            nullable: true,
188            default: None,
189            comment: None,
190            primary_key: None,
191            unique: None,
192            index: None,
193            foreign_key: None,
194        }
195    }
196
197    fn table(
198        name: &str,
199        columns: Vec<ColumnDef>,
200        constraints: Vec<vespertide_core::TableConstraint>,
201        indexes: Vec<IndexDef>,
202    ) -> TableDef {
203        TableDef {
204            name: name.to_string(),
205            columns,
206            constraints,
207            indexes,
208        }
209    }
210
211    #[rstest]
212    #[case::add_column_and_index(
213        vec![table(
214            "users",
215            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
216            vec![],
217            vec![],
218        )],
219        vec![table(
220            "users",
221            vec![
222                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
223                col("name", ColumnType::Simple(SimpleColumnType::Text)),
224            ],
225            vec![],
226            vec![IndexDef {
227                name: "idx_users_name".into(),
228                columns: vec!["name".into()],
229                unique: false,
230            }],
231        )],
232        vec![
233            MigrationAction::AddColumn {
234                table: "users".into(),
235                column: col("name", ColumnType::Simple(SimpleColumnType::Text)),
236                fill_with: None,
237            },
238            MigrationAction::AddIndex {
239                table: "users".into(),
240                index: IndexDef {
241                    name: "idx_users_name".into(),
242                    columns: vec!["name".into()],
243                    unique: false,
244                },
245            },
246        ]
247    )]
248    #[case::drop_table(
249        vec![table(
250            "users",
251            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
252            vec![],
253            vec![],
254        )],
255        vec![],
256        vec![MigrationAction::DeleteTable {
257            table: "users".into()
258        }]
259    )]
260    #[case::add_table(
261        vec![],
262        vec![table(
263            "users",
264            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
265            vec![],
266            vec![IndexDef {
267                name: "idx_users_id".into(),
268                columns: vec!["id".into()],
269                unique: true,
270            }],
271        )],
272        vec![
273            MigrationAction::CreateTable {
274                table: "users".into(),
275                columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
276                constraints: vec![],
277            },
278            MigrationAction::AddIndex {
279                table: "users".into(),
280                index: IndexDef {
281                    name: "idx_users_id".into(),
282                    columns: vec!["id".into()],
283                    unique: true,
284                },
285            },
286        ]
287    )]
288    #[case::delete_column(
289        vec![table(
290            "users",
291            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), col("name", ColumnType::Simple(SimpleColumnType::Text))],
292            vec![],
293            vec![],
294        )],
295        vec![table(
296            "users",
297            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
298            vec![],
299            vec![],
300        )],
301        vec![MigrationAction::DeleteColumn {
302            table: "users".into(),
303            column: "name".into(),
304        }]
305    )]
306    #[case::modify_column_type(
307        vec![table(
308            "users",
309            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
310            vec![],
311            vec![],
312        )],
313        vec![table(
314            "users",
315            vec![col("id", ColumnType::Simple(SimpleColumnType::Text))],
316            vec![],
317            vec![],
318        )],
319        vec![MigrationAction::ModifyColumnType {
320            table: "users".into(),
321            column: "id".into(),
322            new_type: ColumnType::Simple(SimpleColumnType::Text),
323        }]
324    )]
325    #[case::remove_index(
326        vec![table(
327            "users",
328            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
329            vec![],
330            vec![IndexDef {
331                name: "idx_users_id".into(),
332                columns: vec!["id".into()],
333                unique: false,
334            }],
335        )],
336        vec![table(
337            "users",
338            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
339            vec![],
340            vec![],
341        )],
342        vec![MigrationAction::RemoveIndex {
343            table: "users".into(),
344            name: "idx_users_id".into(),
345        }]
346    )]
347    #[case::add_index_existing_table(
348        vec![table(
349            "users",
350            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
351            vec![],
352            vec![],
353        )],
354        vec![table(
355            "users",
356            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
357            vec![],
358            vec![IndexDef {
359                name: "idx_users_id".into(),
360                columns: vec!["id".into()],
361                unique: true,
362            }],
363        )],
364        vec![MigrationAction::AddIndex {
365            table: "users".into(),
366            index: IndexDef {
367                name: "idx_users_id".into(),
368                columns: vec!["id".into()],
369                unique: true,
370            },
371        }]
372    )]
373    fn diff_schemas_detects_additions(
374        #[case] from_schema: Vec<TableDef>,
375        #[case] to_schema: Vec<TableDef>,
376        #[case] expected_actions: Vec<MigrationAction>,
377    ) {
378        let plan = diff_schemas(&from_schema, &to_schema).unwrap();
379        assert_eq!(plan.actions, expected_actions);
380    }
381
382    // Tests for inline column constraints normalization
383    mod inline_constraints {
384        use super::*;
385        use vespertide_core::schema::foreign_key::ForeignKeyDef;
386        use vespertide_core::schema::foreign_key::ForeignKeySyntax;
387        use vespertide_core::schema::primary_key::PrimaryKeySyntax;
388        use vespertide_core::{StrOrBoolOrArray, TableConstraint};
389
390        fn col_with_pk(name: &str, ty: ColumnType) -> ColumnDef {
391            ColumnDef {
392                name: name.to_string(),
393                r#type: ty,
394                nullable: false,
395                default: None,
396                comment: None,
397                primary_key: Some(PrimaryKeySyntax::Bool(true)),
398                unique: None,
399                index: None,
400                foreign_key: None,
401            }
402        }
403
404        fn col_with_unique(name: &str, ty: ColumnType) -> ColumnDef {
405            ColumnDef {
406                name: name.to_string(),
407                r#type: ty,
408                nullable: true,
409                default: None,
410                comment: None,
411                primary_key: None,
412                unique: Some(StrOrBoolOrArray::Bool(true)),
413                index: None,
414                foreign_key: None,
415            }
416        }
417
418        fn col_with_index(name: &str, ty: ColumnType) -> ColumnDef {
419            ColumnDef {
420                name: name.to_string(),
421                r#type: ty,
422                nullable: true,
423                default: None,
424                comment: None,
425                primary_key: None,
426                unique: None,
427                index: Some(StrOrBoolOrArray::Bool(true)),
428                foreign_key: None,
429            }
430        }
431
432        fn col_with_fk(name: &str, ty: ColumnType, ref_table: &str, ref_col: &str) -> ColumnDef {
433            ColumnDef {
434                name: name.to_string(),
435                r#type: ty,
436                nullable: true,
437                default: None,
438                comment: None,
439                primary_key: None,
440                unique: None,
441                index: None,
442                foreign_key: Some(ForeignKeySyntax::Object(ForeignKeyDef {
443                    ref_table: ref_table.to_string(),
444                    ref_columns: vec![ref_col.to_string()],
445                    on_delete: None,
446                    on_update: None,
447                })),
448            }
449        }
450
451        #[test]
452        fn create_table_with_inline_pk() {
453            let plan = diff_schemas(
454                &[],
455                &[table(
456                    "users",
457                    vec![
458                        col_with_pk("id", ColumnType::Simple(SimpleColumnType::Integer)),
459                        col("name", ColumnType::Simple(SimpleColumnType::Text)),
460                    ],
461                    vec![],
462                    vec![],
463                )],
464            )
465            .unwrap();
466
467            assert_eq!(plan.actions.len(), 1);
468            if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] {
469                assert_eq!(constraints.len(), 1);
470                assert!(matches!(
471                    &constraints[0],
472                    TableConstraint::PrimaryKey { columns, .. } if columns == &["id".to_string()]
473                ));
474            } else {
475                panic!("Expected CreateTable action");
476            }
477        }
478
479        #[test]
480        fn create_table_with_inline_unique() {
481            let plan = diff_schemas(
482                &[],
483                &[table(
484                    "users",
485                    vec![
486                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
487                        col_with_unique("email", ColumnType::Simple(SimpleColumnType::Text)),
488                    ],
489                    vec![],
490                    vec![],
491                )],
492            )
493            .unwrap();
494
495            assert_eq!(plan.actions.len(), 1);
496            if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] {
497                assert_eq!(constraints.len(), 1);
498                assert!(matches!(
499                    &constraints[0],
500                    TableConstraint::Unique { name: None, columns } if columns == &["email".to_string()]
501                ));
502            } else {
503                panic!("Expected CreateTable action");
504            }
505        }
506
507        #[test]
508        fn create_table_with_inline_index() {
509            let plan = diff_schemas(
510                &[],
511                &[table(
512                    "users",
513                    vec![
514                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
515                        col_with_index("name", ColumnType::Simple(SimpleColumnType::Text)),
516                    ],
517                    vec![],
518                    vec![],
519                )],
520            )
521            .unwrap();
522
523            // Should have CreateTable + AddIndex
524            assert_eq!(plan.actions.len(), 2);
525            assert!(matches!(
526                &plan.actions[0],
527                MigrationAction::CreateTable { .. }
528            ));
529            if let MigrationAction::AddIndex { index, .. } = &plan.actions[1] {
530                assert_eq!(index.name, "idx_users_name");
531                assert_eq!(index.columns, vec!["name".to_string()]);
532            } else {
533                panic!("Expected AddIndex action");
534            }
535        }
536
537        #[test]
538        fn create_table_with_inline_fk() {
539            let plan = diff_schemas(
540                &[],
541                &[table(
542                    "posts",
543                    vec![
544                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
545                        col_with_fk(
546                            "user_id",
547                            ColumnType::Simple(SimpleColumnType::Integer),
548                            "users",
549                            "id",
550                        ),
551                    ],
552                    vec![],
553                    vec![],
554                )],
555            )
556            .unwrap();
557
558            assert_eq!(plan.actions.len(), 1);
559            if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] {
560                assert_eq!(constraints.len(), 1);
561                assert!(matches!(
562                    &constraints[0],
563                    TableConstraint::ForeignKey { columns, ref_table, ref_columns, .. }
564                        if columns == &["user_id".to_string()]
565                        && ref_table == "users"
566                        && ref_columns == &["id".to_string()]
567                ));
568            } else {
569                panic!("Expected CreateTable action");
570            }
571        }
572
573        #[test]
574        fn add_index_via_inline_constraint() {
575            // Existing table without index -> table with inline index
576            let plan = diff_schemas(
577                &[table(
578                    "users",
579                    vec![
580                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
581                        col("name", ColumnType::Simple(SimpleColumnType::Text)),
582                    ],
583                    vec![],
584                    vec![],
585                )],
586                &[table(
587                    "users",
588                    vec![
589                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
590                        col_with_index("name", ColumnType::Simple(SimpleColumnType::Text)),
591                    ],
592                    vec![],
593                    vec![],
594                )],
595            )
596            .unwrap();
597
598            assert_eq!(plan.actions.len(), 1);
599            if let MigrationAction::AddIndex { table, index } = &plan.actions[0] {
600                assert_eq!(table, "users");
601                assert_eq!(index.name, "idx_users_name");
602                assert_eq!(index.columns, vec!["name".to_string()]);
603            } else {
604                panic!("Expected AddIndex action, got {:?}", plan.actions[0]);
605            }
606        }
607
608        #[test]
609        fn create_table_with_all_inline_constraints() {
610            let mut id_col = col("id", ColumnType::Simple(SimpleColumnType::Integer));
611            id_col.primary_key = Some(PrimaryKeySyntax::Bool(true));
612            id_col.nullable = false;
613
614            let mut email_col = col("email", ColumnType::Simple(SimpleColumnType::Text));
615            email_col.unique = Some(StrOrBoolOrArray::Bool(true));
616
617            let mut name_col = col("name", ColumnType::Simple(SimpleColumnType::Text));
618            name_col.index = Some(StrOrBoolOrArray::Bool(true));
619
620            let mut org_id_col = col("org_id", ColumnType::Simple(SimpleColumnType::Integer));
621            org_id_col.foreign_key = Some(ForeignKeySyntax::Object(ForeignKeyDef {
622                ref_table: "orgs".into(),
623                ref_columns: vec!["id".into()],
624                on_delete: None,
625                on_update: None,
626            }));
627
628            let plan = diff_schemas(
629                &[],
630                &[table(
631                    "users",
632                    vec![id_col, email_col, name_col, org_id_col],
633                    vec![],
634                    vec![],
635                )],
636            )
637            .unwrap();
638
639            // Should have CreateTable + AddIndex
640            assert_eq!(plan.actions.len(), 2);
641
642            if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] {
643                // Should have: PrimaryKey, Unique, ForeignKey (3 constraints)
644                assert_eq!(constraints.len(), 3);
645            } else {
646                panic!("Expected CreateTable action");
647            }
648
649            // Check for AddIndex action
650            assert!(matches!(&plan.actions[1], MigrationAction::AddIndex { .. }));
651        }
652
653        #[test]
654        fn add_constraint_to_existing_table() {
655            // Add a unique constraint to an existing table
656            let from_schema = vec![table(
657                "users",
658                vec![
659                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
660                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
661                ],
662                vec![],
663                vec![],
664            )];
665
666            let to_schema = vec![table(
667                "users",
668                vec![
669                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
670                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
671                ],
672                vec![vespertide_core::TableConstraint::Unique {
673                    name: Some("uq_users_email".into()),
674                    columns: vec!["email".into()],
675                }],
676                vec![],
677            )];
678
679            let plan = diff_schemas(&from_schema, &to_schema).unwrap();
680            assert_eq!(plan.actions.len(), 1);
681            if let MigrationAction::AddConstraint { table, constraint } = &plan.actions[0] {
682                assert_eq!(table, "users");
683                assert!(matches!(
684                    constraint,
685                    vespertide_core::TableConstraint::Unique { name: Some(n), columns }
686                        if n == "uq_users_email" && columns == &vec!["email".to_string()]
687                ));
688            } else {
689                panic!("Expected AddConstraint action, got {:?}", plan.actions[0]);
690            }
691        }
692
693        #[test]
694        fn remove_constraint_from_existing_table() {
695            // Remove a unique constraint from an existing table
696            let from_schema = vec![table(
697                "users",
698                vec![
699                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
700                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
701                ],
702                vec![vespertide_core::TableConstraint::Unique {
703                    name: Some("uq_users_email".into()),
704                    columns: vec!["email".into()],
705                }],
706                vec![],
707            )];
708
709            let to_schema = vec![table(
710                "users",
711                vec![
712                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
713                    col("email", ColumnType::Simple(SimpleColumnType::Text)),
714                ],
715                vec![],
716                vec![],
717            )];
718
719            let plan = diff_schemas(&from_schema, &to_schema).unwrap();
720            assert_eq!(plan.actions.len(), 1);
721            if let MigrationAction::RemoveConstraint { table, constraint } = &plan.actions[0] {
722                assert_eq!(table, "users");
723                assert!(matches!(
724                    constraint,
725                    vespertide_core::TableConstraint::Unique { name: Some(n), columns }
726                        if n == "uq_users_email" && columns == &vec!["email".to_string()]
727                ));
728            } else {
729                panic!(
730                    "Expected RemoveConstraint action, got {:?}",
731                    plan.actions[0]
732                );
733            }
734        }
735
736        #[test]
737        fn diff_schemas_with_normalize_error() {
738            // Test that normalize errors are properly propagated
739            let mut col1 = col("col1", ColumnType::Simple(SimpleColumnType::Text));
740            col1.index = Some(StrOrBoolOrArray::Str("idx1".into()));
741
742            let table = TableDef {
743                name: "test".into(),
744                columns: vec![
745                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
746                    col1.clone(),
747                    {
748                        // Same column with same index name - should error
749                        let mut c = col1.clone();
750                        c.index = Some(StrOrBoolOrArray::Str("idx1".into()));
751                        c
752                    },
753                ],
754                constraints: vec![],
755                indexes: vec![],
756            };
757
758            let result = diff_schemas(&[], &[table]);
759            assert!(result.is_err());
760            if let Err(PlannerError::TableValidation(msg)) = result {
761                assert!(msg.contains("Failed to normalize table"));
762                assert!(msg.contains("Duplicate index"));
763            } else {
764                panic!("Expected TableValidation error, got {:?}", result);
765            }
766        }
767
768        #[test]
769        fn diff_schemas_with_normalize_error_in_from_schema() {
770            // Test that normalize errors in 'from' schema are properly propagated
771            let mut col1 = col("col1", ColumnType::Simple(SimpleColumnType::Text));
772            col1.index = Some(StrOrBoolOrArray::Str("idx1".into()));
773
774            let table = TableDef {
775                name: "test".into(),
776                columns: vec![
777                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
778                    col1.clone(),
779                    {
780                        // Same column with same index name - should error
781                        let mut c = col1.clone();
782                        c.index = Some(StrOrBoolOrArray::Str("idx1".into()));
783                        c
784                    },
785                ],
786                constraints: vec![],
787                indexes: vec![],
788            };
789
790            // 'from' schema has the invalid table
791            let result = diff_schemas(&[table], &[]);
792            assert!(result.is_err());
793            if let Err(PlannerError::TableValidation(msg)) = result {
794                assert!(msg.contains("Failed to normalize table"));
795                assert!(msg.contains("Duplicate index"));
796            } else {
797                panic!("Expected TableValidation error, got {:?}", result);
798            }
799        }
800    }
801}