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