From ec0f33d78f36d015970626d67dc0ca8032327720 Mon Sep 17 00:00:00 2001 From: Robert Brunel Date: Fri, 10 Jul 2026 15:45:29 +0100 Subject: [PATCH] Introduce conditional planner rules (`ConditionalCascadesRule`) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A conditional rule bundles an ordered list of inner rules. It acts as one rule that receives special treatment by the planner. The inner rules are tried in order, stopping as soon as one rule yielded a new expression and falling through to the next rule only when the current on made no progress. In other words, each inner rule runs only under the “condition” that no prior rule made any progress. In effect, only the _first_ useful rule is applied. The motivation behind this is that, by carefully grouping strictly prioritized or mutually exclusive transformations this way, we can substantially prune the planner search space and reduce the memo size. This change adds only the `ConditionalCascadesRule` abstraction and the necessary planner plumbing to dispatch such rules. It is not added to any rule set. * Add a new `ConditionalCascadesRule` class, as well as `ConditionalExplorationCascadesRule` and `ConditionalImplementationCascadesRule` subclasses. * Add a corresponding `ConditionalTransformExpression` task that implements the mechanism of trying the rules in the conditional chain one at a time. * Change all `Task.execute()` implementations to return a boolean signaling whether the task made progress. * Propagate that boolean through `executeRuleCall()` so the surrounding `Transform` task can report whether any change to the planner state was made. Co-authored-by: Normen Seemann --- .../query/plan/cascades/CascadesPlanner.java | 184 ++++++++--- .../cascades/ConditionalCascadesRule.java | 221 ++++++++++++++ .../cascades/ConditionalCascadesRuleTest.java | 289 ++++++++++++++++++ .../PlannerEventSerializationTests.java | 3 +- .../plan/cascades/debug/CommandsTest.java | 3 +- 5 files changed, 650 insertions(+), 50 deletions(-) create mode 100644 fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/ConditionalCascadesRule.java create mode 100644 fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/query/plan/cascades/ConditionalCascadesRuleTest.java diff --git a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/CascadesPlanner.java b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/CascadesPlanner.java index 65482577cb..4c6c68a6f4 100644 --- a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/CascadesPlanner.java +++ b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/CascadesPlanner.java @@ -63,6 +63,7 @@ import com.apple.foundationdb.record.query.plan.plans.RecordQueryPlan; import com.google.common.base.Suppliers; import com.google.common.base.Verify; +import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -71,10 +72,10 @@ import java.util.ArrayDeque; import java.util.Collection; import java.util.Deque; +import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.Set; -import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; import java.util.function.Supplier; @@ -544,7 +545,12 @@ public interface Task { @Nonnull PlannerPhase getPlannerPhase(); - void execute(); + /** + * Executes this task, mutating the planner state (the memo and the task stack) as appropriate for the kind of + * task. The returned boolean reports whether the task made progress, that is, whether it changed planner state, + * for example by yielding new expressions, pushing planner requirements, or pushing more tasks onto the stack. + */ + boolean execute(); PlannerEvent toTaskEvent(Location location); } @@ -576,13 +582,14 @@ public PlannerPhase getPlannerPhase() { } @Override - public void execute() { + public boolean execute() { if (plannerPhase.hasNextPhase()) { // if there is another phase push it first so it gets executed at the very end taskStack.push(new InitiatePlannerPhase(plannerPhase.getNextPhase())); } taskStack.push(new OptimizeGroup(plannerPhase, currentRoot)); taskStack.push(new ExploreGroup(plannerPhase, currentRoot)); + return true; } @Override @@ -630,7 +637,7 @@ public PlannerPhase getPlannerPhase() { } @Override - public void execute() { + public boolean execute() { RelationalExpression bestFinalExpression = null; final var costModel = plannerPhase.createCostModel(configuration); for (final var finalExpression : group.getFinalExpressions()) { @@ -671,6 +678,7 @@ public void execute() { } else { group.pruneWith(bestFinalExpression); } + return true; } @Override @@ -713,13 +721,13 @@ public PlannerPhase getPlannerPhase() { } @Override - public void execute() { + public boolean execute() { final var targetPlannerStage = plannerPhase.getTargetPlannerStage(); final var groupPlannerStage = group.getPlannerStage(); if (targetPlannerStage != groupPlannerStage) { if (targetPlannerStage.precedes(groupPlannerStage)) { // group is further along in the planning process, do not re-explore - return; + return false; } else { // // targetPlannerStage succeeds groupPlannerStage @@ -753,6 +761,7 @@ public void execute() { } else { group.commitExploration(); } + return true; } @Override @@ -821,11 +830,11 @@ public AbstractExploreExpression(@Nonnull final PlannerPhase plannerPhase, } @Override - public void execute() { + public boolean execute() { final var ruleSet = getPlannerPhase().getRuleSet(); // push all rules that need to run after all exploration for a (group, expression) pair is done. - ruleSet.getMatchPartitionRules(rule -> configuration.isRuleEnabled(rule)) + ruleSet.getMatchPartitionRules(configuration::isRuleEnabled) .filter(this::shouldPushRule) .forEach(this::pushTransformMatchPartition); @@ -833,7 +842,7 @@ public void execute() { // by the type of their _root_, not any of the stuff lower down. As a result, we have enough information // right here to determine the set of all possible rules that could ever be applied here, regardless of // what happens towards the leaves of the tree. - ruleSet.getRules(getExpression(), rule -> configuration.isRuleEnabled(rule)) + ruleSet.getRules(getExpression(), configuration::isRuleEnabled) .filter(rule -> !(rule instanceof PreOrderRule) && shouldPushRule(rule)) .forEach(this::pushTransformTask); @@ -845,16 +854,44 @@ public void execute() { .map(Quantifier::getRangesOver) .forEach(this::pushExploreGroup); - ruleSet.getRules(getExpression(), rule -> configuration.isRuleEnabled(rule)) + ruleSet.getRules(getExpression(), configuration::isRuleEnabled) .filter(rule -> rule instanceof PreOrderRule && shouldPushRule(rule)) .forEach(this::pushTransformTask); + return true; } protected abstract boolean shouldPushRule(@Nonnull CascadesRule rule); + /** + * Pushes a task that transforms the current group/expression pair using the given rule. For an ordinary rule, + * pushes a {@link TransformExpression} task. For a {@link ConditionalCascadesRule}, pushes a single + * {@link ConditionalTransformExpression} task holding the enabled inner rules (but only if there are any). + */ private void pushTransformTask(@Nonnull CascadesRule rule) { - taskStack.push(new TransformExpression(getPlannerPhase(), getGroup(), getExpression(), rule)); + final PlannerPhase phase = getPlannerPhase(); + final Reference group = getGroup(); + final RelationalExpression expression = getExpression(); + if (rule instanceof final ConditionalCascadesRule conditionalRule) { + // Filter the individual inner rules by `configuration::isRuleEnabled` here, since the filter that is + // applied earlier at `ruleSet.getRules()` pertains only to the `ConditionalCascadesRule` wrapper. + final var enabledRules = getEnabledRules(conditionalRule); + if (!enabledRules.isEmpty()) { + taskStack.push(new ConditionalTransformExpression(phase, group, expression, enabledRules)); + } + } else { + taskStack.push(new TransformExpression(phase, group, expression, rule)); + } + } + + /** + * Returns the inner rules of the given {@link ConditionalCascadesRule} that are currently enabled according to + * the planner {@link #configuration}, preserving their order. + */ + private List> getEnabledRules(ConditionalCascadesRule rule) { + final var rules = rule.getRules(configuration::isRuleEnabled); + @SuppressWarnings("unchecked") final var result = (List>)rules; + return result; } private void pushTransformMatchPartition(AbstractCascadesRule rule) { @@ -1007,43 +1044,45 @@ protected boolean shouldExecute() { */ @Override @SuppressWarnings("java:S1117") - public void execute() { + public boolean execute() { if (!shouldExecute()) { - return; + return false; } - final PlannerBindings initialBindings = getInitialBindings(); + final Object bindable = getBindable(); + final Iterable boundMatches = + rule.getMatcher().bindMatches(getConfiguration(), initialBindings, bindable)::iterator; + int numMatches = 0; + boolean hasMadeProgress = false; + for (final var bindings : boundMatches) { + final var ruleCall = new CascadesRuleCall(plannerPhase, planContext, rule, group, traversal, taskStack, + bindings, evaluationContext); + ++numMatches; + if (isMaxNumMatchesPerRuleCallExceeded(configuration, numMatches)) { + throw new RecordQueryPlanComplexityException("Maximum number of matches per rule call has been exceeded") + .addLogInfo(LogMessageKeys.RULE, ruleCall) + .addLogInfo(LogMessageKeys.RULE_MATCHES_COUNT, numMatches) + .addLogInfo(LogMessageKeys.MAX_RULE_MATCHES_COUNT, + configuration.getMaxNumMatchesPerRuleCall()); + } + // we notify the debugger (if installed) that the transform task is succeeding and + // about begin and end of the rule call event + PlannerEventListeners.dispatchEvent(() -> toTaskEvent(Location.MATCH_PRE)); + PlannerEventListeners.dispatchEvent(() -> new TransformRuleCallPlannerEvent(plannerPhase, currentRoot, + taskStack, Location.BEGIN, group, bindable, rule, ruleCall)); + try { + hasMadeProgress |= executeRuleCall(ruleCall); + } finally { + PlannerEventListeners.dispatchEvent(() -> new TransformRuleCallPlannerEvent(plannerPhase, currentRoot, + taskStack, Location.END, group, bindable, rule, ruleCall)); + } + } + return hasMadeProgress; + } + + protected boolean executeRuleCall(@Nonnull CascadesRuleCall ruleCall) { + boolean hasMadeProgress = false; - final AtomicInteger numMatches = new AtomicInteger(0); - - rule.getMatcher() - .bindMatches(getConfiguration(), initialBindings, getBindable()) - .map(bindings -> new CascadesRuleCall(plannerPhase, planContext, rule, group, - traversal, taskStack, bindings, evaluationContext)) - .forEach(ruleCall -> { - int ruleMatchesCount = numMatches.incrementAndGet(); - if (isMaxNumMatchesPerRuleCallExceeded(configuration, ruleMatchesCount)) { - throw new RecordQueryPlanComplexityException("Maximum number of matches per rule call has been exceeded") - .addLogInfo(LogMessageKeys.RULE, ruleCall) - .addLogInfo(LogMessageKeys.RULE_MATCHES_COUNT, ruleMatchesCount) - .addLogInfo(LogMessageKeys.MAX_RULE_MATCHES_COUNT, - configuration.getMaxNumMatchesPerRuleCall()); - } - // we notify the debugger (if installed) that the transform task is succeeding and - // about begin and end of the rule call event - PlannerEventListeners.dispatchEvent(() -> toTaskEvent(Location.MATCH_PRE)); - PlannerEventListeners.dispatchEvent(() -> new TransformRuleCallPlannerEvent(plannerPhase, currentRoot, - taskStack, Location.BEGIN, group, getBindable(), rule, ruleCall)); - try { - executeRuleCall(ruleCall); - } finally { - PlannerEventListeners.dispatchEvent(() -> new TransformRuleCallPlannerEvent(plannerPhase, currentRoot, - taskStack, Location.END, group, getBindable(), rule, ruleCall)); - } - }); - } - - protected void executeRuleCall(@Nonnull CascadesRuleCall ruleCall) { ruleCall.run(); // @@ -1059,18 +1098,21 @@ protected void executeRuleCall(@Nonnull CascadesRuleCall ruleCall) { PlannerEventListeners.dispatchEvent(() -> new TransformRuleCallPlannerEvent(plannerPhase, currentRoot, taskStack, Location.YIELD, group, getBindable(), rule, ruleCall)); taskStack.push(new AdjustMatch(getPlannerPhase(), getGroup(), getExpression(), newPartialMatch)); + hasMadeProgress = true; } for (final RelationalExpression newExpression : ruleCall.getNewFinalExpressions()) { PlannerEventListeners.dispatchEvent(() -> new TransformRuleCallPlannerEvent(plannerPhase, currentRoot, taskStack, Location.YIELD, group, getBindable(), rule, ruleCall)); exploreExpressionAndOptimizeInputs(plannerPhase, getGroup(), newExpression, true); + hasMadeProgress = true; } for (final RelationalExpression newExpression : ruleCall.getNewExploratoryExpressions()) { PlannerEventListeners.dispatchEvent(() -> new TransformRuleCallPlannerEvent(plannerPhase, currentRoot, taskStack, Location.YIELD, group, getBindable(), rule, ruleCall)); exploreExpression(plannerPhase, group, newExpression, true); + hasMadeProgress = true; } final var referencesWithPushedRequirements = ruleCall.getReferencesWithPushedConstraints(); @@ -1084,13 +1126,16 @@ protected void executeRuleCall(@Nonnull CascadesRuleCall ruleCall) { // if (!(rule instanceof PreOrderRule)) { taskStack.push(this); + hasMadeProgress = true; } for (final Reference reference : referencesWithPushedRequirements) { if (!reference.hasNeverBeenExplored()) { taskStack.push(new ExploreGroup(plannerPhase, reference)); + hasMadeProgress = true; } } } + return hasMadeProgress; } @Override @@ -1137,6 +1182,47 @@ protected PlannerBindings getInitialBindings() { } } + /** + * Class to transform an expression using the first applicable rule out of an ordered list of rules, as produced by + * a {@link ConditionalCascadesRule}. The rules are tried one at a time. This task first applies the rule at + * {@code index} (by delegating to the {@link TransformExpression} superclass). If that rule makes no progress, + * it pushes a follow-up {@code ConditionalTransformExpression} that resumes at the next index. + */ + private class ConditionalTransformExpression extends TransformExpression { + @Nonnull + private final List> rules; + private final int index; + + public ConditionalTransformExpression(@Nonnull final PlannerPhase plannerPhase, + @Nonnull final Reference group, + @Nonnull final RelationalExpression expression, + @Nonnull final List> rules) { + this(plannerPhase, group, expression, ImmutableList.copyOf(rules), 0); + } + + private ConditionalTransformExpression(@Nonnull final PlannerPhase plannerPhase, + @Nonnull final Reference group, + @Nonnull final RelationalExpression expression, + @Nonnull final List> rules, + final int index) { + super(plannerPhase, group, expression, rules.get(index)); + this.rules = rules; + this.index = index; + } + + @Override + public boolean execute() { + if (super.execute()) { + return true; + } + if (index + 1 < rules.size()) { + taskStack.push(new ConditionalTransformExpression(getPlannerPhase(), getGroup(), getExpression(), + rules, index + 1)); + } + return false; + } + } + /** * Class to transform a match partition using a rule. */ @@ -1205,12 +1291,13 @@ public AdjustMatch(@Nonnull final PlannerPhase plannerPhase, } @Override - public void execute() { + public boolean execute() { final var ruleSet = getPlannerPhase().getRuleSet(); - ruleSet.getPartialMatchRules(rule -> configuration.isRuleEnabled(rule)) + ruleSet.getPartialMatchRules(configuration::isRuleEnabled) .forEach(rule -> taskStack.push(new TransformPartialMatch(getPlannerPhase(), getGroup(), getExpression(), partialMatch, rule))); + return true; } @Override @@ -1260,14 +1347,15 @@ public PlannerPhase getPlannerPhase() { } @Override - public void execute() { + public boolean execute() { if (!group.containsExactly(expression)) { - return; + return false; } for (final Quantifier quantifier : expression.getQuantifiers()) { final Reference rangesOver = quantifier.getRangesOver(); taskStack.push(new OptimizeGroup(plannerPhase, rangesOver)); } + return true; } @Override diff --git a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/ConditionalCascadesRule.java b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/ConditionalCascadesRule.java new file mode 100644 index 0000000000..7d882df034 --- /dev/null +++ b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/ConditionalCascadesRule.java @@ -0,0 +1,221 @@ +/* + * ConditionalCascadesRule.java + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2015-2026 Apple Inc. and the FoundationDB project authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.apple.foundationdb.record.query.plan.cascades; + +import com.apple.foundationdb.annotation.API; +import com.apple.foundationdb.record.RecordCoreException; +import com.apple.foundationdb.record.query.plan.cascades.expressions.RelationalExpression; +import com.apple.foundationdb.record.query.plan.cascades.matching.structure.BindingMatcher; +import com.google.common.base.Verify; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; + +/** + * A {@link CascadesRule} that groups together an ordered sequence of related inner rules for improved planning + * efficiency. + * + *

Instead of letting the planner fire each of the grouped rules independently—with every matching rule + * producing new expressions that then have to be explored and costed—a {@code ConditionalCascadesRule} asks the + * planner to try the inner rules in order and to stop as soon as one of them makes progress, only falling through to + * the next rule when the current one yields nothing. By arranging strictly-prioritized or mutually-exclusive + * transformations in such a conditional chain we can prune the planner’s search space. The alternatives that the later + * rules would have produced are never generated, which keeps the memo smaller and makes planning faster. + * + *

A conditional rule is never applied directly; attempting to call {@link #onMatch} results in a + * {@link RecordCoreException}. Instead, the planner treats conditional rules specially: When it encounters one, it + * selects the subset of inner rules that are currently enabled (via the planner configuration) and schedules those, + * in order, for the conditional application just described. + * + *

All inner rules must agree on the root class of their root binding matcher and on their root operator. + * The wrapping conditional rule exposes a single binding matcher and root operator that stands in for the whole group. + * + * @param a parent planner expression type of all possible root planner expressions that this rule could match + * @param the kind of inner {@link CascadesRule} grouped by this rule + */ +@API(API.Status.EXPERIMENTAL) +public class ConditionalCascadesRule> extends AbstractCascadesRule { + /** + * The grouped inner rules, as an immutable list. + */ + @Nonnull + final List rules; + + /** + * The root operator common to all inner rules, or {@code null} if the inner rules do not declare a root operator. + * All inner rules are required to agree on this value (verified at construction), so this single class stands in + * for the whole group. + */ + @Nullable + final Class rootOperator; + + /** + * Creates a rule that groups the given inner rules. The list of rules must be non-empty. All rules must agree on + * their root operator and on the root class of their binding matcher. + */ + public ConditionalCascadesRule(@Nonnull final List rules) { + super(deriveBindingMatcher(rules), ImmutableSet.of()); + this.rules = ImmutableList.copyOf(rules); + this.rootOperator = deriveRootOperator(rules); + } + + /** + * Creates a rule that groups the given inner rules. This is a convenience overload of + * {@link #ConditionalCascadesRule(List)}. + */ + @SafeVarargs + @SuppressWarnings("varargs") + public ConditionalCascadesRule(@Nonnull final R... rules) { + this(ImmutableList.copyOf(rules)); + } + + /** + * Returns the inner rules grouped by this rule as an immutable list. + */ + @Nonnull + public List getRules() { + return rules; + } + + /** + * Returns the inner rules grouped by this rule that satisfy the given predicate, preserving their order. + */ + @Nonnull + public List getRules(@Nonnull final Predicate rulePredicate) { + return rules.stream() + .filter(rulePredicate) + .collect(ImmutableList.toImmutableList()); + } + + /** + * Returns the root operator shared by all inner rules. If the inner rules do not declare a root operator, returns + * an empty {@link Optional}. + */ + @Nonnull + @Override + public Optional> getRootOperator() { + return Optional.ofNullable(rootOperator); + } + + @Override + public void onMatch(@Nonnull final CascadesRuleCall call) { + throw new RecordCoreException("cannot call this method directly"); + } + + /** + * Returns a string representation of this rule. The string consists of its simple class name followed by its inner + * rules; for example, {@code ConditionalExplorationCascadesRule[DecorrelateValuesRule, …]}. + */ + @Override + public String toString() { + return getClass().getSimpleName() + rules; + } + + /** + * Derives the common binding matcher for a group of inner rules. All rules are expected to share a binding matcher + * with the same root class; this is verified, and the first matcher is returned as the representative. + */ + @Nonnull + private static > BindingMatcher deriveBindingMatcher(@Nonnull final List rules) { + Verify.verify(!rules.isEmpty(), "`ConditionalCascadesRule` must contain at least one rule"); + final BindingMatcher matcher = rules.get(0).getMatcher(); + for (final R rule : rules) { + Verify.verify(rule.getMatcher().getRootClass() == matcher.getRootClass()); + } + return matcher; + } + + /** + * Derives the common root operator for a group of inner rules. All rules are expected to agree on their root + * operator (either the same class, or all empty). This is verified, and the common value is returned. + */ + @Nullable + private static > Class deriveRootOperator(@Nonnull final List rules) { + Verify.verify(!rules.isEmpty(), "`ConditionalCascadesRule` must contain at least one rule"); + final Class op = rules.get(0).getRootOperator().orElse(null); + for (final R rule : rules) { + Verify.verify(rule.getRootOperator().orElse(null) == op); + } + return op; + } + + /** + * A {@link ConditionalCascadesRule} that groups exploration rules and is itself an {@link ExplorationCascadesRule}. + * + * @param a parent planner expression type of all possible root planner expressions that this rule could match + */ + public static class ConditionalExplorationCascadesRule extends ConditionalCascadesRule> implements ExplorationCascadesRule { + /** + * Creates a rule that groups the given inner exploration rules. + */ + public ConditionalExplorationCascadesRule(@Nonnull final List> rules) { + super(rules); + } + + /** + * Creates a rule that groups the given inner exploration rules. + */ + @SafeVarargs + @SuppressWarnings("varargs") + public ConditionalExplorationCascadesRule(@Nonnull final ExplorationCascadesRule... rules) { + super(rules); + } + + @Override + public void onMatch(@Nonnull final ExplorationCascadesRuleCall call) { + throw new RecordCoreException("cannot call this method directly"); + } + } + + /** + * A {@link ConditionalCascadesRule} that groups implementation rules and is itself an + * {@link ImplementationCascadesRule}. + * + * @param a parent planner expression type of all possible root planner expressions that this rule could match + */ + public static class ConditionalImplementationCascadesRule extends ConditionalCascadesRule> implements ImplementationCascadesRule { + /** + * Creates a rule that groups the given inner implementation rules. + */ + public ConditionalImplementationCascadesRule(@Nonnull final List> rules) { + super(rules); + } + + /** + * Creates a rule that groups the given inner implementation rules. + */ + @SafeVarargs + @SuppressWarnings("varargs") + public ConditionalImplementationCascadesRule(@Nonnull final ImplementationCascadesRule... rules) { + super(rules); + } + + @Override + public void onMatch(@Nonnull final ImplementationCascadesRuleCall call) { + throw new RecordCoreException("cannot call this method directly"); + } + } +} diff --git a/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/query/plan/cascades/ConditionalCascadesRuleTest.java b/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/query/plan/cascades/ConditionalCascadesRuleTest.java new file mode 100644 index 0000000000..32d96dfce1 --- /dev/null +++ b/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/query/plan/cascades/ConditionalCascadesRuleTest.java @@ -0,0 +1,289 @@ +/* + * ConditionalCascadesRuleTest.java + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2015-2025 Apple Inc. and the FoundationDB project authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.apple.foundationdb.record.query.plan.cascades; + +import com.apple.foundationdb.record.RecordCoreException; +import com.apple.foundationdb.record.query.plan.cascades.ConditionalCascadesRule.ConditionalExplorationCascadesRule; +import com.apple.foundationdb.record.query.plan.cascades.ConditionalCascadesRule.ConditionalImplementationCascadesRule; +import com.apple.foundationdb.record.query.plan.cascades.expressions.LogicalFilterExpression; +import com.apple.foundationdb.record.query.plan.cascades.expressions.RelationalExpression; +import com.apple.foundationdb.record.query.plan.cascades.expressions.SelectExpression; +import com.apple.foundationdb.record.query.plan.cascades.matching.structure.BindingMatcher; +import com.apple.foundationdb.record.query.plan.cascades.matching.structure.RelationalExpressionMatchers; +import com.google.common.base.VerifyException; +import com.google.common.collect.ImmutableList; +import org.junit.jupiter.api.Test; + +import javax.annotation.Nonnull; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests for {@link ConditionalCascadesRule}. + */ +class ConditionalCascadesRuleTest { + + @Test + void constructorDerivesCommonRootOperator() { + final StubRule first = stubRule(SelectExpression.class); + final StubRule second = stubRule(SelectExpression.class); + final ConditionalCascadesRule rule = conditionalRuleOf(first, second); + assertThat(rule.getRootOperator()).contains(SelectExpression.class); + assertThat(rule.getRules()).containsExactly(first, second); + } + + @Test + void varargsConstructorDerivesCommonRootOperator() { + // Exercises the varargs constructor directly, rather than the list constructor used by `conditionalRuleOf()`. + final StubRule first = stubRule(SelectExpression.class); + final StubRule second = stubRule(SelectExpression.class); + final ConditionalCascadesRule rule = new ConditionalCascadesRule<>(first, second); + assertThat(rule.getRules()).containsExactly(first, second); + assertThat(rule.getRootOperator()).contains(SelectExpression.class); + } + + @Test + void constructorWithSingleRuleSucceeds() { + final StubRule only = stubRule(SelectExpression.class); + final ConditionalCascadesRule rule = conditionalRuleOf(only); + assertThat(rule.getRules()).containsExactly(only); + assertThat(rule.getRootOperator()).contains(SelectExpression.class); + } + + @Test + void constructorWithEmptyListThrows() { + assertThatThrownBy(() -> new ConditionalCascadesRule<>(ImmutableList.of())) + .isInstanceOf(VerifyException.class) + .hasMessageContaining("must contain at least one rule"); + } + + @Test + void constructorWithMismatchedRootClassThrows() { + final StubRule selectRule = stubRule(SelectExpression.class); + final StubRule filterRule = stubRule(LogicalFilterExpression.class); + assertThatThrownBy(() -> conditionalRuleOf(selectRule, filterRule)).isInstanceOf(VerifyException.class); + } + + @Test + void constructorWithMismatchedRootOperatorThrows() { + // Both rules match the same root class, so the binding-matcher check passes, but they advertise different + // root operators, which the root-operator check must reject. + final StubRule first = stubRule(SelectExpression.class, Optional.of(SelectExpression.class)); + final StubRule second = stubRule(SelectExpression.class, Optional.of(LogicalFilterExpression.class)); + assertThatThrownBy(() -> conditionalRuleOf(first, second)).isInstanceOf(VerifyException.class); + } + + @Test + void constructorWithPresentAndEmptyRootOperatorThrows() { + final StubRule present = stubRule(SelectExpression.class, Optional.of(SelectExpression.class)); + final StubRule empty = stubRule(SelectExpression.class, Optional.empty()); + assertThatThrownBy(() -> conditionalRuleOf(present, empty)).isInstanceOf(VerifyException.class); + } + + @Test + void constructorWithAllEmptyRootOperatorsHasEmptyRootOperator() { + final StubRule first = stubRule(SelectExpression.class, Optional.empty()); + final StubRule second = stubRule(SelectExpression.class, Optional.empty()); + final ConditionalCascadesRule rule = conditionalRuleOf(first, second); + assertThat(rule.getRootOperator()).isEmpty(); + } + + @Test + void getRulesReturnsRulesInOrder() { + final StubRule first = stubRule(SelectExpression.class); + final StubRule second = stubRule(SelectExpression.class); + final StubRule third = stubRule(SelectExpression.class); + final ConditionalCascadesRule rule = conditionalRuleOf(first, second, third); + assertThat(rule.getRules()).containsExactly(first, second, third); + } + + @Test + void getRulesWithPredicateFiltersInOrder() { + final StubRule first = stubRule(SelectExpression.class); + final StubRule second = stubRule(SelectExpression.class); + final StubRule third = stubRule(SelectExpression.class); + final ConditionalCascadesRule rule = conditionalRuleOf(first, second, third); + assertThat(rule.getRules(candidate -> candidate == first || candidate == third)).containsExactly(first, third); + } + + @Test + void getRulesWithNeverMatchingPredicateReturnsEmpty() { + final ConditionalCascadesRule rule = + conditionalRuleOf(stubRule(SelectExpression.class), stubRule(SelectExpression.class)); + assertThat(rule.getRules(candidate -> false)).isEmpty(); + } + + @Test + void onMatchThrows() { + final ConditionalCascadesRule rule = + conditionalRuleOf(stubRule(SelectExpression.class)); + assertThatThrownBy(() -> rule.onMatch((CascadesRuleCall) null)) + .isInstanceOf(RecordCoreException.class) + .hasMessageContaining("cannot call this method directly"); + } + + @Test + void explorationVariantOnMatchThrows() { + final ConditionalExplorationCascadesRule rule = + new ConditionalExplorationCascadesRule<>(ImmutableList.of(stubExplorationRule(SelectExpression.class))); + assertThat(rule.getRootOperator()).contains(SelectExpression.class); + assertThatThrownBy(() -> rule.onMatch((ExplorationCascadesRuleCall) null)) + .isInstanceOf(RecordCoreException.class) + .hasMessageContaining("cannot call this method directly"); + assertThatThrownBy(() -> rule.onMatch((CascadesRuleCall) null)) + .isInstanceOf(RecordCoreException.class) + .hasMessageContaining("cannot call this method directly"); + } + + @Test + void explorationVariantVarargsConstructor() { + final StubExplorationRule first = stubExplorationRule(SelectExpression.class); + final StubExplorationRule second = stubExplorationRule(SelectExpression.class); + final ConditionalExplorationCascadesRule rule = + new ConditionalExplorationCascadesRule<>(first, second); + assertThat(rule.getRules()).containsExactly(first, second); + assertThat(rule.getRootOperator()).contains(SelectExpression.class); + } + + @Test + void implementationVariantListConstructorAndOnMatchThrows() { + final ConditionalImplementationCascadesRule rule = + new ConditionalImplementationCascadesRule<>(ImmutableList.of(stubImplementationRule(SelectExpression.class))); + assertThat(rule.getRootOperator()).contains(SelectExpression.class); + assertThatThrownBy(() -> rule.onMatch((ImplementationCascadesRuleCall) null)) + .isInstanceOf(RecordCoreException.class) + .hasMessageContaining("cannot call this method directly"); + assertThatThrownBy(() -> rule.onMatch((CascadesRuleCall) null)) + .isInstanceOf(RecordCoreException.class) + .hasMessageContaining("cannot call this method directly"); + } + + @Test + void implementationVariantVarargsConstructor() { + final StubImplementationRule first = stubImplementationRule(SelectExpression.class); + final StubImplementationRule second = stubImplementationRule(SelectExpression.class); + final ConditionalImplementationCascadesRule rule = + new ConditionalImplementationCascadesRule<>(first, second); + assertThat(rule.getRules()).containsExactly(first, second); + assertThat(rule.getRootOperator()).contains(SelectExpression.class); + } + + @Test + void toStringContainsClassNameAndRules() { + final ConditionalCascadesRule rule = + conditionalRuleOf(stubRule(SelectExpression.class)); + assertThat(rule.toString()) + .startsWith("ConditionalCascadesRule[") + .contains(StubRule.class.getSimpleName()); + } + + @Nonnull + private static ConditionalCascadesRule conditionalRuleOf(@Nonnull final StubRule... rules) { + return new ConditionalCascadesRule<>(ImmutableList.copyOf(rules)); + } + + @Nonnull + private static StubRule stubRule(@Nonnull final Class rootClass) { + return stubRule(rootClass, Optional.of(rootClass)); + } + + @Nonnull + private static StubRule stubRule(@Nonnull final Class rootClass, + @Nonnull final Optional> rootOperator) { + return new StubRule(matcherFor(rootClass), rootOperator); + } + + @Nonnull + private static StubExplorationRule stubExplorationRule(@Nonnull final Class rootClass) { + return new StubExplorationRule(matcherFor(rootClass)); + } + + @Nonnull + private static StubImplementationRule stubImplementationRule(@Nonnull final Class rootClass) { + return new StubImplementationRule(matcherFor(rootClass)); + } + + @Nonnull + @SuppressWarnings("unchecked") + private static BindingMatcher matcherFor(@Nonnull final Class rootClass) { + return (BindingMatcher) (BindingMatcher) RelationalExpressionMatchers.ofType(rootClass); + } + + /** + * A minimal {@link CascadesRule} whose matcher root class and advertised root operator can be set independently, + * so the construction-time invariants of {@link ConditionalCascadesRule} can be exercised in isolation. + */ + private static final class StubRule extends AbstractCascadesRule { + @Nonnull + private final Optional> rootOperator; + + private StubRule(@Nonnull final BindingMatcher matcher, + @Nonnull final Optional> rootOperator) { + super(matcher); + this.rootOperator = rootOperator; + } + + @Nonnull + @Override + public Optional> getRootOperator() { + return rootOperator; + } + + @Override + public void onMatch(@Nonnull final CascadesRuleCall call) { + throw new UnsupportedOperationException("stub rule should not be executed"); + } + } + + /** + * A minimal {@link ExplorationCascadesRule} used to exercise the {@link ConditionalExplorationCascadesRule} + * variant. Its root operator is derived from its matcher, like an ordinary rule. + */ + private static final class StubExplorationRule extends AbstractCascadesRule + implements ExplorationCascadesRule { + private StubExplorationRule(@Nonnull final BindingMatcher matcher) { + super(matcher); + } + + @Override + public void onMatch(@Nonnull final ExplorationCascadesRuleCall call) { + throw new UnsupportedOperationException("stub rule should not be executed"); + } + } + + /** + * A minimal {@link ImplementationCascadesRule} used to exercise the {@link ConditionalImplementationCascadesRule} + * variant. Its root operator is derived from its matcher, like an ordinary rule. + */ + private static final class StubImplementationRule extends AbstractCascadesRule + implements ImplementationCascadesRule { + private StubImplementationRule(@Nonnull final BindingMatcher matcher) { + super(matcher); + } + + @Override + public void onMatch(@Nonnull final ImplementationCascadesRuleCall call) { + throw new UnsupportedOperationException("stub rule should not be executed"); + } + } +} diff --git a/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/query/plan/cascades/events/PlannerEventSerializationTests.java b/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/query/plan/cascades/events/PlannerEventSerializationTests.java index 00983927e6..bbb51c1ac9 100644 --- a/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/query/plan/cascades/events/PlannerEventSerializationTests.java +++ b/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/query/plan/cascades/events/PlannerEventSerializationTests.java @@ -127,7 +127,8 @@ public PlannerPhase getPlannerPhase() { } @Override - public void execute() { + public boolean execute() { + return false; } @Override diff --git a/fdb-record-layer-debugger/src/test/java/com/apple/foundationdb/record/query/plan/cascades/debug/CommandsTest.java b/fdb-record-layer-debugger/src/test/java/com/apple/foundationdb/record/query/plan/cascades/debug/CommandsTest.java index 0d6b8f0651..970449a4a2 100644 --- a/fdb-record-layer-debugger/src/test/java/com/apple/foundationdb/record/query/plan/cascades/debug/CommandsTest.java +++ b/fdb-record-layer-debugger/src/test/java/com/apple/foundationdb/record/query/plan/cascades/debug/CommandsTest.java @@ -614,7 +614,8 @@ public PlannerPhase getPlannerPhase() { } @Override - public void execute() { + public boolean execute() { + return false; } @Override