Skip to content

Commit 07460fb

Browse files
dmealingclaude
andcommitted
feat(csharp-codegen): EntityGenerator round-2 hooks + fixes + native @default
Round-2 extensibility (builds on the round-1 EmitClassHeader/EmitPropertyAttributes hooks), all default-output byte-identical (drift gate green): 1. Per-property emit helpers (ScalarProperty/EnumProperty/CollectEnumDecls/ validator-appenders/Tph*/ReferencedValueObjects/M2mNavProperty/…) promoted private→protected so a subclass can reuse them without copying. 2. EmitFileUsings hook (empty default) — inject extra usings, wired into all four Emit*Class paths. 3. EmitClassBodyTrailer hook (empty default) — emit members before the closing brace, wired into all four paths. 5. EmitClassDeclarationLine seam — the `public [abstract] class <Name>[ : Base]` line for ALL four class kinds (mapped/TPH-subtype/abstract-shape/value-object) routes through one overridable method, so a `partial`/marker-interface override applies uniformly; EF [Table]/[PrimaryKey] stay mapped-entity-only. Two bug fixes: 4. object-typed field + isArray → `ICollection<T> = new List<T>()` (was a single nullable ref). 6. seed value-object POCO emission from `source.rdb @parameterRef` so an FR-015 callable's args VO is emitted (the wrapper wouldn't compile otherwise). Native @default (item A): a literal-safe @default (bool / integer / floating / string) emits a property initializer (`= false;`, `= 3;`, `= "dark";`, `= 2.50m;`) with the right C# suffix, taking precedence over `= default!;`. @default is a cross-port metadata concept (TS/Java/Kotlin/Python mirror is follow-up). 6 new EntityGeneratorRound2Tests; full MetaObjects.Codegen.Tests 177/0 (no Docker, drift gate green). (Recovered + completed from a dropped implementation agent.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent dbf2b26 commit 07460fb

2 files changed

Lines changed: 380 additions & 26 deletions

