Skip to content

Commit 547c698

Browse files
dmealingclaude
andcommitted
feat(#195): Java per-capability origin validation + native typing (cross-port parity)
Mirrors the committed TS reference (63943d0 validation, aff49ce typing) into the Java port. - ExpressionAttribute.java: the closed expression-node grammar as validateExprNode (structural, fail-closed) + inferExprType (entity-aware, bottom-up) over the verbatim Map tree, reusing FilterOps for the shared op vocabulary + per-subtype legality bands (mirrors TS keeping the grammar beside the attr, not in validateValue). - ValidationPhase.java: the per-capability rules in validateOriginNode — any/all (boolean non-array; @filter required; @Of forbidden; @via required + to-many), collect (isArray; @Of required + #185 element-type preservation; @distinct/@orderby collect-only + mutually exclusive; @orderby resolves on @Of), the inverse rule, origin.computed (grammar → ERR_UNKNOWN_EXPR_NODE; inference vs base effective fields; mismatch → ERR_COMPUTED_TYPE_MISMATCH), origin.first (@Of required + type-preserving; not @required; @via inference; @orderby) — reusing the existing origin-path helpers. - ErrorCode.java: ERR_UNKNOWN_EXPR_NODE + ERR_COMPUTED_TYPE_MISMATCH. - SpringDtoGenerator.java: originGuaranteedNonNull → @NotNull on any/all/collect DTO components; first + computed stay nullable. - OriginCapabilityValidationTest.java (new): 20 tests mirroring the TS suite. Verified: mvn -pl metadata test 1161 run / 0 fail (incl. the 20 + the 3 #195 fixtures + registry-conformance); codegen-spring 168 / 0 (incl. a real-javac DTO compile-run). Error CODES identical to TS. Divergence (behavioral parity holds): Java validation is eager-throw vs TS accumulate-all — same inputs accepted/rejected, only simultaneous-error count differs; rule ordering matches so the first-thrown equals the TS-targeted error. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGQ7oSuNcjhsMHWwZzhBwr
1 parent 845b8cf commit 547c698

6 files changed

Lines changed: 1028 additions & 13 deletions

File tree

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

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
import com.metaobjects.identity.MetaIdentity;
1313
import com.metaobjects.loader.MetaDataLoader;
1414
import com.metaobjects.object.MetaObject;
15+
import com.metaobjects.origin.AggregateOrigin;
16+
import com.metaobjects.origin.MetaOrigin;
1517
import com.metaobjects.validator.ArrayValidator;
1618
import com.metaobjects.validator.LengthValidator;
1719
import com.metaobjects.validator.MetaValidator;
@@ -634,6 +636,31 @@ public static boolean isRequired(MetaField<?> field) {
634636
return attrBool(field, MetaField.ATTR_REQUIRED) || hasValidator(field, RequiredValidator.class);
635637
}
636638

639+
/**
640+
* #195 — a field whose value is derived by an {@code origin.aggregate}
641+
* {@code @agg:any|all|collect} is COALESCE-guaranteed non-null in the synthesized
642+
* view (any&rarr;false, all&rarr;true, collect&rarr;empty-array), so its read type
643+
* is non-null even when the field is not {@code @required}. Drives {@code @NotNull}
644+
* in {@link #validationAnnotations} so a projection DTO's read shape agrees with its
645+
* view column — adopters no longer handle a null that never occurs.
646+
*
647+
* <p>{@code origin.first} is deliberately NOT here (an empty related set selects no
648+
* row &rarr; null); {@code origin.computed} nullability is expression-dependent, so
649+
* it stays the conservative nullable default. Mirrors the TS
650+
* {@code originGuaranteedNonNull} (codegen-ts {@code column-mapper}).</p>
651+
*/
652+
public static boolean originGuaranteedNonNull(MetaField<?> field) {
653+
// ADR-0039: own — origin.* never inherits (ADR-0029), so an own-only read is correct.
654+
for (MetaData c : field.getChildren(MetaData.class, false)) {
655+
if (!(c instanceof MetaOrigin)) continue;
656+
if (!AggregateOrigin.SUBTYPE_AGGREGATE.equals(c.getSubType())) continue;
657+
String agg = ((MetaOrigin) c).getAgg();
658+
return MetaOrigin.AGG_ANY.equals(agg) || MetaOrigin.AGG_ALL.equals(agg)
659+
|| MetaOrigin.AGG_COLLECT.equals(agg);
660+
}
661+
return false;
662+
}
663+
637664
/**
638665
* Build the space-joined jakarta.validation annotation string for a record
639666
* component from the field's constraint metadata — both field attrs
@@ -667,7 +694,11 @@ public static String validationAnnotations(MetaField<?> field) {
667694
// @required NON-ARRAY string is enforced by @Size(min>=1) in the string-length arm
668695
// below — NOT @NotBlank, which trims and would wrongly reject a whitespace-only value.
669696
// A @required array keeps @NotNull only (no @NotEmpty).
670-
if (required) {
697+
// #195: an origin.aggregate any/all/collect field is COALESCE-guaranteed non-null on
698+
// read (any→false / all→true / collect→[]), so it is @NotNull even when not @required
699+
// (the non-empty @Size(min=1) floor below stays keyed on @required only — such fields
700+
// are boolean or array, never a non-array string).
701+
if (required || originGuaranteedNonNull(field)) {
671702
out.add("@NotNull");
672703
}
673704

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

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,89 @@ public void projectionDtoCompilesAndNoWriteSurfaceIsEmitted() throws Exception {
150150
compile(gen);
151151
}
152152

153+
/**
154+
* #195 native typing — a projection field derived by {@code origin.aggregate}
155+
* {@code @agg:any|all|collect} is COALESCE-guaranteed non-null on read, so its DTO
156+
* component is {@code @NotNull}; an {@code origin.first} (empty set &rarr; null) and
157+
* an {@code origin.computed} (expression-dependent nullability) component stay nullable
158+
* (no {@code @NotNull}). Mirrors the TS {@code originGuaranteedNonNull} change.
159+
*/
160+
private static final String NULLABILITY_FIXTURE = """
161+
{
162+
"metadata.root": { "package": "acme::sessions", "children": [
163+
{ "object.entity": { "name": "Session", "children": [
164+
{ "source.rdb": { "@table": "sessions" } },
165+
{ "field.long": { "name": "id" } },
166+
{ "relationship.association": { "name": "turns", "@objectRef": "Turn", "@cardinality": "many" } },
167+
{ "identity.primary": { "name": "pk", "@fields": ["id"], "@generation": "increment" } }
168+
] } },
169+
{ "object.entity": { "name": "Turn", "children": [
170+
{ "source.rdb": { "@table": "turns" } },
171+
{ "field.long": { "name": "id" } },
172+
{ "field.boolean": { "name": "success" } },
173+
{ "field.string": { "name": "label" } },
174+
{ "field.timestamp": { "name": "createdAt" } },
175+
{ "identity.primary": { "name": "pk", "@fields": ["id"], "@generation": "increment" } }
176+
] } },
177+
{ "object.projection": { "name": "SessionSummary", "children": [
178+
{ "source.rdb": { "@table": "v_session", "@kind": "view" } },
179+
{ "field.long": { "name": "id", "extends": "Session.id" } },
180+
{ "field.string": { "name": "labelsCollect", "isArray": true, "children": [
181+
{ "origin.aggregate": { "@agg": "collect", "@of": "Turn.label", "@via": "Session.turns" } } ] } },
182+
{ "field.boolean": { "name": "anyError", "children": [
183+
{ "origin.aggregate": { "@agg": "any", "@via": "Session.turns", "@filter": { "success": false } } } ] } },
184+
{ "field.string": { "name": "latestLabel", "children": [
185+
{ "origin.first": { "@of": "Turn.label", "@via": "Session.turns", "@orderBy": ["createdAt:desc"] } } ] } },
186+
{ "field.boolean": { "name": "computedFlag", "children": [
187+
{ "origin.computed": { "@expr": { "op": "isNotNull", "arg": { "field": "id" } } } } ] } },
188+
{ "identity.primary": { "name": "pk", "extends": "Session.pk" } }
189+
] } }
190+
] }
191+
}
192+
""";
193+
194+
@Test
195+
public void originAnyAllCollectFieldsAreNotNull_firstAndComputedStayNullable() throws Exception {
196+
Path gen = tmp.newFolder("gen-nullability").toPath();
197+
Path ws = tmp.newFolder("ws-nullability").toPath();
198+
MetaDataLoader loader = SpringTestFixtures.loadFixture(ws, "nullability", NULLABILITY_FIXTURE);
199+
200+
MetaObject summary = loader.getMetaObjectByName("acme::sessions::SessionSummary");
201+
assertNotNull("projection must load", summary);
202+
203+
Map<String, String> args = new HashMap<>();
204+
args.put("outputDir", gen.toString());
205+
SpringDtoGenerator dtoGen = new SpringDtoGenerator();
206+
dtoGen.setArgs(args);
207+
dtoGen.execute(loader);
208+
209+
Path dto = gen.resolve("acme/sessions/SessionSummaryDto.java");
210+
assertTrue("expected SessionSummaryDto.java at " + dto, Files.exists(dto));
211+
String src = Files.readString(dto);
212+
213+
// collect + any → COALESCE-guaranteed non-null → @NotNull on the component.
214+
assertTrue("collect field must be @NotNull; saw:\n" + src, componentIsNotNull(src, "labelsCollect"));
215+
assertTrue("any-predicate field must be @NotNull; saw:\n" + src, componentIsNotNull(src, "anyError"));
216+
// first (empty set → null) + computed (expression-dependent) → stay nullable.
217+
assertFalse("first field must stay nullable (no @NotNull); saw:\n" + src, componentIsNotNull(src, "latestLabel"));
218+
assertFalse("computed field must stay nullable (no @NotNull); saw:\n" + src, componentIsNotNull(src, "computedFlag"));
219+
220+
compile(gen);
221+
}
222+
223+
/** True iff the DTO record component for {@code fieldName} (one line ending in the
224+
* name, optionally comma-terminated) carries {@code @NotNull}. */
225+
private static boolean componentIsNotNull(String src, String fieldName) {
226+
for (String line : src.split("\n")) {
227+
String t = line.trim();
228+
if (t.equals(fieldName) || t.equals(fieldName + ",")
229+
|| t.endsWith(" " + fieldName) || t.endsWith(" " + fieldName + ",")) {
230+
return t.contains("@NotNull");
231+
}
232+
}
233+
throw new AssertionError("no DTO component line found for field '" + fieldName + "' in:\n" + src);
234+
}
235+
153236
/**
154237
* Compile every generated {@code .java} under {@code gen} with the in-process
155238
* JDK compiler ({@code render}/{@code om} are on the test classpath). Fails

server/java/metadata/src/main/java/com/metaobjects/ErrorCode.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,24 @@ public enum ErrorCode {
364364
*/
365365
ERR_INVALID_INDEX,
366366

367+
/**
368+
* #195: an {@code origin.computed} {@code @expr} tree contains a node whose
369+
* kind/op/fn is not in the closed expression grammar (field/value refs,
370+
* comparisons sharing the filter op vocabulary, {@code isNull}/{@code isNotNull},
371+
* {@code and}/{@code or}/{@code not}, {@code coalesce}). Fail-closed per ADR-0023;
372+
* the detail names the offending token.
373+
*/
374+
ERR_UNKNOWN_EXPR_NODE,
375+
376+
/**
377+
* #195: an {@code origin.computed} {@code @expr} tree's inferred root type does
378+
* not equal the carrying field's declared {@code field.<subType>}. A computed
379+
* column's type is DERIVED from its expression, never asserted (no {@code @convert}
380+
* escape), so a mismatch is a hard load error (sibling of
381+
* {@code ERR_PASSTHROUGH_TYPE_MISMATCH}).
382+
*/
383+
ERR_COMPUTED_TYPE_MISMATCH,
384+
367385
/** An internal loader error with no stable error code. */
368386
ERR_UNKNOWN,
369387
}

0 commit comments

Comments
 (0)