Skip to content

Commit 35dcf6e

Browse files
dmealingclaude
andcommitted
fix(csharp): serialize a TPH collection by each row's runtime type
GET /<base> returns List<Base>, and System.Text.Json serializes a collection by its DECLARED element type — so a discriminated-union list answered 200 with rows quietly missing half their columns. Nothing errored. A single-object read was unaffected, because ASP.NET falls back to the runtime type when it differs from the declared one. That asymmetry is why this survived: GET /parties/{id} showed a Broker's bondNumber while GET /parties did not, so every by-id assertion passed and the shape looked right wherever anyone looked at one row. The base class now carries a [JsonDerivedType(typeof(<Sub>))] per concrete subtype — declarative, so it also covers a base-typed collection nested inside another response, which fixing only the list handler would not. No type discriminator is configured, so NO $type key is emitted: the model already carries @Discriminator as an ordinary property, and a second C#-only key would be an addition to a cross-port wire contract. Deserializing into the base is not something the generated routes do — an abstract base has no create/update handler, and each subtype's handler deserializes that subtype directly. Non-TPH entities are byte-identical; the committed integration fixture moves by exactly the three attribute lines on Auth. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015NrWUxA6euwvnFcwKxcK6A
1 parent e3a7aad commit 35dcf6e

4 files changed

Lines changed: 82 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,17 @@ edit (two registered `description` strings) and was ruled a hold, as 1.0.4's was
181181
projection with no `@filterable` field gets an empty allowlist, exactly as an entity does,
182182
and the api-docs builder now documents the projection's Filter symbol.
183183

184+
- **C#: a TPH base-path collection dropped every subtype field.** `GET /<base>` returns
185+
`List<Base>`, and System.Text.Json serializes a collection by its DECLARED element type —
186+
so a discriminated-union list answered 200 with rows quietly missing half their columns,
187+
with nothing to error on. A single-object read was unaffected, because ASP.NET falls back
188+
to the runtime type when it differs from the declared one, which is exactly why this
189+
survived: `GET /parties/{id}` looked right while `GET /parties` did not. The base class now
190+
carries a `[JsonDerivedType(typeof(<Sub>))]` per concrete subtype. **No `$type` key is
191+
added** — the model already carries `@discriminator`, emitted as an ordinary property, and a
192+
second C#-only key would be an addition to a cross-port wire contract. Non-TPH entities are
193+
byte-identical.
194+
184195
- **C#: a constraint violation answered 500 instead of 409.** A generated CRUD route had no
185196
try/catch around `SaveChangesAsync`, so a client-supplied foreign key naming no row, or a
186197
value duplicating a unique one, reached ASP.NET's default handler and came back as a bare

‎server/csharp/MetaObjects.Codegen.Tests/TphCodegenTests.cs‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,53 @@ public void Entity_base_carries_table_and_discriminator_property()
110110
Assert.DoesNotContain("Quantity", auth);
111111
}
112112

