Skip to main content

sql_orm_migrate/
lib.rs

1//! Migration support foundations.
2
3use sql_orm_core::CrateIdentity;
4
5mod diff;
6mod filesystem;
7mod operation;
8mod snapshot;
9
10pub use diff::{
11    diff_column_operations, diff_relational_operations, diff_schema_and_table_operations,
12};
13pub use filesystem::{
14    MigrationEntry, MigrationScaffold, build_database_downgrade_script,
15    build_database_update_script, create_migration_scaffold,
16    create_migration_scaffold_with_snapshot, latest_migration, list_migrations,
17    read_latest_model_snapshot, read_model_snapshot, write_migration_down_sql,
18    write_migration_up_sql, write_model_snapshot,
19};
20pub use operation::{
21    AddColumn, AddForeignKey, AlterColumn, CreateIndex, CreateSchema, CreateTable, DropColumn,
22    DropForeignKey, DropIndex, DropSchema, DropTable, MigrationOperation, RenameColumn,
23    RenameTable,
24};
25pub use snapshot::{
26    ColumnSnapshot, ForeignKeySnapshot, IndexColumnSnapshot, IndexSnapshot, ModelSnapshot,
27    SchemaSnapshot, TableSnapshot,
28};
29
30/// Placeholder migration engine marker.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct MigrationEngine;
33
34pub const CRATE_IDENTITY: CrateIdentity = CrateIdentity {
35    name: "sql-orm-migrate",
36    responsibility: "code-first snapshots, diffs and migration operations",
37};
38
39#[cfg(test)]
40mod tests {
41    use super::{
42        AddColumn, AddForeignKey, AlterColumn, CRATE_IDENTITY, ColumnSnapshot, CreateIndex,
43        CreateSchema, CreateTable, DropColumn, DropForeignKey, DropIndex, DropSchema, DropTable,
44        ForeignKeySnapshot, IndexColumnSnapshot, IndexSnapshot, MigrationEngine,
45        MigrationOperation, ModelSnapshot, RenameColumn, RenameTable, SchemaSnapshot,
46        TableSnapshot,
47    };
48    use sql_orm_core::{
49        ColumnMetadata, EntityMetadata, ForeignKeyMetadata, IdentityMetadata, IndexColumnMetadata,
50        IndexMetadata, PrimaryKeyMetadata, ReferentialAction, SqlServerType,
51    };
52
53    const CUSTOMER_COLUMNS: [ColumnMetadata; 3] = [
54        ColumnMetadata {
55            rust_field: "id",
56            column_name: "id",
57            renamed_from: None,
58            sql_type: SqlServerType::BigInt,
59            nullable: false,
60            primary_key: true,
61            identity: Some(IdentityMetadata::new(1, 1)),
62            default_sql: None,
63            computed_sql: None,
64            rowversion: false,
65            insertable: false,
66            updatable: false,
67            max_length: None,
68            precision: None,
69            scale: None,
70        },
71        ColumnMetadata {
72            rust_field: "email",
73            column_name: "email",
74            renamed_from: None,
75            sql_type: SqlServerType::NVarChar,
76            nullable: false,
77            primary_key: false,
78            identity: None,
79            default_sql: None,
80            computed_sql: None,
81            rowversion: false,
82            insertable: true,
83            updatable: true,
84            max_length: Some(160),
85            precision: None,
86            scale: None,
87        },
88        ColumnMetadata {
89            rust_field: "version",
90            column_name: "version",
91            renamed_from: None,
92            sql_type: SqlServerType::RowVersion,
93            nullable: false,
94            primary_key: false,
95            identity: None,
96            default_sql: None,
97            computed_sql: None,
98            rowversion: true,
99            insertable: false,
100            updatable: false,
101            max_length: None,
102            precision: None,
103            scale: None,
104        },
105    ];
106
107    const CUSTOMER_PK_COLUMNS: [&str; 1] = ["id"];
108    const CUSTOMER_INDEX_COLUMNS: [IndexColumnMetadata; 1] = [IndexColumnMetadata::asc("email")];
109    const CUSTOMER_INDEXES: [IndexMetadata; 1] = [IndexMetadata {
110        name: "ix_customers_email",
111        columns: &CUSTOMER_INDEX_COLUMNS,
112        unique: true,
113    }];
114    const CUSTOMER_METADATA: EntityMetadata = EntityMetadata {
115        rust_name: "Customer",
116        schema: "sales",
117        table: "customers",
118        renamed_from: None,
119        columns: &CUSTOMER_COLUMNS,
120        primary_key: PrimaryKeyMetadata::new(Some("pk_customers"), &CUSTOMER_PK_COLUMNS),
121        indexes: &CUSTOMER_INDEXES,
122        foreign_keys: &[],
123        navigations: &[],
124    };
125
126    const TENANT_COLUMNS: [ColumnMetadata; 2] = [
127        ColumnMetadata {
128            rust_field: "id",
129            column_name: "id",
130            renamed_from: None,
131            sql_type: SqlServerType::BigInt,
132            nullable: false,
133            primary_key: true,
134            identity: Some(IdentityMetadata::new(100, 5)),
135            default_sql: None,
136            computed_sql: None,
137            rowversion: false,
138            insertable: false,
139            updatable: false,
140            max_length: None,
141            precision: None,
142            scale: None,
143        },
144        ColumnMetadata {
145            rust_field: "display_name",
146            column_name: "display_name",
147            renamed_from: None,
148            sql_type: SqlServerType::NVarChar,
149            nullable: false,
150            primary_key: false,
151            identity: None,
152            default_sql: Some("'tenant'"),
153            computed_sql: None,
154            rowversion: false,
155            insertable: true,
156            updatable: true,
157            max_length: Some(120),
158            precision: None,
159            scale: None,
160        },
161    ];
162
163    const TENANT_PK_COLUMNS: [&str; 1] = ["id"];
164    const TENANT_METADATA: EntityMetadata = EntityMetadata {
165        rust_name: "Tenant",
166        schema: "admin",
167        table: "tenants",
168        renamed_from: None,
169        columns: &TENANT_COLUMNS,
170        primary_key: PrimaryKeyMetadata::new(None, &TENANT_PK_COLUMNS),
171        indexes: &[],
172        foreign_keys: &[],
173        navigations: &[],
174    };
175
176    const COMPOSITE_ORDER_COLUMNS: [ColumnMetadata; 3] = [
177        ColumnMetadata {
178            rust_field: "id",
179            column_name: "id",
180            renamed_from: None,
181            sql_type: SqlServerType::BigInt,
182            nullable: false,
183            primary_key: true,
184            identity: Some(IdentityMetadata::new(1, 1)),
185            default_sql: None,
186            computed_sql: None,
187            rowversion: false,
188            insertable: false,
189            updatable: false,
190            max_length: None,
191            precision: None,
192            scale: None,
193        },
194        ColumnMetadata {
195            rust_field: "customer_id",
196            column_name: "customer_id",
197            renamed_from: None,
198            sql_type: SqlServerType::BigInt,
199            nullable: false,
200            primary_key: false,
201            identity: None,
202            default_sql: None,
203            computed_sql: None,
204            rowversion: false,
205            insertable: true,
206            updatable: true,
207            max_length: None,
208            precision: None,
209            scale: None,
210        },
211        ColumnMetadata {
212            rust_field: "total_cents",
213            column_name: "total_cents",
214            renamed_from: None,
215            sql_type: SqlServerType::BigInt,
216            nullable: false,
217            primary_key: false,
218            identity: None,
219            default_sql: None,
220            computed_sql: None,
221            rowversion: false,
222            insertable: true,
223            updatable: true,
224            max_length: None,
225            precision: None,
226            scale: None,
227        },
228    ];
229    const COMPOSITE_ORDER_PK_COLUMNS: [&str; 1] = ["id"];
230    const COMPOSITE_ORDER_INDEX_COLUMNS: [IndexColumnMetadata; 2] = [
231        IndexColumnMetadata::asc("customer_id"),
232        IndexColumnMetadata::desc("total_cents"),
233    ];
234    const COMPOSITE_ORDER_INDEXES: [IndexMetadata; 1] = [IndexMetadata {
235        name: "ix_orders_customer_total",
236        columns: &COMPOSITE_ORDER_INDEX_COLUMNS,
237        unique: false,
238    }];
239    const COMPOSITE_ORDER_METADATA: EntityMetadata = EntityMetadata {
240        rust_name: "CompositeOrder",
241        schema: "sales",
242        table: "orders",
243        renamed_from: None,
244        columns: &COMPOSITE_ORDER_COLUMNS,
245        primary_key: PrimaryKeyMetadata::new(Some("pk_orders"), &COMPOSITE_ORDER_PK_COLUMNS),
246        indexes: &COMPOSITE_ORDER_INDEXES,
247        foreign_keys: &[],
248        navigations: &[],
249    };
250
251    const ORDER_COLUMNS: [ColumnMetadata; 2] = [
252        ColumnMetadata {
253            rust_field: "id",
254            column_name: "id",
255            renamed_from: None,
256            sql_type: SqlServerType::BigInt,
257            nullable: false,
258            primary_key: true,
259            identity: Some(IdentityMetadata::new(1, 1)),
260            default_sql: None,
261            computed_sql: None,
262            rowversion: false,
263            insertable: false,
264            updatable: false,
265            max_length: None,
266            precision: None,
267            scale: None,
268        },
269        ColumnMetadata {
270            rust_field: "customer_id",
271            column_name: "customer_id",
272            renamed_from: None,
273            sql_type: SqlServerType::BigInt,
274            nullable: false,
275            primary_key: false,
276            identity: None,
277            default_sql: None,
278            computed_sql: None,
279            rowversion: false,
280            insertable: true,
281            updatable: true,
282            max_length: None,
283            precision: None,
284            scale: None,
285        },
286    ];
287
288    const ORDER_PK_COLUMNS: [&str; 1] = ["id"];
289    const ORDER_FOREIGN_KEYS: [ForeignKeyMetadata; 1] = [ForeignKeyMetadata::new(
290        "fk_orders_customer_id_customers",
291        &["customer_id"],
292        "sales",
293        "customers",
294        &["id"],
295        ReferentialAction::NoAction,
296        ReferentialAction::NoAction,
297    )];
298    const ORDER_METADATA: EntityMetadata = EntityMetadata {
299        rust_name: "Order",
300        schema: "sales",
301        table: "orders",
302        renamed_from: None,
303        columns: &ORDER_COLUMNS,
304        primary_key: PrimaryKeyMetadata::new(Some("pk_orders"), &ORDER_PK_COLUMNS),
305        indexes: &[],
306        foreign_keys: &ORDER_FOREIGN_KEYS,
307        navigations: &[],
308    };
309
310    const ORDER_ALLOCATION_COLUMNS: [ColumnMetadata; 3] = [
311        ColumnMetadata {
312            rust_field: "id",
313            column_name: "id",
314            renamed_from: None,
315            sql_type: SqlServerType::BigInt,
316            nullable: false,
317            primary_key: true,
318            identity: Some(IdentityMetadata::new(1, 1)),
319            default_sql: None,
320            computed_sql: None,
321            rowversion: false,
322            insertable: false,
323            updatable: false,
324            max_length: None,
325            precision: None,
326            scale: None,
327        },
328        ColumnMetadata {
329            rust_field: "customer_id",
330            column_name: "customer_id",
331            renamed_from: None,
332            sql_type: SqlServerType::BigInt,
333            nullable: false,
334            primary_key: false,
335            identity: None,
336            default_sql: None,
337            computed_sql: None,
338            rowversion: false,
339            insertable: true,
340            updatable: true,
341            max_length: None,
342            precision: None,
343            scale: None,
344        },
345        ColumnMetadata {
346            rust_field: "branch_id",
347            column_name: "branch_id",
348            renamed_from: None,
349            sql_type: SqlServerType::BigInt,
350            nullable: false,
351            primary_key: false,
352            identity: None,
353            default_sql: None,
354            computed_sql: None,
355            rowversion: false,
356            insertable: true,
357            updatable: true,
358            max_length: None,
359            precision: None,
360            scale: None,
361        },
362    ];
363    const ORDER_ALLOCATION_PK_COLUMNS: [&str; 1] = ["id"];
364    const ORDER_ALLOCATION_FOREIGN_KEYS: [ForeignKeyMetadata; 1] = [ForeignKeyMetadata::new(
365        "fk_order_allocations_customer_branch_customers",
366        &["customer_id", "branch_id"],
367        "sales",
368        "customers",
369        &["id", "branch_id"],
370        ReferentialAction::SetDefault,
371        ReferentialAction::Cascade,
372    )];
373    const ORDER_ALLOCATION_METADATA: EntityMetadata = EntityMetadata {
374        rust_name: "OrderAllocation",
375        schema: "sales",
376        table: "order_allocations",
377        renamed_from: None,
378        columns: &ORDER_ALLOCATION_COLUMNS,
379        primary_key: PrimaryKeyMetadata::new(
380            Some("pk_order_allocations"),
381            &ORDER_ALLOCATION_PK_COLUMNS,
382        ),
383        indexes: &[],
384        foreign_keys: &ORDER_ALLOCATION_FOREIGN_KEYS,
385        navigations: &[],
386    };
387
388    #[test]
389    fn declares_migration_boundary() {
390        let engine = MigrationEngine;
391        assert_eq!(engine, MigrationEngine);
392        assert!(CRATE_IDENTITY.responsibility.contains("migration"));
393    }
394
395    #[test]
396    fn model_snapshot_exposes_schema_table_column_and_index_lookups() {
397        let snapshot = ModelSnapshot::new(vec![SchemaSnapshot::new(
398            "sales",
399            vec![TableSnapshot::new(
400                "customers",
401                vec![
402                    ColumnSnapshot::new(
403                        "id",
404                        SqlServerType::BigInt,
405                        false,
406                        true,
407                        Some(IdentityMetadata::new(1, 1)),
408                        None,
409                        None,
410                        false,
411                        false,
412                        false,
413                        None,
414                        None,
415                        None,
416                    ),
417                    ColumnSnapshot::new(
418                        "email",
419                        SqlServerType::NVarChar,
420                        false,
421                        false,
422                        None,
423                        None,
424                        None,
425                        false,
426                        true,
427                        true,
428                        Some(160),
429                        None,
430                        None,
431                    ),
432                ],
433                Some("pk_customers".to_string()),
434                vec!["id".to_string()],
435                vec![IndexSnapshot::new(
436                    "ix_customers_email",
437                    vec![IndexColumnSnapshot::asc("email")],
438                    false,
439                )],
440                vec![],
441            )],
442        )]);
443
444        let schema = snapshot.schema("sales").expect("schema must exist");
445        let table = schema.table("customers").expect("table must exist");
446        let id = table.column("id").expect("column must exist");
447        let index = table.index("ix_customers_email").expect("index must exist");
448
449        assert_eq!(table.primary_key_name.as_deref(), Some("pk_customers"));
450        assert_eq!(table.primary_key_columns, vec!["id"]);
451        assert_eq!(id.identity, Some(IdentityMetadata::new(1, 1)));
452        assert_eq!(index.columns, vec![IndexColumnSnapshot::asc("email")]);
453    }
454
455    #[test]
456    fn column_snapshot_preserves_sql_server_specific_shape() {
457        let column = ColumnSnapshot::new(
458            "version",
459            SqlServerType::RowVersion,
460            false,
461            false,
462            None,
463            Some("CONVERT(binary(8), 0)".to_string()),
464            Some("([major] + [minor])".to_string()),
465            true,
466            false,
467            false,
468            Some(8),
469            Some(18),
470            Some(4),
471        );
472
473        assert_eq!(column.name, "version");
474        assert_eq!(column.sql_type, SqlServerType::RowVersion);
475        assert_eq!(column.default_sql.as_deref(), Some("CONVERT(binary(8), 0)"));
476        assert_eq!(column.computed_sql.as_deref(), Some("([major] + [minor])"));
477        assert!(column.rowversion);
478        assert!(!column.insertable);
479        assert!(!column.updatable);
480        assert_eq!(column.max_length, Some(8));
481        assert_eq!(column.precision, Some(18));
482        assert_eq!(column.scale, Some(4));
483    }
484
485    #[test]
486    fn table_snapshot_can_be_built_from_entity_metadata() {
487        let table = TableSnapshot::from(&CUSTOMER_METADATA);
488
489        assert_eq!(table.name, "customers");
490        assert_eq!(table.primary_key_name.as_deref(), Some("pk_customers"));
491        assert_eq!(table.primary_key_columns, vec!["id"]);
492        assert_eq!(table.columns.len(), 3);
493        assert_eq!(table.columns[0].name, "id");
494        assert_eq!(table.columns[1].name, "email");
495        assert_eq!(table.indexes.len(), 1);
496        assert_eq!(table.indexes[0].name, "ix_customers_email");
497        assert!(table.indexes[0].unique);
498        assert!(table.foreign_keys.is_empty());
499    }
500
501    #[test]
502    fn table_snapshot_preserves_foreign_keys_from_entity_metadata() {
503        let table = TableSnapshot::from(&ORDER_METADATA);
504        let foreign_key = table
505            .foreign_key("fk_orders_customer_id_customers")
506            .expect("foreign key must exist");
507
508        assert_eq!(table.foreign_keys.len(), 1);
509        assert_eq!(foreign_key.columns, vec!["customer_id"]);
510        assert_eq!(foreign_key.referenced_schema, "sales");
511        assert_eq!(foreign_key.referenced_table, "customers");
512        assert_eq!(foreign_key.referenced_columns, vec!["id"]);
513        assert_eq!(foreign_key.on_delete, ReferentialAction::NoAction);
514        assert_eq!(foreign_key.on_update, ReferentialAction::NoAction);
515    }
516
517    #[test]
518    fn table_snapshot_preserves_composite_foreign_keys_from_entity_metadata() {
519        let table = TableSnapshot::from(&ORDER_ALLOCATION_METADATA);
520        let foreign_key = table
521            .foreign_key("fk_order_allocations_customer_branch_customers")
522            .expect("composite foreign key must exist");
523
524        assert_eq!(table.foreign_keys.len(), 1);
525        assert_eq!(foreign_key.columns, vec!["customer_id", "branch_id"]);
526        assert_eq!(foreign_key.referenced_schema, "sales");
527        assert_eq!(foreign_key.referenced_table, "customers");
528        assert_eq!(foreign_key.referenced_columns, vec!["id", "branch_id"]);
529        assert_eq!(foreign_key.on_delete, ReferentialAction::SetDefault);
530        assert_eq!(foreign_key.on_update, ReferentialAction::Cascade);
531    }
532
533    #[test]
534    fn table_snapshot_preserves_composite_indexes_from_entity_metadata() {
535        let table = TableSnapshot::from(&COMPOSITE_ORDER_METADATA);
536        let index = table
537            .index("ix_orders_customer_total")
538            .expect("composite index must exist");
539
540        assert_eq!(table.indexes.len(), 1);
541        assert_eq!(
542            index.columns,
543            vec![
544                IndexColumnSnapshot::asc("customer_id"),
545                IndexColumnSnapshot::desc("total_cents"),
546            ]
547        );
548        assert!(!index.unique);
549    }
550
551    #[test]
552    fn model_snapshot_groups_entities_by_schema_and_sorts_tables() {
553        let snapshot =
554            ModelSnapshot::from_entities(&[&ORDER_METADATA, &TENANT_METADATA, &CUSTOMER_METADATA]);
555
556        assert_eq!(snapshot.schemas.len(), 2);
557        assert_eq!(snapshot.schemas[0].name, "admin");
558        assert_eq!(snapshot.schemas[1].name, "sales");
559
560        let admin = snapshot.schema("admin").expect("admin schema must exist");
561        assert_eq!(admin.tables.len(), 1);
562        assert_eq!(admin.tables[0].name, "tenants");
563
564        let sales = snapshot.schema("sales").expect("sales schema must exist");
565        assert_eq!(
566            sales
567                .tables
568                .iter()
569                .map(|table| table.name.as_str())
570                .collect::<Vec<_>>(),
571            vec!["customers", "orders"]
572        );
573        assert_eq!(
574            sales
575                .table("customers")
576                .expect("customers table must exist")
577                .column("email")
578                .expect("email column must exist")
579                .max_length,
580            Some(160)
581        );
582    }
583
584    #[test]
585    fn migration_operations_cover_minimum_stage_seven_surface() {
586        let create_schema = MigrationOperation::CreateSchema(CreateSchema::new("sales"));
587        let drop_schema = MigrationOperation::DropSchema(DropSchema::new("legacy"));
588        let create_table = MigrationOperation::CreateTable(CreateTable::new(
589            "sales",
590            TableSnapshot::from(&CUSTOMER_METADATA),
591        ));
592        let drop_table = MigrationOperation::DropTable(DropTable::new("sales", "customers"));
593        let add_column = MigrationOperation::AddColumn(AddColumn::new(
594            "sales",
595            "customers",
596            ColumnSnapshot::from(&CUSTOMER_COLUMNS[1]),
597        ));
598        let drop_column =
599            MigrationOperation::DropColumn(DropColumn::new("sales", "customers", "email"));
600        let rename_column = MigrationOperation::RenameColumn(RenameColumn::new(
601            "sales",
602            "customers",
603            "email",
604            "email_address",
605        ));
606        let rename_table =
607            MigrationOperation::RenameTable(RenameTable::new("sales", "customers", "clients"));
608        let alter_column = MigrationOperation::AlterColumn(AlterColumn::new(
609            "sales",
610            "customers",
611            ColumnSnapshot::from(&CUSTOMER_COLUMNS[1]),
612            ColumnSnapshot::new(
613                "email",
614                SqlServerType::NVarChar,
615                false,
616                false,
617                None,
618                None,
619                None,
620                false,
621                true,
622                true,
623                Some(255),
624                None,
625                None,
626            ),
627        ));
628        let create_index = MigrationOperation::CreateIndex(CreateIndex::new(
629            "sales",
630            "customers",
631            IndexSnapshot::new(
632                "ix_customers_email",
633                vec![IndexColumnSnapshot::asc("email")],
634                true,
635            ),
636        ));
637        let drop_index = MigrationOperation::DropIndex(DropIndex::new(
638            "sales",
639            "customers",
640            "ix_customers_email",
641        ));
642        let add_foreign_key = MigrationOperation::AddForeignKey(AddForeignKey::new(
643            "sales",
644            "orders",
645            ForeignKeySnapshot::new(
646                "fk_orders_customer_id_customers",
647                vec!["customer_id".to_string()],
648                "sales",
649                "customers",
650                vec!["id".to_string()],
651                ReferentialAction::NoAction,
652                ReferentialAction::NoAction,
653            ),
654        ));
655        let drop_foreign_key = MigrationOperation::DropForeignKey(DropForeignKey::new(
656            "sales",
657            "orders",
658            "fk_orders_customer_id_customers",
659        ));
660
661        assert_eq!(create_schema.schema_name(), "sales");
662        assert_eq!(drop_schema.schema_name(), "legacy");
663        assert_eq!(create_table.schema_name(), "sales");
664        assert_eq!(create_table.table_name(), Some("customers"));
665        assert_eq!(drop_table.table_name(), Some("customers"));
666        assert_eq!(add_column.table_name(), Some("customers"));
667        assert_eq!(drop_column.table_name(), Some("customers"));
668        assert_eq!(rename_column.table_name(), Some("customers"));
669        assert_eq!(rename_table.table_name(), Some("clients"));
670        assert_eq!(alter_column.table_name(), Some("customers"));
671        assert_eq!(create_index.table_name(), Some("customers"));
672        assert_eq!(drop_index.table_name(), Some("customers"));
673        assert_eq!(add_foreign_key.table_name(), Some("orders"));
674        assert_eq!(drop_foreign_key.table_name(), Some("orders"));
675    }
676
677    #[test]
678    fn alter_column_retains_previous_and_next_shapes() {
679        let previous = ColumnSnapshot::from(&CUSTOMER_COLUMNS[1]);
680        let next = ColumnSnapshot::new(
681            "email",
682            SqlServerType::NVarChar,
683            true,
684            false,
685            None,
686            Some("'unknown'".to_string()),
687            None,
688            false,
689            true,
690            true,
691            Some(255),
692            None,
693            None,
694        );
695
696        let operation = AlterColumn::new("sales", "customers", previous.clone(), next.clone());
697
698        assert_eq!(operation.schema_name, "sales");
699        assert_eq!(operation.table_name, "customers");
700        assert_eq!(operation.previous, previous);
701        assert_eq!(operation.next, next);
702    }
703}