Skip to content

Commit 461986a

Browse files
dmealingclaude
andcommitted
merge: C# incremental-migration engine — SqlType + diff + emit [FR-003]
The pure migration engine (no live DB yet): canonical SqlType + IsWidening, the SchemaSnapshot/Change model, SchemaDiff (classified expected-vs-actual diff with destructive/lossy gating), and PostgresEmit (staged up/down SQL). Ports migrate-ts; rename heuristic + introspection deferred. Reviewed + simplified pre-merge. Full C# solution green (308 tests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2 parents be48ccb + 822ac9a commit 461986a

8 files changed

Lines changed: 902 additions & 0 deletions

File tree

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
using MetaObjects.Codegen.Migrate;
2+
using Xunit;
3+
4+
namespace MetaObjects.Codegen.Tests.Migrate;
5+
6+
public class PostgresEmitTests
7+
{
8+
private static string Up(params Change[] changes) => PostgresEmit.Render(changes).Up;
9+
private static string Down(params Change[] changes) => PostgresEmit.Render(changes).Down;
10+
11+
[Fact]
12+
public void Create_table_quotes_columns_pk_identity_and_types()
13+
{
14+
var t = new TableDescriptor("posts",
15+
[
16+
new ColumnDescriptor("id", new SqlType.Integer(64), Nullable: false, Identity: IdentityKind.Increment),
17+
new ColumnDescriptor("title", new SqlType.Text(200), Nullable: false),
18+
new ColumnDescriptor("score", new SqlType.Numeric(10, 2), Nullable: true,
19+
Default: new ColumnDefault(DefaultKind.Literal, "0")),
20+
],
21+
Indexes: [], ForeignKeys: [], PrimaryKey: ["id"]);
22+
23+
var sql = Up(new Change.CreateTable(t));
24+
25+
Assert.Contains("CREATE TABLE \"posts\" (", sql);
26+
Assert.Contains("\"id\" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL", sql);
27+
Assert.Contains("\"title\" VARCHAR(200) NOT NULL", sql);
28+
Assert.Contains("\"score\" NUMERIC(10,2) DEFAULT '0'", sql);
29+
Assert.Contains("CONSTRAINT \"posts_pkey\" PRIMARY KEY (\"id\")", sql);
30+
}
31+
32+
[Fact]
33+
public void Uuid_identity_uses_gen_random_uuid_and_no_duplicate_default()
34+
{
35+
var t = new TableDescriptor("u",
36+
[new ColumnDescriptor("id", new SqlType.Uuid(), Nullable: false, Identity: IdentityKind.Uuid,
37+
Default: new ColumnDefault(DefaultKind.Expr, "gen_random_uuid()"))],
38+
[], [], ["id"]);
39+
var sql = Up(new Change.CreateTable(t));
40+
Assert.Contains("\"id\" UUID DEFAULT gen_random_uuid() NOT NULL", sql);
41+
// Only one DEFAULT clause (the identity one); not duplicated from the default attr.
42+
Assert.Equal(1, sql.Split("DEFAULT").Length - 1);
43+
}
44+
45+
[Fact]
46+
public void Add_and_drop_column_up_and_down()
47+
{
48+
var add = new Change.AddColumn("t", new ColumnDescriptor("email", new SqlType.Text(255), Nullable: false));
49+
Assert.Equal("ALTER TABLE \"t\" ADD COLUMN \"email\" VARCHAR(255) NOT NULL;", Up(add));
50+
Assert.Equal("ALTER TABLE \"t\" DROP COLUMN \"email\";", Down(add));
51+
52+
var drop = new Change.DropColumn("t", "stale");
53+
Assert.Equal("ALTER TABLE \"t\" DROP COLUMN \"stale\";", Up(drop));
54+
Assert.Contains("WARNING: down migration cannot restore data", Down(drop));
55+
}
56+
57+
[Fact]
58+
public void Change_type_nullable_and_default()
59+
{
60+
Assert.Equal("ALTER TABLE \"t\" ALTER COLUMN \"n\" TYPE BIGINT;",
61+
Up(new Change.ChangeColumnType("t", "n", new SqlType.Integer(32), new SqlType.Integer(64))));
62+
63+
Assert.Equal("ALTER TABLE \"t\" ALTER COLUMN \"c\" SET NOT NULL;",
64+
Up(new Change.ChangeColumnNullable("t", "c", From: true, To: false)));
65+
Assert.Equal("ALTER TABLE \"t\" ALTER COLUMN \"c\" DROP NOT NULL;",
66+
Up(new Change.ChangeColumnNullable("t", "c", From: false, To: true)));
67+
68+
Assert.Equal("ALTER TABLE \"t\" ALTER COLUMN \"c\" SET DEFAULT 'x';",
69+
Up(new Change.ChangeColumnDefault("t", "c", From: null, To: new ColumnDefault(DefaultKind.Literal, "x"))));
70+
Assert.Equal("ALTER TABLE \"t\" ALTER COLUMN \"c\" DROP DEFAULT;",
71+
Up(new Change.ChangeColumnDefault("t", "c", From: new ColumnDefault(DefaultKind.Literal, "x"), To: null)));
72+
}
73+
74+
[Fact]
75+
public void Index_and_fk_statements()
76+
{
77+
Assert.Equal("CREATE UNIQUE INDEX \"ix\" ON \"t\" (\"a\", \"b\");",
78+
Up(new Change.AddIndex("t", new IndexDescriptor("ix", ["a", "b"], true))));
79+
Assert.Equal("DROP INDEX \"ix\";", Up(new Change.DropIndex("t", "ix")));
80+
81+
var fk = new Change.AddFk("t", new FkDescriptor("t_x_fk", ["x"], "other", ["id"], OnDelete: FkAction.Cascade));
82+
Assert.Equal(
83+
"ALTER TABLE \"t\" ADD CONSTRAINT \"t_x_fk\" FOREIGN KEY (\"x\") REFERENCES \"other\" (\"id\") ON DELETE CASCADE;",
84+
Up(fk));
85+
Assert.Equal("ALTER TABLE \"t\" DROP CONSTRAINT \"t_x_fk\";", Up(new Change.DropFk("t", "t_x_fk")));
86+
}
87+
88+
[Fact]
89+
public void Non_default_schema_is_qualified()
90+
{
91+
var sql = Up(new Change.AddColumn("t", new ColumnDescriptor("c", new SqlType.Text(), Nullable: true), Schema: "billing"));
92+
Assert.Contains("ALTER TABLE \"billing\".\"t\" ADD COLUMN", sql);
93+
// public / null are never qualified.
94+
Assert.DoesNotContain("\"public\".", Up(new Change.DropColumn("t", "c", Schema: "public")));
95+
}
96+
97+
[Fact]
98+
public void Changes_are_staged_and_down_is_reversed()
99+
{
100+
// Deliberately out of stage order on input.
101+
var dropTable = new Change.DropTable("old");
102+
var createTable = new Change.CreateTable(new TableDescriptor("new",
103+
[new ColumnDescriptor("id", new SqlType.Integer(64), Nullable: false)], [], [], ["id"]));
104+
var addFk = new Change.AddFk("new", new FkDescriptor("fk", ["id"], "old", ["id"]));
105+
106+
var up = Up(dropTable, addFk, createTable);
107+
var idxCreate = up.IndexOf("CREATE TABLE", System.StringComparison.Ordinal);
108+
var idxFk = up.IndexOf("ADD CONSTRAINT", System.StringComparison.Ordinal);
109+
var idxDrop = up.IndexOf("DROP TABLE", System.StringComparison.Ordinal);
110+
Assert.True(idxCreate < idxFk && idxFk < idxDrop, "stages: create < add-fk < drop-table");
111+
112+
// Down reverses: the create-table's down (DROP TABLE "new") comes last.
113+
var down = Down(createTable, addFk);
114+
Assert.True(
115+
down.IndexOf("DROP CONSTRAINT", System.StringComparison.Ordinal) <
116+
down.IndexOf("DROP TABLE \"new\"", System.StringComparison.Ordinal),
117+
"down reversed: drop-constraint before drop-table");
118+
}
119+
120+
[Fact]
121+
public void PgType_renders_each_kind()
122+
{
123+
string T(SqlType t) => Up(new Change.AddColumn("t", new ColumnDescriptor("c", t, Nullable: true)));
124+
Assert.Contains("TEXT;", T(new SqlType.Text()));
125+
Assert.Contains("VARCHAR(50)", T(new SqlType.Text(50)));
126+
Assert.Contains("INTEGER;", T(new SqlType.Integer(32)));
127+
Assert.Contains("DOUBLE PRECISION", T(new SqlType.Real()));
128+
Assert.Contains("NUMERIC(10,2)", T(new SqlType.Numeric(10, 2)));
129+
Assert.Contains("NUMERIC(8)", T(new SqlType.Numeric(8)));
130+
Assert.Contains("NUMERIC;", T(new SqlType.Numeric()));
131+
Assert.Contains("BOOLEAN", T(new SqlType.Boolean()));
132+
Assert.Contains("TIMESTAMPTZ", T(new SqlType.Timestamp(true)));
133+
Assert.Contains("TIMESTAMP;", T(new SqlType.Timestamp(false)));
134+
Assert.Contains("DATE", T(new SqlType.Date()));
135+
Assert.Contains("JSONB", T(new SqlType.Json()));
136+
Assert.Contains("BYTEA", T(new SqlType.Blob()));
137+
Assert.Contains("UUID", T(new SqlType.Uuid()));
138+
}
139+
140+
[Fact]
141+
public void Literal_default_escapes_single_quotes()
142+
{
143+
var sql = Up(new Change.ChangeColumnDefault("t", "c", From: null,
144+
To: new ColumnDefault(DefaultKind.Literal, "O'Brien")));
145+
Assert.Contains("SET DEFAULT 'O''Brien';", sql);
146+
}
147+
}
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
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+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
using MetaObjects.Codegen.Migrate;
2+
using Xunit;
3+
4+
namespace MetaObjects.Codegen.Tests.Migrate;
5+
6+
public class SqlTypeTests
7+
{
8+
[Fact]
9+
public void Record_equality_is_structural()
10+
{
11+
Assert.Equal(new SqlType.Text(10), new SqlType.Text(10));
12+
Assert.NotEqual<SqlType>(new SqlType.Text(10), new SqlType.Text(20));
13+
Assert.NotEqual<SqlType>(new SqlType.Text(), new SqlType.Text(10));
14+
Assert.Equal(new SqlType.Integer(64), new SqlType.Integer(64));
15+
Assert.NotEqual<SqlType>(new SqlType.Integer(32), new SqlType.Integer(64));
16+
Assert.Equal<SqlType>(new SqlType.Real(), new SqlType.Real());
17+
Assert.NotEqual<SqlType>(new SqlType.Timestamp(true), new SqlType.Timestamp(false));
18+
Assert.NotEqual<SqlType>(new SqlType.Integer(32), new SqlType.Real());
19+
}
20+
21+
[Fact]
22+
public void Identical_types_are_not_widening()
23+
{
24+
Assert.False(SqlType.IsWidening(new SqlType.Integer(32), new SqlType.Integer(32)));
25+
Assert.False(SqlType.IsWidening(new SqlType.Text(10), new SqlType.Text(10)));
26+
}
27+
28+
[Fact]
29+
public void Text_widening_rules()
30+
{
31+
Assert.False(SqlType.IsWidening(new SqlType.Text(), new SqlType.Text(10))); // unbounded→bounded: lossy
32+
Assert.True(SqlType.IsWidening(new SqlType.Text(10), new SqlType.Text())); // bounded→unbounded: widening
33+
Assert.True(SqlType.IsWidening(new SqlType.Text(10), new SqlType.Text(20))); // grow: widening
34+
Assert.False(SqlType.IsWidening(new SqlType.Text(20), new SqlType.Text(10))); // shrink: lossy
35+
}
36+
37+
[Fact]
38+
public void Integer_widening_rules()
39+
{
40+
Assert.True(SqlType.IsWidening(new SqlType.Integer(32), new SqlType.Integer(64)));
41+
Assert.False(SqlType.IsWidening(new SqlType.Integer(64), new SqlType.Integer(32)));
42+
}
43+
44+
[Fact]
45+
public void Numeric_widening_requires_same_scale_and_nondecreasing_integer_digits()
46+
{
47+
Assert.True(SqlType.IsWidening(new SqlType.Numeric(10, 2), new SqlType.Numeric(12, 2)));
48+
Assert.False(SqlType.IsWidening(new SqlType.Numeric(10, 2), new SqlType.Numeric(10, 3))); // scale changed
49+
Assert.False(SqlType.IsWidening(new SqlType.Numeric(12, 2), new SqlType.Numeric(10, 2))); // precision shrank
50+
}
51+
52+
[Fact]
53+
public void Cross_kind_change_is_never_widening()
54+
{
55+
Assert.False(SqlType.IsWidening(new SqlType.Integer(32), new SqlType.Text()));
56+
Assert.False(SqlType.IsWidening(new SqlType.Numeric(10, 2), new SqlType.Real()));
57+
}
58+
}

0 commit comments

Comments
 (0)