File tree

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
// EntityGeneratorRound2Tests — round-2 extensibility hooks + two bug fixes + native
2+
// @default initializers (open-closed, ADR-0002). Each test proves an adopter seam
3+
// works (override changes output) OR a fix emits the right shape; the base
4+
// (unsubclassed) generator stays byte-stable — the IntegrationFixture drift gate is
5+
// the strong enforcer of that, these lock the new behavior.
6+
7+
using System.Linq;
8+
using System.Text;
9+
using MetaObjects.Codegen;
10+
using MetaObjects.Codegen.Generators;
11+
using MetaObjects.Loader;
12+
using MetaObjects.Meta;
13+
using Xunit;
14+
15+
namespace MetaObjects.Codegen.Tests;
16+
17+
public class EntityGeneratorRound2Tests
18+
{
19+
private static MetaRoot Load(string model)
20+
{
21+
var r = new MetaDataLoader().Load([new InMemoryStringSource(model, id: "r2.json")]);
22+
Assert.Empty(r.Errors);
23+
return r.Root;
24+
}
25+
26+
private static GenContext Ctx(MetaRoot root, bool abstractShapes = false) => new()
27+
{
28+
Entities = root.Objects(),
29+
Root = root,
30+
Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated", EmitAbstractShapes = abstractShapes },
31+
};
32+
33+
private static string File(System.Collections.Generic.IEnumerable<EmittedFile> files, string path) =>
34+
files.Single(f => f.Path == path).Content;
35+
36+
// ----------------------------------------------------------------- items 2 + 3
37+
38+
// Subclass overriding the two new round-2 hooks: an extra using + a class-body trailer.
39+
private sealed class HookedGenerator : EntityGenerator
40+
{
41+
protected override void EmitFileUsings(StringBuilder sb, MetaObject entity, GenContext ctx) =>
42+
sb.AppendLine("using Acme.Adopter;");
43+
44+
protected override void EmitClassBodyTrailer(StringBuilder sb, MetaObject entity, GenContext ctx) =>
45+
sb.AppendLine(" public int Marker() => 42;");
46+
}
47+
48+
private const string SimpleModel = """
49+
{ "metadata.root": { "package": "acme", "children": [
50+
{ "object.entity": { "name": "Subscriber", "children": [
51+
{ "source.rdb": { "@table": "subscribers" } },
52+
{ "field.long": { "name": "id" } },
53+
{ "field.string": { "name": "email", "@required": true } },
54+
{ "identity.primary": { "@fields": "id" } }
55+
]}}
56+
]}}
57+
""";
58+
59+
[Fact]
60+
public void EmitFileUsings_and_BodyTrailer_hooks_apply_and_base_is_unchanged()
61+
{
62+
var root = Load(SimpleModel);
63+
var hooked = File(new HookedGenerator().Generate(Ctx(root)), "Subscriber.g.cs");
64+
Assert.Contains("using Acme.Adopter;", hooked);
65+
Assert.Contains("public int Marker() => 42;", hooked);
66+
// The trailer lands inside the class body, before the closing brace.
67+
Assert.True(hooked.IndexOf("Marker()") < hooked.LastIndexOf("}"));
68+
69+
var baseline = File(new EntityGenerator().Generate(Ctx(root)), "Subscriber.g.cs");
70+
Assert.DoesNotContain("Acme.Adopter", baseline);
71+
Assert.DoesNotContain("Marker()", baseline);
72+
}
73+
74+
// ----------------------------------------------------------------- item 5
75+
76+
// Subclass overriding the single declaration-line seam — a marker comment must show
77+
// up on ALL emitted class kinds (mapped entity, abstract shape, value-object POCO).
78+
private sealed class DeclMarkerGenerator : EntityGenerator
79+
{
80+
protected override void EmitClassDeclarationLine(
81+
StringBuilder sb, MetaObject entity, string className, ClassDeclarationKind kind, GenContext ctx)
82+
{
83+
sb.AppendLine("// adopter-decl");
84+
base.EmitClassDeclarationLine(sb, entity, className, kind, ctx);
85+
}
86+
}
87+
88+
private const string MixedModel = """
89+
{ "metadata.root": { "package": "acme", "children": [
90+
{ "object.value": { "name": "Money", "children": [
91+
{ "field.long": { "name": "cents", "@required": true } }
92+
]}},
93+
{ "object.entity": { "name": "BaseThing", "abstract": true, "children": [
94+
{ "field.long": { "name": "id" } },
95+
{ "identity.primary": { "@fields": "id" } }
96+
]}},
97+
{ "object.entity": { "name": "Order", "children": [
98+
{ "source.rdb": { "@table": "orders" } },
99+
{ "field.long": { "name": "id" } },
100+
{ "field.object": { "name": "total", "@objectRef": "Money" } },
101+
{ "identity.primary": { "@fields": "id" } }
102+
]}}
103+
]}}
104+
""";
105+
106+
[Fact]
107+
public void EmitClassDeclarationLine_seam_reaches_all_class_kinds()
108+
{
109+
var root = Load(MixedModel);
110+
var files = new DeclMarkerGenerator().Generate(Ctx(root, abstractShapes: true)).ToList();
111+
Assert.Contains("// adopter-decl", File(files, "Order.g.cs")); // mapped entity
112+
Assert.Contains("// adopter-decl", File(files, "BaseThing.g.cs")); // abstract shape
113+
Assert.Contains("// adopter-decl", File(files, "Money.g.cs")); // value-object POCO
114+
}
115+
116+
// ----------------------------------------------------------------- item 4 (bug fix)
117+
118+
[Fact]
119+
public void ObjectField_with_isArray_emits_a_collection_not_a_single_ref()
120+
{
121+
var model = """
122+
{ "metadata.root": { "package": "acme", "children": [
123+
{ "object.value": { "name": "ContactInfo", "children": [
124+
{ "field.string": { "name": "phone" } }
125+
]}},
126+
{ "object.entity": { "name": "Customer", "children": [
127+
{ "source.rdb": { "@table": "customers" } },
128+
{ "field.long": { "name": "id" } },
129+
{ "field.object": { "name": "contacts", "@objectRef": "ContactInfo", "isArray": true } },
130+
{ "identity.primary": { "@fields": "id" } }
131+
]}}
132+
]}}
133+
""";
134+
var customer = File(new EntityGenerator().Generate(Ctx(Load(model))), "Customer.g.cs");
135+
Assert.Contains("public ICollection<ContactInfo> Contacts { get; set; } = new List<ContactInfo>();", customer);
136+
Assert.DoesNotContain("public ContactInfo? Contacts", customer);
137+
}
138+
139+
// ----------------------------------------------------------------- item 6 (bug fix)
140+
141+
[Fact]
142+
public void Callable_parameterRef_value_object_is_emitted_as_a_poco()
143+
{
144+
var model = """
145+
{ "metadata.root": { "package": "acme", "children": [
146+
{ "object.value": { "name": "ReportArgs", "children": [
147+
{ "field.long": { "name": "year", "@required": true } }
148+
]}},
149+
{ "object.entity": { "name": "ReportRow", "children": [
150+
{ "source.rdb": { "@table": "fn_report", "@kind": "storedProc", "@parameterRef": "ReportArgs" } },
151+
{ "field.long": { "name": "id" } },
152+
{ "field.string": { "name": "label" } },
153+
{ "identity.primary": { "@fields": "id" } }
154+
]}}
155+
]}}
156+
""";
157+
var files = new EntityGenerator().Generate(Ctx(Load(model))).ToList();
158+
// The args VO is referenced only via source.rdb @parameterRef (not an object-field),
159+
// yet its POCO must be emitted so the FR-015 callable wrapper compiles.
160+
var args = File(files, "ReportArgs.g.cs");
161+
Assert.Contains("public class ReportArgs", args);
162+
Assert.Contains("Year", args);
163+
}
164+
165+
// ----------------------------------------------------------------- item A (native @default)
166+
167+
[Fact]
168+
public void Literal_defaults_emit_property_initializers()
169+
{
170+
var model = """
171+
{ "metadata.root": { "package": "acme", "children": [
172+
{ "object.entity": { "name": "Settings", "children": [
173+
{ "source.rdb": { "@table": "settings" } },
174+
{ "field.long": { "name": "id" } },
175+
{ "field.boolean": { "name": "active", "@required": true, "@default": false } },
176+
{ "field.int": { "name": "retries", "@required": true, "@default": 3 } },
177+
{ "field.long": { "name": "big", "@default": 5000000000 } },
178+
{ "field.string": { "name": "theme", "@required": true, "@default": "dark" } },
179+
{ "field.double": { "name": "ratio", "@default": 1.5 } },
180+
{ "field.decimal": { "name": "rate", "@precision": 10, "@scale": 2, "@default": "2.50" } },
181+
{ "field.string": { "name": "note" } },
182+
{ "identity.primary": { "@fields": "id" } }
183+
]}}
184+
]}}
185+
""";
186+
var c = File(new EntityGenerator().Generate(Ctx(Load(model))), "Settings.g.cs");
187+
Assert.Contains("public bool Active { get; set; } = false;", c);
188+
Assert.Contains("public int Retries { get; set; } = 3;", c);
189+
Assert.Contains("public long? Big { get; set; } = 5000000000L;", c);
190+
Assert.Contains("public string Theme { get; set; } = \"dark\";", c);
191+
Assert.Contains("public double? Ratio { get; set; } = 1.5;", c);
192+
Assert.Contains("public decimal? Rate { get; set; } = 2.50m;", c);
193+
// A field without @default keeps the existing form (no initializer for a nullable string).
194+
Assert.Contains("public string? Note { get; set; }", c);
195+
Assert.DoesNotContain("Note { get; set; } =", c);
196+
}
197+
198+
// ----------------------------------------------------------------- item 1 (promoted helpers)
199+
200+
// A subclass reaching a now-`protected static` helper is a compile-level proof that
201+
// the per-property helpers are no longer private.
202+
private sealed class HelperReachingGenerator : EntityGenerator
203+
{
204+
public string CallScalar(MetaObject owner, MetaField field) =>
205+
ScalarProperty(owner, field, new[] { "id" }, withAttributes: false);
206+
}
207+
208+
[Fact]
209+
public void Per_property_helpers_are_reachable_from_a_subclass()
210+
{
211+
var root = Load(SimpleModel);
212+
var order = root.Objects().Single(o => o.Name == "Subscriber");
213+
var email = order.Fields().Single(f => f.Name == "email");
214+
var line = new HelperReachingGenerator().CallScalar(order, email);
215+
Assert.Contains("public string Email", line);
216+
}
217+
}

0 commit comments

Comments
 (0)