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
2 changes: 1 addition & 1 deletion components/engine/engine-intent/CLAUDE.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -2623,6 +2623,10 @@ private static List<Map<String, Object>> buildPostings(IntentModel model, Map<St
Map<String, Object> rendered = new LinkedHashMap<>();
List<Map<String, Object>> assigns = new ArrayList<>();
Map<String, Object> rowGuard = Map.of();
// The rule columns THIS row reads. Where they are required is decided once the row's
// own `when:` is known (#7649): a column only a guarded row reads must not gate a
// document the guard excludes.
java.util.Set<String> rowRuleColumns = new java.util.LinkedHashSet<>();
for (Map.Entry<String, String> cell : row.entrySet()) {
String value = cell.getValue() == null ? ""
: cell.getValue()
Expand All @@ -2648,7 +2652,7 @@ private static List<Map<String, Object>> buildPostings(IntentModel model, Map<St
assign.put("reading", conditionalRuleReading(ruleSelector.get()));
} else if (ruleRef.matches()) {
String column = IntentNaming.pascalCase(ruleRef.group(1));
usedRuleColumns.add(column);
rowRuleColumns.add(column);
assign.put("reading", Readings.read("ruleRow", column));
} else if (toOneRelation(itemsEntity, cell.getKey()) != null) {
// Source-FK copy (issue #6533): the item cell's key is a to-one relation of the
Expand All @@ -2669,6 +2673,18 @@ private static List<Map<String, Object>> buildPostings(IntentModel model, Map<St
assigns.add(assign);
}
rendered.put("guardReading", rowGuard);
// WHERE this row's rule columns are required (#7649). An UNGUARDED row books on every
// document of this type, so a null column of its own genuinely stops the posting and
// joins the up-front gate. A GUARDED row books only on the documents its `when:`
// selects, so its columns are required INSIDE that guard - otherwise adding an optional
// line (a promotion account on the few invoices that carry one) stopped every document
// of the type from posting on every tenant whose rule row predates the new column, the
// ones the line does not apply to included, silently.
if (rowGuard.isEmpty()) {
usedRuleColumns.addAll(rowRuleColumns);
} else if (!rowRuleColumns.isEmpty()) {
rendered.put("ruleColumns", new ArrayList<>(rowRuleColumns));
}
rendered.put("assigns", assigns);
itemRows.add(rendered);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -768,7 +768,14 @@ field may declare:
references, arithmetic over the SOURCE's fields, or - for a to-one relation cell - a bare SOURCE
relation name whose FK is copied onto the line; a row `when` is `<SourceField> ==|!= <number>`.
A missing rule row or null referenced column SKIPS the posting (the unposted worklist = final-status
documents with no back-referencing target), never throws. `rule.match` is a single
documents with no back-referencing target), never throws - and says so in the log naming the rule
entity, the match value and the source, because nothing else in the system marks the gap (#7649).
**Where a column is required follows the line that reads it.** A column read by an UNGUARDED line is
required of every document of this type and is checked once, up front. A column only a
`when:`-guarded line reads is checked INSIDE that line's own guard: a document the guard excludes
books nothing on the column, so an optional line added later - a promotion account on the few
invoices that carry one - must not stop every document of the type from posting on every tenant
whose rule row predates it. `rule.match` is a single
`column: literal` selector and the literal must be there - an empty one is refused at parse, because
it is rendered into the handler as the authored literal and would select no rule row at all, leaving
every source document on the worklist with nothing failing anywhere.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,15 @@ void postingGlueIsFullyPreRendered() {
"a to-one relation item cell must pre-render as a source-FK copy");
// the third row carries a null-safe Calc guard
assertEquals("Calc.eval(\"Vat\", source, 6).compareTo(new java.math.BigDecimal(\"0\")) != 0", GlueRendering.guard(rows.get(2)));
assertEquals(List.of("ReceivableAccount", "RevenueAccount", "VatAccount"), p.get("usedRuleColumns"));
// Only the columns the UNGUARDED rows read are required of every document of this type
// (#7649): the VAT account is read by the `when: "Vat != 0"` row alone, so it is required where
// that row books and nowhere else - a rule row without one still posts a zero-rated invoice.
assertEquals(List.of("ReceivableAccount", "RevenueAccount"), p.get("usedRuleColumns"));
assertEquals(null, rows.get(0)
.get("ruleColumns"),
"an unguarded row's columns are gated up front, not on the row");
assertEquals(List.of("VatAccount"), rows.get(2)
.get("ruleColumns"));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1648,6 +1648,9 @@ static List<Map<String, Object>> rows(Object raw) {
String guard = declared.containsKey("guardReading") ? JavaExpressions.expression(declared.get("guardReading")) : null;
row.put("guard", guard != null ? guard : strOr(declared, "guard", ""));
row.put("assigns", assignments(declared.get("assigns")));
// The classifier ternaries this row's own cells carry, for the null-check that belongs
// inside its guard (#7649). Empty for an unguarded row - those are gated up front.
row.put("ruleCaseGuards", str(row, "guard").isEmpty() ? List.of() : ruleCaseExpressions(row));
rows.add(row);
}
return rows;
Expand All @@ -1664,15 +1667,28 @@ static List<Map<String, Object>> rows(Object raw) {
static List<String> conditionalRuleGuards(List<Map<String, Object>> rows) {
List<String> guards = new ArrayList<>();
for (Map<String, Object> row : rows) {
for (Map<String, Object> assignment : asMaps(row.get("assigns"))) {
if (assignment.get("reading") instanceof Map<?, ?> reading && "ruleCase".equals(reading.get("kind"))) {
guards.add(str(assignment, "expr"));
}
// A GUARDED row's classifier ternaries are required only where that row books (#7649), so
// they are carried on the row and null-checked inside its own guard; only an unguarded
// row's reach the up-front gate, which every document of this type passes through.
if (!str(row, "guard").isEmpty()) {
continue;
}
guards.addAll(ruleCaseExpressions(row));
}
return guards;
}

/** The classifier ternaries one row's cells carry, already rendered. */
static List<String> ruleCaseExpressions(Map<String, Object> row) {
List<String> expressions = new ArrayList<>();
for (Map<String, Object> assignment : asMaps(row.get("assigns"))) {
if (assignment.get("reading") instanceof Map<?, ?> reading && "ruleCase".equals(reading.get("kind"))) {
expressions.add(str(assignment, "expr"));
}
}
return expressions;
}

/**
* Sanitizes a descriptor's value into a Java identifier.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,29 @@ void theConditionalRuleGuardsAreTheRuleCaseCells() {
"(Calc.eval(\"Method\", source, 6).compareTo(new java.math.BigDecimal(\"1\")) == 0 ? ruleRow.CashAccount : ruleRow.BankAccount)");
}

/**
* ...and a GUARDED row's ternaries are not among them (#7649): a classifier the row needs only
* where it books must not stop a document the row's own {@code when:} excludes. They ride on the
* row instead, for a null check inside that guard.
*/
@Test
void aGuardedRowsRuleCaseCellsGuardThatRowAlone() {
Map<String, Object> ruleCase = new LinkedHashMap<>();
ruleCase.put("targetProp", "Account");
ruleCase.put("reading", Map.of("kind", "ruleCase", "by", "Method", "owner", "source", "cases",
List.of(Map.of("value", "1", "column", "CashAccount")), "otherwise", "BankAccount"));
Map<String, Object> guarded = new LinkedHashMap<>();
guarded.put("guardReading", Map.of("kind", "calcCompare", "owner", "source", "property", "Vat", "equal", false, "text", "0"));
guarded.put("assigns", List.of(ruleCase));

List<Map<String, Object>> rows = GlueGenerator.rows(List.of(guarded));

assertThat(GlueGenerator.conditionalRuleGuards(rows)).isEmpty();
assertThat(rows.get(0)
.get("ruleCaseGuards")).asInstanceOf(org.assertj.core.api.InstanceOfAssertFactories.LIST)
.hasSize(1);
}

/**
* An event binding's guard is rendered from its neutral terms - {@code true} for none - and a
* descriptor written before the split keeps the rendered expression it carries.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,16 +83,27 @@ public class ${className}Posting implements MessageHandler {
new gen.${javaGenFolderName}.data.${ruleJavaPerspective}.${ruleEntity}Repository().findAll(
Criteria.create().eq("${ruleMatchProperty}", ${ruleMatchValueJava}));
if (ruleRows.isEmpty()) {
// Said out loud (#7649): the unposted worklist is otherwise invisible - the handler only
// fires on the event, so a silent skip leaves nothing anywhere to explain the gap.
LOG.warn("Posting ${name}: no ${ruleEntity} matches [{}] - ${sourceEntity} [{}] stays unposted",
${ruleMatchValueJava}, source.${sourceKeyField});
return; // no determination rule -> the document stays on the unposted worklist
}
gen.${javaGenFolderName}.data.${ruleJavaPerspective}.${ruleEntity}Entity ruleRow = ruleRows.get(0);
## The columns every line of this posting reads - the UNGUARDED rows'. A null here stops a document
## that would genuinely have booked on the column, so it is checked once, up front. A column only a
## `when:`-guarded line reads is checked inside that line's own guard below (#7649).
#foreach($column in $usedRuleColumns)
if (ruleRow.${column} == null) {
LOG.warn("Posting ${name}: ${ruleEntity} matching [{}] has no ${column} - ${sourceEntity} [{}] stays unposted",
${ruleMatchValueJava}, source.${sourceKeyField});
return; // incomplete determination -> unposted worklist
}
#end
#foreach($guard in $conditionalRuleGuards)
if (${guard} == null) {
LOG.warn("Posting ${name}: the ${ruleEntity} matching [{}] determines no account for ${sourceEntity} [{}]"
+ " - it stays unposted", ${ruleMatchValueJava}, source.${sourceKeyField});
return; // conditional rule(by: ...) selected no account (unmatched or null) -> unposted worklist
}
#end
Expand All @@ -116,6 +127,23 @@ public class ${className}Posting implements MessageHandler {
#foreach($row in $itemRows)
#if($row.guard != "")
if (${row.guard}) {
## The rule columns only THIS line reads (#7649). Required where the line books, not before: a
## document whose `when:` excludes the line books nothing on the column, so a rule row that predates
## it - every tenant's, the day an optional line is added - must not stop that document from posting.
#foreach($column in $row.ruleColumns)
if (ruleRow.${column} == null) {
LOG.warn("Posting ${name}: ${ruleEntity} matching [{}] has no ${column}, which this ${sourceEntity} [{}] books on"
+ " - it stays unposted", ${ruleMatchValueJava}, source.${sourceKeyField});
return;
}
#end
#foreach($guard in $row.ruleCaseGuards)
if (${guard} == null) {
LOG.warn("Posting ${name}: the ${ruleEntity} matching [{}] determines no account for the line this ${sourceEntity}"
+ " [{}] books - it stays unposted", ${ruleMatchValueJava}, source.${sourceKeyField});
return;
}
#end
#end
{
gen.${javaGenFolderName}.data.${itemsJavaPerspective}.${itemsEntity}Entity item =
Expand Down
Loading