Skip to content

Commit bb2a06e

Browse files
dmealingclaude
andcommitted
feat(conformance): Phase B Unit 2 — C# read-path (temporal + projection)
Make C#'s persistence-conformance pass the shared corpus's new temporal + projection coverage (3 new scenarios + the updated existing ones) against Testcontainers Postgres. Read-path canonicalization (Normalization.cs): - TIMESTAMP vs TIMESTAMPTZ are both Npgsql `DateTime`; discriminate by DateTimeKind (timestamptz → Kind.Utc, value already UTC → append Z; timestamp → Kind.Unspecified, wall-clock → no Z). Mirrors the TS port's OID-keyed temporal parsers; a non-UTC-seeded TIMESTAMPTZ reads back as its UTC equivalent, proving normalization not just formatting. - TIME (TimeOnly) trims trailing-zero subseconds → "HH:MM:SS". - jsonb leaves keep their JSON-native shape (integer → number, non-integer → canonical plain-decimal string, string/bool/null pass through), matching the TS authority's recursion over parsed jsonb — instead of stringifying every leaf (the old hack compensating for the broken expected-side parse below). Expected-side YAML parsing (ScenarioLoader/QueryScenarioRunner): capture the `expect:` block as a raw YamlNode (scalar style preserved) so a plain `45` is a JSON number and a quoted "45" is a JSON string — the same type inference the TS authority's JS YAML loader does. Without it every INTEGER-typed expectation degraded to a string. Codegen fixes surfaced by the new scenarios (regenerated the integration-test Generated/*.g.cs baseline from canonical/meta.fitness.json — only the changed columns differ): - EntityGenerator: a MIN/MAX projection field materializes as its aggregated column's CLR type (MIN(INTEGER) → int), not the widened field.* declaration, so the read surfaces a JSON number. COUNT/SUM (→BIGINT/long) and AVG (→NUMERIC/double) already match. - DbContextGenerator: emit HasConversion<string>() for enum columns on read-only projections too (previously entity-only), so a passed-through enum over a view round-trips instead of EF reading the text column as Int32. - OriginConstants: named per-function constants (count/sum/avg/min/max). Docker available. C# results: query scenarios 14/14 (incl. the 3 new + asset-uuid-roundtrip/filter-is-null/projection-aggregate updated), full integration suite 34/34; Codegen 81/81, Conformance 443/443, CLI 8/8, Render 265/265. Shared fixtures untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b942c5a commit bb2a06e

11 files changed

Lines changed: 247 additions & 47 deletions

File tree

