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