113+
[Fact]
114+
public void Entity_base_declares_its_subtypes_for_polymorphic_serialization()
115+
{
116+
// A LIST route returns List<Auth> whose elements are really subtypes.
117+
// System.Text.Json serializes a collection by its DECLARED element type, so
118+
// without this the subtype's own columns are silently dropped from every
119+
// collection response — while a single-object read still shows them, because
120+
// ASP.NET falls back to the runtime type when it differs from the declared one.
121+
// A base-path list answering 200 with rows that quietly lack half their fields
122+
// is the worst shape this can take: nothing errors.
123+
//
124+
// No type discriminator is configured: the model already HAS one
125+
// (@discriminator), it is emitted as a normal property, and a second `$type`
126+
// key would be a C#-only addition to a cross-port wire contract.
127+
var auth = FileContent(new EntityGenerator().Generate(Ctx(Load())), "Auth.g.cs");
128+
129+
Assert.Contains("[System.Text.Json.Serialization.JsonDerivedType(typeof(BridgeAuth))]", auth);
130+
Assert.Contains("[System.Text.Json.Serialization.JsonDerivedType(typeof(CopayAuth))]", auth);
131+
Assert.Contains("[System.Text.Json.Serialization.JsonDerivedType(typeof(PriorAuthAuth))]", auth);
132+
Assert.DoesNotContain("typeDiscriminator", auth);
133+
}
134+
135+
[Fact]
136+
public void A_non_tph_entity_declares_no_derived_types()
137+
{
138+
// The attribute is TPH-only: putting it on every entity would turn an ordinary
139+
// class into a polymorphic root and change how it serializes.
140+
const string flat = """
141+
{ "metadata.root": { "package": "acme", "children": [
142+
{ "object.entity": { "name": "Author", "children": [
143+
{ "source.rdb": { "@table": "authors" } },
144+
{ "field.long": { "name": "id" } },
145+
{ "identity.primary": { "@fields": "id" } }
146+
]}}
147+
]}}
148+
""";
149+
var r = new MetaDataLoader().Load([new InMemoryStringSource(flat, id: "flat.json")]);
150+
Assert.Empty(r.Errors);
151+
var ctx = new GenContext
152+
{
153+
Entities = r.Root.Objects(), Root = r.Root,
154+
Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated" },
155+
};
156+
Assert.DoesNotContain("JsonDerivedType",
157+
FileContent(new EntityGenerator().Generate(ctx), "Author.g.cs"));
158+
}
159+
113160
[Fact]
114161
public void Entity_subtypes_extend_base_with_only_own_fields_and_no_table()
115162
{

‎server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,27 @@ protected virtual void EmitClassHeader(
370370
if (!isProjection)
371371
sb.AppendLine($"[Table({CSharpNaming.NameRef(entity, ctx.Config.ColumnNamingStrategy, ctx.Config.IncludeNames, CSharpNaming.Table(entity))}"
372372
+ $"{CSharpNaming.TableSchemaArg(entity, ctx.Config.ColumnNamingStrategy, ctx.Config.IncludeNames)})]");
373+
// FR-017 TPH — declare the concrete subtypes so System.Text.Json serializes each row
374+
// by its RUNTIME type.
375+
//
376+
// A collection result is serialized by its DECLARED element type, so a base-path list
377+
// returning List<Base> wrote only the base's properties and silently dropped every
378+
// subtype column — a 200 whose rows are missing half their fields, with nothing to
379+
// error on. (A single-object read was unaffected: ASP.NET falls back to the runtime
380+
// type when it differs from the declared one, which is exactly why this survived —
381+
// GET /parties/{id} looked right while GET /parties did not.)
382+
//
383+
// No type discriminator is configured, so NO `$type` key is emitted: the model
384+
// already carries a discriminator (@discriminator) which is emitted as an ordinary
385+
// property, and a second, C#-only key would be an addition to a cross-port wire
386+
// contract. Deserialization into the BASE is not something the generated routes do —
387+
// an abstract base has no create/update handler, and each subtype's handler
388+
// deserializes that subtype directly.
389+
if (TphPlanBuilder.For(entity, ctx.Root) is { } tphPlan)
390+
foreach (var sub in tphPlan.Subtypes)
391+
sb.AppendLine(
392+
$"[System.Text.Json.Serialization.JsonDerivedType(typeof({CSharpNaming.Pascal(sub.Entity.Name)}))]");
393+
373394
// The `public [abstract] class <Name>[ : Base]` declaration line itself is routed
374395
// through the single overridable seam shared by all four emitted class kinds, so an
375396
// adopter's `partial`/marker-interface override applies uniformly. The EF mapping

‎server/csharp/MetaObjects.IntegrationTests/Generated/Auth.g.cs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
namespace MetaObjects.IntegrationTests.Generated;
1010

1111
[Table(AuthNames.SourcePrimaryTable)]
12+
[System.Text.Json.Serialization.JsonDerivedType(typeof(BridgeAuth))]
13+
[System.Text.Json.Serialization.JsonDerivedType(typeof(CopayAuth))]
14+
[System.Text.Json.Serialization.JsonDerivedType(typeof(PriorAuthAuth))]
1215
public abstract class Auth
1316
{
1417
public enum AuthType { Bridge, Copay, PriorAuth }

0 commit comments

Comments
 (0)