diff --git a/pkg/sql/opt/memo/multiplicity_builder.go b/pkg/sql/opt/memo/multiplicity_builder.go index a28369186d3b..0f13a212d679 100644 --- a/pkg/sql/opt/memo/multiplicity_builder.go +++ b/pkg/sql/opt/memo/multiplicity_builder.go @@ -234,13 +234,18 @@ func filtersMatchLeftRowsAtMostOnce(left, right RelExpr, filters FiltersExpr) bo // must come from the same foreign key. // // In both the self-join and the foreign key cases, the left columns must be -// not-null, and the right columns must be unfiltered. +// not-null, and the right columns must be either unfiltered, or the left and +// right must be Select expressions where the left side filters imply the right +// side filters and right columns are unfiltered in the right Select's input +// (see condition #3b in the comment for verifyFiltersAreValidEqualities). // -// Why do the left columns have to be not-null and the right columns -// unfiltered? In both the self-join and the foreign-key cases, a non-null -// value in the left column guarantees a corresponding value in the right -// column. As long as no nulls have been added to the left column and no values -// have been removed from the right, this property will be valid. +// Why do the left columns have to be non-null, and the right columns unfiltered +// or filtered identically as their corresponding left column? In both the +// self-join and the foreign-key cases, a non-null value in the left column +// guarantees a corresponding value in the right column. As long as no nulls +// have been added to the left column and no values have been removed from the +// right that have not also been removed from the left, this property will be +// valid. // // Why do all foreign key columns in the foreign key case have to come from the // same foreign key? Equalities on different foreign keys may each be @@ -285,12 +290,16 @@ func filtersMatchAllLeftRows(left, right RelExpr, filters FiltersExpr) bool { } // verifyFiltersAreValidEqualities returns the set of equality columns in the -// right relation and true when all of the following conditions are satisfied: +// right relation and true when all the following conditions are satisfied: // // 1. All filters are equalities. // 2. All equalities directly compare two columns. -// 3. All equalities contain one column from the left not-null columns, and -// one column from the right unfiltered columns. +// 3. All equalities x=y (or y=x) have x as a left non-null column and y as a +// right column, and either: +// a. y is an unfiltered column in the right expression, or +// b. both the left and right expressions are Selects; the left side +// filters imply the right side filters when replacing x with y; and y +// is an unfiltered column in the right Select's input. // 4. All equality columns come from a base table. // 5. All left columns come from a single table, and all right columns come // from a single table. @@ -304,10 +313,6 @@ func verifyFiltersAreValidEqualities( var leftTab, rightTab opt.TableID leftNotNullCols := left.Relational().NotNullCols rightUnfilteredCols := deriveUnfilteredCols(right) - if rightUnfilteredCols.Empty() { - // There are no unfiltered columns from the right input. - return opt.ColSet{}, false - } for i := range filters { eq, _ := filters[i].Condition.(*EqExpr) @@ -329,9 +334,21 @@ func verifyFiltersAreValidEqualities( // Normalize leftColID to come from leftColIDs. if !leftNotNullCols.Contains(leftColID) { leftColID, rightColID = rightColID, leftColID + if !leftNotNullCols.Contains(leftColID) { + // Condition #3: Left column is not guaranteed to be non-null. + return opt.ColSet{}, false + } } - if !leftNotNullCols.Contains(leftColID) || !rightUnfilteredCols.Contains(rightColID) { - // Condition #3: Columns don't come from both the left and right ColSets. + + switch { + case rightUnfilteredCols.Contains(rightColID): + // Condition #3a: the right column is unfiltered. + case rightHasSingleFilterThatMatchesLeft(left, right, leftColID, rightColID): + // Condition #3b: The left and right are Selects where the left filters + // imply the right filters when replacing the left column with the right + // column, and the right column is unfiltered in the right Select's + // input. + default: return opt.ColSet{}, false } @@ -356,6 +373,79 @@ func verifyFiltersAreValidEqualities( return rightEqualityCols, true } +// rightHasSingleFilterThatMatchesLeft returns true if: +// +// 1. Both left and right are Select expressions. +// 2. rightCol is unfiltered in right's input. +// 3. The left Select has a filter in the form leftCol=const. +// 4. The right Select has a single filter in the form rightCol=const where +// the const value is the same as the const value in (2). +// +// This function is used by verifyFiltersAreValidEqualities to try to prove that +// every row in the left input of a join will have a match in the right input +// (see condition #3b in the comment of verifyFiltersAreValidEqualities). +// +// TODO(mgartner): Extend this to return true when the left filters imply the +// right filters, after remapping leftCol to rightCol in the left filters. For +// example, leftCol<10 implies rightCol<20 when leftCol and rightCol are held +// equal by the join filters. This may be a good opportunity to reuse +// partialidx.Implicator. Be aware that it might not be possible to simply +// replace columns in a filter when one of the columns has a composite type. +func rightHasSingleFilterThatMatchesLeft(left, right RelExpr, leftCol, rightCol opt.ColumnID) bool { + leftSelect, ok := left.(*SelectExpr) + if !ok { + return false + } + rightSelect, ok := right.(*SelectExpr) + if !ok { + return false + } + + // Return false if the right column has been filtered in the input to + // rightSelect. + rightUnfilteredCols := deriveUnfilteredCols(rightSelect.Input) + if !rightUnfilteredCols.Contains(rightCol) { + return false + } + + // Return false if rightSelect has more than one filter. + if len(rightSelect.Filters) > 1 { + return false + } + + // constValueForCol searches for an expression in the form + // (Eq (Var col) Const) and returns the Const expression, if one is found. + constValueForCol := func(filters FiltersExpr, col opt.ColumnID) (_ *ConstExpr, ok bool) { + var constant *ConstExpr + for i := range filters { + if !filters[i].ScalarProps().OuterCols.Contains(col) { + continue + } + eq, _ := filters[i].Condition.(*EqExpr) + if eq == nil { + continue + } + v, _ := eq.Left.(*VariableExpr) + c, _ := eq.Right.(*ConstExpr) + if v == nil || v.Col != col || c == nil { + continue + } + constant = c + } + return constant, constant != nil + } + + leftConst, ok := constValueForCol(leftSelect.Filters, leftCol) + if !ok { + return false + } + rightConst, ok := constValueForCol(rightSelect.Filters, rightCol) + if !ok { + return false + } + return leftConst == rightConst +} + // checkSelfJoinCase returns true if all equalities in the given FiltersExpr // are between columns from the same position in the same base table. Panics // if verifyFilters is not checked first. diff --git a/pkg/sql/opt/memo/multiplicity_builder_test.go b/pkg/sql/opt/memo/multiplicity_builder_test.go index 8450f8da41bb..e4ac1291c085 100644 --- a/pkg/sql/opt/memo/multiplicity_builder_test.go +++ b/pkg/sql/opt/memo/multiplicity_builder_test.go @@ -19,6 +19,7 @@ import ( "github.com/cockroachdb/cockroach/pkg/sql/opt/testutils/testcat" "github.com/cockroachdb/cockroach/pkg/sql/parser" "github.com/cockroachdb/cockroach/pkg/sql/sem/tree" + "github.com/cockroachdb/cockroach/pkg/sql/types" "github.com/cockroachdb/errors" ) @@ -67,6 +68,7 @@ func TestGetJoinMultiplicity(t *testing.T) { xyScan, xyCols := ob.xyScan() xyScan2, xyCols2 := ob.xyScan() + xyScanFiltered, xyColsFiltered := ob.makeFilteredScan("xy") uvScan, uvCols := ob.uvScan() fkScan, fkCols := ob.fkScan() abcScan, abcCols := ob.abcScan() @@ -300,6 +302,108 @@ func TestGetJoinMultiplicity(t *testing.T) { on: ob.makeFilters(ob.makeEquality(fkCols[0], xyCols2[0])), expected: "left-rows(exactly-one), right-rows(zero-or-more)", }, + { // 21 + // SELECT * FROM fk_tab INNER JOIN xy ON r1 = x WHERE r1 = 5 AND x = 5; + joinOp: opt.InnerJoinOp, + left: ob.makeSelect(fkScan, ob.makeFilters(ob.makeConstEquality(fkCols[0], 5))), + right: ob.makeSelect(xyScan, ob.makeFilters(ob.makeConstEquality(xyCols[0], 5))), + on: ob.makeFilters(ob.makeEquality(fkCols[0], xyCols[0])), + expected: "left-rows(exactly-one), right-rows(zero-or-more)", + }, + { // 22 + // SELECT * FROM xy INNER JOIN xy AS xy2 ON xy.x = xy2.x WHERE xy.x = 5 AND xy2.x = 5; + joinOp: opt.InnerJoinOp, + left: ob.makeSelect(xyScan, ob.makeFilters(ob.makeConstEquality(xyCols[0], 5))), + right: ob.makeSelect(xyScan2, ob.makeFilters(ob.makeConstEquality(xyCols2[0], 5))), + on: ob.makeFilters(ob.makeEquality(xyCols[0], xyCols2[0])), + expected: "left-rows(exactly-one), right-rows(exactly-one)", + }, + { // 23 + // SELECT * FROM fk_tab INNER JOIN xy ON r1 = x WHERE r1 = 5 AND x >= 5; + joinOp: opt.InnerJoinOp, + left: ob.makeSelect(fkScan, ob.makeFilters(ob.makeConstEquality(fkCols[0], 5))), + right: ob.makeSelect(xyScan, ob.makeFilters(ob.makeConstInequality(xyCols[0], 5))), + on: ob.makeFilters(ob.makeEquality(fkCols[0], xyCols[0])), + expected: "left-rows(zero-or-one), right-rows(zero-or-more)", + }, + { // 24 + // SELECT * FROM xy INNER JOIN xy AS xy2 ON xy.x = xy2.x WHERE xy.x = 5 AND xy2.x >= 5; + joinOp: opt.InnerJoinOp, + left: ob.makeSelect(xyScan, ob.makeFilters(ob.makeConstEquality(xyCols[0], 5))), + right: ob.makeSelect(xyScan2, ob.makeFilters(ob.makeConstInequality(xyCols2[0], 5))), + on: ob.makeFilters(ob.makeEquality(xyCols[0], xyCols2[0])), + expected: "left-rows(zero-or-one), right-rows(zero-or-one)", + }, + { // 25 + // SELECT * FROM fk_tab INNER JOIN xy ON r1 = x WHERE r1 = 5 AND y = 5; + joinOp: opt.InnerJoinOp, + left: ob.makeSelect(fkScan, ob.makeFilters(ob.makeConstEquality(fkCols[0], 5))), + right: ob.makeSelect(xyScan, ob.makeFilters(ob.makeConstEquality(xyCols[1], 5))), + on: ob.makeFilters(ob.makeEquality(fkCols[0], xyCols[0])), + expected: "left-rows(zero-or-one), right-rows(zero-or-more)", + }, + { // 26 + // SELECT * FROM xy INNER JOIN xy AS xy2 ON xy.x = xy2.x WHERE xy.x = 5 AND xy2.y = 5; + joinOp: opt.InnerJoinOp, + left: ob.makeSelect(xyScan, ob.makeFilters(ob.makeConstEquality(xyCols[0], 5))), + right: ob.makeSelect(xyScan2, ob.makeFilters(ob.makeConstEquality(xyCols2[1], 5))), + on: ob.makeFilters(ob.makeEquality(xyCols[0], xyCols2[0])), + expected: "left-rows(zero-or-one), right-rows(zero-or-one)", + }, + { // 27 + // SELECT * FROM fk_tab INNER JOIN ( + // SELECT * FROM xy WHERE x = 5 LIMIT 10 + // ) AS xy2 ON r1 = x + // WHERE xy.x = 5; + joinOp: opt.InnerJoinOp, + left: ob.makeSelect(fkScan, ob.makeFilters(ob.makeConstEquality(fkCols[0], 5))), + right: ob.makeSelect(xyScanFiltered, ob.makeFilters( + ob.makeConstEquality(xyColsFiltered[0], 5), + )), + on: ob.makeFilters(ob.makeEquality(fkCols[0], xyColsFiltered[0])), + expected: "left-rows(zero-or-one), right-rows(zero-or-more)", + }, + { // 28 + // SELECT * FROM xy INNER JOIN ( + // SELECT * FROM xy WHERE x = 5 LIMIT 10 + // ) AS xy2 ON xy.x = xy2.x + // WHERE xy.x = 5; + joinOp: opt.InnerJoinOp, + left: ob.makeSelect(xyScan, ob.makeFilters(ob.makeConstEquality(xyCols[0], 5))), + right: ob.makeSelect(xyScanFiltered, ob.makeFilters( + ob.makeConstEquality(xyColsFiltered[0], 5), + )), + on: ob.makeFilters(ob.makeEquality(xyCols[0], xyColsFiltered[0])), + expected: "left-rows(zero-or-one), right-rows(exactly-one)", + }, + { // 29 + // SELECT * FROM fk_tab INNER JOIN ( + // SELECT * FROM xy WHERE x = 5 AND y = 2 + // ) AS xy2 ON r1 = x + // WHERE xy.x = 5; + joinOp: opt.InnerJoinOp, + left: ob.makeSelect(fkScan, ob.makeFilters(ob.makeConstEquality(fkCols[0], 5))), + right: ob.makeSelect(xyScan, ob.makeFilters( + ob.makeConstEquality(xyCols[0], 5), + ob.makeConstEquality(xyCols[1], 2), + )), + on: ob.makeFilters(ob.makeEquality(fkCols[0], xyCols[0])), + expected: "left-rows(zero-or-one), right-rows(zero-or-more)", + }, + { // 30 + // SELECT * FROM xy INNER JOIN ( + // SELECT * FROM xy WHERE x = 5 AND y = 2 + // ) AS xy2 ON xy.x = xy2.x + // WHERE xy.x = 5; + joinOp: opt.InnerJoinOp, + left: ob.makeSelect(xyScan, ob.makeFilters(ob.makeConstEquality(xyCols[0], 5))), + right: ob.makeSelect(xyScan2, ob.makeFilters( + ob.makeConstEquality(xyCols2[0], 5), + ob.makeConstEquality(xyCols2[1], 2), + )), + on: ob.makeFilters(ob.makeEquality(xyCols[0], xyCols2[0])), + expected: "left-rows(zero-or-one), right-rows(exactly-one)", + }, } for i, tc := range testCases { @@ -308,7 +412,7 @@ func TestGetJoinMultiplicity(t *testing.T) { joinWithMult, _ := join.(joinWithMultiplicity) multiplicity := joinWithMult.getMultiplicity() if multiplicity.Format(tc.joinOp) != tc.expected { - t.Fatalf("\nexpected: %s\nactual: %s", tc.expected, multiplicity.Format(tc.joinOp)) + t.Errorf("\nexpected: %s\nactual: %s", tc.expected, multiplicity.Format(tc.joinOp)) } }) } @@ -355,6 +459,18 @@ func (ob *testOpBuilder) createTables(stmts string) { } func (ob *testOpBuilder) makeScan(tableName tree.Name) (scan RelExpr, vars []*VariableExpr) { + return ob.makeScanImpl(tableName, false /* filtered */) +} + +func (ob *testOpBuilder) makeFilteredScan( + tableName tree.Name, +) (scan RelExpr, vars []*VariableExpr) { + return ob.makeScanImpl(tableName, true /* filtered */) +} + +func (ob *testOpBuilder) makeScanImpl( + tableName tree.Name, filtered bool, +) (scan RelExpr, vars []*VariableExpr) { tn := tree.NewUnqualifiedTableName(tableName) tab := ob.cat.Table(tn) tabID := ob.mem.Metadata().AddTable(tab, tn) @@ -365,7 +481,11 @@ func (ob *testOpBuilder) makeScan(tableName tree.Name) (scan RelExpr, vars []*Va newVar := ob.mem.MemoizeVariable(col) vars = append(vars, newVar) } - return ob.mem.MemoizeScan(&ScanPrivate{Table: tabID, Cols: cols}), vars + sp := &ScanPrivate{Table: tabID, Cols: cols} + if filtered { + sp.HardLimit = 10 + } + return ob.mem.MemoizeScan(sp), vars } func (ob *testOpBuilder) xyScan() (scan RelExpr, vars []*VariableExpr) { @@ -392,6 +512,10 @@ func (ob *testOpBuilder) oneNullMultiColFKScan() (scan RelExpr, vars []*Variable return ob.makeScan("one_null_multi_col_fk_tab") } +func (ob *testOpBuilder) makeSelect(input RelExpr, filters FiltersExpr) RelExpr { + return ob.mem.MemoizeSelect(input, filters) +} + func (ob *testOpBuilder) makeInnerJoin(left, right RelExpr, on FiltersExpr) RelExpr { return ob.mem.MemoizeInnerJoin(left, right, on, EmptyJoinPrivate) } @@ -433,6 +557,14 @@ func (ob *testOpBuilder) makeEquality(left, right *VariableExpr) opt.ScalarExpr return ob.mem.MemoizeEq(left, right) } +func (ob *testOpBuilder) makeConstEquality(v *VariableExpr, c int) opt.ScalarExpr { + return ob.mem.MemoizeEq(v, ob.mem.MemoizeConst(tree.NewDInt(tree.DInt(c)), types.Int)) +} + +func (ob *testOpBuilder) makeConstInequality(v *VariableExpr, c int) opt.ScalarExpr { + return ob.mem.MemoizeGe(v, ob.mem.MemoizeConst(tree.NewDInt(tree.DInt(c)), types.Int)) +} + func (ob *testOpBuilder) makeFilters(conditions ...opt.ScalarExpr) (filters FiltersExpr) { for i := range conditions { filtersItem := FiltersItem{Condition: conditions[i]} diff --git a/pkg/sql/opt/memo/testdata/logprops/join b/pkg/sql/opt/memo/testdata/logprops/join index d41cb39e3707..afeca680c877 100644 --- a/pkg/sql/opt/memo/testdata/logprops/join +++ b/pkg/sql/opt/memo/testdata/logprops/join @@ -2023,6 +2023,57 @@ inner-join (hash) ├── variable: x:7 [type=int] └── variable: r1:3 [type=int] +# LeftJoin case with a not-null foreign key and a constant equality filter. The +# filter pushed down on both sides of the join is redundant, so all rows on the +# left side of the join will be preserved. +norm +SELECT * FROM fk LEFT JOIN xysd ON x = r1 WHERE r1 = 10 +---- +inner-join (hash) + ├── columns: k:1(int!null) v:2(int) r1:3(int!null) r2:4(int) x:7(int!null) y:8(int) s:9(string) d:10(decimal!null) + ├── multiplicity: left-rows(exactly-one), right-rows(zero-or-more) + ├── key: (1) + ├── fd: ()-->(3,7-10), (1)-->(2,4), (3)==(7), (7)==(3) + ├── prune: (1,2,4,8-10) + ├── interesting orderings: (+1 opt(3)) + ├── select + │ ├── columns: k:1(int!null) v:2(int) r1:3(int!null) r2:4(int) + │ ├── key: (1) + │ ├── fd: ()-->(3), (1)-->(2,4) + │ ├── prune: (1,2,4) + │ ├── interesting orderings: (+1 opt(3)) + │ ├── scan fk + │ │ ├── columns: k:1(int!null) v:2(int) r1:3(int!null) r2:4(int) + │ │ ├── key: (1) + │ │ ├── fd: (1)-->(2-4) + │ │ ├── prune: (1-4) + │ │ ├── interesting orderings: (+1) + │ │ └── unfiltered-cols: (1-6) + │ └── filters + │ └── eq [type=bool, outer=(3), constraints=(/3: [/10 - /10]; tight), fd=()-->(3)] + │ ├── variable: r1:3 [type=int] + │ └── const: 10 [type=int] + ├── select + │ ├── columns: x:7(int!null) y:8(int) s:9(string) d:10(decimal!null) + │ ├── cardinality: [0 - 1] + │ ├── key: () + │ ├── fd: ()-->(7-10) + │ ├── prune: (8-10) + │ ├── scan xysd + │ │ ├── columns: x:7(int!null) y:8(int) s:9(string) d:10(decimal!null) + │ │ ├── key: (7) + │ │ ├── fd: (7)-->(8-10), (9,10)~~>(7,8) + │ │ ├── prune: (7-10) + │ │ ├── interesting orderings: (+7) (-9,+10,+7) + │ │ └── unfiltered-cols: (7-12) + │ └── filters + │ └── eq [type=bool, outer=(7), constraints=(/7: [/10 - /10]; tight), fd=()-->(7)] + │ ├── variable: x:7 [type=int] + │ └── const: 10 [type=int] + └── filters + └── eq [type=bool, outer=(3,7), constraints=(/3: (/NULL - ]; /7: (/NULL - ]), fd=(3)==(7), (7)==(3)] + ├── variable: x:7 [type=int] + └── variable: r1:3 [type=int] # LeftJoin case with a nullable foreign key. The LeftJoin cannot be simplified # because a nullable foreign key is not guaranteed matches. @@ -2121,6 +2172,58 @@ inner-join (hash) ├── variable: xysd.x:1 [type=int] └── variable: a.x:7 [type=int] +# Self-join case with a constant equality filter. The filter pushed down on both +# sides of the join is redundant, so all rows on the left side of the join will +# be preserved. +norm +SELECT * FROM xysd INNER JOIN xysd AS a ON xysd.x = a.x WHERE xysd.x = 10 +---- +inner-join (hash) + ├── columns: x:1(int!null) y:2(int) s:3(string) d:4(decimal!null) x:7(int!null) y:8(int) s:9(string) d:10(decimal!null) + ├── cardinality: [0 - 1] + ├── multiplicity: left-rows(exactly-one), right-rows(exactly-one) + ├── key: () + ├── fd: ()-->(1-4,7-10), (7)==(1), (1)==(7) + ├── prune: (2-4,8-10) + ├── select + │ ├── columns: xysd.x:1(int!null) xysd.y:2(int) xysd.s:3(string) xysd.d:4(decimal!null) + │ ├── cardinality: [0 - 1] + │ ├── key: () + │ ├── fd: ()-->(1-4) + │ ├── prune: (2-4) + │ ├── scan xysd + │ │ ├── columns: xysd.x:1(int!null) xysd.y:2(int) xysd.s:3(string) xysd.d:4(decimal!null) + │ │ ├── key: (1) + │ │ ├── fd: (1)-->(2-4), (3,4)~~>(1,2) + │ │ ├── prune: (1-4) + │ │ ├── interesting orderings: (+1) (-3,+4,+1) + │ │ └── unfiltered-cols: (1-6) + │ └── filters + │ └── eq [type=bool, outer=(1), constraints=(/1: [/10 - /10]; tight), fd=()-->(1)] + │ ├── variable: xysd.x:1 [type=int] + │ └── const: 10 [type=int] + ├── select + │ ├── columns: a.x:7(int!null) a.y:8(int) a.s:9(string) a.d:10(decimal!null) + │ ├── cardinality: [0 - 1] + │ ├── key: () + │ ├── fd: ()-->(7-10) + │ ├── prune: (8-10) + │ ├── scan xysd [as=a] + │ │ ├── columns: a.x:7(int!null) a.y:8(int) a.s:9(string) a.d:10(decimal!null) + │ │ ├── key: (7) + │ │ ├── fd: (7)-->(8-10), (9,10)~~>(7,8) + │ │ ├── prune: (7-10) + │ │ ├── interesting orderings: (+7) (-9,+10,+7) + │ │ └── unfiltered-cols: (7-12) + │ └── filters + │ └── eq [type=bool, outer=(7), constraints=(/7: [/10 - /10]; tight), fd=()-->(7)] + │ ├── variable: a.x:7 [type=int] + │ └── const: 10 [type=int] + └── filters + └── eq [type=bool, outer=(1,7), constraints=(/1: (/NULL - ]; /7: (/NULL - ]), fd=(1)==(7), (7)==(1)] + ├── variable: xysd.x:1 [type=int] + └── variable: a.x:7 [type=int] + # Case with duplicated referenced columns. norm SELECT * FROM @@ -2444,7 +2547,7 @@ inner-join (hash) norm SELECT * FROM ref -INNER JOIN abc +INNER JOIN abc ON (r1, r2, r3) = (a, b, c) ---- inner-join (hash) diff --git a/pkg/sql/opt/norm/testdata/rules/limit b/pkg/sql/opt/norm/testdata/rules/limit index 347f2e4defbc..36448d5c02c0 100644 --- a/pkg/sql/opt/norm/testdata/rules/limit +++ b/pkg/sql/opt/norm/testdata/rules/limit @@ -1088,6 +1088,49 @@ left-join (hash) └── filters └── a:1 = u:5 [outer=(1,5), constraints=(/1: (/NULL - ]; /5: (/NULL - ]), fd=(1)==(5), (5)==(1)] +# Push the limit if both sides of an inner join have identical equality filters +# on FK equality columns. +norm expect=PushLimitIntoJoinLeft +SELECT * FROM kvr_fk INNER JOIN uv ON r = u WHERE r = 5 LIMIT 10 +---- +inner-join (hash) + ├── columns: k:1!null v:2 r:3!null u:6!null v:7 + ├── cardinality: [0 - 10] + ├── multiplicity: left-rows(zero-or-one), right-rows(zero-or-more) + ├── key: (1) + ├── fd: ()-->(3,6,7), (1)-->(2), (3)==(6), (6)==(3) + ├── limit + │ ├── columns: k:1!null kvr_fk.v:2 r:3!null + │ ├── cardinality: [0 - 10] + │ ├── key: (1) + │ ├── fd: ()-->(3), (1)-->(2) + │ ├── select + │ │ ├── columns: k:1!null kvr_fk.v:2 r:3!null + │ │ ├── key: (1) + │ │ ├── fd: ()-->(3), (1)-->(2) + │ │ ├── limit hint: 10.00 + │ │ ├── scan kvr_fk + │ │ │ ├── columns: k:1!null kvr_fk.v:2 r:3!null + │ │ │ ├── key: (1) + │ │ │ ├── fd: (1)-->(2,3) + │ │ │ └── limit hint: 1000.00 + │ │ └── filters + │ │ └── r:3 = 5 [outer=(3), constraints=(/3: [/5 - /5]; tight), fd=()-->(3)] + │ └── 10 + ├── select + │ ├── columns: u:6!null uv.v:7 + │ ├── cardinality: [0 - 1] + │ ├── key: () + │ ├── fd: ()-->(6,7) + │ ├── scan uv + │ │ ├── columns: u:6!null uv.v:7 + │ │ ├── key: (6) + │ │ └── fd: (6)-->(7) + │ └── filters + │ └── u:6 = 5 [outer=(6), constraints=(/6: [/5 - /5]; tight), fd=()-->(6)] + └── filters + └── r:3 = u:6 [outer=(3,6), constraints=(/3: (/NULL - ]; /6: (/NULL - ]), fd=(3)==(6), (6)==(3)] + # Don't push negative limits (or we would enter an infinite loop). norm expect-not=PushLimitIntoJoinLeft SELECT * FROM ab LEFT JOIN uv ON a = u LIMIT -1