Skip to content

Commit 7a5e576

Browse files
dmealingclaude
andcommitted
feat(program-d): Java codegen-spring generates + PATCHes value-object jsonb columns
codegen-spring emitted nothing for object.value. New SpringValueObjectGenerator emits one Java record per object.value reachable (transitively) from an entity/projection field.object @storage:jsonb column, with jakarta constraints per member + @Valid on nested VO members (depth>=2 cascade). SpringTypeMapper gains an ObjectField arm (no longer throws); <Entity>Dto/<Entity>Patch carry the typed VO field (@Valid, @NotNull when @required); controller PATCH keeps validateValue for own constraints and ADDS explicit validator.validate(voElement) per present element (spec section 0 — validateValue does NOT cascade @Valid); POST validates via the existing validate(dto) @Valid cascade. Reference server + generated harness taught the VO columns + PATCH tristate. Both lanes green (2/2 reference over Testcontainers PG, 2/2 generated over MockMvc); codegen-spring suite 167/0; Author 25/25, TPH 8/8 — no regression. TPH-with-VO and field.map remain staged-out follow-ups. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGQ7oSuNcjhsMHWwZzhBwr
1 parent 7dc71f8 commit 7a5e576

8 files changed

Lines changed: 620 additions & 38 deletions

File tree

server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,12 @@ protected void emit(MetaObject entity, Path outRoot) {
318318
src.append(" return ResponseEntity.badRequest().body(Map.of(\"error\", \"validation\"));\n");
319319
src.append(" }\n");
320320
src.append(" }\n");
321+
// Program D (spec section 0): validateValue does NOT cascade @Valid into a nested value
322+
// object's own constraints, so validate each PRESENT VO element EXPLICITLY. The property's
323+
// OWN constraints (@NotNull on a @required column, present-null tristate) are already
324+
// covered above / in <Entity>Patch.fromJson; this closes the nested-constraint gap (e.g. a
325+
// Marker.label over @maxLength, or null on a @required nested member) with a 400.
326+
appendValueObjectValidation(src, entity);
321327
src.append(" return repository.patch(id, patch)\n");
322328
src.append(" .<ResponseEntity<?>>map(ResponseEntity::ok)\n");
323329
src.append(" .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of(\"error\", \"not_found\")));\n");
@@ -370,6 +376,36 @@ protected void emit(MetaObject entity, Path outRoot) {
370376
}
371377
}
372378

