Skip to content

Commit 9c38669

Browse files
committed
Merge worktree-fr006-java-gaps-2026-05-27 — close two cross-port parity gaps (field.object filter + PascalCase class names)
2 parents e8be420 + 0c048a1 commit 9c38669

3 files changed

Lines changed: 335 additions & 35 deletions

File tree

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

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,14 @@ private void emit(MetaTemplate template, MetaDataLoader loader, Path outRoot) {
114114
String templatePkg = split[0];
115115
String templateShort = split[1];
116116
String outPkg = templatePkg.isEmpty() ? "prompts" : templatePkg + ".prompts";
117-
String parserClass = templateShort + "Parser";
118-
String payloadClass = templateShort + "Payload";
117+
// PascalCase the class names — templates authored in camelCase
118+
// (e.g. `npcTurn`) yield Java-idiomatic `NpcTurnParser` /
119+
// `NpcTurnPayload`. Pairs with SpringPayloadGenerator's matching
120+
// capitalisation so the parser's payload-class reference stays
121+
// consistent.
122+
String capitalized = capitalizeFirst(templateShort);
123+
String parserClass = capitalized + "Parser";
124+
String payloadClass = capitalized + "Payload";
119125

120126
StringBuilder src = new StringBuilder();
121127
src.append("// GENERATED — DO NOT EDIT — parser for template.output `")
@@ -149,6 +155,19 @@ private void emit(MetaTemplate template, MetaDataLoader loader, Path outRoot) {
149155
}
150156
}
151157

