Skip to content

Commit 1d2b009

Browse files
authored
fix(csharp): emit value-object null-omission only on nullable members (#448)
* fix(csharp): emit the value-object null-omission condition only on nullable members, gate required value-type members cross-port * no-mistakes(review): Test C# value-object JSON behavior via Roslyn compile * no-mistakes(document): Updated jsonb fixture README for telemetry VO and invariant wording; no unresolved doc gaps.
1 parent c59838e commit 1d2b009

19 files changed

Lines changed: 222 additions & 35 deletions

File tree

‎fixtures/api-contract-conformance/jsonb/README.md‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Java `Object`, Kotlin `JsonElement`, C# `JsonDocument`); this corpus locks it
1515
in cross-port.
1616

1717
The single `Document` entity (`acme::store`, `@table="documents"`) carries an
18-
open-bag field plus three value-object columns:
18+
open-bag field plus four value-object columns:
1919

2020
| Field | Type | Notes |
2121
|------------------|----------------------------------------------------------|------------------------------------------|
@@ -25,6 +25,7 @@ open-bag field plus three value-object columns:
2525
| `primaryMarker` | `field.object @objectRef=Marker @storage=jsonb @required`| required single VO — present-null → 400 |
2626
| `optionalMarker` | `field.object @objectRef=Marker @storage=jsonb` | nullable single VO — FR-035 tristate |
2727
| `markers` | `field.object @objectRef=Marker @storage=jsonb isArray` | nullable array of VO — `[]` ≠ null |
28+
| `telemetry` | `field.object @objectRef=Telemetry @storage=jsonb` | nullable VO with required non-nullable value types — see `jsonb-value-object-required-value-types` |
2829

2930
**Why `id` is `@required`.** It is the shape every real model has — a shared
3031
`BaseEntity` declares `id` required — and the one no other api-contract entity
@@ -53,13 +54,14 @@ The `Marker` value object (`object.value`) is `{ label: field.string @required
5354
```
5455
jsonb/
5556
├── README.md # this file
56-
├── meta.json # the Document entity + Marker value object
57+
├── meta.json # the Document entity + Marker and Telemetry value objects
5758
├── seed.json # 2 seed Documents (ids 1, 2), jsonb object + VO columns
5859
└── scenarios/
5960
├── jsonb-open-bag-roundtrip.yaml # open-bag parsed-value contract
6061
├── jsonb-value-object-patch.yaml # Program D — cross-port VO-column PATCH/POST
6162
├── jsonb-value-object-map-decimals.yaml # decimals in a field.map of value objects, per map value
62-
└── jsonb-value-object-numbers.yaml # doubles and decimals inside a value object
63+
├── jsonb-value-object-numbers.yaml # doubles and decimals inside a value object
64+
└── jsonb-value-object-required-value-types.yaml # required double + timestamp in a value object
6365
```
6466

6567
`seed.json` is applied fresh before the scenario; the runner advances the id
@@ -162,3 +164,18 @@ Amendment 1; see `../decimal/README.md`), per element for an array of value obje
162164
| `r10`/`r11` PATCH + GET | PATCH single `measure` (decimal as a number) is canonical, `measures` untouched |
163165
| `r12`-`r15` | PATCH array element canonicalized (`"3.10"` gives `"3.1"`); excess scale in an array element or in `measure` is `400` and the stored value is unchanged |
164166
| `r16`-`r18` PUT | PUT canonicalizes and persists; excess scale on PUT is `400` and nothing changes |
167+
168+
## Scenario — `jsonb-value-object-required-value-types.yaml`
169+
170+
A value object whose REQUIRED members are non-nullable value types in the native language
171+
(`Telemetry { capturedAt: field.timestamp @required; latitude: field.double @required; tempC:
172+
field.int }`, held by the nullable `Document.telemetry`). A port whose generated model cannot
173+
serialize or deserialize such a member answers every request that touches the owning entity
174+
with a 500 (C# emitted a null-omission condition on a non-nullable `double` /
175+
`DateTimeOffset`, which System.Text.Json rejects with `InvalidOperationException`;
176+
the condition is emitted on nullable members only).
177+
178+
| Requests | What it pins |
179+
|---|---|
180+
| `r1`/`r2` POST + GET | create is `201`, the double stays a JSON number, the timestamp keeps its UTC `Z` spelling, `tempC` round-trips |
181+
| `r3`/`r4` PATCH + GET | PATCH replaces the value object (`tempC` omitted when absent) and it persists |

‎fixtures/api-contract-conformance/jsonb/meta.json‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@
1717
{ "field.decimal": { "name": "amount", "@precision": 12, "@scale": 4 } }
1818
]
1919
}},
20+
{ "object.value": {
21+
"name": "Telemetry",
22+
"children": [
23+
{ "field.timestamp": { "name": "capturedAt", "@required": true } },
24+
{ "field.double": { "name": "latitude", "@required": true } },
25+
{ "field.int": { "name": "tempC" } }
26+
]
27+
}},
2028
{ "object.value": {
2129
"name": "Tier",
2230
"children": [
@@ -41,6 +49,7 @@
4149
{ "field.object": { "name": "markers", "@objectRef": "Marker", "@storage": "jsonb", "isArray": true } },
4250
{ "field.object": { "name": "measure", "@objectRef": "Measure", "@storage": "jsonb" } },
4351
{ "field.object": { "name": "measures", "@objectRef": "Measure", "@storage": "jsonb", "isArray": true } },
52+
{ "field.object": { "name": "telemetry", "@objectRef": "Telemetry", "@storage": "jsonb" } },
4453
{ "field.map": { "name": "tiers", "@objectRef": "Tier" } },
4554
{ "field.object": { "name": "pricing", "@objectRef": "Pricing", "@storage": "jsonb" } },
4655
{ "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } }
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
name: jsonb-value-object-required-value-types
2+
description: >
3+
A jsonb value object whose REQUIRED members are non-nullable value types (Telemetry:
4+
`capturedAt` field.timestamp @required, `latitude` field.double @required, `tempC` field.int
5+
optional), held by Document.telemetry. A port whose generated model refuses to serialize or
6+
deserialize such a type answers every request that touches the owning entity with a 500
7+
(System.Text.Json threw InvalidOperationException when a null-omission condition sat on a
8+
non-nullable `double` / `DateTimeOffset`, so POST /api/documents answered 500). The contract: a
9+
POST, the read-back and a PATCH all succeed, the double stays a JSON number, the timestamp keeps
10+
its UTC `Z` spelling, and the optional `tempC` is omitted when absent. Implicit-id POSTs land at 3.
11+
requests:
12+
- id: r1
13+
method: POST
14+
path: /api/documents
15+
body:
16+
title: "telemetry-a"
17+
primaryMarker: { label: "a", score: 1 }
18+
telemetry: { capturedAt: "2026-03-01T12:00:00Z", latitude: 51.5, tempC: 18 }
19+
expect:
20+
status: 201
21+
body:
22+
hasId: true
23+
row:
24+
telemetry: { capturedAt: "2026-03-01T12:00:00Z", latitude: 51.5, tempC: 18 }
25+
- id: r2
26+
method: GET
27+
path: /api/documents/3
28+
expect:
29+
status: 200
30+
body:
31+
row:
32+
telemetry: { capturedAt: "2026-03-01T12:00:00Z", latitude: 51.5, tempC: 18 }
33+
- id: r3
34+
method: PATCH
35+
path: /api/documents/3
36+
body:
37+
telemetry: { capturedAt: "2026-03-02T08:30:00Z", latitude: -33.25 }
38+
expect:
39+
status: 200
40+
body:
41+
row:
42+
telemetry: { capturedAt: "2026-03-02T08:30:00Z", latitude: -33.25 }
43+
- id: r4
44+
method: GET
45+
path: /api/documents/3
46+
expect:
47+
status: 200
48+
body:
49+
row:
50+
telemetry: { capturedAt: "2026-03-02T08:30:00Z", latitude: -33.25 }
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
using System.Reflection;
2+
using System.Text.Json;
3+
using Microsoft.CodeAnalysis;
4+
using Microsoft.CodeAnalysis.CSharp;
5+
using MetaObjects.Codegen;
6+
using MetaObjects.Codegen.Generators;
7+
using MetaObjects.Loader;
8+
using Xunit;
9+
10+
namespace MetaObjects.Codegen.Tests;
11+
12+
/// <summary>
13+
/// A value-object member that is null is omitted on the wire via
14+
/// <c>[JsonIgnore(WhenWritingNull)]</c>, but System.Text.Json throws InvalidOperationException
15+
/// the first time it builds type info for a type carrying that condition on a NON-nullable value
16+
/// type (<c>double</c>, <c>DateTimeOffset</c>), so every request that touched the owning entity
17+
/// answered 500. The condition belongs on the nullable members only.
18+
/// </summary>
19+
public class ValueObjectWireIgnoreTests
20+
{
21+
private const string Model = """
22+
{ "metadata.root": { "package": "acme", "children": [
23+
{ "object.entity": { "name": "Leg", "children": [
24+
{ "source.rdb": { "@table": "legs" } },
25+
{ "field.long": { "name": "id" } },
26+
{ "field.object": { "name": "telemetry", "@objectRef": "acme::Telemetry", "@storage": "jsonb" } },
27+
{ "identity.primary": { "@fields": "id" } }
28+
]}},
29+
{ "object.value": { "name": "Telemetry", "children": [
30+
{ "field.timestamp": { "name": "capturedAt", "@required": true } },
31+
{ "field.double": { "name": "latitude", "@required": true } },
32+
{ "field.int": { "name": "tempC" } },
33+
{ "field.boolean": { "name": "live", "@required": true } },
34+
{ "field.decimal": { "name": "fee", "@required": true, "@precision": 10, "@scale": 2 } },
35+
{ "field.uuid": { "name": "token", "@required": true } },
36+
{ "field.string": { "name": "tags", "isArray": true } },
37+
{ "field.string": { "name": "note" } }
38+
]}}
39+
]}}
40+
""";
41+
42+
[Fact]
43+
public void A_value_object_with_required_value_type_members_round_trips_and_omits_a_null_optional_member()
44+
{
45+
var r = new MetaDataLoader().Load([new InMemoryStringSource(Model, id: "model.json")]);
46+
Assert.Empty(r.Errors);
47+
var files = new EntityGenerator().Generate(new GenContext
48+
{
49+
Entities = r.Root.Objects(),
50+
Root = r.Root,
51+
Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated" },
52+
});
53+
var trees = files.Select(f => CSharpSyntaxTree.ParseText(f.Content, new CSharpParseOptions(LanguageVersion.CSharp12), path: f.Path));
54+
var comp = CSharpCompilation.Create("vo_wire_ignore_" + Guid.NewGuid().ToString("N"), trees,
55+
DbContextCompileTests.BuildReferences(), new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
56+
using var ms = new MemoryStream();
57+
var emit = comp.Emit(ms);
58+
Assert.True(emit.Success, string.Join("\n", emit.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error)));
59+
var telemetry = Assembly.Load(ms.ToArray()).GetTypes().Single(t => t.Name == "Telemetry");
60+
61+
const string body = """
62+
{"capturedAt":"2026-03-04T05:06:07Z","latitude":51.5,"live":true,"fee":"12.50","token":"3f2504e0-4f89-11d3-9a0c-0305e82c3301"}
63+
""";
64+
var value = JsonSerializer.Deserialize(body, telemetry)!;
65+
Assert.Equal(51.5, telemetry.GetProperty("Latitude")!.GetValue(value));
66+
Assert.Equal(DateTimeOffset.Parse("2026-03-04T05:06:07Z"), telemetry.GetProperty("CapturedAt")!.GetValue(value));
67+
Assert.Null(telemetry.GetProperty("TempC")!.GetValue(value));
68+
69+
var wire = JsonDocument.Parse(JsonSerializer.Serialize(value, telemetry)).RootElement;
70+
Assert.Equal(51.5, wire.GetProperty("latitude").GetDouble());
71+
Assert.Equal(DateTimeOffset.Parse("2026-03-04T05:06:07Z"), wire.GetProperty("capturedAt").GetDateTimeOffset());
72+
Assert.True(wire.GetProperty("live").GetBoolean());
73+
Assert.False(wire.TryGetProperty("tempC", out _));
74+
Assert.False(wire.TryGetProperty("note", out _));
75+
}
76+
}

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -852,8 +852,14 @@ protected virtual EmittedFile EmitValueObjectPoco(MetaObject vo, GenContext ctx)
852852
if (member is null) continue;
853853
// Pin the jsonb element name (+ wire name) to the metadata field name so the
854854
// stored owned-JSON keys match the shared seed + the cross-port wire contract.
855-
// A null member is omitted on the wire (the cross-port value-object contract).
856-
member = $" [JsonPropertyName(\"{field.Name}\")]\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\n" + member;
855+
// A null member is omitted on the wire (the cross-port value-object contract). Only a
856+
// NULLABLE member can be null, and System.Text.Json throws InvalidOperationException
857+
// when WhenWritingNull sits on a non-nullable value type (double, DateTimeOffset, ...),
858+
// so the condition is emitted for the nullable members alone.
859+
var nullableMember = System.Text.RegularExpressions.Regex.IsMatch(member, @"^\s*public\s+[^=;{(]*\?\s+\w+", System.Text.RegularExpressions.RegexOptions.Multiline);
860+
member = $" [JsonPropertyName(\"{field.Name}\")]\n"
861+
+ (nullableMember ? " [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\n" : "")
862+
+ member;
857863
member = ApplyPropertyAttributes(member, vo, field, ctx);
858864
members.Add(XmlDocBuilder.Prepend(member, field, " "));
859865
}

‎server/csharp/MetaObjects.IntegrationTests/Api/JsonbFixture.cs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ internal static class JsonbFixture
3232
"markers" jsonb,
3333
"measure" jsonb,
3434
"measures" jsonb,
35+
"telemetry" jsonb,
3536
"tiers" jsonb,
3637
"pricing" jsonb,
3738
CONSTRAINT "documents_pkey" PRIMARY KEY ("id")
@@ -74,8 +75,8 @@ public static async Task ApplySeedAsync(string connString, string seedPath)
7475
if (rowNode is not JsonObject row) continue;
7576
await using var ins = c.CreateCommand();
7677
ins.CommandText =
77-
"INSERT INTO \"documents\" (\"id\", \"title\", \"payload\", \"primaryMarker\", \"optionalMarker\", \"markers\", \"measure\", \"measures\", \"tiers\", \"pricing\") " +
78-
"VALUES (@id, @title, @payload, @primaryMarker, @optionalMarker, @markers, @measure, @measures, @tiers, @pricing)";
78+
"INSERT INTO \"documents\" (\"id\", \"title\", \"payload\", \"primaryMarker\", \"optionalMarker\", \"markers\", \"measure\", \"measures\", \"telemetry\", \"tiers\", \"pricing\") " +
79+
"VALUES (@id, @title, @payload, @primaryMarker, @optionalMarker, @markers, @measure, @measures, @telemetry, @tiers, @pricing)";
7980
ins.Parameters.AddWithValue("@id", row["id"]!.GetValue<long>());
8081
ins.Parameters.AddWithValue("@title", row["title"]!.GetValue<string>());
8182
// payload + the VO columns are JSON — serialize each and bind as jsonb (an
@@ -87,6 +88,7 @@ public static async Task ApplySeedAsync(string connString, string seedPath)
8788
AddJsonbParam(ins, "@markers", row["markers"]);
8889
AddJsonbParam(ins, "@measure", row["measure"]);
8990
AddJsonbParam(ins, "@measures", row["measures"]);
91+
AddJsonbParam(ins, "@telemetry", row["telemetry"]);
9092
AddJsonbParam(ins, "@tiers", row["tiers"]);
9193
AddJsonbParam(ins, "@pricing", row["pricing"]);
9294
await ins.ExecuteNonQueryAsync();

‎server/csharp/MetaObjects.IntegrationTests/Api/JsonbGeneratedServerFactory.cs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ public static async Task<JsonbGeneratedServerFactory> StartAsync(PostgresContain
6262
builder.WebHost.UseUrls(baseUrl);
6363
builder.Logging.ClearProviders();
6464
RegisterGeneratedDbContext(builder.Services, dbContextType, pg.ConnectionString);
65+
// The documented host registration: a field.timestamp crosses the wire as UTC with the Z
66+
// designator (a Telemetry value object holds one).
67+
builder.Services.ConfigureHttpJsonOptions(o =>
68+
o.SerializerOptions.Converters.Add(new MetaObjects.Codegen.Runtime.Iso8601TimestampConverter()));
6569

6670
var app = builder.Build();
6771

‎server/csharp/MetaObjects.IntegrationTests/Api/JsonbReferenceServer.cs‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ internal sealed class JsonbReferenceServer : IAsyncDisposable
4141
private const int MarkerLabelMaxLength = 40;
4242

4343
// The jsonb columns carried in a document row's wire projection (id + title are scalar).
44-
private static readonly string[] JsonbColumns = { "payload", "primaryMarker", "optionalMarker", "markers", "measure", "measures", "tiers", "pricing" };
44+
private static readonly string[] JsonbColumns = { "payload", "primaryMarker", "optionalMarker", "markers", "measure", "measures", "telemetry", "tiers", "pricing" };
4545

4646
private JsonbReferenceServer(PostgresContainer pg, HttpListener listener, string baseUrl)
4747
{
@@ -160,15 +160,16 @@ private async Task CreateDocumentAsync(HttpListenerContext ctx)
160160
await using (var ins = c.CreateCommand())
161161
{
162162
ins.CommandText =
163-
"INSERT INTO \"documents\" (\"title\", \"payload\", \"primaryMarker\", \"optionalMarker\", \"markers\", \"measure\", \"measures\", \"tiers\", \"pricing\") " +
164-
"VALUES (@title, @payload, @primaryMarker, @optionalMarker, @markers, @measure, @measures, @tiers, @pricing) RETURNING id";
163+
"INSERT INTO \"documents\" (\"title\", \"payload\", \"primaryMarker\", \"optionalMarker\", \"markers\", \"measure\", \"measures\", \"telemetry\", \"tiers\", \"pricing\") " +
164+
"VALUES (@title, @payload, @primaryMarker, @optionalMarker, @markers, @measure, @measures, @telemetry, @tiers, @pricing) RETURNING id";
165165
ins.Parameters.AddWithValue("@title", body["title"]!.GetValue<string>());
166166
AddJsonbParam(ins, "@payload", body["payload"]);
167167
AddJsonbParam(ins, "@primaryMarker", body["primaryMarker"]);
168168
AddJsonbParam(ins, "@optionalMarker", body["optionalMarker"]);
169169
AddJsonbParam(ins, "@markers", body["markers"]);
170170
AddJsonbParam(ins, "@measure", body["measure"]);
171171
AddJsonbParam(ins, "@measures", body["measures"]);
172+
AddJsonbParam(ins, "@telemetry", body["telemetry"]);
172173
AddJsonbParam(ins, "@tiers", body["tiers"]);
173174
AddJsonbParam(ins, "@pricing", body["pricing"]);
174175
newId = Convert.ToInt64(await ins.ExecuteScalarAsync());

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ namespace MetaObjects.IntegrationTests.Generated;
1212
public class ProgramBrief
1313
{
1414
[JsonPropertyName("title")]
15-
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
1615
[Required(AllowEmptyStrings = true)]
1716
[MaxLength(200)]
1817
[MinLength(1)]

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

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,8 @@ public class ProgramVerdict
1313
{
1414
public enum ProgramVerdictVerdict { GOOD, OK, POOR }
1515
[JsonPropertyName("verdict")]
16-
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
1716
public ProgramVerdictVerdict Verdict { get; set; }
1817
[JsonPropertyName("rationale")]
19-
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
2018
[Required(AllowEmptyStrings = true)]
2119
[MaxLength(480)]
2220
[MinLength(1)]

0 commit comments

Comments
 (0)