From cdf93960c1a3b00c581765914025ee9ce0a50d65 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Thu, 22 Oct 2026 15:13:41 -0400 Subject: [PATCH 1/3] fix(csharp): emit the value-object null-omission condition only on nullable members, gate required value-type members cross-port --- .../api-contract-conformance/jsonb/README.md | 17 ++++- .../api-contract-conformance/jsonb/meta.json | 9 +++ ...onb-value-object-required-value-types.yaml | 50 +++++++++++++++ .../ValueObjectWireIgnoreTests.cs | 63 +++++++++++++++++++ .../Generators/EntityGenerator.cs | 10 ++- .../Api/JsonbFixture.cs | 6 +- .../Api/JsonbGeneratedServerFactory.cs | 4 ++ .../Api/JsonbReferenceServer.cs | 7 ++- .../Generated/ProgramBrief.g.cs | 1 - .../Generated/ProgramVerdict.g.cs | 2 - .../Generated/WeekLabel.g.cs | 1 - .../kotlin/api/jsonb/DocumentApiServer.kt | 7 ++- .../GeneratedDocumentControllerHarness.kt | 4 +- .../integration/api/JsonbReferenceServer.java | 25 +++++--- .../GeneratedJsonbControllerHarness.java | 6 +- .../InMemoryDocumentRepositorySource.java | 4 +- .../integration/api_contract_jsonb_server.py | 19 +++--- .../api-contract-jsonb-generated-server.ts | 1 + .../src/api-contract-jsonb-server.ts | 2 + 19 files changed, 205 insertions(+), 33 deletions(-) create mode 100644 fixtures/api-contract-conformance/jsonb/scenarios/jsonb-value-object-required-value-types.yaml create mode 100644 server/csharp/MetaObjects.Codegen.Tests/ValueObjectWireIgnoreTests.cs diff --git a/fixtures/api-contract-conformance/jsonb/README.md b/fixtures/api-contract-conformance/jsonb/README.md index a108f1586..7c6bcce77 100644 --- a/fixtures/api-contract-conformance/jsonb/README.md +++ b/fixtures/api-contract-conformance/jsonb/README.md @@ -59,7 +59,8 @@ jsonb/ ├── jsonb-open-bag-roundtrip.yaml # open-bag parsed-value contract ├── jsonb-value-object-patch.yaml # Program D — cross-port VO-column PATCH/POST ├── jsonb-value-object-map-decimals.yaml # decimals in a field.map of value objects, per map value - └── jsonb-value-object-numbers.yaml # doubles and decimals inside a value object + ├── jsonb-value-object-numbers.yaml # doubles and decimals inside a value object + └── jsonb-value-object-required-value-types.yaml # required double + timestamp in a value object ``` `seed.json` is applied fresh before the scenario; the runner advances the id @@ -162,3 +163,17 @@ Amendment 1; see `../decimal/README.md`), per element for an array of value obje | `r10`/`r11` PATCH + GET | PATCH single `measure` (decimal as a number) is canonical, `measures` untouched | | `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 | | `r16`-`r18` PUT | PUT canonicalizes and persists; excess scale on PUT is `400` and nothing changes | + +## Scenario — `jsonb-value-object-required-value-types.yaml` + +A value object whose REQUIRED members are non-nullable value types in the native language +(`Telemetry { capturedAt: field.timestamp @required; latitude: field.double @required; tempC: +field.int }`, held by the nullable `Document.telemetry`). A port whose generated model cannot +serialize or deserialize such a member answers every request that touches the owning entity +with a 500 (C# 1.1.0-rc.8: a null-omission condition on a non-nullable `double` / +`DateTimeOffset` made System.Text.Json throw). + +| Requests | What it pins | +|---|---| +| `r1`/`r2` POST + GET | create is `201`, the double stays a JSON number, the timestamp keeps its UTC `Z` spelling, `tempC` round-trips | +| `r3`/`r4` PATCH + GET | PATCH replaces the value object (`tempC` omitted when absent) and it persists | diff --git a/fixtures/api-contract-conformance/jsonb/meta.json b/fixtures/api-contract-conformance/jsonb/meta.json index 7dc9b1352..944d3a1ff 100644 --- a/fixtures/api-contract-conformance/jsonb/meta.json +++ b/fixtures/api-contract-conformance/jsonb/meta.json @@ -17,6 +17,14 @@ { "field.decimal": { "name": "amount", "@precision": 12, "@scale": 4 } } ] }}, + { "object.value": { + "name": "Telemetry", + "children": [ + { "field.timestamp": { "name": "capturedAt", "@required": true } }, + { "field.double": { "name": "latitude", "@required": true } }, + { "field.int": { "name": "tempC" } } + ] + }}, { "object.value": { "name": "Tier", "children": [ @@ -41,6 +49,7 @@ { "field.object": { "name": "markers", "@objectRef": "Marker", "@storage": "jsonb", "isArray": true } }, { "field.object": { "name": "measure", "@objectRef": "Measure", "@storage": "jsonb" } }, { "field.object": { "name": "measures", "@objectRef": "Measure", "@storage": "jsonb", "isArray": true } }, + { "field.object": { "name": "telemetry", "@objectRef": "Telemetry", "@storage": "jsonb" } }, { "field.map": { "name": "tiers", "@objectRef": "Tier" } }, { "field.object": { "name": "pricing", "@objectRef": "Pricing", "@storage": "jsonb" } }, { "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } } diff --git a/fixtures/api-contract-conformance/jsonb/scenarios/jsonb-value-object-required-value-types.yaml b/fixtures/api-contract-conformance/jsonb/scenarios/jsonb-value-object-required-value-types.yaml new file mode 100644 index 000000000..687025250 --- /dev/null +++ b/fixtures/api-contract-conformance/jsonb/scenarios/jsonb-value-object-required-value-types.yaml @@ -0,0 +1,50 @@ +name: jsonb-value-object-required-value-types +description: > + A jsonb value object whose REQUIRED members are non-nullable value types (Telemetry: + `capturedAt` field.timestamp @required, `latitude` field.double @required, `tempC` field.int + optional), held by Document.telemetry. A port whose generated model refuses to serialize or + deserialize such a type answers every request that touches the owning entity with a 500 + (System.Text.Json threw InvalidOperationException when a null-omission condition sat on a + non-nullable `double` / `DateTimeOffset`, so POST /api/documents answered 500). The contract: a + POST, the read-back and a PATCH all succeed, the double stays a JSON number, the timestamp keeps + its UTC `Z` spelling, and the optional `tempC` is omitted when absent. Implicit-id POSTs land at 3. +requests: + - id: r1 + method: POST + path: /api/documents + body: + title: "telemetry-a" + primaryMarker: { label: "a", score: 1 } + telemetry: { capturedAt: "2026-03-01T12:00:00Z", latitude: 51.5, tempC: 18 } + expect: + status: 201 + body: + hasId: true + row: + telemetry: { capturedAt: "2026-03-01T12:00:00Z", latitude: 51.5, tempC: 18 } + - id: r2 + method: GET + path: /api/documents/3 + expect: + status: 200 + body: + row: + telemetry: { capturedAt: "2026-03-01T12:00:00Z", latitude: 51.5, tempC: 18 } + - id: r3 + method: PATCH + path: /api/documents/3 + body: + telemetry: { capturedAt: "2026-03-02T08:30:00Z", latitude: -33.25 } + expect: + status: 200 + body: + row: + telemetry: { capturedAt: "2026-03-02T08:30:00Z", latitude: -33.25 } + - id: r4 + method: GET + path: /api/documents/3 + expect: + status: 200 + body: + row: + telemetry: { capturedAt: "2026-03-02T08:30:00Z", latitude: -33.25 } diff --git a/server/csharp/MetaObjects.Codegen.Tests/ValueObjectWireIgnoreTests.cs b/server/csharp/MetaObjects.Codegen.Tests/ValueObjectWireIgnoreTests.cs new file mode 100644 index 000000000..9b89340c3 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/ValueObjectWireIgnoreTests.cs @@ -0,0 +1,63 @@ +using MetaObjects.Codegen; +using MetaObjects.Codegen.Generators; +using MetaObjects.Loader; +using Xunit; + +namespace MetaObjects.Codegen.Tests; + +/// +/// A value-object member that is null is omitted on the wire via +/// [JsonIgnore(WhenWritingNull)], but System.Text.Json throws InvalidOperationException +/// the first time it builds type info for a type carrying that condition on a NON-nullable value +/// type (double, DateTimeOffset), so every request that touched the owning entity +/// answered 500. The condition belongs on the nullable members only. +/// +public class ValueObjectWireIgnoreTests +{ + private const string Model = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.entity": { "name": "Leg", "children": [ + { "source.rdb": { "@table": "legs" } }, + { "field.long": { "name": "id" } }, + { "field.object": { "name": "telemetry", "@objectRef": "acme::Telemetry", "@storage": "jsonb" } }, + { "identity.primary": { "@fields": "id" } } + ]}}, + { "object.value": { "name": "Telemetry", "children": [ + { "field.timestamp": { "name": "capturedAt", "@required": true } }, + { "field.double": { "name": "latitude", "@required": true } }, + { "field.int": { "name": "tempC" } }, + { "field.boolean": { "name": "live", "@required": true } }, + { "field.decimal": { "name": "fee", "@required": true, "@precision": 10, "@scale": 2 } }, + { "field.uuid": { "name": "token", "@required": true } }, + { "field.string": { "name": "tags", "isArray": true } }, + { "field.string": { "name": "note" } } + ]}} + ]}} + """; + + [Fact] + public void A_non_nullable_value_type_member_carries_no_ignore_condition() + { + var r = new MetaDataLoader().Load([new InMemoryStringSource(Model, id: "model.json")]); + Assert.Empty(r.Errors); + var files = new EntityGenerator().Generate(new GenContext + { + Entities = r.Root.Objects(), + Root = r.Root, + Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated" }, + }); + var src = Assert.Single(files, f => f.Path == "Telemetry.g.cs").Content; + + var lines = src.Split('\n').Select(l => l.TrimEnd()).ToList(); + string[] Attrs(string decl) => + lines.TakeWhile(l => !l.Contains(decl)).Reverse().TakeWhile(l => l.TrimStart().StartsWith("[")).ToArray(); + + Assert.DoesNotContain(Attrs("public double Latitude"), l => l.Contains("WhenWritingNull")); + Assert.DoesNotContain(Attrs("public DateTimeOffset CapturedAt"), l => l.Contains("WhenWritingNull")); + Assert.DoesNotContain(Attrs("public bool Live"), l => l.Contains("WhenWritingNull")); + Assert.DoesNotContain(Attrs("public decimal Fee"), l => l.Contains("WhenWritingNull")); + Assert.DoesNotContain(Attrs("public Guid Token"), l => l.Contains("WhenWritingNull")); + Assert.Contains(Attrs("public int? TempC"), l => l.Contains("WhenWritingNull")); + Assert.Contains(Attrs("public string? Note"), l => l.Contains("WhenWritingNull")); + } +} diff --git a/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs index 9a5172370..846c73818 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs @@ -852,8 +852,14 @@ protected virtual EmittedFile EmitValueObjectPoco(MetaObject vo, GenContext ctx) if (member is null) continue; // Pin the jsonb element name (+ wire name) to the metadata field name so the // stored owned-JSON keys match the shared seed + the cross-port wire contract. - // A null member is omitted on the wire (the cross-port value-object contract). - member = $" [JsonPropertyName(\"{field.Name}\")]\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\n" + member; + // A null member is omitted on the wire (the cross-port value-object contract). Only a + // NULLABLE member can be null, and System.Text.Json throws InvalidOperationException + // when WhenWritingNull sits on a non-nullable value type (double, DateTimeOffset, ...), + // so the condition is emitted for the nullable members alone. + var nullableMember = System.Text.RegularExpressions.Regex.IsMatch(member, @"^\s*public\s+[^=;{(]*\?\s+\w+", System.Text.RegularExpressions.RegexOptions.Multiline); + member = $" [JsonPropertyName(\"{field.Name}\")]\n" + + (nullableMember ? " [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\n" : "") + + member; member = ApplyPropertyAttributes(member, vo, field, ctx); members.Add(XmlDocBuilder.Prepend(member, field, " ")); } diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/JsonbFixture.cs b/server/csharp/MetaObjects.IntegrationTests/Api/JsonbFixture.cs index ab0c827ac..23cb0682e 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/JsonbFixture.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/JsonbFixture.cs @@ -32,6 +32,7 @@ internal static class JsonbFixture "markers" jsonb, "measure" jsonb, "measures" jsonb, + "telemetry" jsonb, "tiers" jsonb, "pricing" jsonb, CONSTRAINT "documents_pkey" PRIMARY KEY ("id") @@ -74,8 +75,8 @@ public static async Task ApplySeedAsync(string connString, string seedPath) if (rowNode is not JsonObject row) continue; await using var ins = c.CreateCommand(); ins.CommandText = - "INSERT INTO \"documents\" (\"id\", \"title\", \"payload\", \"primaryMarker\", \"optionalMarker\", \"markers\", \"measure\", \"measures\", \"tiers\", \"pricing\") " + - "VALUES (@id, @title, @payload, @primaryMarker, @optionalMarker, @markers, @measure, @measures, @tiers, @pricing)"; + "INSERT INTO \"documents\" (\"id\", \"title\", \"payload\", \"primaryMarker\", \"optionalMarker\", \"markers\", \"measure\", \"measures\", \"telemetry\", \"tiers\", \"pricing\") " + + "VALUES (@id, @title, @payload, @primaryMarker, @optionalMarker, @markers, @measure, @measures, @telemetry, @tiers, @pricing)"; ins.Parameters.AddWithValue("@id", row["id"]!.GetValue()); ins.Parameters.AddWithValue("@title", row["title"]!.GetValue()); // 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) AddJsonbParam(ins, "@markers", row["markers"]); AddJsonbParam(ins, "@measure", row["measure"]); AddJsonbParam(ins, "@measures", row["measures"]); + AddJsonbParam(ins, "@telemetry", row["telemetry"]); AddJsonbParam(ins, "@tiers", row["tiers"]); AddJsonbParam(ins, "@pricing", row["pricing"]); await ins.ExecuteNonQueryAsync(); diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/JsonbGeneratedServerFactory.cs b/server/csharp/MetaObjects.IntegrationTests/Api/JsonbGeneratedServerFactory.cs index 8e1dfac80..9ced81211 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/JsonbGeneratedServerFactory.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/JsonbGeneratedServerFactory.cs @@ -62,6 +62,10 @@ public static async Task StartAsync(PostgresContain builder.WebHost.UseUrls(baseUrl); builder.Logging.ClearProviders(); RegisterGeneratedDbContext(builder.Services, dbContextType, pg.ConnectionString); + // The documented host registration: a field.timestamp crosses the wire as UTC with the Z + // designator (a Telemetry value object holds one). + builder.Services.ConfigureHttpJsonOptions(o => + o.SerializerOptions.Converters.Add(new MetaObjects.Codegen.Runtime.Iso8601TimestampConverter())); var app = builder.Build(); diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/JsonbReferenceServer.cs b/server/csharp/MetaObjects.IntegrationTests/Api/JsonbReferenceServer.cs index 69bd8cac5..2d79f3ef1 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/JsonbReferenceServer.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/JsonbReferenceServer.cs @@ -41,7 +41,7 @@ internal sealed class JsonbReferenceServer : IAsyncDisposable private const int MarkerLabelMaxLength = 40; // The jsonb columns carried in a document row's wire projection (id + title are scalar). - private static readonly string[] JsonbColumns = { "payload", "primaryMarker", "optionalMarker", "markers", "measure", "measures", "tiers", "pricing" }; + private static readonly string[] JsonbColumns = { "payload", "primaryMarker", "optionalMarker", "markers", "measure", "measures", "telemetry", "tiers", "pricing" }; private JsonbReferenceServer(PostgresContainer pg, HttpListener listener, string baseUrl) { @@ -160,8 +160,8 @@ private async Task CreateDocumentAsync(HttpListenerContext ctx) await using (var ins = c.CreateCommand()) { ins.CommandText = - "INSERT INTO \"documents\" (\"title\", \"payload\", \"primaryMarker\", \"optionalMarker\", \"markers\", \"measure\", \"measures\", \"tiers\", \"pricing\") " + - "VALUES (@title, @payload, @primaryMarker, @optionalMarker, @markers, @measure, @measures, @tiers, @pricing) RETURNING id"; + "INSERT INTO \"documents\" (\"title\", \"payload\", \"primaryMarker\", \"optionalMarker\", \"markers\", \"measure\", \"measures\", \"telemetry\", \"tiers\", \"pricing\") " + + "VALUES (@title, @payload, @primaryMarker, @optionalMarker, @markers, @measure, @measures, @telemetry, @tiers, @pricing) RETURNING id"; ins.Parameters.AddWithValue("@title", body["title"]!.GetValue()); AddJsonbParam(ins, "@payload", body["payload"]); AddJsonbParam(ins, "@primaryMarker", body["primaryMarker"]); @@ -169,6 +169,7 @@ private async Task CreateDocumentAsync(HttpListenerContext ctx) AddJsonbParam(ins, "@markers", body["markers"]); AddJsonbParam(ins, "@measure", body["measure"]); AddJsonbParam(ins, "@measures", body["measures"]); + AddJsonbParam(ins, "@telemetry", body["telemetry"]); AddJsonbParam(ins, "@tiers", body["tiers"]); AddJsonbParam(ins, "@pricing", body["pricing"]); newId = Convert.ToInt64(await ins.ExecuteScalarAsync()); diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramBrief.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramBrief.g.cs index 2a291fd74..ab933938d 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramBrief.g.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramBrief.g.cs @@ -12,7 +12,6 @@ namespace MetaObjects.IntegrationTests.Generated; public class ProgramBrief { [JsonPropertyName("title")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [Required(AllowEmptyStrings = true)] [MaxLength(200)] [MinLength(1)] diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramVerdict.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramVerdict.g.cs index 2c4829c00..6e52e6d5b 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramVerdict.g.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramVerdict.g.cs @@ -13,10 +13,8 @@ public class ProgramVerdict { public enum ProgramVerdictVerdict { GOOD, OK, POOR } [JsonPropertyName("verdict")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ProgramVerdictVerdict Verdict { get; set; } [JsonPropertyName("rationale")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [Required(AllowEmptyStrings = true)] [MaxLength(480)] [MinLength(1)] diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/WeekLabel.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/WeekLabel.g.cs index ede7456b1..13626fb5e 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Generated/WeekLabel.g.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/WeekLabel.g.cs @@ -12,7 +12,6 @@ namespace MetaObjects.IntegrationTests.Generated; public class WeekLabel { [JsonPropertyName("label")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [Required(AllowEmptyStrings = true)] [MaxLength(80)] [MinLength(1)] diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/jsonb/DocumentApiServer.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/jsonb/DocumentApiServer.kt index bb97528a4..6bde5f560 100644 --- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/jsonb/DocumentApiServer.kt +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/jsonb/DocumentApiServer.kt @@ -82,6 +82,7 @@ class DocumentApiServer(private val pg: PostgresContainer) : AutoCloseable { it[markers] = toJsonElement(r["markers"]) it[measure] = toJsonElement(r["measure"]) it[measures] = toJsonElement(r["measures"]) + it[telemetry] = toJsonElement(r["telemetry"]) it[tiers] = toJsonElement(r["tiers"]) it[pricing] = toJsonElement(r["pricing"]) } @@ -155,6 +156,7 @@ class DocumentApiServer(private val pg: PostgresContainer) : AutoCloseable { it[markers] = toJsonElement(markersVal) it[measure] = toJsonElement(canon["measure"]) it[measures] = toJsonElement(canon["measures"]) + it[telemetry] = toJsonElement(body["telemetry"]) it[tiers] = toJsonElement(canon["tiers"]) it[pricing] = toJsonElement(canon["pricing"]) }[DocumentTable.id] @@ -198,7 +200,7 @@ class DocumentApiServer(private val pg: PostgresContainer) : AutoCloseable { } // Apply only the present keys (absent = untouched). Guard the empty-SET case (Exposed throws). val canon = canonicalizeMeasures(body) ?: return sendJson(exchange, 400, mapOf("error" to "validation")) - val settableKeys = listOf("title", "payload", "primaryMarker", "optionalMarker", "markers", "measure", "measures", "tiers", "pricing") + val settableKeys = listOf("title", "payload", "primaryMarker", "optionalMarker", "markers", "measure", "measures", "telemetry", "tiers", "pricing") if (settableKeys.any { body.containsKey(it) }) { transaction(db) { DocumentTable.update({ DocumentTable.id eq id }) { @@ -209,6 +211,7 @@ class DocumentApiServer(private val pg: PostgresContainer) : AutoCloseable { if (body.containsKey("markers")) it[markers] = toJsonElement(body["markers"]) if (body.containsKey("measure")) it[measure] = toJsonElement(canon["measure"]) if (body.containsKey("measures")) it[measures] = toJsonElement(canon["measures"]) + if (body.containsKey("telemetry")) it[telemetry] = toJsonElement(body["telemetry"]) if (body.containsKey("tiers")) it[tiers] = toJsonElement(canon["tiers"]) if (body.containsKey("pricing")) it[pricing] = toJsonElement(canon["pricing"]) } @@ -310,6 +313,7 @@ class DocumentApiServer(private val pg: PostgresContainer) : AutoCloseable { "markers" to fromJsonElement(row[DocumentTable.markers]), "measure" to fromJsonElement(row[DocumentTable.measure]), "measures" to fromJsonElement(row[DocumentTable.measures]), + "telemetry" to fromJsonElement(row[DocumentTable.telemetry]), "tiers" to fromJsonElement(row[DocumentTable.tiers]), "pricing" to fromJsonElement(row[DocumentTable.pricing]), ) @@ -356,6 +360,7 @@ class DocumentApiServer(private val pg: PostgresContainer) : AutoCloseable { val markers = jsonb("markers", { it.toString() }, { Json.parseToJsonElement(it) }).nullable() val measure = jsonb("measure", { it.toString() }, { Json.parseToJsonElement(it) }).nullable() val measures = jsonb("measures", { it.toString() }, { Json.parseToJsonElement(it) }).nullable() + val telemetry = jsonb("telemetry", { it.toString() }, { Json.parseToJsonElement(it) }).nullable() val tiers = jsonb("tiers", { it.toString() }, { Json.parseToJsonElement(it) }).nullable() val pricing = jsonb("pricing", { it.toString() }, { Json.parseToJsonElement(it) }).nullable() diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/jsonb/generated/GeneratedDocumentControllerHarness.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/jsonb/generated/GeneratedDocumentControllerHarness.kt index 2050ce96d..c74065f5b 100644 --- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/jsonb/generated/GeneratedDocumentControllerHarness.kt +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/jsonb/generated/GeneratedDocumentControllerHarness.kt @@ -78,6 +78,8 @@ class GeneratedDocumentControllerHarness( * serialization seam). */ private val mapper: ObjectMapper = ObjectMapper() .registerKotlinModule() + .registerModule(com.fasterxml.jackson.datatype.jsr310.JavaTimeModule()) + .disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .registerModule(jsonElementModule()) private val classLoader: ClassLoader @@ -172,7 +174,7 @@ class GeneratedDocumentControllerHarness( // Program D: the seed rows carry the required primaryMarker (+ optional single/array // VO on row 1). Pass every present VO column through the generated controller's OWN // create path — so the create MUST handle value objects for seeding to succeed. - for (k in listOf("primaryMarker", "optionalMarker", "markers", "measure", "measures", "tiers", "pricing")) { + for (k in listOf("primaryMarker", "optionalMarker", "markers", "measure", "measures", "telemetry", "tiers", "pricing")) { if (r.containsKey(k)) body[k] = r[k] } val res = exchange("POST", "/api/documents", body) diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/JsonbReferenceServer.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/JsonbReferenceServer.java index e959236ee..d476a4c0b 100644 --- a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/JsonbReferenceServer.java +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/JsonbReferenceServer.java @@ -82,6 +82,7 @@ private void createSchema() throws SQLException { + "markers JSONB, " // nullable array-of-VO + "measure JSONB, " // nullable single Measure VO (decimal amount) + "measures JSONB, " // nullable array-of-Measure VO + + "telemetry JSONB, " // nullable single Telemetry VO (required value-type members) + "tiers JSONB, " // nullable map of Tier VO (decimal amount per value) + "pricing JSONB)"); // nullable Pricing VO holding a map of Tier } @@ -90,8 +91,8 @@ private void createSchema() throws SQLException { private void applySeed(List> seedRows) throws SQLException { try (Connection c = connect()) { try (PreparedStatement ps = c.prepareStatement( - "INSERT INTO \"documents\" (id, title, payload, primary_marker, optional_marker, markers, measure, measures, tiers, pricing) " - + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")) { + "INSERT INTO \"documents\" (id, title, payload, primary_marker, optional_marker, markers, measure, measures, telemetry, tiers, pricing) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")) { for (Map r : seedRows) { ps.setLong(1, ((Number) r.get("id")).longValue()); ps.setString(2, String.valueOf(r.get("title"))); @@ -101,8 +102,9 @@ private void applySeed(List> seedRows) throws SQLException { bindJsonb(ps, 6, r.get("markers")); bindJsonb(ps, 7, r.get("measure")); bindJsonb(ps, 8, r.get("measures")); - bindJsonb(ps, 9, r.get("tiers")); - bindJsonb(ps, 10, r.get("pricing")); + bindJsonb(ps, 9, r.get("telemetry")); + bindJsonb(ps, 10, r.get("tiers")); + bindJsonb(ps, 11, r.get("pricing")); ps.executeUpdate(); } } @@ -167,7 +169,7 @@ private void doHandle(HttpExchange exchange) throws IOException, SQLException { private Map findById(long id) throws SQLException { try (Connection c = connect(); PreparedStatement ps = c.prepareStatement( - "SELECT id, title, payload, primary_marker, optional_marker, markers, measure, measures, tiers, pricing " + "SELECT id, title, payload, primary_marker, optional_marker, markers, measure, measures, telemetry, tiers, pricing " + "FROM \"documents\" WHERE id = ?")) { ps.setLong(1, id); try (ResultSet rs = ps.executeQuery()) { @@ -179,9 +181,9 @@ private Map findById(long id) throws SQLException { private Map create(Map body) throws SQLException { try (Connection c = connect(); PreparedStatement ps = c.prepareStatement( - "INSERT INTO \"documents\" (title, payload, primary_marker, optional_marker, markers, measure, measures, tiers, pricing) " - + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) " - + "RETURNING id, title, payload, primary_marker, optional_marker, markers, measure, measures, tiers, pricing")) { + "INSERT INTO \"documents\" (title, payload, primary_marker, optional_marker, markers, measure, measures, telemetry, tiers, pricing) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + "RETURNING id, title, payload, primary_marker, optional_marker, markers, measure, measures, telemetry, tiers, pricing")) { ps.setString(1, String.valueOf(body.get("title"))); bindJsonb(ps, 2, body.get("payload")); bindJsonb(ps, 3, body.get("primaryMarker")); @@ -189,8 +191,9 @@ private Map create(Map body) throws SQLException bindJsonb(ps, 5, body.get("markers")); bindJsonb(ps, 6, body.get("measure")); bindJsonb(ps, 7, body.get("measures")); - bindJsonb(ps, 8, body.get("tiers")); - bindJsonb(ps, 9, body.get("pricing")); + bindJsonb(ps, 8, body.get("telemetry")); + bindJsonb(ps, 9, body.get("tiers")); + bindJsonb(ps, 10, body.get("pricing")); try (ResultSet rs = ps.executeQuery()) { rs.next(); return rowFrom(rs); @@ -215,6 +218,7 @@ private Map patch(long id, Map body) throws SQLE addAssignment(body, "markers", "markers", true, setClauses, values, jsonb); addAssignment(body, "measure", "measure", true, setClauses, values, jsonb); addAssignment(body, "measures", "measures", true, setClauses, values, jsonb); + addAssignment(body, "telemetry", "telemetry", true, setClauses, values, jsonb); addAssignment(body, "tiers", "tiers", true, setClauses, values, jsonb); addAssignment(body, "pricing", "pricing", true, setClauses, values, jsonb); if (!setClauses.isEmpty()) { @@ -252,6 +256,7 @@ private Map rowFrom(ResultSet rs) throws SQLException { row.put("markers", parseJson(rs.getString("markers"))); row.put("measure", parseJson(rs.getString("measure"))); row.put("measures", parseJson(rs.getString("measures"))); + row.put("telemetry", parseJson(rs.getString("telemetry"))); row.put("tiers", parseJson(rs.getString("tiers"))); row.put("pricing", parseJson(rs.getString("pricing"))); return row; diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedJsonbControllerHarness.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedJsonbControllerHarness.java index 7b840eb3d..49c3e53f3 100644 --- a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedJsonbControllerHarness.java +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedJsonbControllerHarness.java @@ -55,7 +55,11 @@ public final class GeneratedJsonbControllerHarness implements AutoCloseable { private static final String DTO_FQCN = ENTITY_PKG + ".DocumentDto"; private static final String REPO_FQCN = ENTITY_PKG + ".DocumentRepository"; - private final ObjectMapper mapper = new ObjectMapper(); + // The mapper a Spring Boot host provides by default: JavaTimeModule, dates as ISO strings (a + // Telemetry value object holds a field.timestamp, an Instant). + private final ObjectMapper mapper = new ObjectMapper() + .registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule()) + .disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); private final URLClassLoader classLoader; private final Class dtoClass; // acme.store.DocumentDto (record w/ VO components) private final Constructor controllerCtor; diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryDocumentRepositorySource.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryDocumentRepositorySource.java index fb4e0d08e..8f6ab6dd7 100644 --- a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryDocumentRepositorySource.java +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryDocumentRepositorySource.java @@ -80,7 +80,7 @@ public Optional findById(Long id) { @Override public DocumentDto create(DocumentDto dto) { DocumentDto saved = new DocumentDto(nextId.getAndIncrement(), dto.title(), dto.payload(), - dto.primaryMarker(), dto.optionalMarker(), dto.markers(), dto.measure(), dto.measures(), dto.tiers(), dto.pricing()); + dto.primaryMarker(), dto.optionalMarker(), dto.markers(), dto.measure(), dto.measures(), dto.telemetry(), dto.tiers(), dto.pricing()); rows.add(saved); return saved; } @@ -99,6 +99,7 @@ public Optional update(Long id, DocumentDto dto) { dto.markers() != null ? dto.markers() : cur.markers(), dto.measure() != null ? dto.measure() : cur.measure(), dto.measures() != null ? dto.measures() : cur.measures(), + dto.telemetry() != null ? dto.telemetry() : cur.telemetry(), dto.tiers() != null ? dto.tiers() : cur.tiers(), dto.pricing() != null ? dto.pricing() : cur.pricing()); rows.set(i, merged); @@ -124,6 +125,7 @@ public Optional patch(Long id, DocumentPatch patch) { patch.hasMarkers() ? patch.markers() : cur.markers(), patch.hasMeasure() ? patch.measure() : cur.measure(), patch.hasMeasures() ? patch.measures() : cur.measures(), + patch.hasTelemetry() ? patch.telemetry() : cur.telemetry(), patch.hasTiers() ? patch.tiers() : cur.tiers(), patch.hasPricing() ? patch.pricing() : cur.pricing()); rows.set(i, merged); diff --git a/server/python/tests/integration/api_contract_jsonb_server.py b/server/python/tests/integration/api_contract_jsonb_server.py index 46f87206e..6e0444767 100644 --- a/server/python/tests/integration/api_contract_jsonb_server.py +++ b/server/python/tests/integration/api_contract_jsonb_server.py @@ -60,11 +60,12 @@ "markers": "markers", "measure": "measure", "measures": "measures", + "telemetry": "telemetry", "tiers": "tiers", "pricing": "pricing", } _JSONB_FIELDS: frozenset[str] = frozenset( - {"payload", "primaryMarker", "optionalMarker", "markers", "measure", "measures", "tiers", "pricing"} + {"payload", "primaryMarker", "optionalMarker", "markers", "measure", "measures", "telemetry", "tiers", "pricing"} ) @@ -85,6 +86,7 @@ def create_schema(self) -> None: " markers JSONB," " measure JSONB," " measures JSONB," + " telemetry JSONB," " tiers JSONB," " pricing JSONB" ")" @@ -104,8 +106,8 @@ def apply_seed(self, rows: list[dict[str, Any]]) -> None: for r in rows: cur.execute( 'INSERT INTO "documents" ' - "(id, title, payload, primary_marker, optional_marker, markers, measure, measures, tiers, pricing) " - "VALUES (%s, %s, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb)", + "(id, title, payload, primary_marker, optional_marker, markers, measure, measures, telemetry, tiers, pricing) " + "VALUES (%s, %s, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb)", ( int(r["id"]), r["title"], @@ -115,6 +117,7 @@ def apply_seed(self, rows: list[dict[str, Any]]) -> None: _dump_payload(r.get("markers")), _dump_payload(r.get("measure")), _dump_payload(r.get("measures")), + _dump_payload(r.get("telemetry")), _dump_payload(r.get("tiers")), _dump_payload(r.get("pricing")), ), @@ -132,7 +135,7 @@ def find_by_id(self, ident: int) -> dict[str, Any] | None: cur = conn.cursor() try: cur.execute( - "SELECT id, title, payload, primary_marker, optional_marker, markers, measure, measures, tiers, pricing " + "SELECT id, title, payload, primary_marker, optional_marker, markers, measure, measures, telemetry, tiers, pricing " 'FROM "documents" WHERE id = %s', (int(ident),), ) @@ -147,8 +150,8 @@ def create(self, dto: dict[str, Any]) -> dict[str, Any]: try: cur.execute( 'INSERT INTO "documents" ' - "(title, payload, primary_marker, optional_marker, markers, measure, measures, tiers, pricing) " - "VALUES (%s, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb) RETURNING id", + "(title, payload, primary_marker, optional_marker, markers, measure, measures, telemetry, tiers, pricing) " + "VALUES (%s, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb) RETURNING id", ( dto.get("title"), _dump_payload(dto.get("payload")), @@ -157,6 +160,7 @@ def create(self, dto: dict[str, Any]) -> dict[str, Any]: _dump_payload(dto.get("markers")), _dump_payload(dto.get("measure")), _dump_payload(dto.get("measures")), + _dump_payload(dto.get("telemetry")), _dump_payload(dto.get("tiers")), _dump_payload(dto.get("pricing")), ), @@ -419,7 +423,7 @@ def _decode_jsonb(value: Any) -> Any: def _row_to_dict(row: Any) -> dict[str, Any]: - ident, title, payload, primary_marker, optional_marker, markers, measure, measures, tiers, pricing = row + ident, title, payload, primary_marker, optional_marker, markers, measure, measures, telemetry, tiers, pricing = row return { "id": int(ident), "title": title, @@ -429,6 +433,7 @@ def _row_to_dict(row: Any) -> dict[str, Any]: "markers": _decode_jsonb(markers), "measure": _decode_jsonb(measure), "measures": _decode_jsonb(measures), + "telemetry": _decode_jsonb(telemetry), "tiers": _decode_jsonb(tiers), "pricing": _decode_jsonb(pricing), } diff --git a/server/typescript/packages/integration-tests/src/api-contract-jsonb-generated-server.ts b/server/typescript/packages/integration-tests/src/api-contract-jsonb-generated-server.ts index 502f122da..ea3a42da4 100644 --- a/server/typescript/packages/integration-tests/src/api-contract-jsonb-generated-server.ts +++ b/server/typescript/packages/integration-tests/src/api-contract-jsonb-generated-server.ts @@ -90,6 +90,7 @@ export const db = drizzle(pool); "markers" jsonb, "measure" jsonb, "measures" jsonb, + "telemetry" jsonb, "tiers" jsonb, "pricing" jsonb )`, diff --git a/server/typescript/packages/integration-tests/src/api-contract-jsonb-server.ts b/server/typescript/packages/integration-tests/src/api-contract-jsonb-server.ts index c5a013a9a..f4f3209ba 100644 --- a/server/typescript/packages/integration-tests/src/api-contract-jsonb-server.ts +++ b/server/typescript/packages/integration-tests/src/api-contract-jsonb-server.ts @@ -44,6 +44,7 @@ export interface DocumentRow { markers?: Marker[] | null; measure?: Measure | null; measures?: Measure[] | null; + telemetry?: { capturedAt: string; latitude: number; tempC?: number } | null; tiers?: Record | null; pricing?: { tiers?: Record | null } | null; } @@ -81,6 +82,7 @@ export async function startJsonbServer(connectionUri: string, root: MetaRoot): P .addColumn("markers", "jsonb") .addColumn("measure", "jsonb") .addColumn("measures", "jsonb") + .addColumn("telemetry", "jsonb") .addColumn("tiers", "jsonb") .addColumn("pricing", "jsonb") .execute(); From 52ec9a41fc88d912a94ef6fa2be95e129d6f45cd Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Thu, 22 Oct 2026 16:32:02 -0400 Subject: [PATCH 2/3] no-mistakes(review): Test C# value-object JSON behavior via Roslyn compile --- .../ValueObjectWireIgnoreTests.cs | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/server/csharp/MetaObjects.Codegen.Tests/ValueObjectWireIgnoreTests.cs b/server/csharp/MetaObjects.Codegen.Tests/ValueObjectWireIgnoreTests.cs index 9b89340c3..f72690240 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/ValueObjectWireIgnoreTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/ValueObjectWireIgnoreTests.cs @@ -1,3 +1,7 @@ +using System.Reflection; +using System.Text.Json; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using MetaObjects.Codegen; using MetaObjects.Codegen.Generators; using MetaObjects.Loader; @@ -36,7 +40,7 @@ public class ValueObjectWireIgnoreTests """; [Fact] - public void A_non_nullable_value_type_member_carries_no_ignore_condition() + public void A_value_object_with_required_value_type_members_round_trips_and_omits_a_null_optional_member() { var r = new MetaDataLoader().Load([new InMemoryStringSource(Model, id: "model.json")]); Assert.Empty(r.Errors); @@ -46,18 +50,27 @@ public void A_non_nullable_value_type_member_carries_no_ignore_condition() Root = r.Root, Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated" }, }); - var src = Assert.Single(files, f => f.Path == "Telemetry.g.cs").Content; + var trees = files.Select(f => CSharpSyntaxTree.ParseText(f.Content, new CSharpParseOptions(LanguageVersion.CSharp12), path: f.Path)); + var comp = CSharpCompilation.Create("vo_wire_ignore_" + Guid.NewGuid().ToString("N"), trees, + DbContextCompileTests.BuildReferences(), new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + using var ms = new MemoryStream(); + var emit = comp.Emit(ms); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error))); + var telemetry = Assembly.Load(ms.ToArray()).GetTypes().Single(t => t.Name == "Telemetry"); - var lines = src.Split('\n').Select(l => l.TrimEnd()).ToList(); - string[] Attrs(string decl) => - lines.TakeWhile(l => !l.Contains(decl)).Reverse().TakeWhile(l => l.TrimStart().StartsWith("[")).ToArray(); + const string body = """ + {"capturedAt":"2026-03-04T05:06:07Z","latitude":51.5,"live":true,"fee":"12.50","token":"3f2504e0-4f89-11d3-9a0c-0305e82c3301"} + """; + var value = JsonSerializer.Deserialize(body, telemetry)!; + Assert.Equal(51.5, telemetry.GetProperty("Latitude")!.GetValue(value)); + Assert.Equal(DateTimeOffset.Parse("2026-03-04T05:06:07Z"), telemetry.GetProperty("CapturedAt")!.GetValue(value)); + Assert.Null(telemetry.GetProperty("TempC")!.GetValue(value)); - Assert.DoesNotContain(Attrs("public double Latitude"), l => l.Contains("WhenWritingNull")); - Assert.DoesNotContain(Attrs("public DateTimeOffset CapturedAt"), l => l.Contains("WhenWritingNull")); - Assert.DoesNotContain(Attrs("public bool Live"), l => l.Contains("WhenWritingNull")); - Assert.DoesNotContain(Attrs("public decimal Fee"), l => l.Contains("WhenWritingNull")); - Assert.DoesNotContain(Attrs("public Guid Token"), l => l.Contains("WhenWritingNull")); - Assert.Contains(Attrs("public int? TempC"), l => l.Contains("WhenWritingNull")); - Assert.Contains(Attrs("public string? Note"), l => l.Contains("WhenWritingNull")); + var wire = JsonDocument.Parse(JsonSerializer.Serialize(value, telemetry)).RootElement; + Assert.Equal(51.5, wire.GetProperty("latitude").GetDouble()); + Assert.Equal(DateTimeOffset.Parse("2026-03-04T05:06:07Z"), wire.GetProperty("capturedAt").GetDateTimeOffset()); + Assert.True(wire.GetProperty("live").GetBoolean()); + Assert.False(wire.TryGetProperty("tempC", out _)); + Assert.False(wire.TryGetProperty("note", out _)); } } From 7ff69989adfdf2699dd041042d7e8d5900f72d28 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Thu, 22 Oct 2026 16:48:54 -0400 Subject: [PATCH 3/3] no-mistakes(document): Updated jsonb fixture README for telemetry VO and invariant wording; no unresolved doc gaps. --- fixtures/api-contract-conformance/jsonb/README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/fixtures/api-contract-conformance/jsonb/README.md b/fixtures/api-contract-conformance/jsonb/README.md index 7c6bcce77..07cb22672 100644 --- a/fixtures/api-contract-conformance/jsonb/README.md +++ b/fixtures/api-contract-conformance/jsonb/README.md @@ -15,7 +15,7 @@ Java `Object`, Kotlin `JsonElement`, C# `JsonDocument`); this corpus locks it in cross-port. The single `Document` entity (`acme::store`, `@table="documents"`) carries an -open-bag field plus three value-object columns: +open-bag field plus four value-object columns: | Field | Type | Notes | |------------------|----------------------------------------------------------|------------------------------------------| @@ -25,6 +25,7 @@ open-bag field plus three value-object columns: | `primaryMarker` | `field.object @objectRef=Marker @storage=jsonb @required`| required single VO — present-null → 400 | | `optionalMarker` | `field.object @objectRef=Marker @storage=jsonb` | nullable single VO — FR-035 tristate | | `markers` | `field.object @objectRef=Marker @storage=jsonb isArray` | nullable array of VO — `[]` ≠ null | +| `telemetry` | `field.object @objectRef=Telemetry @storage=jsonb` | nullable VO with required non-nullable value types — see `jsonb-value-object-required-value-types` | **Why `id` is `@required`.** It is the shape every real model has — a shared `BaseEntity` declares `id` required — and the one no other api-contract entity @@ -53,7 +54,7 @@ The `Marker` value object (`object.value`) is `{ label: field.string @required ``` jsonb/ ├── README.md # this file -├── meta.json # the Document entity + Marker value object +├── meta.json # the Document entity + Marker and Telemetry value objects ├── seed.json # 2 seed Documents (ids 1, 2), jsonb object + VO columns └── scenarios/ ├── jsonb-open-bag-roundtrip.yaml # open-bag parsed-value contract @@ -170,8 +171,9 @@ A value object whose REQUIRED members are non-nullable value types in the native (`Telemetry { capturedAt: field.timestamp @required; latitude: field.double @required; tempC: field.int }`, held by the nullable `Document.telemetry`). A port whose generated model cannot serialize or deserialize such a member answers every request that touches the owning entity -with a 500 (C# 1.1.0-rc.8: a null-omission condition on a non-nullable `double` / -`DateTimeOffset` made System.Text.Json throw). +with a 500 (C# emitted a null-omission condition on a non-nullable `double` / +`DateTimeOffset`, which System.Text.Json rejects with `InvalidOperationException`; +the condition is emitted on nullable members only). | Requests | What it pins | |---|---|