Skip to content

Commit 2b8b0ff

Browse files
dmealingclaude
andcommitted
merge: C# incremental-migration #2 — ExpectedSchema (metadata→snapshot) [FR-003]
Bridges the source-v2 metadata layer to the migration engine: SchemaDiff can now compare a metadata-derived snapshot against an introspected one. Source-v2 aware (primary writable source.rdb supplies @table/@Schema; fields use @column; FK referential actions threaded via ReferentialActions). Pre-merge code-review caught one CRITICAL (enum drift), two MAJOR (isArray drift, FKs to non-writable targets), and one MINOR (flattened-unresolvable fall-through); all fixed with new tests. Simplifier consolidated FindPrimaryWritableSource ?? Name onto CSharpNaming.Table. Full C# solution green (462 tests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2 parents 7a8c80e + 987a7a8 commit 2b8b0ff

2 files changed

Lines changed: 460 additions & 0 deletions

File tree

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
using MetaObjects.Codegen.Migrate;
2+
using MetaObjects.Loader;
3+
using MetaObjects.Meta;
4+
using Xunit;
5+
6+
namespace MetaObjects.Codegen.Tests.Migrate;
7+
8+
// Source-v2 (ADR-0007) metadata → SchemaSnapshot. The model below intentionally
9+
// exercises: primary writable source.rdb, schema-qualified source, @column override,
10+
// composite PK in @fields order, secondary unique identity, identity.reference with
11+
// @enforce true (FK emitted, OnDelete from the matching aggregation relationship)
12+
// and @enforce false (FK suppressed), a pure projection (read-only source — should
13+
// be skipped), and @storage flattened + jsonb object fields.
14+
public class ExpectedSchemaTests
15+
{
16+
private const string Model = """
17+
{ "metadata.root": { "package": "acme", "children": [
18+
{ "object.value": { "name": "Address", "children": [
19+
{ "field.string": { "name": "street", "@required": true, "@maxLength": 120 } },
20+
{ "field.string": { "name": "city", "@maxLength": 80 } }
21+
]}},
22+
{ "object.entity": { "name": "Program", "children": [
23+
{ "source.rdb": { "@table": "programs", "@schema": "billing" } },
24+
{ "field.long": { "name": "id" } },
25+
{ "field.string": { "name": "title", "@required": true, "@maxLength": 200 } },
26+
{ "field.currency": { "name": "priceCents" } },
27+
{ "field.enum": { "name": "status", "@values": ["draft", "active", "archived"] } },
28+
{ "field.string": { "name": "tags", "isArray": true } },
29+
{ "field.object": { "name": "homeAddress", "@objectRef": "Address", "@storage": "flattened" } },
30+
{ "field.object": { "name": "config", "@objectRef": "Address" } },
31+
{ "relationship.aggregation": { "name": "weeks", "@objectRef": "Week", "@cardinality": "many" } },
32+
{ "identity.primary": { "@fields": "id", "@generation": "increment" } },
33+
{ "identity.secondary": { "name": "byTitle", "@fields": "title", "@unique": true } }
34+
]}},
35+
{ "object.entity": { "name": "Bad", "children": [
36+
{ "source.rdb": { "@table": "bad" } },
37+
{ "field.long": { "name": "id" } },
38+
{ "field.long": { "name": "addressId" } },
39+
{ "identity.primary": { "@fields": "id", "@generation": "increment" } },
40+
{ "identity.reference": { "name": "fkAddress", "@fields": "addressId", "@references": "Address" } }
41+
]}},
42+
{ "object.entity": { "name": "Week", "children": [
43+
{ "source.rdb": { "@table": "weeks" } },
44+
{ "field.long": { "name": "id" } },
45+
{ "field.long": { "name": "programId" } },
46+
{ "field.string": { "name": "label", "@column": "week_label" } },
47+
{ "identity.primary": { "@fields": "id", "@generation": "increment" } },
48+
{ "identity.reference": { "name": "fkProgram", "@fields": "programId", "@references": "Program" } },
49+
{ "relationship.composition": { "name": "program", "@objectRef": "Program", "@cardinality": "one" } }
50+
]}},
51+
{ "object.entity": { "name": "Tag", "children": [
52+
{ "source.rdb": { "@table": "tags" } },
53+
{ "field.long": { "name": "id" } },
54+
{ "field.long": { "name": "programId" } },
55+
{ "identity.primary": { "@fields": "id", "@generation": "increment" } },
56+
{ "identity.reference": { "name": "fkProgramLogical", "@fields": "programId", "@references": "Program", "@enforce": false } }
57+
]}},
58+
{ "object.entity": { "name": "Link", "children": [
59+
{ "source.rdb": { "@table": "links" } },
60+
{ "field.long": { "name": "a" } },
61+
{ "field.long": { "name": "b" } },
62+
{ "identity.primary": { "@fields": ["b", "a"] } }
63+
]}},
64+
{ "object.value": { "name": "ProgramView", "children": [
65+
{ "source.rdb": { "@kind": "view", "@table": "v_program" } },
66+
{ "field.long": { "name": "id", "children": [ { "origin.passthrough": { "@from": "Program.id" } } ] } },
67+
{ "field.string": { "name": "title", "children": [ { "origin.passthrough": { "@from": "Program.title" } } ] } }
68+
]}}
69+
]}}
70+
""";
71+
72+
private static SchemaSnapshot Load()
73+
{
74+
var r = new MetaDataLoader().Load([new InMemorySource(Model, id: "exp.json")]);
75+
Assert.Empty(r.Errors);
76+
return ExpectedSchema.Build(r.Root);
77+
}
78+
79+
[Fact]
80+
public void Skips_value_objects_and_pure_projections()
81+
{
82+
var snap = Load();
83+
// Address (value), ProgramView (read-only projection — only a read-only source).
84+
Assert.DoesNotContain(snap.Tables, t => t.Name is "Address" or "v_program");
85+
// Only writable entities — alphabetical by metadata name: Bad, Link, Program, Tag, Week.
86+
Assert.Equal(["bad", "links", "programs", "tags", "weeks"], snap.Tables.Select(t => t.Name).ToArray());
87+
}
88+
89+
[Fact]
90+
public void Source_table_and_schema_threaded_through()
91+
{
92+
var program = Load().Tables.Single(t => t.Name == "programs");
93+
Assert.Equal("billing", program.Schema); // from source.rdb @schema
94+
var week = Load().Tables.Single(t => t.Name == "weeks");
95+
Assert.Null(week.Schema); // no @schema → null (engine treats as "public")
96+
}
97+
98+
[Fact]
99+
public void Pk_column_is_notnull_with_identity_and_in_declared_field_order()
100+
{
101+
var link = Load().Tables.Single(t => t.Name == "links");
102+
// Composite PK declared as @fields ["b","a"] — must preserve that order.
103+
Assert.Equal(["b", "a"], link.PrimaryKey.ToArray());
104+
var program = Load().Tables.Single(t => t.Name == "programs");
105+
var idCol = program.Columns.Single(c => c.Name == "id");
106+
Assert.False(idCol.Nullable);
107+
Assert.Equal(IdentityKind.Increment, idCol.Identity);
108+
}
109+
110+
[Fact]
111+
public void Column_override_uses_at_column_attr()
112+
{
113+
var week = Load().Tables.Single(t => t.Name == "weeks");
114+
Assert.Contains(week.Columns, c => c.Name == "week_label"); // override applied
115+
Assert.DoesNotContain(week.Columns, c => c.Name == "label"); // raw name suppressed
116+
}
117+
118+
[Fact]
119+
public void Scalar_types_map_to_canonical_sqltypes()
120+
{
121+
var program = Load().Tables.Single(t => t.Name == "programs");
122+
Assert.Equal(new SqlType.Text(200), program.Columns.Single(c => c.Name == "title").SqlType);
123+
// currency → integer minor units (bits=64), per the cross-language wire contract.
124+
Assert.Equal(new SqlType.Integer(64), program.Columns.Single(c => c.Name == "priceCents").SqlType);
125+
Assert.Equal(new SqlType.Integer(64), program.Columns.Single(c => c.Name == "id").SqlType);
126+
}
127+
128+
[Fact]
129+
public void Secondary_identity_becomes_unique_index_with_bare_identity_name()
130+
{
131+
var program = Load().Tables.Single(t => t.Name == "programs");
132+
var idx = Assert.Single(program.Indexes);
133+
Assert.Equal("byTitle", idx.Name); // bare identity name (matches TS canonical; not "{table}_{name}_uniq")
134+
Assert.True(idx.Unique);
135+
Assert.Equal(["title"], idx.Columns.ToArray());
136+
}
137+
138+
[Fact]
139+
public void Enforced_reference_emits_fk_with_referential_actions_from_relationship()
140+
{
141+
var week = Load().Tables.Single(t => t.Name == "weeks");
142+
var fk = Assert.Single(week.ForeignKeys);
143+
Assert.Equal("weeks_programId_fk", fk.Name);
144+
Assert.Equal(["programId"], fk.Columns.ToArray());
145+
Assert.Equal("programs", fk.RefTable);
146+
Assert.Equal(["id"], fk.RefColumns.ToArray()); // bare @references "Program" → target PK
147+
// Week.program is a composition; per-subtype default is cascade for both.
148+
Assert.Equal(FkAction.Cascade, fk.OnDelete);
149+
Assert.Equal(FkAction.Cascade, fk.OnUpdate);
150+
}
151+
152+
[Fact]
153+
public void Logical_only_reference_emits_no_fk()
154+
{
155+
var tag = Load().Tables.Single(t => t.Name == "tags");
156+
Assert.Empty(tag.ForeignKeys); // @enforce: false → no FK
157+
}
158+
159+
[Fact]
160+
public void Storage_flattened_expands_to_prefixed_columns()
161+
{
162+
var program = Load().Tables.Single(t => t.Name == "programs");
163+
Assert.Contains(program.Columns, c => c.Name == "homeAddress_street" && c.SqlType is SqlType.Text { MaxLength: 120 } && !c.Nullable);
164+
Assert.Contains(program.Columns, c => c.Name == "homeAddress_city" && c.SqlType is SqlType.Text { MaxLength: 80 } && c.Nullable);
165+
// The parent object field itself contributes no column.
166+
Assert.DoesNotContain(program.Columns, c => c.Name == "homeAddress");
167+
}
168+
169+
[Fact]
170+
public void Storage_absent_collapses_to_single_json_column()
171+
{
172+
var program = Load().Tables.Single(t => t.Name == "programs");
173+
var config = program.Columns.Single(c => c.Name == "config");
174+
Assert.IsType<SqlType.Json>(config.SqlType);
175+
Assert.True(config.Nullable); // field is optional
176+
}
177+
178+
[Fact]
179+
public void Snapshot_feeds_diff_engine_for_an_empty_actual_db()
180+
{
181+
// Round-trip sanity: an empty actual DB → diff produces a create-table per
182+
// expected table, each with its indexes and FKs.
183+
var expected = Load();
184+
var result = SchemaDiff.Diff(expected, new SchemaSnapshot([]));
185+
Assert.Empty(result.Blocked);
186+
var createTables = result.Changes.OfType<Change.CreateTable>()
187+
.Select(c => c.Table.Name).OrderBy(n => n, StringComparer.Ordinal).ToArray();
188+
Assert.Equal(["bad", "links", "programs", "tags", "weeks"], createTables);
189+
// The single secondary index + the single enforced FK that survives the
190+
// writable-target gate (Week→Program) arrive as separate change records.
191+
Assert.Single(result.Changes.OfType<Change.AddIndex>());
192+
Assert.Single(result.Changes.OfType<Change.AddFk>());
193+
}
194+
195+
[Fact]
196+
public void Enum_field_emits_text_column_matching_PostgresSchema()
197+
{
198+
// PostgresSchema.PgType maps FIELD_SUBTYPE_ENUM -> "text"; the snapshot must agree,
199+
// else a post-gen migrate would propose a destructive drop of the actual column.
200+
var program = Load().Tables.Single(t => t.Name == "programs");
201+
var status = program.Columns.Single(c => c.Name == "status");
202+
Assert.IsType<SqlType.Text>(status.SqlType);
203+
}
204+
205+
[Fact]
206+
public void IsArray_scalar_field_collapses_to_json_column()
207+
{
208+
// PostgresSchema renders f.IsArray as "jsonb" regardless of underlying subtype.
209+
var program = Load().Tables.Single(t => t.Name == "programs");
210+
var tags = program.Columns.Single(c => c.Name == "tags");
211+
Assert.IsType<SqlType.Json>(tags.SqlType);
212+
}
213+
214+
[Fact]
215+
public void Fk_targeting_non_writable_entity_is_dropped()
216+
{
217+
var snap = Load();
218+
var bad = snap.Tables.Single(t => t.Name == "bad");
219+
// Bad declares identity.reference @references "Address" (a value object). Address
220+
// isn't a writable entity, so the FK would dangle in REFERENCES — drop it instead.
221+
Assert.Empty(bad.ForeignKeys);
222+
// The FK column itself is still emitted (it's a real metadata field); only the
223+
// physical constraint is suppressed.
224+
Assert.Contains(bad.Columns, c => c.Name == "addressId");
225+
}
226+
}

0 commit comments

Comments
 (0)