Repository navigation
Push in-joins over comparands through fetches - #4830
Conversation
Registers PushInJoinThroughFetchRule for RecordQueryInComparandJoinPlan, as it already is for RecordQueryInValuesJoinPlan and RecordQueryInParameterJoinPlan. SQL IN predicates are planned as comparand in-joins, so their plans can now run the in-join over a covering index scan and fetch the records above it.
The query has no ORDER BY, and with in-joins over comparands pushed through fetches its rows come back in a different order.
📊 Metrics Diff Analysis ReportSummary
ℹ️ About this analysisThis automated analysis compares query planner metrics between the base branch and this PR. It categorizes changes into:
The last category in particular may indicate planner regressions that should be investigated. Plan and Metrics ChangedThese queries experienced both plan and metrics changes. This generally indicates that there was some planner change Total: 6 queries Statistical Summary (Plan and Metrics Changed)
There were no queries with significant regressions detected. Minor Changes (Plan and Metrics Changed)In addition, there were 6 queries with minor changes. Only Metrics ChangedThese queries experienced only metrics changes without any plan changes. If these metrics have substantially changed, Total: 43 queries Statistical Summary (Only Metrics Changed)
Significant Regressions (Only Metrics Changed)There were 3 outliers detected. Outlier queries have a significant regression in at least one field. Statistically, this represents either an increase of more than two standard deviations above the mean or a large absolute increase (e.g., 100).
Minor Changes (Only Metrics Changed)In addition, there were 40 queries with minor changes. |
| # Join with IN on non-existent values (should return empty) | ||
| - query: select emp.id from emp, dept where emp.dept_id = dept.id and dept.id = 1 and emp.id in (10, 20, 30) | ||
| - explain: "SCAN([IS DEPT, EQUALS promote(@c21 AS LONG)]) | FLATMAP q0 -> { [IN arrayDistinct(promote(@c27 AS ARRAY(LONG)))] | INJOIN q1 -> { SCAN([IS EMP, EQUALS q1]) | FILTER _.DEPT_ID EQUALS q0.ID } AS q2 RETURN (q2.ID AS ID) }" | ||
| - explain: "[IN arrayDistinct(promote(@c27 AS ARRAY(LONG)))] | INJOIN q0 -> { SCAN([IS EMP, EQUALS q0]) } | FLATMAP q1 -> { SCAN([IS DEPT, EQUALS promote(@c21 AS LONG)]) | FILTER q1.DEPT_ID EQUALS _.ID AS q2 RETURN (q1.ID AS ID) }" |
There was a problem hiding this comment.
Why did the plan change here? There is no visible FETCH in the plans.
There was a problem hiding this comment.
Right. this is sort of an interesting case. At the root there are two shapes:
Dept outer, emp in-join inner<- plan in mainIn-join outer, dept lookup inner<- plan in this PR.
In this PR, Dept outer, emp in-join inner variant also has another contender: Dept outer, covering empdept index with fetch inner. The later wins against the former in LowestOuterCardinalityTiebreaker. This, when compared to 2, loses in LowestNumUnmatchedFieldsTiebreaker.
Ports 37 `fdb-record-layer-core` record query tests to the yamsql suite under `yaml-tests/src/test/resources/cascades-query-tests/`. A trimmed-down version of #4588 holding only these tests. These are the tests whose `IN` source (a `LiteralValue` list or a named parameter) is no longer compatible with the Cascades run of the JUnit test after #4816, so their Cascades coverage moves to SQL. The JUnit tests stay in place, are marked with `// SQL Port status: (#4843) …`, and skip the Cascades run via `Assumptions.assumeFalse(useCascadesPlanner)` (`FDBInQueryTest`) or are restricted to the old planner. ## What is in the PR - 17 yamsql files (84 queries) plus the shared `includes/simple-record-100.yamsql`, registered in `CascadesQueryTests`. Metrics are generated for every file. - `MIGRATION.md` (per-method state) and `README.md`, cut down to these three classes. - `SQL Port status` / `SQL Port` comments on all 37 JUnit tests. ## How the `IN` source is ported | Core test uses | Port | |---|---| | literal list (`ListComparison`), or `ConstantObjectValue` | `IN (…)` literal list. `AstNormalizer` lifts every literal into a `ConstantObjectValue`, so both are the same SQL | | named parameter (`ParameterComparison`, `equalsParameter`) | parameter injection, `in !! !in {[…]} !!` / `= !! 0 !!`, in a separate test block with `statement_type: prepared` (named `<file>-prepared`) | Every query has its own test: nothing is shared between two tests, and each `reverse` / `p` / `str_list` / ordering variant is its own query. The remaining parameterizations (`InAsOrUnionMode`, `omitPrimaryKeyInOrdering`, `replans`, `normalizeNestedFields`, `attemptFailedInJoinAsUnionMaxSize`) are planner or old-planner settings with no SQL surface and collapse. `LIMIT` is not supported in SQL, so `testInWithLimit` pages with `maxRows: 3`. Where a variant needs its own schema (`dropNumValue3Index`), it has its own file. ## The 37 tests `qN` is the position among the executed queries in the file; the `-prepared` block follows the literal one. ### `FDBInQueryTest` (31) | Test | IN source in core | yamsql (queries) | State | |---|---|---|---| | `testInQueryNoIndex` | literal `(0, 2)`, residual filter | in-query q1 | ported | | `testInQueryNoIndexWithParameter` | parameter `valuesThree`, residual filter | in-query q10 (prepared) | ported | | `testInQueryIndex` | literal `(1, 2, 4)`, forward and reverse | in-query q2-q3 | ported | | `testInQueryIndexSorted` | literal `(1, 4, 2)` (out of order), forward and reverse | in-query q4-q5 | ported | | `testInQueryCoveringIndex` | literal `(1, 2, 4)`, forward and reverse, covering | in-query q6-q7 | ported | | `testInQueryIndexSortedDifferently` | literal `(1, 4, 2)`, sorted on another column, forward and reverse | in-query q8-q9 | ported | | `testInQueryParameter` | parameter `valueThrees`, no sort | in-query q11 (prepared) | ported | | `testInQuerySortedByParameter` | parameter `valueThrees`, forward and reverse | in-query q12-q13 (prepared) | ported | | `testInQueryParameter2` | parameter `valueThrees` + `equalsParameter("p")`, p = 0..4, forward and reverse | in-query-multi-index q1-q10 (prepared) | ported | | `testInQueryParameter2UsingCoveringIndexScans` | same, covering | in-query-multi-index q11-q20 (prepared) | ported | | `testInQueryParameterBad` | parameter bound to a non-list | none | not portable: a SQL IN list is always a list | | `testListInQuerySortedWithExtraUnorderedColumns` | literal `(1, 2, 4)`, forward and reverse | in-query-sorted-extra-unordered-columns q1-q2 | ported | | `testParameterizedInQuerySortedWithExtraUnorderedColumns` | parameter list + `equalsParameter`, forward and reverse | in-query-sorted-extra-unordered-columns q3-q4 (prepared) | ported | | `inQueryWithSortBySecondFieldOfCompoundIndex` | literal `(1, 4, 2)` | in-query-compound-index q1 | ported | | `inQueryWithSortAndRangePredicateOnSecondFieldOfCompoundIndex` | literal `(1, 4, 2)` + range | in-query-compound-index q2 | ported | | `inQueryWithSortAndParameter` | parameter `inList` + range | in-query-compound-index q3 (prepared) | ported | | `testInWithNesting` | literal, 4 strings, nested field | in-query-header q1 | ported | | `testMultipleInQueryIndex` | two literals (`rec_no`, `path`) | in-query-header q2 | ported | | `testMultipleInQueryIndexSorted` | two literals, index order requested | in-query-header q3 | ported | | `testInWithLimit` | literal `ls`, returned-row limit | in-query-header q4 (`maxRows: 3`) | ported | | `testInWithContinuation` | literal `ls`, continuations | in-query-header q5 (`maxRows: 1`) | ported | | `testInQueryOr` | literal `(903, 905, 901)` OR a range | in-query-or q1 | ported | | `testInQueryOrDifferentCondition` | literal `(2, 0)` OR a range, residual filter | in-query-or q2 | ported | | `testInQueryOrMultipleIndexes` | literal `(1, 3)` OR another index | in-query-or q3 | ported | | `testInUnionWithNonSargable` | literal `(1, 3, 5, 7, 9)`, residual filter | in-query-or q4 | ported | | `testInQueryEmptyList` | empty literal list | in-query-or q5 (prepared; `in ()` does not parse) | ported | | `testInQueryOrOverlap` | two overlapping literals | in-query-or-overlap q1 | ported | | `testInQueryOrCompound` | literal `(1, 3)` in OR in AND | in-query-or-compound q1 | ported | | `testInUnionWithSomeSargable` | parameters `nv2_list`, `str_list` | in-query-union-sargable q1-q3 (prepared) | ported | | `testInUnionWithIntersectionOnTwoPredicates` | parameters `nv2_list`, `str_list`; `dropNumValue3Index` changes the schema | in-query-union-intersection q1-q3, in-query-union-intersection-no-nv3 q1-q3 (prepared) | ported | | `enumIn` | literal list of enum values | none | deferred: needs `CREATE TYPE AS ENUM` | ### `FDBPermutedMinMaxQueryTest` (4) Each is parameterized over `InComparisonCase`: `byLiteral` and `byConstantObjectValue` are the literal block, `byParameter` is the `-prepared` block. | Test | yamsql (queries) | State | |---|---|---| | `selectMaxWithInOrderByMax` | permuted-min-max q1-q3 literal, q4-q6 prepared | ported | | `testMaxWithInAndDupes` | permuted-min-max-num-value-2 q1 literal, q2 prepared | ported | | `testSortedMaxWithInOnRepeater` | permuted-min-max-repeater q1-q2 literal, q3-q4 prepared | ported | | `testMaxUniqueByStr2And3WithDifferentOrderingKeys` | permuted-min-max-ordering-keys q1-q4 literal, q5-q8 prepared (one per ordering key) | ported | ### `FDBSimpleQueryGraphTest` (2) | Test | IN source in core | yamsql (queries) | State | |---|---|---|---| | `testSimpleExistentialPredicateOnSimpleIndex` | parameter `t`; `inComparison` flips `EQUALS` / `IN` | simple-query-graph-tag-index q1 (`=`), q2 (`IN`), prepared | ported | | `testEqualityAndSimpleExistentialPredicate` | parameters on `name` and `t` | simple-query-graph-name-tag-index q1 (prepared) | ported | Totals: 35 ported, 1 not portable (`testInQueryParameterBad`), 1 deferred (`enumIn`). ## Notes - Expectations are generated against current `main`, so plans carry the covering-scan-plus-fetch shape from #4830. - Plan-shape divergence from the JUnit plans: for the extra-columns tests the in-union comparison key has three values instead of four, and SQL always emits a `MAP` above a covering scan. This is documented in the header of `in-query-sorted-extra-unordered-columns.yamsql`.
Registers
PushInJoinThroughFetchRuleforRecordQueryInComparandJoinPlan, as it already is forRecordQueryInValuesJoinPlanandRecordQueryInParameterJoinPlan. The rule turnsInJoin(Fetch(inner))intoFetch(InJoin(inner)).SQL
INpredicates are planned as in-joins over anInComparandSource, so their plans can now run the in-join over a covering index scan and fetch the records above it. For example, injoin-with-order-by-tests.yamsql:Plan changes
join-with-order-by-tests.yamsql(4 queries) andjoin-tests-outer.yamsql:460: the in-join runs over a covering index scan, and the records are fetched above it.join-tests.yamsql:399: the join order flips, and the in-join overempnow drives the lookup ofdept. Both sides are primary-key lookups.array-join-at,in-predicate,join-tests,join-tests-outer,join-with-order-by-tests,right-deep-plan-testsandversions-tests. No results change.PreparedStatementTests.prepareInListOfTupleasserted the order of the rows of a query withoutORDER BY, which changes with the new plan. It now checks the set of rows, as Replace ExplodeExpression with a table function over an ExplodeValue #4816 does.#4816 plans every IN predicate as an in-join over an
InExplodeSource, which is a comparand source, and registers this rule too. The changes it lists as not yet accepted (join-tests.yamsql:332,join-tests-outer.yamsql:456andright-deep-plan-tests.yamsql:37) come from this rule.