@@ -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 /**
0 commit comments