Skip to content

Commit 4d296d1

Browse files
dmealingclaude
andcommitted
feat(program-d): C# codegen PATCHes + validates value-object jsonb columns
Value-object jsonb columns (field.object @storage:jsonb, single AND @isarray) are EF owned-nav properties invisible to the PATCH merge loop's FindProperty and to the create handler's top-level TryValidateObject. Now: - EntityGenerator splits validation attrs from EF-mapping attrs so VO POCOs carry [Required(AllowEmptyStrings=true)]/[MaxLength]/... + [JsonPropertyName] (pins the stored jsonb keys to the camelCase metadata field names). - RoutesGenerator emits typed per-field VO merge arms (deserialize -> recursive validate -> assign the CLR owned-nav; EF .ToJson persists via full-document replacement) + per-VO POST validation; present-null clears a nullable column but 400s a @required one, driven by @required METADATA not EF nullability; a post-save raw UPDATE NULLs a nullable array column (EF OwnsMany.ToJson writes [] for a null nav, never SQL NULL). - New ValueObjectValidator (Codegen.Runtime, the established generated-code helper namespace): TryValidateObject per element + manual recursion into nested VO members (spec section 0 — TryValidateObject is non-recursive). Both lanes green (reference + generated full-stack over Testcontainers PG, all 20 VO scenarios); Codegen.Tests 278/0; persistence roundtrip 24/24 (Label/Settings VO POCOs unaffected); other api-contract lanes 40/40 — no regression. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGQ7oSuNcjhsMHWwZzhBwr
1 parent 7a5e576 commit 4d296d1

8 files changed