server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ public IEnumerable<EmittedFile> Generate(GenContext ctx)
3333
var name = CSharpNaming.Pascal(p.Name);
3434
var noKey = p.PrimaryIdentity() is null ? ".HasNoKey()" : string.Empty;
3535
modelLines.Add($" modelBuilder.Entity<{name}>(){noKey}.ToView(\"{p.DbView}\");");
36+
// Enum-typed projection columns persist as their string symbol in the view
37+
// (string-backed enums, CHECK-constrained varchar/text). Without an explicit
38+
// string conversion EF defaults to the int-ordinal mapping and reads the text
39+
// column as Int32 at materialization — an InvalidCastException. Mirror the
40+
// entity-side HasConversion<string>() so a projection that passes an enum
41+
// through (e.g. ProgramView.status over v_program) round-trips.
42+
foreach (var f in p.Fields().Where(f => f.SubType == FIELD_SUBTYPE_ENUM && !f.IsArray))
43+
modelLines.Add($" modelBuilder.Entity<{name}>().Property(x => x.{CSharpNaming.Pascal(f.Name)}).HasConversion<string>();");
3644
}
3745
foreach (var e in objects.Where(o => o.IsEntity() && !o.IsReadOnlyProjection()))
3846
{

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

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
using System.Text;
1616
using MetaObjects.Codegen.Docs;
1717
using MetaObjects.Meta;
18+
using MetaObjects.Persistence.Origin;
1819
using static MetaObjects.Core.Field.FieldConstants;
20+
using static MetaObjects.Persistence.Origin.OriginConstants;
1921

2022
namespace MetaObjects.Codegen.Generators;
2123

@@ -104,7 +106,8 @@ private EmittedFile EmitMappedClass(MetaObject entity, GenContext ctx)
104106
string? member = null;
105107
if (CSharpNaming.ScalarFor(field.SubType) is not null)
106108
{
107-
member = ScalarProperty(entity, field, pkFields, withAttributes: true, strategy);
109+
member = ScalarProperty(entity, field, pkFields, withAttributes: true, strategy,
110+
AggregateResultScalar(field, ctx));
108111
}
109112
else if (field.SubType == FIELD_SUBTYPE_ENUM)
110113
{
@@ -282,9 +285,10 @@ private static string EnumProperty(
282285
// (the jsonb column holds the list; the list itself is never null in C#).
283286
private static string ScalarProperty(
284287
MetaObject owner, MetaField field, IReadOnlyList<string> pkFields,
285-
bool withAttributes, ColumnNamingStrategy strategy = ColumnNamingStrategy.Literal)
288+
bool withAttributes, ColumnNamingStrategy strategy = ColumnNamingStrategy.Literal,
289+
string? baseTypeOverride = null)
286290
{
287-
var baseType = CSharpNaming.ScalarFor(field.SubType)!;
291+
var baseType = baseTypeOverride ?? CSharpNaming.ScalarFor(field.SubType)!;
288292
var propName = CSharpNaming.Pascal(field.Name);
289293

290294
// Array fields: emit List<T> with an empty-list initializer and only a [Column]
@@ -337,6 +341,27 @@ private static string ScalarProperty(
337341
: $" public {typeName}? {propName} {{ get; set; }}";
338342
}
339343

344+
// The CLR scalar type for a projection field whose value is a MIN/MAX aggregate:
345+
// the result type of MIN(col)/MAX(col) in SQL is the aggregated column's own type,
346+
// NOT the (often-widened) field.* declaration. e.g. MIN over an INTEGER column is
347+
// INTEGER — so the projection property must materialize as `int`, which the read
348+
// path then surfaces as a JSON number (vs. a BIGINT → string). COUNT/SUM widen to
349+
// BIGINT and AVG to NUMERIC, both already matched by the declared field.* type, so
350+
// only MIN/MAX need the source-type narrowing. Returns null (use the declared type)
351+
// for non-aggregate fields, non-MIN/MAX aggregates, or an unresolvable @of target.
352+
private static string? AggregateResultScalar(MetaField field, GenContext ctx)
353+
{
354+
var agg = field.Children().OfType<MetaAggregateOrigin>().FirstOrDefault();
355+
if (agg?.Agg is not (AGGREGATE_FN_MIN or AGGREGATE_FN_MAX)) return null;
356+
// @of is a dotted "Entity.field" reference to the aggregated column.
357+
if (agg.Of is not { } of) return null;
358+
var dot = of.LastIndexOf('.');
359+
if (dot <= 0 || dot == of.Length - 1) return null;
360+
var sourceObj = ctx.Root.FindObject(CSharpNaming.StripPkg(of[..dot]));
361+
var sourceField = sourceObj?.FindField(of[(dot + 1)..]);
362+
return sourceField is null ? null : CSharpNaming.ScalarFor(sourceField.SubType);
363+
}
364+
340365
// Transitive closure of plain value objects reachable through entity object-fields.
341366
private static List<MetaObject> ReferencedValueObjects(IReadOnlyList<MetaObject> mapped, GenContext ctx)
342367
{

server/csharp/MetaObjects.IntegrationTests/Generated/AppDbContext.g.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
2020
{
2121
modelBuilder.Entity<ProgramStat>().ToView("v_program_stat");
2222
modelBuilder.Entity<ProgramView>().ToView("v_program");
23+
modelBuilder.Entity<ProgramView>().Property(x => x.Status).HasConversion<string>();
2324
modelBuilder.Entity<Asset>().Property(x => x.ExternalId).HasColumnType("uuid").HasConversion(v => Guid.Parse(v!), g => g.ToString("D"));
2425
modelBuilder.Entity<Asset>().Property(x => x.Payload).HasColumnType("jsonb");
2526
modelBuilder.Entity<Asset>().Property(x => x.RecordedAt).HasColumnType("timestamp with time zone");

server/csharp/MetaObjects.IntegrationTests/Generated/Asset.g.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,10 @@ public class Asset
2222
public string? Payload { get; set; }
2323
[Column("recordedAt")]
2424
public DateTime RecordedAt { get; set; }
25+
[Column("observedAt")]
26+
public DateTime ObservedAt { get; set; }
27+
[Column("asOfDate")]
28+
public DateOnly AsOfDate { get; set; }
29+
[Column("atTime")]
30+
public TimeOnly AtTime { get; set; }
2531
}

server/csharp/MetaObjects.IntegrationTests/Generated/ProgramStat.g.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,12 @@ public class ProgramStat
1515
public long ProgramId { get; set; }
1616
[Column("weekCount")]
1717
public long? WeekCount { get; set; }
18+
[Column("totalMinutes")]
19+
public long? TotalMinutes { get; set; }
20+
[Column("avgMinutes")]
21+
public double? AvgMinutes { get; set; }
22+
[Column("minMinutes")]
23+
public int? MinMinutes { get; set; }
24+
[Column("maxMinutes")]
25+
public int? MaxMinutes { get; set; }
1826
}

server/csharp/MetaObjects.IntegrationTests/Generated/Week.g.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,6 @@ public class Week
1919
[Column("label")]
2020
[MaxLength(80)]
2121
public string? Label { get; set; }
22+
[Column("durationMinutes")]
23+
public int DurationMinutes { get; set; }
2224
}

server/csharp/MetaObjects.IntegrationTests/Runner/Normalization.cs

Lines changed: 63 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -43,16 +43,18 @@ public static class Normalization
4343
decimal dec => CanonicalDecimal(dec),
4444
// A jsonb/json column is read-as-string by Npgsql. Re-serialize with sorted
4545
// keys per the cross-port contract (normalization.md: JSON/JSONB → sorted
46-
// keys). Scalar leaves are stringified so the shape matches the expect: block
47-
// (YAML scalars deserialize as strings) and the corpus's string-scalar
48-
// philosophy (BIGINT/NUMERIC as strings). A plain text value that does not
49-
// parse as a JSON object/array stays a string.
50-
string js when TryParseJsonContainer(js, out var node) => StringifyLeaves(SortKeys(node!)),
46+
// keys), recursing through containers and normalizing each scalar leaf by its
47+
// JSON type. Leaves keep their JSON-native shape (matching the TS authority,
48+
// whose parsed-jsonb leaves stay JS numbers/booleans): integer numbers stay
49+
// JSON numbers, non-integer numbers become canonical plain-decimal strings,
50+
// strings/bools/null pass through. A plain text value that does not parse as a
51+
// JSON object/array stays a string.
52+
string js when TryParseJsonContainer(js, out var node) => NormalizeJsonContainer(node!),
5153
// Strings already canonical.
5254
string str => str,
5355
// Date/time discriminated by source CLR type.
5456
DateOnly date => date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
55-
TimeOnly time => time.ToString("HH:mm:ss.fffffff", CultureInfo.InvariantCulture),
57+
TimeOnly time => FormatTimeOnly(time),
5658
DateTime dt => FormatDateTime(dt),
5759
DateTimeOffset dto => dto.UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ss.fffK", CultureInfo.InvariantCulture)
5860
.TrimEnd('0').TrimEnd('.') + "Z",
@@ -104,15 +106,34 @@ private static string CanonicalFloat(double d)
104106
return s;
105107
}
106108

107-
// TIMESTAMP (no TZ) → "YYYY-MM-DDTHH:MM:SS[.fff]" with no trailing zeros and no Z.
108-
// TIMESTAMPTZ comes via DateTimeOffset above and gets a Z; the bare DateTime
109-
// branch never appends one.
109+
// TIMESTAMP vs TIMESTAMPTZ are BOTH surfaced by Npgsql/EF as `DateTime`; the
110+
// discriminator is DateTimeKind (Npgsql 6+/EF Core contract):
111+
// * `timestamp with time zone` → DateTimeKind.Utc, value already in UTC
112+
// → "YYYY-MM-DDTHH:MM:SS[.fff]Z".
113+
// * `timestamp` (no tz) → DateTimeKind.Unspecified, wall-clock value
114+
// → "YYYY-MM-DDTHH:MM:SS[.fff]" (no Z).
115+
// This mirrors the TS port's OID-keyed temporal parsers (canonicalTimestamptz
116+
// converts any offset to UTC + appends Z; canonicalTimestamp passes the wall
117+
// clock through with no Z) — see fixtures/persistence-conformance/normalization.md
118+
// and the TS temporal-parsers.ts. A non-UTC-seeded TIMESTAMPTZ thus reads back
119+
// as its UTC equivalent, proving normalization (not just formatting).
110120
private static string FormatDateTime(DateTime dt)
111121
{
112-
var withSubseconds = dt.ToString("yyyy-MM-ddTHH:mm:ss.fffffff", CultureInfo.InvariantCulture);
113-
// Trim trailing zeros in the subsecond fraction, then trim the decimal point
114-
// if the subsecond portion was entirely zero.
115-
var s = withSubseconds.TrimEnd('0');
122+
var s = TrimSubseconds(dt.ToString("yyyy-MM-ddTHH:mm:ss.fffffff", CultureInfo.InvariantCulture));
123+
return dt.Kind == DateTimeKind.Utc ? s + "Z" : s;
124+
}
125+
126+
// TIME → "HH:MM:SS[.fff]" with trailing zero subseconds (and a bare decimal
127+
// point) stripped. Phase B seeds whole seconds, so this yields "HH:MM:SS".
128+
private static string FormatTimeOnly(TimeOnly time) =>
129+
TrimSubseconds(time.ToString("HH:mm:ss.fffffff", CultureInfo.InvariantCulture));
130+
131+
// Strip trailing zeros from a subsecond fraction, then drop the decimal point
132+
// if the subsecond portion was entirely zero.
133+
private static string TrimSubseconds(string formatted)
134+
{
135+
if (!formatted.Contains('.')) return formatted;
136+
var s = formatted.TrimEnd('0');
116137
if (s.EndsWith('.')) s = s[..^1];
117138
return s;
118139
}
@@ -151,32 +172,48 @@ private static bool TryParseJsonContainer(string s, out JsonNode? node)
151172
catch (JsonException) { return false; }
152173
}
153174

