From 1af5e85f495fedd3fea04c0c8930aa6c0a768d7d Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 9 Sep 2026 21:16:12 -0400 Subject: [PATCH 01/22] feat(codegen-spring): field.map reaches Java codegen instead of failing the build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `field.map` is registered in all five ports, but the Spring port had no `MapField` arm at all: `scalarFields()` excludes only `ObjectField`, so a mapped field flowed straight into the DTO record and hit `SpringTypeMapper`'s unsupported-type throw. Any entity carrying one failed Java codegen outright. Kotlin is the reference implementation and is complete; this mirrors it rather than inventing semantics: - `javaTypeName` gains a `MapField` arm returning `java.util.Map`. V is the value object named by `@objectRef` — resolved exactly as the `field.object` arm resolves its own — or the scalar named by `@valueType`, over the same 11 subtypes the loader admits. Scalars are the WRAPPED types (a Java type argument cannot be primitive); `@valueType: timestamp` is an absolute `Instant` unconditionally, since a map value has no column of its own to be "without time zone". - The two `List<>` wrap sites skip a map. isArray does not apply to one, and every other port emits the map bare — wrapping would produce a `List>` no other port can round-trip. - The value-object reachability walk now spans a map's `@objectRef`, mirroring the C# `ReferencesValueObject` predicate, which already did. A VO reached only through a map was never emitted, leaving the DTO naming a record that did not exist. - `@Valid` cascades onto a value-object map component. Bean Validation descends into a map's values, matching the TS zod emit's `z.record(z.string(), InsertSchema)`. A bare `field.map` (neither attr set) still throws rather than guessing a value type — the loader forbids that state, so the throw is the same contract a bare `ObjectField` gets. Tests: 11 mapper arms + a generator suite that asserts the emitted component types, the VO emission, the `@Valid` cascade, and — the strongest proof — that the generated sources actually compile. 241 green on `clean test`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013sjn9TaHoZFdDPF2dLcppv --- .../generator/spring/SpringDtoGenerator.java | 50 ++++- .../spring/SpringPayloadGenerator.java | 5 +- .../generator/spring/SpringTypeMapper.java | 61 ++++++ .../spring/SpringMapFieldCodegenTest.java | 176 ++++++++++++++++++ .../spring/SpringTypeMapperTest.java | 96 ++++++++++ 5 files changed, 381 insertions(+), 7 deletions(-) create mode 100644 server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringMapFieldCodegenTest.java diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringDtoGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringDtoGenerator.java index 3d04d6f12..a0e991d37 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringDtoGenerator.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringDtoGenerator.java @@ -4,6 +4,7 @@ import com.metaobjects.field.EnumField; import com.metaobjects.field.InetField; import com.metaobjects.field.MetaField; +import com.metaobjects.field.MapField; import com.metaobjects.field.ObjectField; import com.metaobjects.field.StringField; import com.metaobjects.field.UriField; @@ -155,7 +156,11 @@ protected void emit(MetaObject entity, Path outRoot) { List annotationsPerField = new ArrayList<>(fields.size()); for (MetaField field : fields) { String a = validationAnnotations(field); - if (isValueObjectJsonbField(field)) a = a.isEmpty() ? "@Valid" : "@Valid " + a; + // @Valid also cascades on a Map component -- Bean Validation descends into a map's + // VALUES -- so a field.map @objectRef gets it too. Parity with the TS zod emit, + // which types such a map as z.record(z.string(), InsertSchema): the map's + // values ARE validated there, so the JVM port must validate them as well. + if (valueObjectRefOf(field) != null) a = a.isEmpty() ? "@Valid" : "@Valid " + a; // #234: a STRICT field.uri / field.inet component binds through the codegen-owned // literal deserializer (absolute-scheme URI / IPv4-or-IPv6 literal, no DNS) so a // malformed value is rejected at the wire tier (HTTP 400), aligning the JVM DTO with @@ -739,10 +744,40 @@ public static boolean isValueObjectJsonbField(MetaField field) { return STORAGE_JSONB.equalsIgnoreCase(String.valueOf(storage).trim()); } - /** The referenced {@code object.value} when {@code field} is a value-object jsonb column, - * else {@code null}. Used by {@link SpringValueObjectGenerator}'s reachability walk. */ + /** + * The {@code object.value} that {@code field} carries by reference, else {@code null}. + * TWO field shapes carry one, and both must be reached: + *
    + *
  • a {@code field.object @objectRef} jsonb column ({@link #isValueObjectJsonbField});
  • + *
  • a {@code field.map @objectRef} -- a {@code Map}. A map's storage is + * ALWAYS the single jsonb column, so unlike {@code field.object} it has no + * {@code @storage} axis to gate on.
  • + *
+ * Mirrors the C# {@code EntityGenerator.ReferencesValueObject} predicate, which already + * spans both. Drives {@link SpringValueObjectGenerator}'s reachability walk (a VO reached + * only through a map would otherwise never be EMITTED, leaving the DTO naming a record + * that does not exist) and the {@code @Valid} cascade on the DTO component. + */ static MetaObject valueObjectRefOf(MetaField field) { - return isValueObjectJsonbField(field) ? ((ObjectField) field).getObjectRef() : null; + if (isValueObjectJsonbField(field)) return ((ObjectField) field).getObjectRef(); + return mapValueObjectRefOf(field); + } + + /** + * The {@code object.value} a {@code field.map @objectRef} maps its values to, else + * {@code null} (a scalar-valued {@code @valueType} map, a non-map field, or a + * {@code @objectRef} that does not resolve to an {@code object.value}). + */ + static MetaObject mapValueObjectRefOf(MetaField field) { + if (!(field instanceof MapField mf)) return null; + if (!mf.hasMetaAttr(MapField.ATTR_OBJECTREF)) return null; + MetaObject ref; + try { + ref = mf.getObjectRef(); + } catch (RuntimeException unresolved) { + return null; + } + return (ref != null && MetaObject.SUBTYPE_VALUE.equals(ref.getSubType())) ? ref : null; } // === validation (SP-C validator parity) ================================= @@ -761,7 +796,12 @@ public static String componentType(MetaField field, MetaObject owner) { return SpringTypeMapper.payloadJavaTypeName(field, owner, ""); } String element = SpringTypeMapper.javaTypeName(field); - return field.isArrayType() ? "java.util.List<" + element + ">" : element; + // A field.map is NEVER wrapped: isArray does not apply to a map, and every other port + // emits the map type bare (Kotlin Map, TS Record, Python + // dict[str,V]). Without this guard a declared isArray would silently produce + // List>, a shape no other port can round-trip. + return field.isArrayType() && !(field instanceof MapField) + ? "java.util.List<" + element + ">" : element; } /** diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringPayloadGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringPayloadGenerator.java index 2c682245f..77037bd01 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringPayloadGenerator.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringPayloadGenerator.java @@ -484,8 +484,9 @@ public String resolveFieldType(MetaField field, // type and the declared array-ness is silently dropped (#270 fix round 2). String scalarType = SpringTypeMapper.javaTypeName(field); // ADR-0039: resolving array-ness (isArrayType() is the effective flag; isArray() - // is the own-only native flag). - if (field.isArrayType()) { + // is the own-only native flag). A field.map is exempt: isArray does not apply to a + // map, and every port emits Map/Record/dict un-wrapped (see SpringTypeMapper's map arm). + if (field.isArrayType() && !(field instanceof com.metaobjects.field.MapField)) { return "java.util.List<" + scalarType + ">"; } return scalarType; diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringTypeMapper.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringTypeMapper.java index 6978ece0d..895f2bc3b 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringTypeMapper.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringTypeMapper.java @@ -9,6 +9,7 @@ import com.metaobjects.field.FloatField; import com.metaobjects.field.IntegerField; import com.metaobjects.field.LongField; +import com.metaobjects.field.MapField; import com.metaobjects.field.MetaField; import com.metaobjects.field.ObjectField; import com.metaobjects.field.StringField; @@ -139,11 +140,71 @@ public static String javaTypeName(MetaField field) { return split[0].isEmpty() ? split[1] : split[0] + "." + split[1]; } } + // field.map -> java.util.Map: an open-keyed map stored in a SINGLE jsonb + // column (the map analog of the field.object arm above). Keys are always String (the + // JSON-object constraint); V is the value object named by @objectRef or the scalar + // named by @valueType. isArray does not apply to a map, so the callers that wrap an + // array element in List<> skip a MapField -- every other port emits the map type + // un-wrapped (Kotlin Map, TS Record, Python dict[str,V]). + // Fully-qualified so the consuming DTO / Patch / VO record needs no import. + if (field instanceof MapField mf) { + String value = mapValueJavaType(mf); + if (value != null) return "java.util.Map"; + } throw new IllegalArgumentException( "unsupported Spring DTO type mapping for " + field.getClass().getSimpleName() + " '" + field.getName() + "'"); } + /** + * The Java value type of a {@link MapField} -- the {@code V} in {@code Map}: + * the value object named by {@code @objectRef}, else the scalar named by + * {@code @valueType}. Mirrors {@code KotlinTypeMapper.mapValueScalarTypeName} arm for arm + * (and resolves {@code @objectRef} exactly as the {@link ObjectField} arm of + * {@link #javaTypeName(MetaField)} does), so the two JVM ports agree on the map's value type. + * + *

The scalar arms return the WRAPPED types ({@code Integer}, {@code Long}, ...): a Java + * type argument cannot be a primitive, and the wrapper is what the rest of the DTO surface + * uses so a missing JSON entry deserialises to {@code null}. A {@code @valueType:timestamp} + * is an absolute {@code java.time.Instant} UNCONDITIONALLY -- a map VALUE has no column of + * its own to be "timestamp without time zone", so {@code @localTime} has no place on one + * (same rule as Kotlin's map arm, which reads only {@code @valueType}).

+ * + *

Returns {@code null} when neither attr is set, or when {@code @objectRef} does not + * resolve. The loader forbids both states -- {@code ValidationPhase.validateFieldMap} + * requires EXACTLY ONE of the two and rejects a {@code @valueType} outside the scalar set, + * and the {@code MapField} {@code ReferenceDescriptor} rejects a dangling {@code @objectRef} + * -- so the null lets {@link #javaTypeName(MetaField)} fall through to its unsupported-type + * throw rather than guessing a value type, exactly as a bare {@link ObjectField} does.

+ */ + private static String mapValueJavaType(MapField field) { + // ADR-0039: @objectRef / @valueType are EFFECTIVE properties -- hasMetaAttr/getMetaAttr + // (the one-arg, RESOLVING forms) so a value inherited via `extends` is not dropped. + if (field.hasMetaAttr(MapField.ATTR_OBJECTREF)) { + MetaObject ref = field.getObjectRef(); + if (ref == null) return null; + String[] split = SpringNaming.splitFqn(ref.getName()); + return split[0].isEmpty() ? split[1] : split[0] + "." + split[1]; + } + if (!field.hasMetaAttr(MapField.ATTR_VALUE_TYPE)) return null; + String valueType = field.getMetaAttr(MapField.ATTR_VALUE_TYPE).getValueAsString(); + if (valueType == null) return null; + return switch (valueType) { + case StringField.SUBTYPE_STRING -> "String"; + case IntegerField.SUBTYPE_INT -> "Integer"; + case LongField.SUBTYPE_LONG -> "Long"; + case DoubleField.SUBTYPE_DOUBLE -> "Double"; + case FloatField.SUBTYPE_FLOAT -> "Float"; + case DecimalField.SUBTYPE_DECIMAL -> "java.math.BigDecimal"; + case BooleanField.SUBTYPE_BOOLEAN -> "Boolean"; + case DateField.SUBTYPE_DATE -> "java.time.LocalDate"; + case TimeField.SUBTYPE_TIME -> "java.time.LocalTime"; + case TimestampField.SUBTYPE_TIMESTAMP -> "java.time.Instant"; + case UuidField.SUBTYPE_UUID -> "java.util.UUID"; + default -> null; + }; + } + /** * The Java type of {@code entity}'s primary key — the type every generated * by-id surface must trade: the controller's {@code @PathVariable} diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringMapFieldCodegenTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringMapFieldCodegenTest.java new file mode 100644 index 000000000..bc0ecc113 --- /dev/null +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringMapFieldCodegenTest.java @@ -0,0 +1,176 @@ +package com.metaobjects.generator.spring; + +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.registry.SharedRegistryTestBase; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import javax.tools.DiagnosticCollector; +import javax.tools.JavaCompiler; +import javax.tools.JavaFileObject; +import javax.tools.ToolProvider; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * {@code field.map} — an open-keyed map ({@code Map}) stored in a single + * jsonb column (the map analog of {@code field.object}). The value type is a scalar + * ({@code @valueType}) or a value object ({@code @objectRef}). + * + *

Cross-port parity: the C# {@code MapFieldCodegenTests}, the Kotlin + * {@code KotlinTypeMapperTest} map arms, the TS {@code field-map.test.ts} and the + * Python entity-model map branch. Before this suite the Spring port had NO + * {@code MapField} arm at all, so any entity carrying a mapped field failed codegen + * outright at {@code SpringTypeMapper}'s unsupported-type throw.

+ */ +public class SpringMapFieldCodegenTest extends SharedRegistryTestBase { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private static final String MAP_FIXTURE = """ + { + "metadata.root": { "package": "acme::crm", "children": [ + { "object.value": { "name": "Address", "children": [ + { "field.string": { "name": "street", "@required": true, "@maxLength": 120 } }, + { "field.string": { "name": "city", "@maxLength": 80 } } + ] } }, + { "object.entity": { "name": "Customer", "children": [ + { "source.rdb": { "@table": "customers" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "name", "@required": true } }, + { "field.map": { "name": "labels", "@valueType": "string" } }, + { "field.map": { "name": "scores", "@valueType": "int" } }, + { "field.map": { "name": "addresses", "@objectRef": "Address" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"], "@generation": "increment" } } + ] } } + ] } + } + """; + + /** Run {@link SpringDtoGenerator} + {@link SpringValueObjectGenerator} over the fixture. */ + private Path generate(String label) throws Exception { + Path gen = tmp.newFolder("gen-" + label).toPath(); + Path ws = tmp.newFolder("ws-" + label).toPath(); + MetaDataLoader loader = SpringTestFixtures.loadFixture(ws, "map-" + label, MAP_FIXTURE); + + Map args = new HashMap<>(); + args.put("outputDir", gen.toString()); + + SpringDtoGenerator dtoGen = new SpringDtoGenerator(); + dtoGen.setArgs(args); + dtoGen.execute(loader); + + SpringValueObjectGenerator voGen = new SpringValueObjectGenerator(); + voGen.setArgs(args); + voGen.execute(loader); + + return gen; + } + + @Test + public void scalarValuedMapBecomesAMapRecordComponent() throws Exception { + Path gen = generate("scalar"); + String dto = Files.readString(gen.resolve("acme/crm/CustomerDto.java")); + + // @valueType:string -> Map; @valueType:int -> Map + // (the WRAPPED Integer — Map is not expressible in Java, and the + // wrapper is what the rest of the DTO surface uses for missing-field nullability). + assertTrue("expected a Map component for @valueType:string; saw:\n" + dto, + dto.contains("java.util.Map labels")); + assertTrue("expected a Map component for @valueType:int; saw:\n" + dto, + dto.contains("java.util.Map scores")); + } + + @Test + public void objectValuedMapBecomesAMapOfTheValueObjectRecord() throws Exception { + Path gen = generate("objectref"); + String dto = Files.readString(gen.resolve("acme/crm/CustomerDto.java")); + + // @objectRef:Address -> Map, resolved exactly as the field.object + // arm resolves its @objectRef (SpringNaming.splitFqn over the RESOLVED MetaObject). + assertTrue("expected a Map component for @objectRef; saw:\n" + dto, + dto.contains("java.util.Map addresses")); + + // The referenced value object must actually be EMITTED. The reachability walk used + // to look only at field.object, so a map-only VO reference produced a DTO naming a + // record that was never generated — a guaranteed compile failure downstream. + Path vo = gen.resolve("acme/crm/Address.java"); + assertTrue("expected the map-referenced value object at " + vo, Files.exists(vo)); + assertTrue("expected an Address record declaration; saw:\n" + Files.readString(vo), + Files.readString(vo).contains("record Address")); + } + + @Test + public void objectValuedMapComponentCascadesValidation() throws Exception { + Path gen = generate("valid"); + String dto = Files.readString(gen.resolve("acme/crm/CustomerDto.java")); + + // Parity with the TS zod emit, which types an @objectRef map as + // z.record(z.string(), InsertSchema) — i.e. the map VALUES are validated. + // @Valid on a Map component cascades to its values under Bean Validation, so the + // nested VO's own constraints (Address.street @NotNull/@Size) are enforced on POST. + assertTrue("expected @Valid on the value-object map component; saw:\n" + dto, + dto.contains("@Valid java.util.Map addresses")); + // A SCALAR-valued map has no nested bean to cascade into — it must NOT get @Valid. + assertFalse("a scalar-valued map must not be annotated @Valid; saw:\n" + dto, + dto.contains("@Valid java.util.Map labels")); + } + + @Test + public void generatedMapCarryingSourcesCompile() throws Exception { + // The strongest proof: the emitted DTO + value-object records compile together. + // This is what catches a Map (illegal type argument) or a DTO naming + // a value object the reachability walk never emitted. + Path gen = generate("compile"); + + List sources; + try (Stream s = Files.walk(gen)) { + sources = s.filter(p -> p.toString().endsWith(".java")) + .map(Path::toFile) + .collect(Collectors.toList()); + } + assertFalse("expected generated .java files under " + gen, sources.isEmpty()); + + JavaCompiler javac = ToolProvider.getSystemJavaCompiler(); + assertNotNull("JDK (not JRE) required — getSystemJavaCompiler() returned null", javac); + + Path classes = tmp.newFolder("classes-map").toPath(); + DiagnosticCollector diags = new DiagnosticCollector<>(); + var fm = javac.getStandardFileManager(diags, null, null); + List opts = List.of( + "-classpath", System.getProperty("java.class.path"), + "-d", classes.toString()); + + boolean ok = javac.getTask(null, fm, diags, opts, null, + fm.getJavaFileObjectsFromFiles(sources)).call(); + if (!ok) { + StringBuilder sb = new StringBuilder("generated map-carrying sources failed to compile:\n"); + for (var d : diags.getDiagnostics()) { + sb.append(" ").append(d.getKind()).append(": ").append(d.getMessage(null)).append('\n'); + if (d.getSource() != null) { + sb.append(" at ").append(d.getSource().getName()) + .append(':').append(d.getLineNumber()).append('\n'); + } + } + for (File f : sources) { + sb.append("\n=== ").append(f.getName()).append(" ===\n"); + sb.append(Files.readString(f.toPath())).append('\n'); + } + fail(sb.toString()); + } + } +} diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringTypeMapperTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringTypeMapperTest.java index 0c85e8523..5293723bc 100644 --- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringTypeMapperTest.java +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringTypeMapperTest.java @@ -195,6 +195,102 @@ public void uuidArrayFieldDtoComponentIsListOfUUID() { assertEquals("java.util.List", SpringDtoGenerator.componentType(f, null)); } + // === field.map (open-keyed map) ========================================== + + /** + * Build a {@code field.map} carrying {@code @valueType=}. The loader + * requires EXACTLY ONE of {@code @valueType} / {@code @objectRef} + * ({@code ValidationPhase.validateFieldMap}); these unit tests pin the mapper arm, + * so the attr is set directly rather than through a load. + */ + private static com.metaobjects.field.MapField scalarMap(String name, String valueSubType) { + com.metaobjects.field.MapField f = new com.metaobjects.field.MapField(name); + f.addMetaAttr(com.metaobjects.attr.StringAttribute.create( + com.metaobjects.field.MapField.ATTR_VALUE_TYPE, valueSubType)); + return f; + } + + @Test + public void scalarValuedMapFieldMapsToMapOfString() { + // field.map @valueType:string -> java.util.Map. Keys are ALWAYS + // String (the JSON-object constraint); the value is the named scalar. Cross-port: + // Kotlin Map, TS Record, Python dict[str, str]. + assertEquals("java.util.Map", + SpringTypeMapper.javaTypeName(scalarMap("labels", StringField.SUBTYPE_STRING))); + } + + @Test + public void mapValueTypeCoversEveryScalarSubtypeTheLoaderAllows() { + // The 11 scalar @valueType subtypes ValidationPhase.MAP_SCALAR_VALUE_SUBTYPES admits, + // each mapped to the SAME Java type the corresponding field. maps to above + // (wrapped primitives, so a missing JSON entry deserialises to null). Mirrors + // KotlinTypeMapper.mapValueScalarTypeName arm for arm. + assertEquals("java.util.Map", + SpringTypeMapper.javaTypeName(scalarMap("m", StringField.SUBTYPE_STRING))); + assertEquals("java.util.Map", + SpringTypeMapper.javaTypeName(scalarMap("m", IntegerField.SUBTYPE_INT))); + assertEquals("java.util.Map", + SpringTypeMapper.javaTypeName(scalarMap("m", LongField.SUBTYPE_LONG))); + assertEquals("java.util.Map", + SpringTypeMapper.javaTypeName(scalarMap("m", DoubleField.SUBTYPE_DOUBLE))); + assertEquals("java.util.Map", + SpringTypeMapper.javaTypeName(scalarMap("m", com.metaobjects.field.FloatField.SUBTYPE_FLOAT))); + assertEquals("java.util.Map", + SpringTypeMapper.javaTypeName(scalarMap("m", com.metaobjects.field.DecimalField.SUBTYPE_DECIMAL))); + assertEquals("java.util.Map", + SpringTypeMapper.javaTypeName(scalarMap("m", BooleanField.SUBTYPE_BOOLEAN))); + assertEquals("java.util.Map", + SpringTypeMapper.javaTypeName(scalarMap("m", DateField.SUBTYPE_DATE))); + assertEquals("java.util.Map", + SpringTypeMapper.javaTypeName(scalarMap("m", TimeField.SUBTYPE_TIME))); + assertEquals("java.util.Map", + SpringTypeMapper.javaTypeName(scalarMap("m", TimestampField.SUBTYPE_TIMESTAMP))); + assertEquals("java.util.Map", + SpringTypeMapper.javaTypeName(scalarMap("m", com.metaobjects.field.UuidField.SUBTYPE_UUID))); + } + + @Test + public void mapValueTypeTimestampIgnoresLocalTimeAndStaysInstant() { + // A map VALUE has no column of its own to be "timestamp without time zone", so + // @localTime has no place on one: a @valueType:timestamp is the absolute-instant + // DEFAULT unconditionally. Mirrors KotlinTypeMapper's map arm (which reads only + // @valueType, never @localTime). + com.metaobjects.field.MapField f = scalarMap("seenAt", TimestampField.SUBTYPE_TIMESTAMP); + f.addMetaAttr(com.metaobjects.attr.BooleanAttribute.create("localTime", true)); + assertEquals("java.util.Map", SpringTypeMapper.javaTypeName(f)); + } + + @Test + public void scalarValuedMapIsNeverWrappedInList() { + // isArray does NOT apply to a map — every port emits the map type un-wrapped + // (Kotlin Map, TS Record, Python dict[str,V] all skip the + // array wrap). Without the MapField guard in componentType a declared isArray + // would silently produce List>, which no other port emits. + com.metaobjects.field.MapField f = scalarMap("labels", StringField.SUBTYPE_STRING); + f.setArray(true); + assertEquals("java.util.Map", SpringDtoGenerator.componentType(f, null)); + } + + @Test + public void mapFieldWithoutValueTypeOrObjectRefThrows() { + // The loader requires EXACTLY ONE of @valueType / @objectRef, so a bare field.map + // cannot reach codegen from a validly-loaded model. The mapper still refuses it + // loudly rather than guessing a value type — same contract as a bare ObjectField. + com.metaobjects.field.MapField bare = new com.metaobjects.field.MapField("attrs"); + try { + SpringTypeMapper.javaTypeName(bare); + fail("expected IllegalArgumentException for a field.map with no value type"); + } catch (IllegalArgumentException e) { + String msg = e.getMessage(); + org.junit.Assert.assertTrue( + "expected message to mention MapField; got: " + msg, + msg != null && msg.contains("MapField")); + org.junit.Assert.assertTrue( + "expected message to mention field name 'attrs'; got: " + msg, + msg != null && msg.contains("attrs")); + } + } + @Test public void unsupportedFieldThrowsIllegalArgumentException() { // ObjectField is intentionally not in the mapper (deferred — see SpringDtoGenerator From 58574fc981ab1643eaeb1ed7a421d03ef6ca0502 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 9 Sep 2026 21:23:58 -0400 Subject: [PATCH 02/22] feat(csharp-codegen): a field.map now gets an EF storage mapping instead of none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `EntityGenerator.MapProperty` already emitted `Dictionary` with a `[Column(...)]`, but `DbContextGenerator` had no map branch at all, so EF got no STORAGE mapping — no column type, no value converter. The property did not persist the way the TS-owned schema DDL declares it: on Npgsql a `Dictionary` binds to HSTORE by default and a `Dictionary` binds to nothing, so the column the migration creates (jsonb) and the column EF writes disagreed. Silently. - A top-level `field.map` now emits `.HasColumnType("jsonb").HasConversion(...)` through a shared `MapJsonb` converter/comparer pair — the C# analog of Kotlin's `jsonb(col, encoder, decoder)`: an explicit (de)serializer, with no reliance on Npgsql's dynamic-JSON opt-in that generated code cannot make for a consumer. - The COMPARER is not decoration. EF snapshots a value-converted property by reference, so with a converter alone an in-place `entity.Labels["k"] = v` is never detected and the UPDATE never fires — the same silent non-persistence being fixed here. The snapshot deep-copies; equality compares the serialized JSON, which is the right notion for a value object as well as a scalar. - The helper is gated on the model carrying a map, so a map-free model stays byte-identical — same discipline as `UnmappedEnumValue`. The gate spans the flattened-VO case too, or a model whose only map sits inside a flattened value object would name a helper the file never declares. - A FLATTENED value object's map member now binds its `_` jsonb column rather than warning. That warning's stated reason was that the port configured a top-level map nowhere either, so there was no proven mapping to mirror; that is no longer true. Its requirement was never "warn" — it was "do not bind silently to a column the migration does not create", which an explicit `HasColumnName` satisfies properly. The test that pinned the warning now pins the binding. - The value object's type is emitted FULLY QUALIFIED: the DbContext's usings are a fixed set covering entity namespaces only, and a value object is neither an entity nor a view. Same rule the `System.Guid` / `System.Uri` emissions already follow. The EF surface is proven by compilation, not by string matching: `DbContextCompileTests` now carries scalar, value-object and flattened-member maps and compiles the emitted context against real EF Core 8 assemblies, so the two-arg `HasConversion(ValueConverter, ValueComparer)` overload and the generic helper are shown to resolve on both receivers. All C# suites green: 441 codegen, 1024 conformance, 291 render, 77 cli, and 120 Testcontainers-Postgres integration tests. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013sjn9TaHoZFdDPF2dLcppv --- .../DbContextCompileTests.cs | 17 +- .../MapFieldCodegenTests.cs | 63 ++++++++ .../ObjectFieldCodegenTests.cs | 30 ++-- .../Generators/DbContextGenerator.cs | 146 +++++++++++++++++- 4 files changed, 238 insertions(+), 18 deletions(-) diff --git a/server/csharp/MetaObjects.Codegen.Tests/DbContextCompileTests.cs b/server/csharp/MetaObjects.Codegen.Tests/DbContextCompileTests.cs index bac86d799..0953b2ad1 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/DbContextCompileTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/DbContextCompileTests.cs @@ -12,12 +12,23 @@ // field.enum, so the FLATTENED owner's per-member `.HasColumnName(...).HasConversion(...)` // chain — on an OwnedNavigationBuilder's PropertyBuilder, naming the VO-nested // enum type and the UnmappedEnumValue helper — is proven to resolve, not just to read +// It ALSO carries a field.map `hints`, so the flattened owner pins that member to its +// `_` jsonb column through the same MapJsonb converter/comparer pair — on an +// OwnedNavigationBuilder's PropertyBuilder>, a different receiver +// from the entity-level one, which only a compile can prove resolves // - object.entity Order with: // scalar enum "status" → .HasConversion() // array enum "statuses" (isArray) → .PrimitiveCollection().ElementType().HasConversion() // array string "tags" (isArray) → .PrimitiveCollection() // field.object homeAddress @storage flattened → OwnsOne(...) per-property column names // field.object config (default storage) → OwnsOne(...).ToJson(...) +// field.map labels/scores (@valueType) → .HasColumnType("jsonb").HasConversion( +// MapJsonb.Converter(), MapJsonb.Comparer()) +// field.map sites (@objectRef) → the same, typed by the value object. +// These prove the EF API surface actually RESOLVES: the two-arg +// HasConversion(ValueConverter, ValueComparer) overload, and a generic helper +// returning ValueConverter,string>. A string-contains test +// cannot tell a real overload from a plausible-looking one. // - object.projection ProgramSummary (view-kind source, keyless) → .ToView(...).HasNoKey() // - object.entity Invoice — a #214 WRITE-THROUGH entity (table invoices + replica view // v_invoice_with_client + a derived origin.passthrough clientName): the derived-free @@ -58,7 +69,8 @@ public class DbContextCompileTests { "field.string": { "name": "street", "@required": true, "@maxLength": 120 } }, { "field.string": { "name": "city", "@maxLength": 80 } }, { "field.enum": { "name": "kind", "@values": ["HOME", "WORK"] } }, - { "field.enum": { "name": "tier", "@values": ["A", "B"], "@intValueMap": { "A": 1, "B": 2 } } } + { "field.enum": { "name": "tier", "@values": ["A", "B"], "@intValueMap": { "A": 1, "B": 2 } } }, + { "field.map": { "name": "hints", "@valueType": "string" } } ]}}, { "object.entity": { "name": "Order", "children": [ { "source.rdb": { "@table": "orders" } }, @@ -70,6 +82,9 @@ public class DbContextCompileTests { "field.string": { "name": "tags", "isArray": true } }, { "field.object": { "name": "homeAddress", "@objectRef": "Address", "@storage": "flattened" } }, { "field.object": { "name": "config", "@objectRef": "Address" } }, + { "field.map": { "name": "labels", "@valueType": "string" } }, + { "field.map": { "name": "scores", "@valueType": "int" } }, + { "field.map": { "name": "sites", "@objectRef": "Address" } }, { "identity.primary": { "@fields": "id" } } ]}}, { "object.projection": { "name": "ProgramSummary", "children": [ diff --git a/server/csharp/MetaObjects.Codegen.Tests/MapFieldCodegenTests.cs b/server/csharp/MetaObjects.Codegen.Tests/MapFieldCodegenTests.cs index 38c8e5993..9308cc6e7 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/MapFieldCodegenTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/MapFieldCodegenTests.cs @@ -70,6 +70,69 @@ public void Object_valued_map_emits_a_value_object_value_dictionary() Assert.Contains("public class Address", files.Single(f => f.Path == "Address.g.cs").Content); } + [Fact] + public void Scalar_valued_map_gets_a_jsonb_storage_mapping_in_the_DbContext() + { + var dbCtx = Assert.Single(new DbContextGenerator().Generate(Ctx(Load()))).Content; + + // Without an explicit mapping EF does not persist a Dictionary the way the + // TS-owned schema DDL declares it: on Npgsql a Dictionary binds to + // HSTORE by default, and a Dictionary binds to nothing at all. The + // column the migration creates is jsonb, so the context must say jsonb and supply + // the (de)serializer -- the C# analog of Kotlin's jsonb(col, encoder, decoder). + Assert.Contains( + "modelBuilder.Entity().Property(x => x.Labels).HasColumnType(\"jsonb\")" + + ".HasConversion(MapJsonb.Converter(), MapJsonb.Comparer());", + dbCtx); + } + + [Fact] + public void Object_valued_map_gets_a_jsonb_storage_mapping_typed_by_the_value_object() + { + var dbCtx = Assert.Single(new DbContextGenerator().Generate(Ctx(Load()))).Content; + + Assert.Contains( + "modelBuilder.Entity().Property(x => x.Addresses).HasColumnType(\"jsonb\")" + + ".HasConversion(MapJsonb.Converter()" + + ", MapJsonb.Comparer());", + dbCtx); + } + + [Fact] + public void Map_jsonb_helper_is_emitted_only_when_a_map_is_present() + { + var withMap = Assert.Single(new DbContextGenerator().Generate(Ctx(Load()))).Content; + + // The shared converter/comparer pair. The COMPARER is the load-bearing half: with a + // value converter and no comparer EF snapshots the dictionary by reference, so an + // in-place `entity.Labels["k"] = v` is never detected and the UPDATE never fires -- + // the same silent non-persistence this mapping exists to fix. + Assert.Contains("private static class MapJsonb", withMap); + Assert.Contains("Dictionary, string> Converter()", withMap); + Assert.Contains("Dictionary> Comparer()", withMap); + + // A model with no field.map must stay byte-identical -- the helper is gated, exactly + // as the UnmappedEnumValue helper is. + const string noMap = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.entity": { "name": "Plain", "children": [ + { "source.rdb": { "@table": "plains" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "name" } }, + { "identity.primary": { "@fields": "id" } } + ]}} + ]}} + """; + var r = new MetaDataLoader().Load([new InMemoryStringSource(noMap, id: "nomap.json")]); + Assert.Empty(r.Errors); + var without = Assert.Single(new DbContextGenerator().Generate(new GenContext + { + Entities = r.Root.Objects(), Root = r.Root, + Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated" }, + })).Content; + Assert.DoesNotContain("MapJsonb", without); + } + [Fact] public void Generated_entities_and_value_objects_compile_together() { diff --git a/server/csharp/MetaObjects.Codegen.Tests/ObjectFieldCodegenTests.cs b/server/csharp/MetaObjects.Codegen.Tests/ObjectFieldCodegenTests.cs index ab77920c8..e83f6f1f5 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/ObjectFieldCodegenTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/ObjectFieldCodegenTests.cs @@ -217,13 +217,15 @@ public void DbContext_names_every_flattened_member_the_migration_creates() dbContext); } - // The one member kind deliberately NOT named. This port configures a top-level field.map - // nowhere either, so there is no proven mapping to mirror, and forcing b.Property onto a - // Dictionary can make EF's model builder throw where it currently ignores the - // member — trading a wrong column for a broken build. The requirement is that it be LOUD: - // a silent skip here is exactly the defect class this whole test exists for. + // A field.map member USED to be the one member kind deliberately not named: the port + // configured a top-level field.map nowhere either, so there was no proven mapping to + // mirror and the generator warned rather than risk binding a wrong column. Now that the + // top-level map branch exists, the same converter/comparer pair pins this member to the + // `_` jsonb column the migration creates — which is what the warning was + // standing in for. The requirement never was "warn"; it was "do not bind silently to a + // column the migration does not create". [Fact] - public void A_flattened_map_member_warns_instead_of_binding_a_wrong_column() + public void A_flattened_map_member_binds_its_prefixed_jsonb_column() { var warnings = new List(); var root = Load(); @@ -235,9 +237,19 @@ public void A_flattened_map_member_warns_instead_of_binding_a_wrong_column() }; var dbContext = new DbContextGenerator().Generate(ctx).Single().Content; - Assert.Contains(warnings, w => w.Contains("\"prefs\"") && w.Contains("profile_prefs")); - // ...and it must not have quietly emitted a mapping for it either. - Assert.DoesNotContain("p.Prefs", dbContext); + // Pinned to the migration's flattened column name, typed jsonb, and converted through + // the shared helper. The VO value type is FULLY QUALIFIED — the DbContext's usings + // cover entity namespaces only, and a value object is not an entity. + Assert.Contains( + "b.Property(p => p.Prefs).HasColumnName(\"profile_prefs\").HasColumnType(\"jsonb\")" + + ".HasConversion(MapJsonb.Converter()" + + ", MapJsonb.Comparer());", + dbContext); + // EF's own `