379+
/**
380+
* Program D — append per-value-object nested-validation guards into the vanilla PATCH handler,
381+
* one per {@code field.object @storage:jsonb} column (spec section 0). A present single VO
382+
* value is validated whole; a present array is validated element-by-element. Skips null
383+
* (present-null clears / is caught upstream) so the tristate composes. Emits nothing for an
384+
* entity with no value-object columns, keeping non-VO controllers byte-identical.
385+
*/
386+
private static void appendValueObjectValidation(StringBuilder src, MetaObject entity) {
387+
for (ObjectField vf : SpringDtoGenerator.valueObjectJsonbFields(entity)) {
388+
String name = vf.getName();
389+
String cap = SpringNaming.capitalize(name);
390+
if (vf.isArrayType()) {
391+
src.append(" if (patch.has").append(cap).append("() && patch.")
392+
.append(name).append("() != null) {\n");
393+
src.append(" for (var __el : patch.").append(name).append("()) {\n");
394+
src.append(" if (__el != null && !validator.validate(__el).isEmpty()) {\n");
395+
src.append(" return ResponseEntity.badRequest().body(Map.of(\"error\", \"validation\"));\n");
396+
src.append(" }\n");
397+
src.append(" }\n");
398+
src.append(" }\n");
399+
} else {
400+
src.append(" if (patch.has").append(cap).append("() && patch.")
401+
.append(name).append("() != null && !validator.validate(patch.")
402+
.append(name).append("()).isEmpty()) {\n");
403+
src.append(" return ResponseEntity.badRequest().body(Map.of(\"error\", \"validation\"));\n");
404+
src.append(" }\n");
405+
}
406+
}
407+
}
408+
373409
/**
374410
* FR-017 TPH: emit the discriminator-base controller. ONE {@code @RestController} at
375411
* {@code /api/<base-plural>} mounting:

server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringDtoGenerator.java

Lines changed: 100 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -138,10 +138,17 @@ public static boolean appliesTo(MetaObject entity) {
138138
}
139139

140140
protected void emit(MetaObject entity, Path outRoot) {
141-
List<MetaField> fields = scalarFields(entity);
142-
// Each scalar field carries its full validation (the standard, non-TPH DTO).
141+
List<MetaField> fields = dtoComponentFields(entity);
142+
// Each field carries its full validation (the standard, non-TPH DTO). A value-object
143+
// jsonb column additionally gets @Valid so the POST path's validator.validate(dto)
144+
// cascades into the nested VO's constraints (spec section 0 — @Valid cascades on
145+
// validate(bean), unlike the PATCH path's per-field validateValue).
143146
List<String> annotationsPerField = new ArrayList<>(fields.size());
144-
for (MetaField field : fields) annotationsPerField.add(validationAnnotations(field));
147+
for (MetaField field : fields) {
148+
String a = validationAnnotations(field);
149+
if (isValueObjectJsonbField(field)) a = a.isEmpty() ? "@Valid" : "@Valid " + a;
150+
annotationsPerField.add(a);
151+
}
145152
emitRecord(entity, outRoot, fields, annotationsPerField);
146153
}
147154

@@ -272,15 +279,19 @@ private void writePatch(MetaObject entity, Path outRoot, String patchName, Strin
272279
writeJavaFile(entity, outRoot, pkg, patchName, src.toString());
273280
}
274281

275-
/** Scalar (non-object) fields MINUS the primary-key field(s) — the caller-settable set. */
282+
/**
283+
* The vanilla {@code <Entity>Patch} settable set: DTO component fields (scalars PLUS
284+
* value-object jsonb columns, Program D) MINUS the primary-key field(s).
285+
*/
276286
private static List<MetaField> settableFields(MetaObject entity) {
277-
return settableFields(entity, null);
287+
return minusPk(entity, dtoComponentFields(entity), null);
278288
}
279289