154-
// Coerce every scalar leaf to its string form (numbers/bools → string), preserving
155-
// object/array structure. Matches the expect: block, whose YAML scalars deserialize
156-
// as strings, and the corpus's string-scalar normalization philosophy.
157-
private static JsonNode StringifyLeaves(JsonNode node)
175+
// Normalize a parsed jsonb container: sort object keys recursively and normalize
176+
// each scalar leaf by its JSON type, mirroring the TS authority's recursion over
177+
// parsed jsonb (normalizeValue): integer numbers stay JSON numbers, non-integer
178+
// numbers become canonical plain-decimal strings (no trailing zeros), and
179+
// strings/bools/null pass through unchanged. Object/array structure is preserved.
180+
private static JsonNode? NormalizeJsonContainer(JsonNode node)
158181
{
159182
switch (node)
160183
{
161184
case JsonObject obj:
162185
var o = new JsonObject();
163-
foreach (var (k, v) in obj) o[k] = v is null ? null : StringifyLeaves(v.DeepClone());
186+
foreach (var (k, v) in obj.OrderBy(p => p.Key, StringComparer.Ordinal))
187+
o[k] = v is null ? null : NormalizeJsonContainer(v.DeepClone());
164188
return o;
165189
case JsonArray arr:
166190
var a = new JsonArray();
167-
foreach (var item in arr) a.Add(item is null ? null : StringifyLeaves(item.DeepClone()));
191+
foreach (var item in arr) a.Add(item is null ? null : NormalizeJsonContainer(item.DeepClone()));
168192
return a;
169193
default:
170-
// JsonValue scalar → its canonical string form. Parse (not Trim('"'))
171-
// so a string leaf containing embedded quotes/escapes round-trips
172-
// losslessly: take the raw string value for JSON strings, and the
173-
// serialized form for non-string scalars (numbers/bools/null).
174-
return JsonValue.Create(node.GetValueKind() == JsonValueKind.String
175-
? node.GetValue<string>()
176-
: node.ToJsonString());
194+
return NormalizeJsonLeaf(node);
177195
}
178196
}
179197

