Skip to content

Commit 25bc2d2

Browse files
dmealingclaude
andcommitted
feat(fr-035): present-key PATCH tristate — Java port
Green the cross-port `update-explicit-null-clears` gate on the Java GENERATED api-contract lane (the reference lane already passed). The generated controller bound the full `@Valid @RequestBody <Entity>Dto`, so a partial PATCH that omits a @required field 400'd (its @NotNull) and an explicit null was indistinguishable from absent after Jackson binding — it could neither clear a column nor honor the tristate. Move the generated update path off full-DTO binding onto a presence-tracked patch: - codegen-spring: `SpringDtoGenerator` now emits a `<Entity>Patch` alongside each `<Entity>Dto` (settable = scalar fields minus the PK; `has<F>()`/`<f>()` accessors; `fromJson(JsonNode, ObjectMapper)` — a non-object body or an explicit null on a @required field is a `PatchValidationException`, a present null on a non-required field is stored (clears), an absent key is untouched, a present value binds via `mapper.treeToValue(node, TypeReference<Type>)` so generic element types and inline enums survive). Emitting the Patch from the DTO generator — which every consumer already runs — means a re-run of an existing `<generators>` config can't produce a controller/repository that references a missing `<Entity>Patch`. - `SpringRepositoryGenerator`: the non-TPH interface gains `Optional<Dto> patch(pk, <Entity>Patch)` (keeps `update` for full-merge use; the TPH per-subtype interface is untouched). - `SpringControllerGenerator`: the non-TPH update handler binds `@RequestBody JsonNode`, constructor-injects `ObjectMapper`, builds the patch, maps a `PatchValidationException` → 400 `{"error":"validation"}`, then calls `repository.patch`. - `SpringDtoGenerator.isRequired` is the SINGLE required-predicate shared by the DTO's `@NotNull` and the patch's present-null-on-@required rule (can't drift). - reference server + in-memory reference repos implement the tristate merge; an inline-enum compile-run test (`SpringPatchEnumCompileRunTest`) gates the `<Entity>Dto.<EnumName>` qualification. Known gap (KNOWN_GAPS.md): the partial-PATCH path does not run `@Size`/`@Pattern`/ `@NotBlank`/etc. constraint validation on present VALUES (only null-on-@required → 400) — a cross-port PATCH-4 follow-up; TS/Python enforce it, C#/Java don't yet. Gate green both Java lanes (23/0 each); codegen-spring 165/0; jsonb 1/0, m2m 3/0, tph generated 5/0; full integration-tests reactor green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGQ7oSuNcjhsMHWwZzhBwr
1 parent c3021bb commit 25bc2d2

15 files changed

Lines changed: 395 additions & 14 deletions

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,18 @@ escaping. Field names and enum members are identifier-safe and alias
117117
*values* are canonical members, so the only at-risk input is an
118118
`@enumAlias` *key* containing a `"` or `\`. Add a `javaStringLiteral(...)`
119119
escape if adopters hit it.
120+
121+
## FR-035 partial PATCH — no present-value constraint validation
122+
123+
The generated `PATCH`/`PUT` handler binds a raw `JsonNode` and builds an
124+
`<Entity>Patch` (presence-tracked), rather than an `@Valid` DTO. This is what
125+
gives it the FR-035 tristate (absent ≠ explicit null), but it means the
126+
`jakarta.validation` constraints the DTO carries — `@Size` / `@Pattern` /
127+
`@NotBlank` / `@Email` / `@Min` / `@Max` — are **not** enforced on *present
128+
values* over the PATCH path. The only PATCH-time validation is
129+
present-null-on-`@required` → HTTP 400 (via `PatchValidationException`).
130+
131+
This is a cross-port PATCH-4 follow-up: TS/Python DO run per-field value
132+
validation on PATCH; C# and Java do not yet. Tracked here rather than fixed —
133+
adding constraint validation to the patch path (re-running the same rules the
134+
DTO's annotations express) is a deliberate, separately-scoped change.

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

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,9 @@ protected void emit(MetaObject entity, Path outRoot) {
174174
if (!pkg.isEmpty()) {
175175
src.append("package ").append(pkg).append(";\n\n");
176176
}
177+
src.append("import com.fasterxml.jackson.databind.JsonNode;\n");
178+
src.append("import com.fasterxml.jackson.databind.ObjectMapper;\n");
179+
src.append("import com.metaobjects.generator.spring.runtime.PatchValidationException;\n");
177180
src.append("import org.springframework.http.HttpStatus;\n");
178181
src.append("import org.springframework.http.ResponseEntity;\n");
179182
src.append("import org.springframework.web.bind.annotation.DeleteMapping;\n");
@@ -210,10 +213,15 @@ protected void emit(MetaObject entity, Path outRoot) {
210213
src.append(");\n\n");
211214

212215
// Repository wiring — constructor injection (Spring's recommended idiom; avoids
213-
// field-injection magic, plays well with final fields + final test seams).
214-
src.append(" private final ").append(repoName).append(" repository;\n\n");
215-
src.append(" public ").append(controllerName).append("(").append(repoName).append(" repository) {\n");
216+
// field-injection magic, plays well with final fields + final test seams). The
217+
// ObjectMapper (Spring's configured bean) binds the FR-035 patch body's per-field
218+
// values via <Entity>Patch.fromJson, so the same wire codecs apply as on create.
219+
src.append(" private final ").append(repoName).append(" repository;\n");
220+
src.append(" private final ObjectMapper objectMapper;\n\n");
221+
src.append(" public ").append(controllerName).append("(").append(repoName)
222+
.append(" repository, ObjectMapper objectMapper) {\n");
216223
src.append(" this.repository = repository;\n");
224+
src.append(" this.objectMapper = objectMapper;\n");
217225
src.append(" }\n\n");
218226

219227
// GET (list) — pagination + sort + withCount + FR-009 filter operators.
@@ -273,10 +281,23 @@ protected void emit(MetaObject entity, Path outRoot) {
273281
// Stacking @PatchMapping + @PutMapping on the same method does NOT register both
274282
// in Spring MVC — only one composed @RequestMapping per method is honored, so the
275283
// other verb 405s. (Surfaced by the SP-F generated-controller HTTP lane.)
284+
// FR-035 present-key PATCH: bind the RAW JsonNode (not the @Valid full DTO, which
285+
// conflates absent with null and 400s an omitted @required field), build the
286+
// presence-tracked <Entity>Patch, and delegate to repository.patch. An explicit
287+
// null clears a nullable column; an explicit null on a @required field throws
288+
// PatchValidationException → 400 {"error":"validation"}. update(id, dto) stays on
289+
// the interface for programmatic full-DTO use.
290+
String patchName = SpringNaming.patchName(shortName);
276291
src.append(" @RequestMapping(value = \"/{id}\", method = { RequestMethod.PATCH, RequestMethod.PUT })\n");
277292
src.append(" public ResponseEntity<?> update(@PathVariable ").append(pkType)
278-
.append(" id, @Valid @RequestBody ").append(dtoName).append(" dto) {\n");
279-
src.append(" return repository.update(id, dto)\n");
293+
.append(" id, @RequestBody JsonNode body) {\n");
294+
src.append(" ").append(patchName).append(" patch;\n");
295+
src.append(" try {\n");
296+
src.append(" patch = ").append(patchName).append(".fromJson(body, objectMapper);\n");
297+
src.append(" } catch (PatchValidationException e) {\n");
298+
src.append(" return ResponseEntity.badRequest().body(Map.of(\"error\", \"validation\"));\n");
299+
src.append(" }\n");
300+
src.append(" return repository.patch(id, patch)\n");
280301
src.append(" .<ResponseEntity<?>>map(ResponseEntity::ok)\n");
281302
src.append(" .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of(\"error\", \"not_found\")));\n");
282303
src.append(" }\n\n");

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

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import com.metaobjects.generator.GeneratorIOWriter;
1010
import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase;
1111
import com.metaobjects.generator.util.GeneratorUtil;
12+
import com.metaobjects.identity.MetaIdentity;
1213
import com.metaobjects.loader.MetaDataLoader;
1314
import com.metaobjects.object.MetaObject;
1415
import com.metaobjects.validator.ArrayValidator;
@@ -94,6 +95,17 @@ public void execute(MetaDataLoader loader) {
9495
// (each folded nullable) so polymorphic + per-subtype endpoints share one wire shape.
9596
if (TphPlan.isTphBase(entity, loader)) emitTphUnion(entity, loader, outRoot);
9697
else emit(entity, outRoot);
98+
// FR-035: a presence-tracked <Entity>Patch alongside the DTO, for exactly the
99+
// entities whose non-TPH repository interface gains patch(...) — a concrete
100+
// writable table entity that is neither a TPH base nor a TPH subtype. Emitted
101+
// from THIS generator (which every consumer already runs) so a re-run of an
102+
// existing <generators> config that omits a separate patch generator cannot
103+
// produce a controller/repository that references a missing <Entity>Patch.
104+
if (SpringRepositoryGenerator.appliesTo(entity)
105+
&& !TphPlan.isTphBase(entity, loader)
106+
&& !TphPlan.isTphSubtype(entity)) {
107+
emitPatch(entity, outRoot);
108+
}
97109
}
98110
}
99111

@@ -125,6 +137,127 @@ protected void emit(MetaObject entity, Path outRoot) {
125137
emitRecord(entity, outRoot, fields, annotationsPerField);
126138
}
127139

140+
/**
141+
* FR-035: emit the presence-tracked {@code <Entity>Patch} — a sibling of the DTO that
142+
* carries the absent / null / value tristate a record cannot. Only the caller-settable
143+
* fields (scalar fields MINUS the primary key) are members; {@code has<F>()} reports
144+
* presence (incl. an explicit null), {@code <f>()} the value. {@code fromJson} rejects a
145+
* non-object body, binds present values via Jackson exactly as create does, clears a
146+
* non-{@code @required} field on an explicit null, and throws a
147+
* {@code PatchValidationException} on an explicit null on a {@code @required} field (the
148+
* controller maps both to HTTP 400). Emitted here — from the DTO generator every consumer
149+
* already runs — so no separate generator can be forgotten and leave a controller /
150+
* repository referencing a missing {@code <Entity>Patch}.
151+
*/
152+
private void emitPatch(MetaObject entity, Path outRoot) {
153+
String[] split = SpringNaming.splitFqn(entity.getName());
154+
String pkg = split[0];
155+
String shortName = split[1];
156+
String patchName = SpringNaming.patchName(shortName);
157+
String dtoName = SpringNaming.dtoName(shortName);
158+
List<MetaField> settable = settableFields(entity);
159+
160+
StringBuilder src = new StringBuilder();
161+
if (!pkg.isEmpty()) src.append("package ").append(pkg).append(";\n\n");
162+
src.append("import com.fasterxml.jackson.core.JsonProcessingException;\n");
163+
src.append("import com.fasterxml.jackson.core.type.TypeReference;\n");
164+
src.append("import com.fasterxml.jackson.databind.JsonNode;\n");
165+
src.append("import com.fasterxml.jackson.databind.ObjectMapper;\n");
166+
src.append("import com.metaobjects.generator.spring.runtime.PatchValidationException;\n");
167+
src.append("import java.util.LinkedHashMap;\n");
168+
src.append("import java.util.Map;\n");
169+
src.append("import java.util.Set;\n\n");
170+
src.append("/** GENERATED — presence-tracked partial-update (PATCH) shape for ")
171+
.append(shortName).append(". Do not hand-edit; regenerated from metadata. */\n");
172+
src.append("public final class ").append(patchName).append(" {\n\n");
173+
src.append(" private final Map<String, Object> assigned;\n\n");
174+
src.append(" private ").append(patchName)
175+
.append("(Map<String, Object> assigned) { this.assigned = assigned; }\n\n");
176+
177+
// Per settable field: has<Field>() (presence, incl. explicit null) + <field>() (value).
178+
for (MetaField field : settable) {
179+
String name = field.getName();
180+
String cap = SpringNaming.capitalize(name);
181+
String type = patchComponentType(field, entity, dtoName);
182+
src.append(" public boolean has").append(cap)
183+
.append("() { return assigned.containsKey(\"").append(name).append("\"); }\n");
184+
src.append(" public ").append(type).append(' ').append(name).append("() { return (")
185+
.append(type).append(") assigned.get(\"").append(name).append("\"); }\n");
186+
}
187+
src.append("\n /** The field names ASSIGNED by this patch (present in the body), in order. */\n");
188+
src.append(" public Set<String> assignedFields() { return assigned.keySet(); }\n\n");
189+
190+
// fromJson — the presence-tracked builder.
191+
src.append(" /**\n");
192+
src.append(" * Build from a JSON body. A non-object body is rejected; an ABSENT key is\n");
193+
src.append(" * untouched; a present null CLEARS a non-@required field and is a validation\n");
194+
src.append(" * error on a @required one; a present value binds via Jackson exactly as\n");
195+
src.append(" * create does. Unknown keys are ignored (FR-035 PATCH-3 deferred).\n");
196+
src.append(" */\n");
197+
src.append(" public static ").append(patchName)
198+
.append(" fromJson(JsonNode body, ObjectMapper mapper) {\n");
199+
src.append(" if (!body.isObject()) throw new PatchValidationException(\"(request body)\");\n");
200+
src.append(" Map<String, Object> assigned = new LinkedHashMap<>();\n");
201+
for (MetaField field : settable) {
202+
String name = field.getName();
203+
String type = patchComponentType(field, entity, dtoName);
204+
boolean required = isRequired(field);
205+
// A TypeReference (not a raw Class) so a generic component — e.g. a
206+
// field.<scalar> @isArray List<Long> — binds with its element type intact.
207+
src.append(" bind(body, mapper, assigned, \"").append(name)
208+
.append("\", new TypeReference<").append(type).append(">() {}, ")
209+
.append(required).append(");\n");
210+
}
211+
src.append(" return new ").append(patchName).append("(assigned);\n");
212+
src.append(" }\n\n");
213+
214+
// bind — one settable field. treeToValue lets Jackson handle every subtype (no hand-rolled arms).
215+
src.append(" private static void bind(JsonNode body, ObjectMapper mapper, Map<String, Object> assigned,\n");
216+
src.append(" String field, TypeReference<?> type, boolean required) {\n");
217+
src.append(" if (!body.has(field)) return; // absent → untouched\n");
218+
src.append(" JsonNode node = body.get(field);\n");
219+
src.append(" if (node.isNull()) {\n");
220+
src.append(" if (required) throw new PatchValidationException(field);\n");
221+
src.append(" assigned.put(field, null); // explicit null → clear\n");
222+
src.append(" return;\n");
223+
src.append(" }\n");
224+
src.append(" try {\n");
225+
src.append(" assigned.put(field, mapper.treeToValue(node, type));\n");
226+
src.append(" } catch (JsonProcessingException e) {\n");
227+
src.append(" throw new PatchValidationException(field);\n");
228+
src.append(" }\n");
229+
src.append(" }\n");
230+
src.append("}\n");
231+
232+
writeJavaFile(entity, outRoot, pkg, patchName, src.toString());
233+
}
234+
235+
/** Scalar (non-object) fields MINUS the primary-key field(s) — the caller-settable set. */
236+
private static List<MetaField> settableFields(MetaObject entity) {
237+
List<String> pkFields = entity.getIdentities(true).stream()
238+
.filter(MetaIdentity::isPrimary)
239+
.findFirst()
240+
.map(MetaIdentity::getFields)
241+
.orElse(List.of());
242+
List<MetaField> out = new ArrayList<>();
243+
for (MetaField field : scalarFields(entity)) {
244+
if (!pkFields.contains(field.getName())) out.add(field);
245+
}
246+
return out;
247+
}
248+
249+
/** The {@code <Entity>Patch} component type for {@code field}. Identical to the DTO's
250+
* FR-019-aware {@link #componentTypeFr019} EXCEPT for an INLINE {@code field.enum}: its
251+
* enum is declared nested in the DTO record body, so the sibling {@code <Entity>Patch}
252+
* must qualify it as {@code <Entity>Dto.<EnumName>}. A shared / materialized / {@code @provided}
253+
* enum is a top-level type, resolvable unqualified. */
254+
private String patchComponentType(MetaField<?> field, MetaObject entity, String dtoName) {
255+
if (field instanceof EnumField && Fr019SharedEnum.sharedEnumForField(field) == null) {
256+
return SpringTypeMapper.payloadJavaTypeName(field, entity, dtoName + ".");
257+
}
258+
return componentTypeFr019(field, entity);
259+
}
260+
128261
/**
129262
* FR-017 TPH: emit the discriminator-base DTO as the UNION of the base's own scalar columns
130263
* (full validation) plus every subtype-only column (folded NULLABLE — no validation, since a
@@ -348,6 +481,14 @@ private static List<String> collectEnumDecls(MetaObject owner, List<MetaField> f
348481
return decls;
349482
}
350483

484+
/** True iff {@code field} is {@code @required} — its own {@code @required} attr OR a
485+
* {@code validator.required} child. The SINGLE required-predicate reused across the
486+
* DTO's {@code @NotNull} and the FR-035 patch's present-null-on-@required → 400 rule
487+
* (the FR-035 patch emit below), so the two cannot drift. */
488+
public static boolean isRequired(MetaField<?> field) {
489+
return attrBool(field, MetaField.ATTR_REQUIRED) || hasValidator(field, RequiredValidator.class);
490+
}
491+
351492
/**
352493
* Build the space-joined jakarta.validation annotation string for a record
353494
* component from the field's constraint metadata — both field attrs
@@ -372,7 +513,7 @@ public static String validationAnnotations(MetaField<?> field) {
372513
boolean isString = field instanceof StringField;
373514
List<String> out = new ArrayList<>();
374515

375-
boolean required = attrBool(field, MetaField.ATTR_REQUIRED) || hasValidator(field, RequiredValidator.class);
516+
boolean required = isRequired(field);
376517
if (required) {
377518
out.add("@NotNull");
378519
if (isString && !isArray) out.add("@NotBlank");

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,11 @@ public static String repositoryName(String shortName) {
9696
return shortName + "Repository";
9797
}
9898

99+
/** {@code SpringDtoGenerator} (FR-035): {@code shortName + "Patch"} — the presence-tracked patch shape. */
100+
public static String patchName(String shortName) {
101+
return shortName + "Patch";
102+
}
103+
99104
/** {@code SpringControllerGenerator}: {@code shortName + "Controller"}. */
100105
public static String controllerName(String shortName) {
101106
return shortName + "Controller";

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,12 @@ protected void emit(MetaObject entity, Path outRoot) {
116116
src.append(" ").append(dtoName).append(" create(").append(dtoName).append(" dto);\n");
117117
src.append(" Optional<").append(dtoName).append("> update(").append(pkType).append(" id, ")
118118
.append(dtoName).append(" dto);\n");
119+
// FR-035: presence-tracked partial update. The <Entity>Patch carries the
120+
// absent/null/value tristate the full DTO cannot, so an explicit null clears a
121+
// nullable column (an explicit null on a @required field is rejected at the
122+
// controller). Same package as the interface — no import needed.
123+
src.append(" Optional<").append(dtoName).append("> patch(").append(pkType).append(" id, ")
124+
.append(SpringNaming.patchName(shortName)).append(" patch);\n");
119125
src.append(" boolean delete(").append(pkType).append(" id);\n");
120126

121127
// FR-018 M:N finders — one per @cardinality:"many" + @through relationship.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.metaobjects.generator.spring.runtime;
2+
3+
/**
4+
* Thrown by a generated {@code <Entity>Patch.fromJson(...)} when a PATCH body fails
5+
* FR-035 validation — specifically an explicit {@code null} on a {@code @required}
6+
* field. The generated controller catches it and maps it to the cross-port HTTP 400
7+
* {@code {"error":"validation"}} envelope.
8+
*/
9+
public final class PatchValidationException extends RuntimeException {
10+
11+
private final String field;
12+
13+
public PatchValidationException(String field) {
14+
super("field '" + field + "' cannot be null");
15+
this.field = field;
16+
}
17+
18+
/** The offending field name. */
19+
public String field() {
20+
return field;
21+
}
22+
}

server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/GeneratedM2mTraversalCompileRunTest.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@ public InMemoryPostRepository(List<Map<String, Object>> tagRows,
250250
@Override public Optional<PostDto> findById(Long id) { return Optional.empty(); }
251251
@Override public PostDto create(PostDto dto) { return dto; }
252252
@Override public Optional<PostDto> update(Long id, PostDto dto) { return Optional.empty(); }
253+
@Override public Optional<PostDto> patch(Long id, PostPatch patch) { return Optional.empty(); }
253254
@Override public boolean delete(Long id) { return false; }
254255
}
255256
""";
@@ -314,6 +315,7 @@ private List<PersonDto> loadPeople(List<Object> ids) {
314315
@Override public Optional<PersonDto> findById(Long id) { return Optional.empty(); }
315316
@Override public PersonDto create(PersonDto dto) { return dto; }
316317
@Override public Optional<PersonDto> update(Long id, PersonDto dto) { return Optional.empty(); }
318+
@Override public Optional<PersonDto> patch(Long id, PersonPatch patch) { return Optional.empty(); }
317319
@Override public boolean delete(Long id) { return false; }
318320
}
319321
""";

0 commit comments

Comments
 (0)