Lines changed: 549 additions & 73 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
using Microsoft.CodeAnalysis;
2+
using Microsoft.CodeAnalysis.CSharp;
3+
using MetaObjects.Codegen;
4+
using MetaObjects.Codegen.Generators;
5+
using MetaObjects.Loader;
6+
using MetaObjects.Meta;
7+
using Xunit;
8+
9+
namespace MetaObjects.Codegen.Tests;
10+
11+
// Program D — value-object jsonb columns (field.object @storage:jsonb, single AND @isArray)
12+
// must be POST-able + PATCH-able with FULL nested validation. Mirrors the gate fixture
13+
// (fixtures/api-contract-conformance/jsonb): a reused Marker VO (label @required @maxLength 40)
14+
// on a required single column, a nullable single column, and a nullable array column.
15+
public class ValueObjectValidationCodegenTests
16+
{
17+
private const string Model = """
18+
{ "metadata.root": { "package": "acme::store", "children": [
19+
{ "object.value": { "name": "Marker", "children": [
20+
{ "field.string": { "name": "label", "@required": true, "@maxLength": 40 } },
21+
{ "field.int": { "name": "score" } }
22+
]}},
23+
{ "object.entity": { "name": "Document", "children": [
24+
{ "source.rdb": { "@table": "documents" } },
25+
{ "field.long": { "name": "id" } },
26+
{ "field.string": { "name": "title", "@required": true, "@maxLength": 200 } },
27+
{ "field.object": { "name": "primaryMarker", "@objectRef": "Marker", "@storage": "jsonb", "@required": true } },
28+
{ "field.object": { "name": "optionalMarker", "@objectRef": "Marker", "@storage": "jsonb" } },
29+
{ "field.object": { "name": "markers", "@objectRef": "Marker", "@storage": "jsonb", "isArray": true } },
30+
{ "identity.primary": { "@fields": "id" } }
31+
]}}
32+
]}}
33+
""";
34+
35+
private static MetaRoot Load()
36+
{
37+
var r = new MetaDataLoader().Load([new InMemoryStringSource(Model, id: "vo.json")]);
38+
Assert.Empty(r.Errors);
39+
return r.Root;
40+
}
41+
42+
private static GenContext Ctx(MetaRoot root) => new()
43+
{
44+
Entities = root.Objects(), Root = root,
45+
Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Store.Generated" },
46+
};
47+
48+
[Fact]
49+
public void Marker_poco_carries_validation_and_json_property_name_attributes()
50+
{
51+
var files = new EntityGenerator().Generate(Ctx(Load())).ToList();
52+
var marker = files.Single(f => f.Path == "Marker.g.cs").Content;
53+
54+
// The VO's own members carry validation DataAnnotations so a nested VO validates in
55+
// FULL on POST/PATCH: label is required (non-empty) AND <= 40 chars.
56+
Assert.Contains("[Required(AllowEmptyStrings = true)]", marker);
57+
Assert.Contains("[MaxLength(40)]", marker);
58+
// [JsonPropertyName] pins the owned-JSON element name (+ wire name) to the metadata
59+
// field name so EF Core 8 storage matches the shared seed + cross-port wire contract.
60+
Assert.Contains("[JsonPropertyName(\"label\")]", marker);
61+
Assert.Contains("[JsonPropertyName(\"score\")]", marker);
62+
// A VO POCO still carries NO EF-mapping attributes (JSON mapping is fluent, .ToJson).
63+
Assert.DoesNotContain("[Column(", marker);
64+
Assert.DoesNotContain("[Key]", marker);
65+
}
66+
67+
[Fact]
68+
public void Routes_emit_typed_vo_merge_arms_and_post_validation()
69+
{
70+
var files = new RoutesGenerator().Generate(Ctx(Load())).ToList();
71+
var routes = files.Single(f => f.Path == "DocumentRoutes.g.cs").Content;
72+
73+
// PATCH merge — a typed arm per VO column (keyed on the wire field name), assigning
74+
// the CLR owned-nav property (EF .ToJson persists via full-document replacement).
75+
Assert.Contains("\"primaryMarker\"", routes);
76+
Assert.Contains("\"optionalMarker\"", routes);
77+
Assert.Contains("\"markers\"", routes);
78+
Assert.Contains("existing.OptionalMarker = null;", routes); // nullable single: present-null clears
79+
Assert.Contains("existing.Markers = null;", routes); // nullable array: present-null clears
80+
Assert.Contains("Deserialize<Marker>", routes); // single VO
81+
Assert.Contains("Deserialize<System.Collections.Generic.List<Marker>>", routes); // array VO
82+
// Recursive VO validation on BOTH PATCH and POST.
83+
Assert.Contains("ValueObjectValidator.Validate", routes);
84+
Assert.Contains("ValueObjectValidator.Validate(input.PrimaryMarker)", routes);
85+
}
86+
87+
[Fact]
88+
public void Generated_entity_and_value_object_compile_together()
89+
{
90+
// The VO POCO now carries validation DataAnnotations + [JsonPropertyName]; verify the
91+
// added usings compile (the full generated server incl. routes is compiled by the
92+
// integration generated lane). Entity + VO only — no ASP.NET reference needed.
93+
var files = new EntityGenerator().Generate(Ctx(Load())).ToList();
94+
var trees = files.Select(f =>
95+
CSharpSyntaxTree.ParseText(f.Content, new CSharpParseOptions(LanguageVersion.CSharp12))).ToList();
96+
var refs = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!)
97+
.Split(Path.PathSeparator).Where(p => p.Length > 0)
98+
.Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)).ToList();
99+
var comp = CSharpCompilation.Create("vocompile_" + Guid.NewGuid().ToString("N"),
100+
trees, refs, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
101+
102+
var errors = comp.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error)
103+
.Select(d => $"{d.Id}: {d.GetMessage()}").ToList();
104+
Assert.True(errors.Count == 0, "generated VO entity should compile, got: " + string.Join("; ", errors));
105+
}
106+
}

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

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -566,9 +566,16 @@ protected virtual EmittedFile EmitValueObjectPoco(MetaObject vo, GenContext ctx)
566566
sb.AppendLine("#nullable enable");
567567
sb.AppendLine("using System;");
568568
sb.AppendLine("using System.Collections.Generic;");
569+
// A VO's own members carry validation DataAnnotations ([Required]/[MaxLength]/etc.)
570+
// so a nested value-object is validated in FULL on POST/PATCH (Program D).
571+
sb.AppendLine("using System.ComponentModel.DataAnnotations;");
569572
// A value-object field with an explicit @column emits [Column(...)] — same as the entity
570573
// paths, so it needs the Schema namespace too (was omitted here → CS0246 on Column).
571574
sb.AppendLine("using System.ComponentModel.DataAnnotations.Schema;");
575+
// [JsonPropertyName] pins each VO member's jsonb element name (+ wire name) to the
576+
// metadata field name (camelCase). EF Core 8 owned-JSON (.ToJson) honors it, so the
577+
// stored jsonb keys match the shared seed + the wire contract cross-port (Program D).
578+
sb.AppendLine("using System.Text.Json.Serialization;");
572579
// A @dbColumnType:jsonb field surfaces as a System.Text.Json.JsonDocument (issue #98).
573580
if (CSharpNaming.RequiresSystemTextJson(vo))
574581
sb.AppendLine("using System.Text.Json;");
@@ -593,14 +600,19 @@ protected virtual EmittedFile EmitValueObjectPoco(MetaObject vo, GenContext ctx)
593600
{
594601
string? member = null;
595602
if (CSharpNaming.ScalarFor(field.SubType) is not null)
596-
member = ScalarProperty(vo, field, [], withAttributes: false);
603+
// VO POCO members carry NO EF-mapping attrs but DO carry validation
604+
// DataAnnotations (the VO is validated in full on POST/PATCH; Program D).
605+
member = ScalarProperty(vo, field, [], withAttributes: false, withValidationAttributes: true);
597606
else if (field.SubType == FIELD_SUBTYPE_ENUM)
598607
member = EnumProperty(vo, field, ctx.Config, ctx.Config.ColumnNamingStrategy);
599608
else if (field.SubType == FIELD_SUBTYPE_OBJECT && ObjectNavProperty(vo, field, ctx) is { } nav)
600609
member = nav;
601610
else if (field.SubType == FIELD_SUBTYPE_MAP)
602611
member = MapProperty(vo, field, ctx, withAttributes: false);
603612
if (member is null) continue;
613+
// Pin the jsonb element name (+ wire name) to the metadata field name so the
614+
// stored owned-JSON keys match the shared seed + the cross-port wire contract.
615+
member = $" [JsonPropertyName(\"{field.Name}\")]\n" + member;
604616
member = ApplyPropertyAttributes(member, vo, field, ctx);
605617
members.Add(XmlDocBuilder.Prepend(member, field, " "));
606618
}
@@ -756,18 +768,25 @@ protected virtual string EnumProperty(
756768
return $"{colAttr} public {type} {propName} {{ get; set; }}";
757769
}
758770