198+
private static JsonNode? NormalizeJsonLeaf(JsonNode node) => node.GetValueKind() switch
199+
{
200+
JsonValueKind.String => JsonValue.Create(node.GetValue<string>()),
201+
JsonValueKind.True => JsonValue.Create(true),
202+
JsonValueKind.False => JsonValue.Create(false),
203+
JsonValueKind.Null => null,
204+
// Numbers: an integer-valued number stays a JSON number (INTEGER → number per
205+
// the contract); a non-integer becomes the canonical plain-decimal string.
206+
JsonValueKind.Number => NormalizeJsonNumber(node),
207+
_ => node.DeepClone(),
208+
};
209+
210+
private static JsonNode NormalizeJsonNumber(JsonNode node)
211+
{
212+
var element = node.GetValue<System.Text.Json.JsonElement>();
213+
if (element.TryGetInt64(out var l)) return JsonValue.Create(l);
214+
return JsonValue.Create(CanonicalFloat(element.GetDouble()));
215+
}
216+
180217
/// <summary>
181218
/// Canonical-JSON serialization of an arbitrary node: object keys sorted
182219
/// recursively so two semantically-equal trees compare byte-equal.

server/csharp/MetaObjects.IntegrationTests/Runner/QueryScenarioRunner.cs

Lines changed: 65 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,14 @@
1515
// [Column(...)], so the EF Core runtime addresses the schema's columns directly.
1616
// See docs/superpowers/specs/2026-05-30-ts-schema-authority-consolidation-design.md.
1717

