From 296fb3e58fd434004fabc6830455c87afa048691 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Thu, 9 Jul 2026 07:08:53 -0400 Subject: [PATCH 1/6] feat(jsonb): typed value-object jsonb columns work end-to-end across all ports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typed @objectRef value-object columns stored as jsonb (field.object @storage:jsonb, single AND @isArray) now write+read correctly through every port's persistence layer, gated by a new cross-port array-of-VO roundtrip scenario. Three genuine, runtime-proven bugs fixed (the array-of-VO path was untested everywhere, which is how they shipped): Kotlin (codegen-kotlin) — REFACTOR to Jackson, drop decorative @Serializable. The port stamped kotlinx-serialization @Serializable on every entity/value/ projection data class, and the typed jsonb column emitted Json.encodeToString(VO.serializer(), ...) — which requires the kotlinx compiler plugin, and the moment that plugin is enabled EVERY class with a UUID/Instant/BigDecimal/URI/Inet/temporal field fails to compile (kotlinx has no serializer for those java.* types). No consumer ever used kotlinx (all Jackson); @Serializable was decorative. Now: entity/value/projection classes are plain data classes (Jackson-compatible); the typed jsonb + field.map columns use a generated per-package MetaJsonbMapper.kt (Jackson ObjectMapper with kotlinModule + JavaTimeModule) via Exposed's serialize/deserialize lambda overload — no compiler plugin, matching Java(Gson)/C#(System.Text.Json)/TS/ Python. @Serializable kept only on prompt payloads + enums (which genuinely kotlinx-decode). New gate: a compile-without-the-plugin + Testcontainers-PG roundtrip of a GENERATED typed-jsonb table (the missing gate that let the broken form ship). Supersedes the unmerged fix/kotlin-exposed-jsonb-object-column. C# (MetaObjects.Codegen) — array-of-VO jsonb was BROKEN (EF model finalization threw "ICollection must be a non-interface reference type"). DbContextGenerator now emits .OwnsMany (not .OwnsOne) when field.ResolvedIsArray(); a coupled nullability defect (a non-null List initializer couldn't represent a NULL jsonb column, conflating empty-[] with null) is fixed to mirror single-VO nullability. Java (OMDB) — array-of-VO jsonb was a GAP (read threw "Expected BEGIN_OBJECT but was BEGIN_ARRAY"). GenericSQLDriver.deserializeJsonb now branches on isArray (TypeToken>) and parseField routes through setObjectArray; the integration-tests RoundtripWriter authors a List insert value. Oracle: fixtures/persistence-conformance — a Label VO + a labels array-of-VO jsonb field on AllTypes (regenerated canonical schema); roundtrip scenarios exercise 2-element / empty-[] / 1-element arrays + the null case. TS was already correct (27/0); it proved the fixture and disproved nothing. All ports green: TS 27/0, C# 1402/0, Java OMDB 48/0 + persistence 24/0, Kotlin 272/0 + integration 97/0 (all Testcontainers PG). Co-Authored-By: Claude --- .../canonical/meta.fitness.json | 8 + .../canonical/schema.postgres.sql | 1 + .../queries/roundtrip-all-types.yaml | 20 ++ .../queries/update-delete-all-types.yaml | 4 + .../EntityGeneratorRound2Tests.cs | 4 +- ...ndsAbstractFieldInheritanceCodegenTests.cs | 5 +- .../ObjectFieldCodegenTests.cs | 15 ++ .../Generators/DbContextGenerator.cs | 10 + .../Generators/EntityGenerator.cs | 16 +- .../Generated/AllTypes.g.cs | 1 + .../Generated/AppDbContext.g.cs | 1 + .../Generated/Label.g.cs | 14 ++ .../Runner/EntityRow.cs | 10 + .../Runner/WriteCoercion.cs | 35 +++ server/java/codegen-kotlin/pom.xml | 9 + .../generator/kotlin/KotlinEntityGenerator.kt | 15 +- .../kotlin/KotlinExposedTableGenerator.kt | 207 ++++++++++++++++-- .../generator/kotlin/KotlinGenUtil.kt | 9 +- .../generator/kotlin/KotlinTypeMapper.kt | 31 ++- .../kotlin/apidocs/KotlinApiModelBuilder.kt | 11 +- .../kotlin/KotlinEntityGeneratorTest.kt | 14 +- .../kotlin/KotlinExposedTableGeneratorTest.kt | 22 +- .../generator/kotlin/KotlinTypeMapperTest.kt | 7 +- .../acme/blog/Author.kt | 2 - .../acme/blog/Post.kt | 2 - .../acme/blog/Author.kt | 2 - .../entity-with-enum/acme/game/Player.kt | 2 - .../entity-with-fk/acme/blog/Author.kt | 2 - .../entity-with-fk/acme/blog/Post.kt | 2 - .../acme/commerce/Address.kt | 2 - .../acme/commerce/MetaJsonbMapper.kt | 20 ++ .../entity-with-storage/acme/commerce/User.kt | 2 - .../acme/commerce/UserMetadata.kt | 2 - .../acme/commerce/UserTable.kt | 3 +- .../acme/ops/OrderReport.kt | 2 - .../entity-with-tph/acme/auth/Auth.kt | 2 - .../entity-with-tph/acme/auth/AuthLine.kt | 2 - .../acme/report/SalesReport.kt | 2 - .../acme/demo/Author.kt | 2 - .../integration/kotlin/QueryScenarioRunner.kt | 10 +- .../kotlin/tables/AllTypesTable.kt | 18 ++ .../jsonb/GeneratedTypedJsonbRoundTripTest.kt | 196 +++++++++++++++++ .../integration/QueryScenarioTests.java | 1 + .../integration/RoundtripWriter.java | 10 + .../manager/db/driver/GenericSQLDriver.java | 33 ++- .../manager/db/JsonbFieldDBTest.java | 112 +++++++++- .../omdb/src/test/resources/meta.jsonb.json | 29 +++ 47 files changed, 826 insertions(+), 103 deletions(-) create mode 100644 server/csharp/MetaObjects.IntegrationTests/Generated/Label.g.cs create mode 100644 server/java/codegen-kotlin/src/test/resources/snapshots/entity-with-storage/acme/commerce/MetaJsonbMapper.kt create mode 100644 server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/jsonb/GeneratedTypedJsonbRoundTripTest.kt diff --git a/fixtures/persistence-conformance/canonical/meta.fitness.json b/fixtures/persistence-conformance/canonical/meta.fitness.json index 39b6c0183..de39f2e59 100644 --- a/fixtures/persistence-conformance/canonical/meta.fitness.json +++ b/fixtures/persistence-conformance/canonical/meta.fitness.json @@ -216,6 +216,13 @@ { "field.int": { "name": "retries" } } ] }}, + { "object.value": { + "name": "Label", + "children": [ + { "field.string": { "name": "key" } }, + { "field.int": { "name": "weight" } } + ] + }}, { "object.entity": { "name": "AllTypes", "children": [ @@ -239,6 +246,7 @@ { "field.inet": { "name": "inetVal", "@required": true } }, { "field.inet": { "name": "inet6Val", "@required": true } }, { "field.object": { "name": "settings", "@objectRef": "Settings", "@storage": "jsonb" } }, + { "field.object": { "name": "labels", "@objectRef": "Label", "@storage": "jsonb", "isArray": true } }, { "identity.primary": { "name": "id", "@fields": "id", "@generation": "uuid" } } ] }} diff --git a/fixtures/persistence-conformance/canonical/schema.postgres.sql b/fixtures/persistence-conformance/canonical/schema.postgres.sql index eeee1080d..4f9a60cbe 100644 --- a/fixtures/persistence-conformance/canonical/schema.postgres.sql +++ b/fixtures/persistence-conformance/canonical/schema.postgres.sql @@ -117,6 +117,7 @@ CREATE TABLE "all_types" ( "inetVal" INET NOT NULL, "inet6Val" INET NOT NULL, "settings" JSONB, + "labels" JSONB, CONSTRAINT "all_types_pkey" PRIMARY KEY ("id"), CONSTRAINT "all_types_enumVal_chk" CHECK ("enumVal" IN ('LOW', 'MEDIUM', 'HIGH')) ); diff --git a/fixtures/persistence-conformance/queries/roundtrip-all-types.yaml b/fixtures/persistence-conformance/queries/roundtrip-all-types.yaml index 1bb41d488..85ef825f9 100644 --- a/fixtures/persistence-conformance/queries/roundtrip-all-types.yaml +++ b/fixtures/persistence-conformance/queries/roundtrip-all-types.yaml @@ -37,6 +37,11 @@ description: | renders the identical minimal plain-decimal string (see normalization.md). - object: a typed @objectRef value-object stored as JSONB → re-serialized with sorted keys. + - object array: a typed @objectRef value-object with isArray:true stored as + JSONB (`labels`) → a JSON array of sorted-key objects; element order is + preserved. Row 1 writes 2 elements, row 2 an empty array ([] ≠ null), row 3 + a single element — exercising the array-of-VO write+read codec each port + must implement (the gap that let a non-compiling/wrong array serializer ship). - uri: field.uri → TEXT column; round-trips as the verbatim URI string (no normalization — the port stores/returns it unchanged). - inet: field.inet → Postgres-native INET column. A HOST address (no CIDR @@ -72,6 +77,11 @@ queries: inetVal: "192.168.1.1" inet6Val: "2001:db8::1" settings: { theme: "dark", notify: true, retries: 3 } + # array-of-VO stored as JSONB (field.object @isArray @storage:jsonb). Element + # order is preserved; each element's keys are sorted on read (like settings). + labels: + - { key: "alpha", weight: 10 } + - { key: "beta", weight: 20 } expect: sVal: "hello" iVal: 42 @@ -91,6 +101,9 @@ queries: inetVal: "192.168.1.1" inet6Val: "2001:db8::1" settings: { notify: true, retries: 3, theme: "dark" } + labels: + - { key: "alpha", weight: 10 } + - { key: "beta", weight: 20 } - name: write-whole-second-temporal-then-read-back op: roundtrip @@ -114,6 +127,8 @@ queries: inetVal: "10.0.0.5" inet6Val: "fe80::1" settings: { theme: "light", notify: false, retries: 0 } + # empty array-of-VO: an empty JSON array must round-trip as [] (distinct from null). + labels: [] expect: sVal: "second row" iVal: -7 @@ -133,6 +148,7 @@ queries: inetVal: "10.0.0.5" inet6Val: "fe80::1" settings: { notify: false, retries: 0, theme: "light" } + labels: [] - name: write-full-int64-then-read-back description: | @@ -170,6 +186,8 @@ queries: # compressed form, so the expected read-back is `2001:db8::8a2e:370:7334`. inet6Val: "2001:0db8:0000:0000:0000:8a2e:0370:7334" settings: { theme: "dark", notify: true, retries: 9 } + labels: + - { key: "solo", weight: 99 } expect: sVal: "int64 row" iVal: 1 @@ -189,3 +207,5 @@ queries: inetVal: "255.255.255.255" inet6Val: "2001:db8::8a2e:370:7334" settings: { notify: true, retries: 9, theme: "dark" } + labels: + - { key: "solo", weight: 99 } diff --git a/fixtures/persistence-conformance/queries/update-delete-all-types.yaml b/fixtures/persistence-conformance/queries/update-delete-all-types.yaml index 6f4f6b38e..8683ac16d 100644 --- a/fixtures/persistence-conformance/queries/update-delete-all-types.yaml +++ b/fixtures/persistence-conformance/queries/update-delete-all-types.yaml @@ -100,6 +100,8 @@ queries: inetVal: "192.168.10.20" inet6Val: "2001:db8::dead:beef" settings: { notify: true, retries: 7, theme: "dark" } + # labels (array-of-VO jsonb) is neither seeded nor updated here → reads back null. + labels: null - name: read-back-after-update description: re-read by PK; the persisted row carries every re-encoded value. @@ -126,6 +128,8 @@ queries: inetVal: "192.168.10.20" inet6Val: "2001:db8::dead:beef" settings: { notify: true, retries: 7, theme: "dark" } + # labels (array-of-VO jsonb) is neither seeded nor updated here → reads back null. + labels: null - name: delete-by-pk description: DELETE the row through the runtime; true = a row was deleted. diff --git a/server/csharp/MetaObjects.Codegen.Tests/EntityGeneratorRound2Tests.cs b/server/csharp/MetaObjects.Codegen.Tests/EntityGeneratorRound2Tests.cs index 86cc08281..e9e1353a1 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/EntityGeneratorRound2Tests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/EntityGeneratorRound2Tests.cs @@ -132,7 +132,9 @@ public void ObjectField_with_isArray_emits_a_collection_not_a_single_ref() ]}} """; var customer = File(new EntityGenerator().Generate(Ctx(Load(model))), "Customer.g.cs"); - Assert.Contains("public ICollection Contacts { get; set; } = new List();", customer); + // A NON-required array-of-VO is a NULLABLE collection (no empty-list initializer) so + // an absent value stays a NULL jsonb column, distinct from an explicit empty `[]`. + Assert.Contains("public ICollection? Contacts { get; set; }", customer); Assert.DoesNotContain("public ContactInfo? Contacts", customer); } diff --git a/server/csharp/MetaObjects.Codegen.Tests/ExtendsAbstractFieldInheritanceCodegenTests.cs b/server/csharp/MetaObjects.Codegen.Tests/ExtendsAbstractFieldInheritanceCodegenTests.cs index a172de024..59e109070 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/ExtendsAbstractFieldInheritanceCodegenTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/ExtendsAbstractFieldInheritanceCodegenTests.cs @@ -68,8 +68,9 @@ public void Generated_entity_honors_every_inherited_field_property() Assert.DoesNotContain("public string Tags ", contact); // `addresses` extends field.object(isArray:true, @objectRef:Address, @storage:jsonb): - // array-of-Address, honoring the inherited @objectRef + isArray. - Assert.Contains("ICollection
Addresses", contact); + // array-of-Address, honoring the inherited @objectRef + isArray. Non-required (the + // abstract AddressBag has no @required) → a NULLABLE collection (null-vs-empty distinct). + Assert.Contains("ICollection
? Addresses", contact); // `balance` extends field.decimal(@precision:12, @scale:2) -> decimal property. Assert.Contains("Balance", contact); diff --git a/server/csharp/MetaObjects.Codegen.Tests/ObjectFieldCodegenTests.cs b/server/csharp/MetaObjects.Codegen.Tests/ObjectFieldCodegenTests.cs index 7d9adb5c3..6f10c1e9e 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/ObjectFieldCodegenTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/ObjectFieldCodegenTests.cs @@ -25,6 +25,7 @@ public class ObjectFieldCodegenTests { "field.string": { "name": "name", "@required": true } }, { "field.object": { "name": "homeAddress", "@objectRef": "Address", "@storage": "flattened" } }, { "field.object": { "name": "config", "@objectRef": "Address" } }, + { "field.object": { "name": "tags", "@objectRef": "Address", "@storage": "jsonb", "isArray": true } }, { "identity.primary": { "@fields": "id" } } ]}} ]}} @@ -85,6 +86,20 @@ public void DbContext_configures_owned_types_flattened_and_json() Assert.Contains("modelBuilder.Entity().OwnsOne(x => x.Config, b => b.ToJson(\"config\"));", dbContext); } + [Fact] + public void DbContext_configures_array_of_value_object_jsonb_as_OwnsMany() + { + var ctx = Ctx(Load()); + var dbContext = new DbContextGenerator().Generate(ctx).Single().Content; + + // An @isArray object field is a COLLECTION of the value object (ICollection
), + // so EF must map it with .OwnsMany(...).ToJson(...) — .OwnsOne over a collection fails + // at EF model finalization ("must be a non-interface reference type to be used as an + // entity type"). Regression gate for the array-of-VO jsonb path. + Assert.Contains("modelBuilder.Entity().OwnsMany(x => x.Tags, b => b.ToJson(\"tags\"));", dbContext); + Assert.DoesNotContain("OwnsOne(x => x.Tags", dbContext); + } + [Fact] public void Generated_entities_and_value_objects_compile_together() { diff --git a/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs index 0fb65b49d..12e71f272 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs @@ -379,6 +379,16 @@ private static string UsingEntityConfig(M2MNavigation nav) var nav = CSharpNaming.Pascal(field.Name); var parentCol = CSharpNaming.Column(field, strategy); + // An @isArray object field is a COLLECTION of the value object (the EntityGenerator + // emits it as ICollection), so EF must map it with .OwnsMany(...).ToJson(...) — + // .OwnsOne over a collection compiles but fails at EF model finalization ("must be a + // non-interface reference type to be used as an entity type"). An array is always a + // single JSON column (flattening N objects onto fixed columns is nonsensical), so it + // never takes the flattened branch below. ResolvedIsArray per ADR-0039 (array-ness is + // inheritable via extends). + if (field.ResolvedIsArray()) + return $" modelBuilder.Entity<{owner}>().OwnsMany(x => x.{nav}, b => b.ToJson(\"{parentCol}\"));"; + if (field.Storage != STORAGE_FLATTENED) return $" modelBuilder.Entity<{owner}>().OwnsOne(x => x.{nav}, b => b.ToJson(\"{parentCol}\"));"; diff --git a/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs index 993b421c5..ffb8290c4 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs @@ -965,12 +965,18 @@ protected static string EscapeAttrString(string s) => } var typeName = CSharpNaming.Pascal(target.Name); var propName = PropertyName(field); - // An object-typed field with @isArray:true is a COLLECTION of the value object, - // not a single nullable ref. Emit a non-nullable ICollection with an empty-list - // initializer (matching the scalar/enum array convention — the list is never null). - if (field.ResolvedIsArray()) // ADR-0039: resolving — array-ness inheritable via extends - return $" public ICollection<{typeName}> {propName} {{ get; set; }} = new List<{typeName}>();"; var required = CSharpNaming.IsRequired(owner, field); + // An object-typed field with @isArray:true is a COLLECTION of the value object + // (EF maps it via .OwnsMany(...).ToJson(...)), not a single nullable ref. A REQUIRED + // array is a non-null collection with an empty-list initializer (never null). A + // NON-required array is a NULLABLE collection with NO initializer, so an absent value + // stays a NULL jsonb column (distinct from an explicit empty `[]`) — mirroring the + // single-VO nullability below. A non-null empty-list default would erase that + // null-vs-empty distinction (EF re-serializes the empty list to `[]` on write). + if (field.ResolvedIsArray()) // ADR-0039: resolving — array-ness inheritable via extends + return required + ? $" public ICollection<{typeName}> {propName} {{ get; set; }} = new List<{typeName}>();" + : $" public ICollection<{typeName}>? {propName} {{ get; set; }}"; return required ? $" public {typeName} {propName} {{ get; set; }} = default!;" : $" public {typeName}? {propName} {{ get; set; }}"; diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/AllTypes.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/AllTypes.g.cs index 4450d1493..9ddea118a 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Generated/AllTypes.g.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/AllTypes.g.cs @@ -56,4 +56,5 @@ public enum AllTypesEnumVal { LOW, MEDIUM, HIGH } [Required] public IPAddress Inet6Val { get; set; } = default!; public Settings? Settings { get; set; } + public ICollection