Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions fixtures/api-contract-conformance/jsonb/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|------------------|----------------------------------------------------------|------------------------------------------|
Expand All @@ -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
Expand Down Expand Up @@ -53,13 +54,14 @@ 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
├── 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
Expand Down Expand Up @@ -162,3 +164,18 @@ 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# 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 |
|---|---|
| `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 |
9 changes: 9 additions & 0 deletions fixtures/api-contract-conformance/jsonb/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -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" } }
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using System.Reflection;
using System.Text.Json;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using MetaObjects.Codegen;
using MetaObjects.Codegen.Generators;
using MetaObjects.Loader;
using Xunit;

namespace MetaObjects.Codegen.Tests;

/// <summary>
/// A value-object member that is null is omitted on the wire via
/// <c>[JsonIgnore(WhenWritingNull)]</c>, 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 (<c>double</c>, <c>DateTimeOffset</c>), so every request that touched the owning entity
/// answered 500. The condition belongs on the nullable members only.
/// </summary>
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_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);
var files = new EntityGenerator().Generate(new GenContext
{
Entities = r.Root.Objects(),
Root = r.Root,
Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated" },
});
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");

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));

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 _));
}
}
10 changes: 8 additions & 2 deletions server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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, " "));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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<long>());
ins.Parameters.AddWithValue("@title", row["title"]!.GetValue<string>());
// payload + the VO columns are JSON — serialize each and bind as jsonb (an
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ public static async Task<JsonbGeneratedServerFactory> 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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -160,15 +160,16 @@ 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<string>());
AddJsonbParam(ins, "@payload", body["payload"]);
AddJsonbParam(ins, "@primaryMarker", body["primaryMarker"]);
AddJsonbParam(ins, "@optionalMarker", body["optionalMarker"]);
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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ namespace MetaObjects.IntegrationTests.Generated;
public class ProgramBrief
{
[JsonPropertyName("title")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[Required(AllowEmptyStrings = true)]
[MaxLength(200)]
[MinLength(1)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ namespace MetaObjects.IntegrationTests.Generated;
public class WeekLabel
{
[JsonPropertyName("label")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[Required(AllowEmptyStrings = true)]
[MaxLength(80)]
[MinLength(1)]
Expand Down
Loading
Loading