759-
// A scalar property. withAttributes adds the EF mapping annotations ([Key] for a
760-
// single-column PK, [Column], [MaxLength], [Required]); value-object POCOs omit
761-
// them (column mapping is fluent/JSON, not annotation-driven).
771+
// A scalar property. withAttributes adds the EF-mapping annotations ([Key] for a
772+
// single-column PK, [Column]); value-object POCOs omit them (column mapping is
773+
// fluent/JSON, not annotation-driven). withValidationAttributes controls the
774+
// DataAnnotations validation attrs ([Required]/[StringLength]/[MinLength]/
775+
// [MaxLength]/[Range]/[RegularExpression]) INDEPENDENTLY — a VO POCO carries these
776+
// (its members are validated on POST/PATCH — Program D) even though it carries NO
777+
// EF-mapping attrs; an attribute-free abstract shape carries neither. When null,
778+
// validation attrs follow withAttributes (mapped entities emit both, byte-identical
779+
// to before).
762780
//
763781
// When the field is an array (isArray: true), emit List<T> with an empty-list
764782
// initializer instead of a scalar T property. Arrays are always non-nullable
765783
// (the jsonb column holds the list; the list itself is never null in C#).
766784
protected virtual string ScalarProperty(
767785
MetaObject owner, MetaField field, IReadOnlyList<string> pkFields,
768786
bool withAttributes, ColumnNamingStrategy strategy = ColumnNamingStrategy.Literal,
769-
string? baseTypeOverride = null)
787+
string? baseTypeOverride = null, bool? withValidationAttributes = null)
770788
{
789+
var emitValidation = withValidationAttributes ?? withAttributes;
771790
// A @dbColumnType:jsonb open-bag shifts the CLR property to a parsed JSON value
772791
// (JsonDocument) — issue #98 — taking precedence over the logical scalar. A uuid
773792
// override keeps the logical CLR type (converted at the DB seam). ScalarForField
@@ -784,11 +803,10 @@ protected virtual string ScalarProperty(
784803
{
785804
var arr = new StringBuilder();
786805
if (withAttributes)
787-
{
788806
arr.AppendLine($" [Column(\"{CSharpNaming.Column(field, strategy)}\")]");
789-
// validator.array @min/@max → element-count bounds on the collection.
807+
// validator.array @min/@max → element-count bounds on the collection.
808+
if (emitValidation)
790809
AppendArrayValidatorAttributes(arr, field);
791-
}
792810
arr.Append($" public ICollection<{baseType}> {propName} {{ get; set; }} = new List<{baseType}>();");
793811
return arr.ToString();
794812
}
@@ -797,11 +815,19 @@ protected virtual string ScalarProperty(
797815
var isValue = CSharpNaming.IsValueType(baseType);
798816

799817
var sb = new StringBuilder();
818+
// EF-mapping attributes ([Key]/[Column]) — mapped entities only; a VO POCO's
819+
// JSON member mapping is fluent (.ToJson), so it carries none of these.
800820
if (withAttributes)
801821
{
802822
if (pkFields.Count == 1 && pkFields[0] == field.Name)
803823
sb.AppendLine(" [Key]");
804824
sb.AppendLine($" [Column(\"{CSharpNaming.Column(field, strategy)}\")]");
825+
}
826+
// Validation DataAnnotations — mapped entities AND value-object POCOs (a VO's
827+
// own members are validated on POST/PATCH; Program D), never an attribute-free
828+
// abstract shape.
829+
if (emitValidation)
830+
{
805831
if (baseType == "string")
806832
{
807833
// FR-036 A1 — a @required string is NON-EMPTY, but whitespace-only is ACCEPTED

0 commit comments

Comments
 (0)