Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -910,6 +910,8 @@ private static void validateScheduleGenerate(ScheduleIntent schedule, EntityInte
+ "] (add a uses: alias if the target lives in another model)");
}
validateMapSource(source, byName, g.getMap(), "schedule [" + name + "]", "generate map", true, issues);
validateMapTarget(crossModel || g.getTo() == null ? null : byName.get(g.getTo()), g.getMap(), "schedule [" + name + "]",
"generate map", issues);
if (g.getItems() != null || (g.getItemLines() != null && !g.getItemLines()
.isEmpty())) {
issues.add("schedule [" + name + "] generate declares items - item cloning is not supported for a scheduled generation;"
Expand Down Expand Up @@ -6336,6 +6338,8 @@ private static void validateGenerates(IntentModel model, Set<String> entityNames
}
validateGeneratesEvent(g, name, source, crossModelSource, model, issues);
validateMapSource(source, byName, g.getMap(), "generates [" + name + "]", "map", true, issues);
validateMapTarget(crossModel || g.getTo() == null ? null : byName.get(g.getTo()), g.getMap(), "generates [" + name + "]", "map",
issues);
if (g.getItems() != null) {
GeneratesItemsIntent items = g.getItems();
EntityIntent itemSource = null;
Expand All @@ -6355,6 +6359,10 @@ private static void validateGenerates(IntentModel model, Set<String> entityNames
issues.add("generates [" + name + "] items has no to entity");
}
validateMapSource(itemSource, byName, items.getMap(), "generates [" + name + "]", "items map", false, issues);
// The item target lives in the SAME model as the header target, so a cross-model header
// implies a cross-model item - resolved in the owner's .model, not here.
validateMapTarget(crossModel || items.getTo() == null ? null : byName.get(items.getTo()), items.getMap(),
"generates [" + name + "]", "items map", issues);
}
validateGeneratesItemLines(g, name, source, byName, crossModel, issues);
validateGeneratesPrompt(g, name, byName, crossModel, issues);
Expand Down Expand Up @@ -7186,6 +7194,44 @@ private static void validateMapSource(EntityIntent source, Map<String, EntityInt
}
}

/**
* The other half of a {@code map} entry (issue #6953): each <b>key</b> names a field or a to-one
* relation of the TARGET being created. The generator pascal-cases the key and emits
* {@code target.<Key> = ...}, so a key the target does not declare is not a mis-mapping that
* degrades at run time - it is Java that does not compile, and because client Java compiles as one
* registry-wide batch the failure takes every module's beans down with it.
*
* <p>
* {@code postings:} has always checked its {@code map} keys against its {@code creates} target; a
* {@code generates:} (and a schedule's {@code generate:}) checked only the value side. This closes
* that asymmetry, with the same message shape and the same case-insensitive match - the key is
* authored PascalCase by convention, the target's field camelCase.
*
* <p>
* Skipped when the target is unknown or CROSS-MODEL ({@code uses:}): a foreign target's property
* names live in the owner's {@code .model} and are resolved at generation time, the convention
* every cross-model reference follows.
*
* @param target the entity the map writes into, or {@code null} when it is not resolvable here
* @param map the authored {@code target property -> source property} map
* @param subject the message prefix naming the offending block
* @param role the map's role in that block ({@code map} / {@code items map} / {@code generate map})
* @param issues the collected issues
*/
private static void validateMapTarget(EntityIntent target, Map<String, String> map, String subject, String role, List<String> issues) {
if (target == null || map == null) {
return;
}
for (String key : map.keySet()) {
if (key == null || key.isBlank()) {
continue;
}
if (!hasPropertyIgnoreCase(target, key)) {
issues.add(subject + " " + role + " [" + key + "] is not a field or to-one relation of [" + target.getName() + "]");
}
}
}

/**
* One {@code relation.field} map source: the head must be a to-one relation of the mapping source,
* the tail a field of the entity that relation points at. Anything deeper, or a tail that is itself
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1570,6 +1570,14 @@ through a **cross-model** relation is fine. The two ends are not type-checked, e

A schedule's `generate.map` takes the same hop, off the row the cron query returned.

**The KEY side is checked too.** Each `map:` key must name a field or a to-one relation of the
target (`to:`) - the generator emits `target.<Key> = ...`, so a key the target does not declare is
not a mis-mapping that shows up at run time, it is Java that does not compile, and client Java
compiles as one registry-wide batch (one bad key takes every module's beans down). The same check
applies to an `items:` map (against the items `to:`) and to a schedule's `generate.map`. A
**cross-model** target (`uses:`) is exempt - its property names live in the owner's `.model` and are
resolved at generation time.

**Cross-model SOURCE (`fromUses:`) - author the create-from on the TARGET's module.** By default the
`from` entity is local and the target may be foreign (`uses:`). `fromUses:` mirrors that: the SOURCE is
owned by another model and the TARGET is the local one. Both directions describe the same button; they
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,7 @@ void anEventDrivenGenerateWithoutABackReferenceFailsLoudly() {
- name: Declaration
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: note, type: string }
relations:
- { name: Fine, kind: manyToOne, to: Fine, model: fines }
generates:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@ void rejectsMapSourceThatIsNotASourceProperty() {
- name: Order
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: note, type: string }
generates:
- name: bad
from: Quote
Expand Down Expand Up @@ -1335,4 +1336,172 @@ void aReopenTheSourceLifecycleDeclaresParses() {
.getSourceStatusOnRetire());
}

/**
* Issue #6953: a {@code map} KEY names a property of the target. An unknown one is not a
* mis-mapping that degrades at run time - the generator emits {@code target.<Key> = ...}, so it is
* Java that does not compile, and client Java compiles as one registry-wide batch.
*/
@Test
void rejectsMapKeyThatIsNotATargetProperty() {
String yaml = """
name: fines
entities:
- name: Fine
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: violationAt, type: timestamp }
relations:
- { name: Vehicle, kind: manyToOne, to: Vehicle }
- name: Vehicle
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: plateNumber, type: string }
- name: FineLog
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: plate, type: string }
generates:
- name: identificationLog
from: Fine
to: FineLog
map:
Plate: Vehicle.plateNumber
Vehicle: Vehicle.plateNumber
violationAt: violationAt
""";
IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml));
assertTrue(ex.getIssues()
.stream()
.anyMatch(
i -> i.contains("generates [identificationLog] map [Vehicle] is not a field or to-one relation of [FineLog]")),
"got: " + ex.getIssues());
assertTrue(ex.getIssues()
.stream()
.anyMatch(i -> i.contains(
"generates [identificationLog] map [violationAt] is not a field or to-one relation of [FineLog]")),
"got: " + ex.getIssues());
// The key that IS a target field is not reported, hop-valued or not.
assertFalse(ex.getIssues()
.stream()
.anyMatch(i -> i.contains("map [Plate]")),
"got: " + ex.getIssues());
}

