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

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -2226,7 +2226,8 @@ private static List<Map<String, Object>> buildResolves(IntentModel model, Map<St
private static ResolvePathSupport.Path operand(String authored, ResolvePathSupport.Walker walker) {
if (!ResolvePathSupport.isPath(authored)) {
String pascal = IntentNaming.pascalCase(authored);
return new ResolvePathSupport.Path(ResolvePathSupport.RECORD_LOCAL + "." + pascal, pascal, null, null);
return new ResolvePathSupport.Path(ResolvePathSupport.RECORD_LOCAL + "." + pascal, pascal, null,
ResolvePathSupport.RECORD_LOCAL, pascal, null);
}
ResolvePathSupport.Path path = walker.resolve(authored);
return path.resolved() ? path : null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,14 @@ public record Hop(String local, String sourceExpression, String entity, String p
* @param terminalType the declared type of the terminal field, {@link #RELATION_TERMINAL} when the
* terminal is a to-one, or {@code null} when it sits on a cross-model target and is
* therefore not known here
* @param owner where the value is read from - {@link CheckSupport#RECORD} for a bare property, else
* the local holding the last hop's record. With {@code property}, this is {@code expression}
* taken apart, for a caller that must describe the read as data rather than emit it as code
* (issue #7405)
* @param property the terminal segment in its generated PascalCase form
* @param failure the reason the path did not resolve, or {@code null} when it did
*/
public record Path(String expression, String label, String terminalType, String failure) {
public record Path(String expression, String label, String terminalType, String owner, String property, String failure) {

/**
* @return whether the path resolved
Expand Down Expand Up @@ -223,7 +228,7 @@ public Path resolve(String authored) {
if (terminal.failure() != null) {
return failed(authored, terminal.failure());
}
return new Path(access(owner, last), label.toString(), terminal.type(), null);
return new Path(access(owner, last), label.toString(), terminal.type(), owner, pascal, null);
}

/**
Expand Down Expand Up @@ -251,7 +256,7 @@ private Terminal terminal(EntityIntent current, NotificationSupport.CrossModelTa
}

private static Path failed(String authored, String reason) {
return new Path("", authored == null ? "" : authored, null, "[" + authored + "] " + reason);
return new Path("", authored == null ? "" : authored, null, RECORD_LOCAL, "", "[" + authored + "] " + reason);
}

/** A null-guarded property read off the local holding the record it belongs to. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2192,11 +2192,11 @@ private static List<Map<String, Object>> buildChecks(EntityIntent entity, List<E
}
checkMap.put("valueExpression", path.expression());
checkMap.put("label", path.label());
String guard = requiredWhenGuard(entity, byName, check.getWhen());
if (guard == null) {
List<Map<String, Object>> when = requiredWhenTerms(entity, byName, check.getWhen());
if (when == null) {
continue; // the parser already reported it
}
checkMap.put("guard", guard);
checkMap.put("when", when);
List<Map<String, Object>> pathLoads = pathLoadsOf(walker);
if (!pathLoads.isEmpty()) {
checkMap.put("pathLoads", pathLoads);
Expand All @@ -2222,11 +2222,11 @@ private static List<Map<String, Object>> buildChecks(EntityIntent entity, List<E
// as requiredWhen's value path does. No value expression: the check rejects on the
// condition alone.
ResolvePathSupport.Walker walker = ResolvePathSupport.walker(entity, byName, compositionParents, crossModel);
String guard = forbidWhenGuard(entity, byName, walker, check.getWhen());
if (guard == null) {
List<Map<String, Object>> when = forbidWhenTerms(entity, byName, walker, check.getWhen());
if (when == null) {
continue; // the parser already reported it
}
checkMap.put("guard", guard);
checkMap.put("when", when);
List<Map<String, Object>> pathLoads = pathLoadsOf(walker);
if (!pathLoads.isEmpty()) {
checkMap.put("pathLoads", pathLoads);
Expand Down Expand Up @@ -2273,7 +2273,7 @@ private static List<Map<String, Object>> buildChecks(EntityIntent entity, List<E
if (!literal.valid()) {
continue; // the parser already reported it
}
checkMap.put("literal", literal.javaExpression());
checkMap.put("value", literal.reading());
checkMap.put("numeric", isNumericType(left.getType()) ? "true" : "false");
} else {
Boolean numeric = isNumericCompare(entity, check);
Expand Down Expand Up @@ -2393,8 +2393,8 @@ private static FieldIntent fieldOf(EntityIntent entity, String name) {
* already reported it, and a condition that silently degrades to {@code true} would make
* the value unconditionally required)
*/
private static String requiredWhenGuard(EntityIntent entity, Map<String, EntityIntent> byName, Object when) {
return CheckSupport.condition(entity, byName, when);
private static List<Map<String, Object>> requiredWhenTerms(EntityIntent entity, Map<String, EntityIntent> byName, Object when) {
return CheckSupport.conditionTerms(entity, byName, when);
}

/**
Expand All @@ -2418,36 +2418,37 @@ private static List<Map<String, Object>> pathLoadsOf(ResolvePathSupport.Walker w
}

/**
* Compiles a {@code forbidWhen} condition into the Java boolean the generated reject tests. Each
* term is either the record's own property ({@code entity.Prop}) or a one-hop
* {@code Relation.field} whose parent the walker loads first - the added reach over
* {@code requiredWhen}, which is why a child can refuse a write on its parent's state. Rendered
* against each operand's DECLARED type (a to-one is compared by its integer foreign key), ANDed,
* and null when a comparison does not compile - the parser has already reported it, and a condition
* degrading to {@code true} would refuse every write.
* Reads a {@code forbidWhen} condition into the neutral terms the model carries (issue #7405). Each
* term reads either the record's own property or a one-hop {@code Relation.field} whose parent the
* walker loads first - the added reach over {@code requiredWhen}, which is why a child can refuse a
* write on its parent's state. Typed against each operand's DECLARED type (a to-one by its integer
* foreign key), and null when a comparison does not read - the parser has already reported it, and
* a condition degrading to {@code true} would refuse every write.
*
* @param entity the entity carrying the check
* @param byName the local entities by name
* @param walker the shared path walker, which accumulates the hops the terms read through
* @param when the authored condition
* @return the Java expression, or {@code null} when a comparison does not compile
* @return the terms, or {@code null} when a comparison does not read
*/
private static String forbidWhenGuard(EntityIntent entity, Map<String, EntityIntent> byName, ResolvePathSupport.Walker walker,
Object when) {
List<String> conditions = new ArrayList<>();
private static List<Map<String, Object>> forbidWhenTerms(EntityIntent entity, Map<String, EntityIntent> byName,
ResolvePathSupport.Walker walker, Object when) {
List<Map<String, Object>> terms = new ArrayList<>();
for (String term : CheckSupport.terms(when)) {
CheckSupport.Comparison comparison = CheckSupport.parse(term);
if (comparison == null) {
return null;
}
String access;
String owner;
String property;
String type;
if (ResolvePathSupport.isPath(comparison.property())) {
ResolvePathSupport.Path path = walker.resolve(comparison.property());
if (!path.resolved()) {
return null;
}
access = path.expression();
owner = path.owner();
property = path.property();
type = ResolvePathSupport.RELATION_TERMINAL.equals(path.terminalType()) ? "integer"
: path.terminalType() != null ? path.terminalType() : inferGuardType(comparison.literal());
} else {
Expand All @@ -2456,16 +2457,17 @@ private static String forbidWhenGuard(EntityIntent entity, Map<String, EntityInt
if (field == null && relation == null) {
return null;
}
access = "entity." + IntentNaming.pascalCase(comparison.property());
owner = CheckSupport.RECORD;
property = IntentNaming.pascalCase(comparison.property());
type = field != null ? field.getType() : relationKeyType(relation, byName);
}
String literal = CheckSupport.javaLiteral(type, comparison.literal());
if (literal == null) {
Map<String, Object> read = CheckSupport.term(owner, property, comparison, type, false);
if (read == null) {
return null;
}
conditions.add(CheckSupport.comparison(access, comparison.equal(), literal));
terms.add(read);
}
return conditions.isEmpty() ? null : String.join(" && ", conditions);
return terms.isEmpty() ? null : terms;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import java.util.Map;

import org.eclipse.dirigible.components.intent.model.IntentModel;
import org.eclipse.dirigible.components.ide.template.service.model.JavaLiterals;
import org.eclipse.dirigible.components.intent.parser.IntentParser;
import org.junit.jupiter.api.Test;

Expand Down Expand Up @@ -1396,7 +1397,14 @@ void conditionallyRequiredValuesEmitTheirConditionAndTheHopsTheirValueIsReadThro
Map<String, Object> overHop = checks.get(0);
// The value is read through the relation, so the hop the reader must load rides along - the
// .model twin cannot re-derive it, and this is what lets the check reach a related record.
assertEquals("java.util.Objects.equals(entity.SentMethod, 1)", overHop.get("guard"));
// The condition is carried as DATA (#7405) - the terms, typed, not the Java that tests them -
// and JavaLiterals renders it in the template layer. Both halves are pinned here so the model
// shape and the code it ends up as cannot drift apart.
assertEquals(List.of(
Map.of("owner", "entity", "property", "SentMethod", "equal", true, "type", "integer", "value", "1", "numericKey", false)),
overHop.get("when"));
assertNull(overHop.get("guard"), "the model carries no Java");
assertEquals("java.util.Objects.equals(entity.SentMethod, 1)", guardJava(overHop));
assertEquals("(hop0 == null ? null : hop0.Email)", overHop.get("valueExpression"));
assertEquals("Customer.Email", overHop.get("label"));
List<Map<String, Object>> loads = (List<Map<String, Object>>) overHop.get("pathLoads");
Expand All @@ -1414,7 +1422,7 @@ void conditionallyRequiredValuesEmitTheirConditionAndTheHopsTheirValueIsReadThro
// A list condition is an implicit AND, and each comparison is rendered against its property's
// declared type - a string literal quoted, an integer bare.
assertEquals("java.util.Objects.equals(entity.SentMethod, 1) && java.util.Objects.equals(entity.Kind, \"export\")",
ownField.get("guard"));
guardJava(ownField));
assertEquals("entity.Reference", ownField.get("valueExpression"));
assertNull(ownField.get("pathLoads"));
// No gate declared, so the rule holds on every user write and carries no status at all.
Expand Down Expand Up @@ -1453,9 +1461,16 @@ void aGuardOnAToOneIsComparedNumericallyBecauseItsKeyWidthIsNotKnownHere() {
""";
Map<String, Object> model = EdmIntentGenerator.buildModelJsonForTest(IntentParser.parse(yaml), "sales");
List<Map<String, Object>> checks = (List<Map<String, Object>>) entityByName(entities(model), "SalesInvoice").get("checks");
// The key's term carries numericKey, and the renderer is what turns that into a comparison by
// value - the model says WHY, not HOW.
List<Map<String, Object>> when = (List<Map<String, Object>>) checks.get(0)
.get("when");
assertEquals("long", when.get(0)
.get("type"));
assertEquals(true, when.get(0)
.get("numericKey"));
assertEquals("(entity.Status != null && entity.Status.longValue() == 4L)" + " && !java.util.Objects.equals(entity.SentMethod, 1)",
checks.get(0)
.get("guard"));
guardJava(checks.get(0)));
}

/**
Expand Down Expand Up @@ -1502,7 +1517,10 @@ void forbidWhenEmitsItsConditionTheHopsItReadsThroughAndTheMasterGuard() {
assertEquals(1, checks.size());
Map<String, Object> check = checks.get(0);
// The parent's status is loaded by FK and compared to the resolved seed id; there is no value.
assertEquals("java.util.Objects.equals((hop0 == null ? null : hop0.Status), 7)", check.get("guard"));
assertEquals(
List.of(Map.of("owner", "hop0", "property", "Status", "equal", true, "type", "integer", "value", "7", "numericKey", false)),
check.get("when"));
assertEquals("java.util.Objects.equals((hop0 == null ? null : hop0.Status), 7)", guardJava(check));
assertNull(check.get("valueExpression"));
List<Map<String, Object>> loads = (List<Map<String, Object>>) check.get("pathLoads");
assertEquals("hop0", loads.get(0)
Expand Down Expand Up @@ -1608,16 +1626,21 @@ void compareChecksAgainstLiteralsEmitJavaExpressions() {
assertEquals("Days", positive.get("field"));
assertEquals(">", positive.get("op"));
assertEquals("true", positive.get("numeric"));
assertEquals("new java.math.BigDecimal(\"0\")", positive.get("literal"));
// The reading is data - the kind and the exact decimal - and the Java appears only downstream.
assertEquals(Map.of("kind", "number", "text", "0"), positive.get("value"));
assertNull(positive.get("literal"), "the model carries no Java");
assertEquals("new java.math.BigDecimal(\"0\")", literalJava(positive));
assertNull(positive.get("than"), "a literal comparison has no second property");
assertEquals("2", positive.get("status"), "the gate routes the check to the repository");
assertEquals("Status", positive.get("statusProperty"));
Map<String, Object> notPast = checks.get(1);
assertEquals("false", notPast.get("numeric"));
assertEquals("java.time.LocalDate.now()", notPast.get("literal"));
assertEquals(Map.of("kind", "moment", "shape", "date"), notPast.get("value"));
assertEquals("java.time.LocalDate.now()", literalJava(notPast));
assertNull(notPast.get("status"), "an ungated comparison stays the controllers' - every user write");
assertEquals("java.time.Instant.now().plus(java.time.Duration.parse(\"PT1H\"))", checks.get(2)
.get("literal"),
assertEquals(Map.of("kind", "moment", "shape", "timestamp", "offset", "PT1H", "forward", "true"), checks.get(2)
.get("value"));
assertEquals("java.time.Instant.now().plus(java.time.Duration.parse(\"PT1H\"))", literalJava(checks.get(2)),
"a timestamp column binds java.time.Instant, so the moment renders in THAT shape");
}

Expand Down Expand Up @@ -2921,4 +2944,16 @@ void aStatusRollupParentCarriesTheDisplacedStatusColumn() {
assertFalse(names.contains("DisplacedStatus"), other + " is not the parent of the roll-up");
}
}

/** The Java a check's neutral condition renders as - the template layer's half of #7405. */
@SuppressWarnings("unchecked")
private static String guardJava(Map<String, Object> check) {
return JavaLiterals.conditionExpression((List<Map<String, Object>>) check.get("when"));
}

/** The Java a check's neutral literal reading renders as - the template layer's half of #7405. */
@SuppressWarnings("unchecked")
private static String literalJava(Map<String, Object> check) {
return JavaLiterals.compareLiteralExpression((Map<String, Object>) check.get("value"));
}
}
Loading
Loading