158+
/**
159+
* Uppercase the first character of {@code s}; pass through unchanged when
160+
* empty or already capitalised. Pairs with {@link SpringPayloadGenerator}'s
161+
* matching helper so the {@code <PayloadClass>} reference in the parser
162+
* matches the actual generated file name.
163+
*/
164+
private static String capitalizeFirst(String s) {
165+
if (s == null || s.isEmpty()) return s;
166+
char c0 = s.charAt(0);
167+
if (Character.isUpperCase(c0)) return s;
168+
return Character.toUpperCase(c0) + s.substring(1);
169+
}
170+
152171
/** Resolve {@code @payloadRef} to its {@code object.value} target (rejects entities). */
153172
private static MetaObject resolveValueObject(MetaDataLoader loader, String ref) {
154173
for (MetaObject obj : loader.getMetaObjects()) {
@@ -188,6 +207,6 @@ protected String getSingleOutputFilePath(MetaObject md) {
188207

189208
@Override
190209
protected String getSingleOutputFilename(MetaObject md) {
191-
return SpringNaming.splitFqn(md.getName())[1] + "Parser.java";
210+
return capitalizeFirst(SpringNaming.splitFqn(md.getName())[1]) + "Parser.java";
192211
}
193212
}

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

Lines changed: 86 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -67,17 +67,30 @@
6767
* <li>{@code origin.collection} ({@code @via "Parent.rel"}) —
6868
* {@code List<TargetPayload>}, and the nested payload class is recursively
6969
* emitted alongside (deduped per {@link #execute(MetaDataLoader)} run).</li>
70-
* <li>No origin child — fall back to {@link SpringTypeMapper#javaTypeName(MetaField)}.</li>
70+
* <li>{@code field.object} with {@code @objectRef} (no {@code origin.*} child) —
71+
* recursively emit {@code <TargetShortName>Payload} (per-run deduped) and
72+
* return that type, or {@code java.util.List<TargetPayload>} when the field
73+
* is {@code isArray: true}. Mirrors Kotlin's plain {@code ObjectField}
74+
* arm.</li>
75+
* <li>No origin child and not a {@code field.object} — fall back to
76+
* {@link SpringTypeMapper#javaTypeName(MetaField)}.</li>
7177
* </ul>
7278
*
79+
* <p>Class-naming convention: the emitted record name is
80+
* {@code <PascalCaseTemplateShortName>Payload}. Templates declared in
81+
* {@code camelCase} (e.g. {@code adjudicationUser}) are capitalised before
82+
* appending {@code Payload}, matching Kotlin/C#/TS/Python and Java's own
83+
* PascalCase class-naming convention. Nested payload class names from
84+
* {@code origin.collection} / {@code field.object} arms are capitalised the
85+
* same way.
86+
*
7387
* <p>Skips and defensive cases:
7488
* <ul>
7589
* <li>Missing {@code @payloadRef} — skipped (loader's validation pass
7690
* normally rejects this first; defensive only).</li>
7791
* <li>{@code @payloadRef} resolves to a non-VO target — skipped (payloads
7892
* must be {@code object.value}; matches the cross-port contract).</li>
7993
* <li>Templates are processed in stable name order for deterministic emission.</li>
80-
* <li>{@link ObjectField} children are skipped (mirrors {@link SpringDtoGenerator}).</li>
8194
* </ul>
8295
*
8396
* <p>Args:
@@ -133,7 +146,7 @@ private void emit(MetaTemplate template, MetaDataLoader loader, Path outRoot,
133146
String templatePkg = split[0];
134147
String templateShort = split[1];
135148
String outPkg = templatePkg.isEmpty() ? "prompts" : templatePkg + ".prompts";
136-
String recordName = templateShort + "Payload";
149+
String recordName = capitalizeFirst(templateShort) + "Payload";
137150

138151
emitPayloadRecord(
139152
outPkg,
@@ -165,8 +178,12 @@ private void emitPayloadRecord(String outPkg,
165178
src.append("/** ").append(banner).append(" */\n");
166179
src.append("public record ").append(recordName).append("(\n");
167180

168-
List<MetaField> fields = scalarFields(voObject);
169-
Iterator<MetaField> it = fields.iterator();
181+
// Route every field through resolveFieldType — including ObjectField,
182+
// which needs the field.object arm (single ref or isArray list). The
183+
// previous scalarFields() filter dropped ObjectField entirely; mirror
184+
// KotlinPayloadGenerator's no-filter iteration. getMetaFields()
185+
// returns Collection, so go through the iterator directly.
186+
Iterator<MetaField> it = voObject.getMetaFields().iterator();
170187
while (it.hasNext()) {
171188
MetaField field = it.next();
172189
String type = resolveFieldType(field, loader, outPkg, outRoot, emittedNestedFqns);
@@ -187,19 +204,17 @@ private void emitPayloadRecord(String outPkg,
187204
}
188205

189206
/**
190-
* Resolve the Java type expression of a single payload-VO field, honoring
191-
* any {@code origin.*} child. Falls back to
192-
* {@link SpringTypeMapper#javaTypeName} when no origin is present.
207+
* Resolve the Java type expression of a single payload-VO field. Precedence:
208+
* (1) {@code origin.*} child wins if present; (2) otherwise a
209+
* {@code field.object} routes through the nested-payload emission arm; (3)
210+
* otherwise the scalar fallback via {@link SpringTypeMapper#javaTypeName}.
193211
*/
194212
private String resolveFieldType(MetaField<?> field,
195213
MetaDataLoader loader,
196214
String nestedPkg,
197215
Path outRoot,
198216
Set<String> emittedNestedFqns) {
199217
MetaOrigin origin = firstOriginChild(field);
200-
if (origin == null) {
201-
return SpringTypeMapper.javaTypeName(field);
202-
}
203218
if (origin instanceof PassthroughOrigin pt) {
204219
return resolvePassthroughType(pt, loader, field);
205220
}
@@ -209,9 +224,35 @@ private String resolveFieldType(MetaField<?> field,
209224
if (origin instanceof CollectionOrigin co) {
210225
return resolveCollectionType(co, loader, nestedPkg, outRoot, emittedNestedFqns, field);
211226
}
227+
if (field instanceof ObjectField of) {
228+
return resolveObjectFieldType(of, loader, nestedPkg, outRoot, emittedNestedFqns);
229+
}
212230
return SpringTypeMapper.javaTypeName(field);
213231
}
214232

233+
/**
234+
* Naked {@code field.object @objectRef}: recursively emit
235+
* {@code <CapitalizedTargetShortName>Payload} for the referenced VO, and
236+
* return that type — or {@code java.util.List<TargetPayload>} when
237+
* {@code isArray: true}. Mirrors Kotlin's plain-{@code ObjectField} arm in
238+
* {@code KotlinPayloadGenerator.resolveFieldType}.
239+
*/
240+
private String resolveObjectFieldType(ObjectField field,
241+
MetaDataLoader loader,
242+
String nestedPkg,
243+
Path outRoot,
244+
Set<String> emittedNestedFqns) {
245+
MetaObject target = field.getObjectRef();
246+
if (target == null) return SpringTypeMapper.javaTypeName(field);
247+
if (!MetaObject.SUBTYPE_VALUE.equals(target.getSubType())) {
248+
// Same payload-VO contract as @payloadRef — nested payloads must
249+
// themselves be object.value. Loader validation normally catches
250+
// this; defensive fallback to scalar type.
251+
return SpringTypeMapper.javaTypeName(field);
252+
}
253+
return emitNestedAndReturnType(target, loader, nestedPkg, outRoot, emittedNestedFqns, field.isArray());
254+
}
255+
215256
/**
216257
* {@code origin.passthrough @from "Entity.field"}: resolve to the source
217258
* field's Java type. Falls back to the payload field's own type if the
@@ -293,22 +334,39 @@ private String resolveCollectionType(CollectionOrigin origin,
293334

294335
MetaObject target = resolveObjectByShortOrFqn(loader, targetRef);
295336
if (target == null) return SpringTypeMapper.javaTypeName(fallbackField);
337+
return emitNestedAndReturnType(target, loader, nestedPkg, outRoot, emittedNestedFqns, true);
338+
}
296339

297-
String nestedShort = SpringNaming.splitFqn(target.getName())[1];
340+
/**
341+
* Shared nested-payload emit path used by {@link #resolveCollectionType}
342+
* (always a list) and {@link #resolveObjectFieldType} (single or list,
343+
* depending on {@code asList}). Emits the record at most once per run via
344+
* the {@code emittedNestedFqns} dedupe set, and returns the type expression
345+
* to use as the parent field's component type. Returns
346+
* {@code java.util.List<TargetPayload>} (fully-qualified to sidestep any
347+
* potential {@code List} import collision in generated code) when
348+
* {@code asList} is true, else just {@code TargetPayload}.
349+
*/
350+
private String emitNestedAndReturnType(MetaObject target,
351+
MetaDataLoader loader,
352+
String nestedPkg,
353+
Path outRoot,
354+
Set<String> emittedNestedFqns,
355+
boolean asList) {
356+
String nestedShort = capitalizeFirst(SpringNaming.splitFqn(target.getName())[1]);
298357
String nestedRecord = nestedShort + "Payload";
299-
300358
if (emittedNestedFqns.add(target.getName())) {
301359
emitPayloadRecord(
302360
nestedPkg,
303361
nestedRecord,
304-
"GENERATED — nested payload for collection target `" + target.getName() + "`. "
362+
"GENERATED — nested payload for `" + target.getName() + "`. "
305363
+ "Do not hand-edit; regenerated from metadata.",
306364
target,
307365
loader,
308366
outRoot,
309367
emittedNestedFqns);
310368
}
311-
return "java.util.List<" + nestedRecord + ">";
369+
return asList ? "java.util.List<" + nestedRecord + ">" : nestedRecord;
312370
}
313371

314372
// -------------------------------------------------------------------------
@@ -380,14 +438,18 @@ private static String shortName(String fqn) {
380438
return idx < 0 ? fqn : fqn.substring(idx + 2);
381439
}
382440

383-
/** Scalar payload fields — same filter SpringDtoGenerator uses (skips ObjectField). */
384-
private static List<MetaField> scalarFields(MetaObject vo) {
385-
List<MetaField> out = new ArrayList<>();
386-
for (MetaField field : vo.getMetaFields()) {
387-
if (field instanceof ObjectField) continue;
388-
out.add(field);
389-
}
390-
return out;
441+
/**
442+
* Uppercase the first character of {@code s}; pass {@code s} through
443+
* unchanged when empty or already capitalised. Used to PascalCase the
444+
* payload record name regardless of whether the template short name was
445+
* authored as {@code camelCase} or {@code PascalCase}, matching Kotlin /
446+
* C# / TS / Python convention + Java's PascalCase class-naming rule.
447+
*/
448+
private static String capitalizeFirst(String s) {
449+
if (s == null || s.isEmpty()) return s;
450+
char c0 = s.charAt(0);
451+
if (Character.isUpperCase(c0)) return s;
452+
return Character.toUpperCase(c0) + s.substring(1);
391453
}
392454

393455
// === MultiFileDirectGeneratorBase abstract-method stubs ====================
@@ -418,6 +480,6 @@ protected String getSingleOutputFilePath(MetaObject md) {
418480

419481
@Override
420482
protected String getSingleOutputFilename(MetaObject md) {
421-
return SpringNaming.splitFqn(md.getName())[1] + "Payload.java";
483+
return capitalizeFirst(SpringNaming.splitFqn(md.getName())[1]) + "Payload.java";
422484
}
423485
}

0 commit comments

Comments
 (0)