|
| 1 | +using MetaObjects.Codegen.Migrate; |
| 2 | +using Xunit; |
| 3 | + |
| 4 | +namespace MetaObjects.Codegen.Tests.Migrate; |
| 5 | + |
| 6 | +public class SchemaDiffTests |
| 7 | +{ |
| 8 | + private static ColumnDescriptor Col(string name, SqlType type, bool nullable = true, |
| 9 | + ColumnDefault? def = null, IdentityKind? identity = null) => |
| 10 | + new(name, type, nullable, def, identity); |
| 11 | + |
| 12 | + private static TableDescriptor Table(string name, IReadOnlyList<ColumnDescriptor> cols, |
| 13 | + IReadOnlyList<string>? pk = null, IReadOnlyList<IndexDescriptor>? idx = null, |
| 14 | + IReadOnlyList<FkDescriptor>? fks = null, string? schema = null) => |
| 15 | + new(name, cols, idx ?? [], fks ?? [], pk ?? [], schema); |
| 16 | + |
| 17 | + private static SchemaSnapshot Snap(params TableDescriptor[] tables) => new(tables); |
| 18 | + private static readonly SchemaSnapshot Empty = new([]); |
| 19 | + |
| 20 | + [Fact] |
| 21 | + public void New_table_emits_create_plus_indexes_and_fks() |
| 22 | + { |
| 23 | + var t = Table("posts", |
| 24 | + [Col("id", new SqlType.Integer(64), nullable: false), Col("authorId", new SqlType.Integer(64), nullable: false)], |
| 25 | + pk: ["id"], |
| 26 | + idx: [new IndexDescriptor("posts_author_idx", ["authorId"], false)], |
| 27 | + fks: [new FkDescriptor("posts_authorId_fk", ["authorId"], "authors", ["id"])]); |
| 28 | + |
| 29 | + var result = SchemaDiff.Diff(Snap(t), Empty); |
| 30 | + |
| 31 | + Assert.Collection(result.Changes, |
| 32 | + c => Assert.IsType<Change.CreateTable>(c), |
| 33 | + c => Assert.IsType<Change.AddIndex>(c), |
| 34 | + c => Assert.IsType<Change.AddFk>(c)); |
| 35 | + Assert.Empty(result.Blocked); |
| 36 | + } |
| 37 | + |
| 38 | + [Fact] |
| 39 | + public void Dropped_table_blocked_unless_allowed() |
| 40 | + { |
| 41 | + var actual = Snap(Table("legacy", [Col("id", new SqlType.Integer(64))], pk: ["id"])); |
| 42 | + |
| 43 | + var blocked = SchemaDiff.Diff(Empty, actual); |
| 44 | + var drop = Assert.IsType<Change.DropTable>(Assert.Single(blocked.Changes)); |
| 45 | + Assert.False(drop.Status.Allowed); |
| 46 | + Assert.Single(blocked.Blocked); |
| 47 | + |
| 48 | + var allowed = SchemaDiff.Diff(Empty, actual, new AllowOptions(DropTable: true)); |
| 49 | + Assert.True(allowed.Changes.Single().Status.Allowed); |
| 50 | + } |
| 51 | + |
| 52 | + [Fact] |
| 53 | + public void Added_column_is_allowed() |
| 54 | + { |
| 55 | + var expected = Snap(Table("t", [Col("id", new SqlType.Integer(64)), Col("email", new SqlType.Text(255))])); |
| 56 | + var actual = Snap(Table("t", [Col("id", new SqlType.Integer(64))])); |
| 57 | + |
| 58 | + var add = Assert.IsType<Change.AddColumn>(Assert.Single(SchemaDiff.Diff(expected, actual).Changes)); |
| 59 | + Assert.Equal("email", add.Column.Name); |
| 60 | + Assert.True(add.Status.Allowed); |
| 61 | + } |
| 62 | + |
| 63 | + [Fact] |
| 64 | + public void Dropped_column_blocked_unless_allowed() |
| 65 | + { |
| 66 | + var expected = Snap(Table("t", [Col("id", new SqlType.Integer(64))])); |
| 67 | + var actual = Snap(Table("t", [Col("id", new SqlType.Integer(64)), Col("stale", new SqlType.Text())])); |
| 68 | + |
| 69 | + Assert.False(SchemaDiff.Diff(expected, actual).Changes.Single().Status.Allowed); |
| 70 | + Assert.True(SchemaDiff.Diff(expected, actual, new AllowOptions(DropColumn: true)).Changes.Single().Status.Allowed); |
| 71 | + } |
| 72 | + |
| 73 | + [Fact] |
| 74 | + public void Type_change_widening_allowed_narrowing_blocked() |
| 75 | + { |
| 76 | + var widen = SchemaDiff.Diff( |
| 77 | + Snap(Table("t", [Col("n", new SqlType.Integer(64))])), |
| 78 | + Snap(Table("t", [Col("n", new SqlType.Integer(32))]))); |
| 79 | + var wc = Assert.IsType<Change.ChangeColumnType>(Assert.Single(widen.Changes)); |
| 80 | + Assert.True(wc.Status.Allowed); |
| 81 | + Assert.Equal(new SqlType.Integer(32), wc.From); |
| 82 | + Assert.Equal(new SqlType.Integer(64), wc.To); |
| 83 | + |
| 84 | + var narrow = SchemaDiff.Diff( |
| 85 | + Snap(Table("t", [Col("n", new SqlType.Integer(32))])), |
| 86 | + Snap(Table("t", [Col("n", new SqlType.Integer(64))]))); |
| 87 | + Assert.False(narrow.Changes.Single().Status.Allowed); |
| 88 | + Assert.True(SchemaDiff.Diff( |
| 89 | + Snap(Table("t", [Col("n", new SqlType.Integer(32))])), |
| 90 | + Snap(Table("t", [Col("n", new SqlType.Integer(64))])), |
| 91 | + new AllowOptions(TypeChange: true)).Changes.Single().Status.Allowed); |
| 92 | + } |
| 93 | + |
| 94 | + [Fact] |
| 95 | + public void Nullable_transitions_classified() |
| 96 | + { |
| 97 | + // notnull (actual) -> nullable (expected): allowed. |
| 98 | + var relax = SchemaDiff.Diff( |
| 99 | + Snap(Table("t", [Col("c", new SqlType.Text(), nullable: true)])), |
| 100 | + Snap(Table("t", [Col("c", new SqlType.Text(), nullable: false)]))); |
| 101 | + var rc = Assert.IsType<Change.ChangeColumnNullable>(Assert.Single(relax.Changes)); |
| 102 | + Assert.True(rc.Status.Allowed); |
| 103 | + Assert.False(rc.From); |
| 104 | + Assert.True(rc.To); |
| 105 | + |
| 106 | + // nullable (actual) -> notnull (expected): blocked unless allowed. |
| 107 | + var tighten = SchemaDiff.Diff( |
| 108 | + Snap(Table("t", [Col("c", new SqlType.Text(), nullable: false)])), |
| 109 | + Snap(Table("t", [Col("c", new SqlType.Text(), nullable: true)]))); |
| 110 | + Assert.False(tighten.Changes.Single().Status.Allowed); |
| 111 | + Assert.True(SchemaDiff.Diff( |
| 112 | + Snap(Table("t", [Col("c", new SqlType.Text(), nullable: false)])), |
| 113 | + Snap(Table("t", [Col("c", new SqlType.Text(), nullable: true)])), |
| 114 | + new AllowOptions(NullableToNotNull: true)).Changes.Single().Status.Allowed); |
| 115 | + } |
| 116 | + |
| 117 | + [Fact] |
| 118 | + public void Default_change_detected() |
| 119 | + { |
| 120 | + var expected = Snap(Table("t", [Col("c", new SqlType.Integer(32), def: new ColumnDefault(DefaultKind.Literal, "0"))])); |
| 121 | + var actual = Snap(Table("t", [Col("c", new SqlType.Integer(32))])); |
| 122 | + var ch = Assert.IsType<Change.ChangeColumnDefault>(Assert.Single(SchemaDiff.Diff(expected, actual).Changes)); |
| 123 | + Assert.Null(ch.From); |
| 124 | + Assert.Equal(new ColumnDefault(DefaultKind.Literal, "0"), ch.To); |
| 125 | + } |
| 126 | + |
| 127 | + [Fact] |
| 128 | + public void Index_shape_change_emits_drop_then_add() |
| 129 | + { |
| 130 | + var expected = Snap(Table("t", [Col("a", new SqlType.Text())], idx: [new IndexDescriptor("ix", ["a"], true)])); |
| 131 | + var actual = Snap(Table("t", [Col("a", new SqlType.Text())], idx: [new IndexDescriptor("ix", ["a"], false)])); |
| 132 | + var changes = SchemaDiff.Diff(expected, actual, new AllowOptions(DropIndex: true)).Changes; |
| 133 | + Assert.Collection(changes, |
| 134 | + c => Assert.IsType<Change.DropIndex>(c), |
| 135 | + c => Assert.IsType<Change.AddIndex>(c)); |
| 136 | + } |
| 137 | + |
| 138 | + [Fact] |
| 139 | + public void Fk_change_emits_drop_then_add() |
| 140 | + { |
| 141 | + var expected = Snap(Table("t", [Col("x", new SqlType.Integer(64))], |
| 142 | + fks: [new FkDescriptor("fk", ["x"], "other", ["id"], OnDelete: FkAction.Cascade)])); |
| 143 | + var actual = Snap(Table("t", [Col("x", new SqlType.Integer(64))], |
| 144 | + fks: [new FkDescriptor("fk", ["x"], "other", ["id"])])); |
| 145 | + var changes = SchemaDiff.Diff(expected, actual, new AllowOptions(DropFk: true)).Changes; |
| 146 | + Assert.Collection(changes, |
| 147 | + c => Assert.IsType<Change.DropFk>(c), |
| 148 | + c => Assert.IsType<Change.AddFk>(c)); |
| 149 | + } |
| 150 | + |
| 151 | + [Fact] |
| 152 | + public void Identical_snapshots_produce_no_changes() |
| 153 | + { |
| 154 | + var t = Table("t", [Col("id", new SqlType.Integer(64), nullable: false)], pk: ["id"], |
| 155 | + idx: [new IndexDescriptor("ix", ["id"], true)], |
| 156 | + fks: [new FkDescriptor("fk", ["id"], "o", ["id"])]); |
| 157 | + Assert.Empty(SchemaDiff.Diff(Snap(t), Snap(t)).Changes); |
| 158 | + } |
| 159 | + |
| 160 | + [Fact] |
| 161 | + public void Ignored_tables_are_excluded_from_both_sides() |
| 162 | + { |
| 163 | + var expected = Snap(Table("posts", [Col("id", new SqlType.Integer(64))])); |
| 164 | + var actual = Snap(Table("__EFMigrationsHistory", [Col("MigrationId", new SqlType.Text())])); |
| 165 | + var changes = SchemaDiff.Diff(expected, actual, ignoreTables: ["__EFMigrationsHistory", "_litestream_*"]).Changes; |
| 166 | + // Only the create-table for posts; the history table is neither created nor dropped. |
| 167 | + Assert.IsType<Change.CreateTable>(Assert.Single(changes)); |
| 168 | + } |
| 169 | +} |
0 commit comments