/**
* The same check on the {@code items} map, whose target is the items {@code to:} entity.
*/
@Test
void rejectsItemsMapKeyThatIsNotATargetItemProperty() {
String yaml = """
name: sales
entities:
- name: Quote
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- name: QuoteItem
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: amount, type: decimal }
relations:
- { name: Quote, kind: manyToOne, to: Quote, composition: true, required: true }
- name: Order
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- name: OrderItem
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: amount, type: decimal }
relations:
- { name: Order, kind: manyToOne, to: Order, composition: true, required: true }
generates:
- name: order-from-quote
from: Quote
to: Order
items:
from: QuoteItem
to: OrderItem
map:
Amount: amount
Discount: amount
""";
IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml));
assertTrue(ex.getIssues()
.stream()
.anyMatch(i -> i.contains(
"generates [order-from-quote] items map [Discount] is not a field or to-one relation of [OrderItem]")),
"got: " + ex.getIssues());
}

/**
* And on a schedule's {@code generate} map, whose target is that generate's {@code to:}.
*/
@Test
void rejectsScheduleGenerateMapKeyThatIsNotATargetProperty() {
String yaml = """
name: hr
entities:
- name: Person
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: name, type: string }
- name: Claim
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
relations:
- { name: Person, kind: manyToOne, to: Person }
schedules:
- name: monthly
cron: "0 0 4 1 * *"
entity: Person
generate:
to: Claim
map: { Person: id, Note: name }
""";
IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml));
assertTrue(ex.getIssues()
.stream()
.anyMatch(i -> i.contains("schedule [monthly] generate map [Note] is not a field or to-one relation of [Claim]")),
"got: " + ex.getIssues());
}

/**
* A CROSS-MODEL target is skipped: its property names live in the owner's {@code .model} and are
* resolved at generation time, the convention every cross-model reference follows. Both maps - the
* header's and the items' - since a cross-model header implies a cross-model item target.
*/
@Test
void aCrossModelTargetSkipsTheMapKeyCheck() {
IntentModel model = IntentParser.parse("""
name: timesheets
uses:
- { model: sales }
entities:
- name: ProjectTimesheet
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: note, type: string }
- name: ProjectTimesheetLine
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: hours, type: decimal }
relations:
- { name: ProjectTimesheet, kind: manyToOne, to: ProjectTimesheet, composition: true, required: true }
generates:
- name: invoice-from-timesheet
from: ProjectTimesheet
to: SalesInvoice
uses: sales
map:
NothingCheckableHere: note
items:
from: ProjectTimesheetLine
to: SalesInvoiceItem
map:
NorHere: hours
""");
assertEquals("SalesInvoice", model.getGenerates()
.get(0)
.getTo());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -2000,6 +2000,8 @@ void scheduleGenerateChildrenValidate() {
+ " - name: Claim\n" //
+ " fields:\n" //
+ " - { name: id, type: integer, primaryKey: true, generated: true }\n" //
+ " relations:\n" //
+ " - { name: Person, kind: manyToOne, to: Person }\n" //
+ " - name: ClaimLine\n" //
+ " fields:\n" //
+ " - { name: id, type: integer, primaryKey: true, generated: true }\n" //
Expand Down
Loading