18+
using System.Globalization;
1819
using System.Text.Json.Nodes;
1920
using MetaObjects.IntegrationTests.Generated;
2021
using Microsoft.EntityFrameworkCore;
2122
using Npgsql;
2223
using Xunit.Sdk;
24+
using YamlDotNet.Core;
25+
using YamlDotNet.RepresentationModel;
2326

2427
namespace MetaObjects.IntegrationTests.Runner;
2528

@@ -80,25 +83,72 @@ private static void AssertResult(string scenarioPath, QuerySpec spec, object? ac
8083
$"{scenarioPath} / {spec.Name}: result mismatch\n expected: {expectedJson}\n actual: {actualJson}");
8184
}
8285

83-
private static string CanonicalizeExpected(object? expect, string op)
86+
private static string CanonicalizeExpected(YamlNode? expect, string op)
8487
{
85-
// YamlDotNet hands back nested mappings as Dictionary<object,object?> and
86-
// bare scalars (including integers) as strings when the parent property is
87-
// typed as object?. For `op:count` the expected is logically an integer;
88-
// parse it so the comparison is number-vs-number, not string-vs-number.
88+
// For `op:count` the expected is logically an integer; parse the scalar so
89+
// the comparison is number-vs-number, not string-vs-number.
8990
if (op == "count")
9091
{
91-
var n = expect switch
92-
{
93-
long l => l,
94-
int i => i,
95-
string s when long.TryParse(s, out var p) => p,
96-
_ => throw new InvalidOperationException($"op:count expects an integer, got: {expect}"),
97-
};
98-
return n.ToString(System.Globalization.CultureInfo.InvariantCulture);
92+
var raw = (expect as YamlScalarNode)?.Value;
93+
if (raw is null || !long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
94+
throw new InvalidOperationException($"op:count expects an integer, got: {raw ?? "null"}");
95+
return n.ToString(CultureInfo.InvariantCulture);
9996
}
100-
var node = ToJsonNode(expect);
101-
return Canonical(node);
97+
return Canonical(YamlExpectToJsonNode(expect));
98+
}
99+
100+
// Convert the raw `expect:` YAML subtree to a JsonNode, honoring scalar STYLE so
101+
// the comparison matches the wire contract (normalization.md): a plain (unquoted)
102+
// scalar is YAML-core-schema-inferred (45 → number, true → bool, null/~ → null),
103+
// while a QUOTED scalar is always a JSON string ("45" → "45"). This is exactly the
104+
// type inference the TS authority's JS YAML loader performs; without it every
105+
// INTEGER-typed expectation would degrade to a string and never match the
106+
// number-shaped actual rows.
107+
private static JsonNode? YamlExpectToJsonNode(YamlNode? node)
108+
{
109+
switch (node)
110+
{
111+
case null:
112+
case YamlScalarNode { Value: null }:
113+
return null;
114+
case YamlScalarNode scalar:
115+
return ScalarToJsonNode(scalar);
116+
case YamlMappingNode map:
117+
var obj = new JsonObject();
118+
foreach (var (k, v) in map.Children)
119+
obj[((YamlScalarNode)k).Value!] = YamlExpectToJsonNode(v);
120+
return obj;
121+
case YamlSequenceNode seq:
122+
var arr = new JsonArray();
123+
foreach (var item in seq.Children) arr.Add(YamlExpectToJsonNode(item));
124+
return arr;
125+
default:
126+
throw new InvalidOperationException($"unsupported YAML node kind in expect: {node.GetType().Name}");
127+
}
128+
}
129+
130+
// A quoted scalar is always a string. A plain scalar follows YAML core-schema
131+
// inference: null/~ → null, true/false → bool, integer → long, otherwise the
132+
// literal string (NUMERIC/BIGINT/UUID/float values are authored as strings, so
133+
// floats and big integers stay strings here and the contract's string forms hold).
134+
private static JsonNode? ScalarToJsonNode(YamlScalarNode scalar)
135+
{
136+
var value = scalar.Value!;
137+
var quoted = scalar.Style is ScalarStyle.SingleQuoted or ScalarStyle.DoubleQuoted;
138+
if (quoted) return JsonValue.Create(value);
139+
140+
switch (value)
141+
{
142+
case "" or "~" or "null" or "Null" or "NULL":
143+
return null;
144+
case "true" or "True" or "TRUE":
145+
return JsonValue.Create(true);
146+
case "false" or "False" or "FALSE":
147+
return JsonValue.Create(false);
148+
}
149+
if (long.TryParse(value, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var l))
150+
return JsonValue.Create(l);
151+
return JsonValue.Create(value);
102152
}
103153

104154
private static string CanonicalizeActual(object? actual, string op)

server/csharp/MetaObjects.IntegrationTests/Runner/Scenario.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
//
55
// ScenarioLoader parses YAML into these records. The runners consume them.
66

7+
using YamlDotNet.RepresentationModel;
8+
79
namespace MetaObjects.IntegrationTests.Runner;
810

911
/// <summary>Base type for any scenario in the corpus.</summary>
@@ -28,6 +30,6 @@ public sealed record QuerySpec(
2830
IReadOnlyList<SortSpec>? Sort,
2931
int? Limit,
3032
int? Offset,
31-
object? Expect); // shape depends on Op (list → array, get → object|null, count → int)
33+
YamlNode? Expect); // raw YAML subtree (scalar style preserved); shape depends on Op
3234

3335
public sealed record SortSpec(string Field, string Dir); // dir: asc | desc

0 commit comments

Comments
 (0)