280290
/**
281-
* As {@link #settableFields(MetaObject)}, additionally excluding the field named
282-
* {@code alsoExclude} (or nothing when {@code null}) — the FR-036 TPH per-subtype patch passes
283-
* the discriminator field name so the immutable discriminator is never a settable member.
291+
* The TPH per-subtype settable set: {@link #scalarFields(MetaObject)} MINUS the primary key
292+
* MINUS the field named {@code alsoExclude} (the immutable discriminator, or nothing when
293+
* {@code null}). Scalar-only — value-object columns on a TPH hierarchy are out of scope
294+
* (Program D), so a TPH base's union DTO and per-subtype patch stay VO-free.
284295
*
285296
* <p>Public so {@link SpringControllerGenerator#emitTph} can validate a TPH per-subtype CREATE
286297
* body against exactly the field set the sibling {@code <Sub>Patch} covers (effective scalars
@@ -289,13 +300,18 @@ private static List<MetaField> settableFields(MetaObject entity) {
289300
* validated from the body.</p>
290301
*/
291302
public static List<MetaField> settableFields(MetaObject entity, String alsoExclude) {
303+
return minusPk(entity, scalarFields(entity), alsoExclude);
304+
}
305+
306+
/** {@code pool} MINUS the entity's primary-key field(s) MINUS {@code alsoExclude} (when non-null). */
307+
private static List<MetaField> minusPk(MetaObject entity, List<MetaField> pool, String alsoExclude) {
292308
List<String> pkFields = entity.getIdentities(true).stream()
293309
.filter(MetaIdentity::isPrimary)
294310
.findFirst()
295311
.map(MetaIdentity::getFields)
296312
.orElse(List.of());
297313
List<MetaField> out = new ArrayList<>();
298-
for (MetaField field : scalarFields(entity)) {
314+
for (MetaField field : pool) {
299315
if (pkFields.contains(field.getName())) continue;
300316
if (alsoExclude != null && alsoExclude.equals(field.getName())) continue;
301317
out.add(field);
@@ -345,15 +361,24 @@ private void emitRecord(MetaObject entity, Path outRoot, List<MetaField> fields,
345361
String recordName = SpringNaming.dtoName(shortName);
346362

347363
boolean usesValidation = annotationsPerField.stream().anyMatch(a -> !a.isEmpty());
364+
// @Valid (jakarta.validation, NOT ...constraints) cascades validation into a
365+
// value-object jsonb component — emitted on VO fields by emit() (Program D).
366+
boolean usesValid = annotationsPerField.stream().anyMatch(a -> a.contains("@Valid"));
348367

349368
StringBuilder src = new StringBuilder();
350369
if (!pkg.isEmpty()) {
351370
src.append("package ").append(pkg).append(";\n\n");
352371
}
353372
// Wildcard import keeps the emit simple — record components carry a small,
354373
// closed set of jakarta.validation.constraints annotations.
374+
if (usesValid) {
375+
src.append("import jakarta.validation.Valid;\n");
376+
}
355377
if (usesValidation) {
356-
src.append("import jakarta.validation.constraints.*;\n\n");
378+
src.append("import jakarta.validation.constraints.*;\n");
379+
}
380+
if (usesValid || usesValidation) {
381+
src.append('\n');
357382
}
358383
src.append("/** GENERATED — wire DTO for ").append(shortName)
359384
.append(". Do not hand-edit; regenerated from metadata. */\n");
@@ -434,8 +459,10 @@ protected void writeJavaFile(MetaObject entity, Path outRoot, String pkg, String
434459

435460
/**
436461
* Return the entity's scalar fields — i.e. every {@link MetaField} that is
437-
* not an {@link ObjectField}. The {@code field.object} arm is deliberately
438-
* deferred (see class javadoc).
462+
* not an {@link ObjectField}. Value-object {@code field.object} columns are
463+
* carried separately by {@link #dtoComponentFields(MetaObject)}; this
464+
* scalar-only view still backs the TPH union / abstract-shape / sort surfaces
465+
* (which are value-object-agnostic).
439466
*/
440467
public static List<MetaField> scalarFields(MetaObject entity) {
441468
List<MetaField> out = new ArrayList<>();
@@ -446,6 +473,67 @@ public static List<MetaField> scalarFields(MetaObject entity) {
446473
return out;
447474
}
448475

476+
/** The single legal {@code @storage} value for a value-object jsonb column (Program D);
477+
* {@code flattened} / {@code subdocument} owned-object storage is out of scope. */
478+
static final String STORAGE_JSONB = "jsonb";
479+
480+
/**
481+
* The vanilla {@code <Entity>Dto} / {@code <Entity>Patch} component fields: every scalar
482+
* field PLUS every value-object jsonb column ({@code field.object @objectRef=<value> @storage:jsonb},
483+
* single or {@code @isArray}), in declared order. Program D: the VO columns become
484+
* strongly-typed record components (bound to the generated {@code <VO>} record) so they
485+
* POST + PATCH with nested validation. A non-VO {@link ObjectField} (flattened / subdocument
486+
* storage, or an unresolved / non-value {@code @objectRef}) is still skipped.
487+
*/
488+
public static List<MetaField> dtoComponentFields(MetaObject entity) {
489+
List<MetaField> out = new ArrayList<>();
490+
for (MetaField field : entity.getMetaFields()) {
491+
if (field instanceof ObjectField) {
492+
if (isValueObjectJsonbField(field)) out.add(field);
493+
continue;
494+
}
495+
out.add(field);
496+
}
497+
return out;
498+
}
499+
500+
/** The value-object jsonb columns of {@code entity}, in declared order (Program D) — the
501+
* fields the controller runs explicit nested-VO validation over on PATCH (spec section 0). */
502+
public static List<ObjectField> valueObjectJsonbFields(MetaObject entity) {
503+
List<ObjectField> out = new ArrayList<>();
504+
for (MetaField field : entity.getMetaFields()) {
505+
if (isValueObjectJsonbField(field)) out.add((ObjectField) field);
506+
}
507+
return out;
508+
}
509+
510+
/**
511+
* True iff {@code field} is a value-object jsonb column: a {@link ObjectField} whose
512+
* {@code @objectRef} resolves to an {@code object.value} and whose {@code @storage} is
513+
* jsonb (explicit, or the single-jsonb-column default when {@code @storage} is absent).
514+
* Flattened / subdocument owned-object storage is out of scope (Program D).
515+
*/
516+
public static boolean isValueObjectJsonbField(MetaField<?> field) {
517+
if (!(field instanceof ObjectField of)) return false;
518+
if (!of.hasMetaAttr(ObjectField.ATTR_OBJECTREF)) return false;
519+
MetaObject ref;
520+
try {
521+
ref = of.getObjectRef();
522+
} catch (RuntimeException unresolved) {
523+
return false;
524+
}
525+
if (ref == null || !MetaObject.SUBTYPE_VALUE.equals(ref.getSubType())) return false;
526+
if (!of.hasMetaAttr(ObjectField.ATTR_STORAGE)) return true; // default single-jsonb-column
527+
Object storage = of.getMetaAttr(ObjectField.ATTR_STORAGE).getValue();
528+
return STORAGE_JSONB.equalsIgnoreCase(String.valueOf(storage).trim());
529+
}
530+
531+
/** The referenced {@code object.value} when {@code field} is a value-object jsonb column,
532+
* else {@code null}. Used by {@link SpringValueObjectGenerator}'s reachability walk. */
533+
static MetaObject valueObjectRefOf(MetaField<?> field) {
534+
return isValueObjectJsonbField(field) ? ((ObjectField) field).getObjectRef() : null;
535+
}
536+
449537
// === validation (SP-C validator parity) =================================
450538

451539
/**

server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringTypeMapper.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import com.metaobjects.field.IntegerField;
1111
import com.metaobjects.field.LongField;
1212
import com.metaobjects.field.MetaField;
13+
import com.metaobjects.field.ObjectField;
1314
import com.metaobjects.field.StringField;
1415
import com.metaobjects.field.TimeField;
1516
import com.metaobjects.field.TimestampField;
@@ -121,6 +122,19 @@ public static String javaTypeName(MetaField<?> field) {
121122
// inet — native java.net.InetAddress binding (ADR-0036/0037 Wave 3). Wire/storage
122123
// stays a String; the DB column is the Postgres-native inet type.
123124
if (field instanceof InetField) return "java.net.InetAddress";
125+
// field.object @objectRef → the referenced value object's generated record type
126+
// (Program D). A jsonb value-object column surfaces as the <VO> record (single) or
127+
// — via componentType wrapping — List<<VO>> (isArray). Fully-qualified so the
128+
// consuming DTO / <Entity>Patch / VO record needs no import. Only reached for the
129+
// VO-ref fields the DTO/patch paths select via
130+
// SpringDtoGenerator.isValueObjectJsonbField; a non-VO ObjectField never lands here.
131+
if (field instanceof ObjectField of && of.hasMetaAttr(ObjectField.ATTR_OBJECTREF)) {
132+
MetaObject ref = of.getObjectRef();
133+
if (ref != null) {
134+
String[] split = SpringNaming.splitFqn(ref.getName());
135+
return split[0].isEmpty() ? split[1] : split[0] + "." + split[1];
136+
}
137+
}
124138
throw new IllegalArgumentException(
125139
"unsupported Spring DTO type mapping for "
126140
+ field.getClass().getSimpleName() + " '" + field.getName() + "'");

0 commit comments

Comments
 (0)