Skip to content

Commit

Permalink
executor: fix outer join bug in hash join v2 (#56855)
Browse files Browse the repository at this point in the history
close #56856
  • Loading branch information
windtalker authored Oct 28, 2024
1 parent 7292117 commit 3c8f2f3
Show file tree
Hide file tree
Showing 6 changed files with 122 additions and 12 deletions.
77 changes: 69 additions & 8 deletions pkg/executor/join/inner_join_probe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,10 +196,56 @@ func checkChunksEqual(t *testing.T, expectedChunks []*chunk.Chunk, resultChunks
}
}

// copy data from src to dst, the caller should ensure that src.NumCols() >= dst.NumCols()
func copySelectedRows(src *chunk.Chunk, dst *chunk.Chunk, selected []bool) (bool, error) {
if src.NumRows() == 0 {
return false, nil
}
if src.Sel() != nil || dst.Sel() != nil {
return false, errors.New("copy with sel")
}
if src.NumCols() == 0 {
numSelected := 0
for _, s := range selected {
if s {
numSelected++
}
}
dst.SetNumVirtualRows(dst.GetNumVirtualRows() + numSelected)
return numSelected > 0, nil
}

oldLen := dst.NumRows()
for j := 0; j < src.NumCols(); j++ {
if j >= dst.NumCols() {
break
}
srcCol := src.Column(j)
dstCol := dst.Column(j)
chunk.CopySelectedRows(dstCol, srcCol, selected)
}
numSelected := dst.NumRows() - oldLen
dst.SetNumVirtualRows(dst.GetNumVirtualRows() + numSelected)
return numSelected > 0, nil
}

func testJoinProbe(t *testing.T, withSel bool, leftKeyIndex []int, rightKeyIndex []int, leftKeyTypes []*types.FieldType, rightKeyTypes []*types.FieldType,
leftTypes []*types.FieldType, rightTypes []*types.FieldType, rightAsBuildSide bool, leftUsed []int, rightUsed []int,
leftUsedByOtherCondition []int, rightUsedByOtherCondition []int, leftFilter expression.CNFExprs, rightFilter expression.CNFExprs,
otherCondition expression.CNFExprs, partitionNumber int, joinType logicalop.JoinType, inputRowNumber int) {
// leftUsed/rightUsed is nil, it means select all columns
if leftUsed == nil {
leftUsed = make([]int, 0)
for index := range leftTypes {
leftUsed = append(leftUsed, index)
}
}
if rightUsed == nil {
rightUsed = make([]int, 0)
for index := range rightTypes {
rightUsed = append(rightUsed, index)
}
}
buildKeyIndex, probeKeyIndex := leftKeyIndex, rightKeyIndex
buildKeyTypes, probeKeyTypes := leftKeyTypes, rightKeyTypes
buildTypes, probeTypes := leftTypes, rightTypes
Expand Down Expand Up @@ -305,18 +351,27 @@ func testJoinProbe(t *testing.T, withSel bool, leftKeyIndex []int, rightKeyIndex
}
}
// check if build column can be inserted to probe column directly
for i := 0; i < len(buildTypes); i++ {
for i := 0; i < min(len(buildTypes), len(probeTypes)); i++ {
buildLength := chunk.GetFixedLen(buildTypes[i])
probeLength := chunk.GetFixedLen(probeTypes[i])
require.Equal(t, buildLength, probeLength, "build type and probe type is not compatible")
}
for i := 0; i < chunkNumber; i++ {
buildChunks = append(buildChunks, testutil.GenRandomChunks(buildTypes, inputRowNumber))
probeChunk := testutil.GenRandomChunks(probeTypes, inputRowNumber*2/3)
// copy some build data to probe side, to make sure there is some matched rows
_, err := chunk.CopySelectedJoinRowsDirect(buildChunks[i], selected, probeChunk)
probeChunks = append(probeChunks, probeChunk)
require.NoError(t, err)
if len(buildTypes) >= len(probeTypes) {
buildChunks = append(buildChunks, testutil.GenRandomChunks(buildTypes, inputRowNumber))
probeChunk := testutil.GenRandomChunks(probeTypes, inputRowNumber*2/3)
// copy some build data to probe side, to make sure there is some matched rows
_, err := copySelectedRows(buildChunks[i], probeChunk, selected)
require.NoError(t, err)
probeChunks = append(probeChunks, probeChunk)
} else {
probeChunks = append(probeChunks, testutil.GenRandomChunks(probeTypes, inputRowNumber))
buildChunk := testutil.GenRandomChunks(buildTypes, inputRowNumber*2/3)
// copy some build data to probe side, to make sure there is some matched rows
_, err := copySelectedRows(probeChunks[i], buildChunk, selected)
require.NoError(t, err)
buildChunks = append(buildChunks, buildChunk)
}
}

if withSel {
Expand Down Expand Up @@ -436,14 +491,16 @@ func TestInnerJoinProbeBasic(t *testing.T) {

lTypes := []*types.FieldType{intTp, stringTp, uintTp, stringTp, tinyTp}
rTypes := []*types.FieldType{intTp, stringTp, uintTp, stringTp, tinyTp}
rTypes = append(rTypes, retTypes...)
rTypes1 := []*types.FieldType{uintTp, stringTp, intTp, stringTp, tinyTp}
rTypes1 = append(rTypes1, rTypes1...)

rightAsBuildSide := []bool{true, false}
partitionNumber := 4

testCases := []testCase{
// normal case
{[]int{0}, []int{0}, []*types.FieldType{intTp}, []*types.FieldType{intTp}, lTypes, rTypes, []int{0, 1, 2, 3}, []int{0, 1, 2, 3}, nil, nil, nil},
{[]int{0}, []int{0}, []*types.FieldType{intTp}, []*types.FieldType{intTp}, lTypes, rTypes, nil, nil, nil, nil, nil},
// rightUsed is empty
{[]int{0}, []int{0}, []*types.FieldType{intTp}, []*types.FieldType{intTp}, lTypes, rTypes, []int{0, 1, 2, 3}, []int{}, nil, nil, nil},
// leftUsed is empty
Expand Down Expand Up @@ -570,6 +627,7 @@ func TestInnerJoinProbeOtherCondition(t *testing.T) {

lTypes := []*types.FieldType{intTp, intTp, stringTp, uintTp, stringTp}
rTypes := []*types.FieldType{intTp, intTp, stringTp, uintTp, stringTp}
rTypes = append(rTypes, rTypes...)

tinyTp := types.NewFieldType(mysql.TypeTiny)
a := &expression.Column{Index: 1, RetType: nullableIntTp}
Expand All @@ -585,6 +643,7 @@ func TestInnerJoinProbeOtherCondition(t *testing.T) {
testJoinProbe(t, false, []int{0}, []int{0}, []*types.FieldType{intTp}, []*types.FieldType{intTp}, lTypes, rTypes, rightAsBuild, []int{1, 2, 4}, []int{0}, []int{1}, []int{3}, nil, nil, otherCondition, partitionNumber, logicalop.InnerJoin, 200)
testJoinProbe(t, false, []int{0}, []int{0}, []*types.FieldType{intTp}, []*types.FieldType{intTp}, lTypes, rTypes, rightAsBuild, []int{}, []int{}, []int{1}, []int{3}, nil, nil, otherCondition, partitionNumber, logicalop.InnerJoin, 200)
testJoinProbe(t, false, []int{0}, []int{0}, []*types.FieldType{nullableIntTp}, []*types.FieldType{nullableIntTp}, toNullableTypes(lTypes), toNullableTypes(rTypes), rightAsBuild, []int{1, 2, 4}, []int{0}, []int{1}, []int{3}, nil, nil, otherCondition, partitionNumber, logicalop.InnerJoin, 200)
testJoinProbe(t, false, []int{0}, []int{0}, []*types.FieldType{nullableIntTp}, []*types.FieldType{nullableIntTp}, toNullableTypes(lTypes), toNullableTypes(rTypes), rightAsBuild, nil, nil, []int{1}, []int{3}, nil, nil, otherCondition, partitionNumber, logicalop.InnerJoin, 200)
}
}

Expand All @@ -602,6 +661,7 @@ func TestInnerJoinProbeWithSel(t *testing.T) {

lTypes := []*types.FieldType{intTp, intTp, stringTp, uintTp, stringTp}
rTypes := []*types.FieldType{intTp, intTp, stringTp, uintTp, stringTp}
rTypes = append(rTypes, rTypes...)

tinyTp := types.NewFieldType(mysql.TypeTiny)
a := &expression.Column{Index: 1, RetType: nullableIntTp}
Expand All @@ -620,6 +680,7 @@ func TestInnerJoinProbeWithSel(t *testing.T) {
testJoinProbe(t, true, []int{0}, []int{0}, []*types.FieldType{intTp}, []*types.FieldType{intTp}, lTypes, rTypes, rightAsBuild, []int{1, 2, 4}, []int{0}, []int{1}, []int{3}, nil, nil, oc, partitionNumber, logicalop.InnerJoin, 500)
testJoinProbe(t, true, []int{0}, []int{0}, []*types.FieldType{intTp}, []*types.FieldType{intTp}, lTypes, rTypes, rightAsBuild, []int{}, []int{}, []int{1}, []int{3}, nil, nil, oc, partitionNumber, logicalop.InnerJoin, 500)
testJoinProbe(t, true, []int{0}, []int{0}, []*types.FieldType{nullableIntTp}, []*types.FieldType{nullableIntTp}, toNullableTypes(lTypes), toNullableTypes(rTypes), rightAsBuild, []int{1, 2, 4}, []int{0}, []int{1}, []int{3}, nil, nil, oc, partitionNumber, logicalop.InnerJoin, 500)
testJoinProbe(t, true, []int{0}, []int{0}, []*types.FieldType{nullableIntTp}, []*types.FieldType{nullableIntTp}, toNullableTypes(lTypes), toNullableTypes(rTypes), rightAsBuild, nil, nil, []int{1}, []int{3}, nil, nil, oc, partitionNumber, logicalop.InnerJoin, 500)
}
}
}
8 changes: 7 additions & 1 deletion pkg/executor/join/left_outer_join_probe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,9 @@ func TestLeftOuterJoinProbeBasic(t *testing.T) {

lTypes := []*types.FieldType{intTp, stringTp, uintTp, stringTp, tinyTp}
rTypes := []*types.FieldType{intTp, stringTp, uintTp, stringTp, tinyTp}
rTypes = append(rTypes, retTypes...)
rTypes1 := []*types.FieldType{uintTp, stringTp, intTp, stringTp, tinyTp}
rTypes1 = append(rTypes1, rTypes1...)

rightAsBuildSide := []bool{true, false}
partitionNumber := 4
Expand All @@ -129,7 +131,7 @@ func TestLeftOuterJoinProbeBasic(t *testing.T) {

testCases := []testCase{
// normal case
{[]int{0}, []int{0}, []*types.FieldType{intTp}, []*types.FieldType{intTp}, lTypes, rTypes, []int{0, 1, 2, 3}, []int{0, 1, 2, 3}, nil, nil, nil},
{[]int{0}, []int{0}, []*types.FieldType{intTp}, []*types.FieldType{intTp}, lTypes, rTypes, nil, nil, nil, nil, nil},
// rightUsed is empty
{[]int{0}, []int{0}, []*types.FieldType{intTp}, []*types.FieldType{intTp}, lTypes, rTypes, []int{0, 1, 2, 3}, []int{}, nil, nil, nil},
// leftUsed is empty
Expand Down Expand Up @@ -262,6 +264,7 @@ func TestLeftOuterJoinProbeOtherCondition(t *testing.T) {

lTypes := []*types.FieldType{intTp, intTp, stringTp, uintTp, stringTp}
rTypes := []*types.FieldType{intTp, intTp, stringTp, uintTp, stringTp}
rTypes = append(rTypes, rTypes...)

tinyTp := types.NewFieldType(mysql.TypeTiny)
a := &expression.Column{Index: 1, RetType: nullableIntTp}
Expand All @@ -285,6 +288,7 @@ func TestLeftOuterJoinProbeOtherCondition(t *testing.T) {
testJoinProbe(t, false, []int{0}, []int{0}, []*types.FieldType{intTp}, []*types.FieldType{intTp}, lTypes, rTypes, rightBuild, []int{1, 2, 4}, []int{0}, []int{1}, []int{3}, leftFilter, nil, otherCondition, partitionNumber, joinType, 200)
testJoinProbe(t, false, []int{0}, []int{0}, []*types.FieldType{intTp}, []*types.FieldType{intTp}, lTypes, rTypes, rightBuild, []int{}, []int{}, []int{1}, []int{3}, leftFilter, nil, otherCondition, partitionNumber, joinType, 200)
testJoinProbe(t, false, []int{0}, []int{0}, []*types.FieldType{nullableIntTp}, []*types.FieldType{nullableIntTp}, toNullableTypes(lTypes), toNullableTypes(rTypes), rightBuild, []int{1, 2, 4}, []int{0}, []int{1}, []int{3}, leftFilter, nil, otherCondition, partitionNumber, joinType, 200)
testJoinProbe(t, false, []int{0}, []int{0}, []*types.FieldType{nullableIntTp}, []*types.FieldType{nullableIntTp}, toNullableTypes(lTypes), toNullableTypes(rTypes), rightBuild, nil, nil, []int{1}, []int{3}, leftFilter, nil, otherCondition, partitionNumber, joinType, 200)
}
}
}
Expand All @@ -303,6 +307,7 @@ func TestLeftOuterJoinProbeWithSel(t *testing.T) {

lTypes := []*types.FieldType{intTp, intTp, stringTp, uintTp, stringTp}
rTypes := []*types.FieldType{intTp, intTp, stringTp, uintTp, stringTp}
rTypes = append(rTypes, rTypes...)

tinyTp := types.NewFieldType(mysql.TypeTiny)
a := &expression.Column{Index: 1, RetType: nullableIntTp}
Expand All @@ -326,6 +331,7 @@ func TestLeftOuterJoinProbeWithSel(t *testing.T) {
testJoinProbe(t, true, []int{0}, []int{0}, []*types.FieldType{intTp}, []*types.FieldType{intTp}, lTypes, rTypes, rightBuild, []int{1, 2, 4}, []int{0}, []int{1}, []int{3}, leftFilter, nil, otherCondition, partitionNumber, joinType, 500)
testJoinProbe(t, true, []int{0}, []int{0}, []*types.FieldType{intTp}, []*types.FieldType{intTp}, lTypes, rTypes, rightBuild, []int{}, []int{}, []int{1}, []int{3}, leftFilter, nil, otherCondition, partitionNumber, joinType, 500)
testJoinProbe(t, true, []int{0}, []int{0}, []*types.FieldType{nullableIntTp}, []*types.FieldType{nullableIntTp}, toNullableTypes(lTypes), toNullableTypes(rTypes), rightBuild, []int{1, 2, 4}, []int{0}, []int{1}, []int{3}, leftFilter, nil, otherCondition, partitionNumber, joinType, 500)
testJoinProbe(t, true, []int{0}, []int{0}, []*types.FieldType{nullableIntTp}, []*types.FieldType{nullableIntTp}, toNullableTypes(lTypes), toNullableTypes(rTypes), rightBuild, nil, nil, []int{1}, []int{3}, leftFilter, nil, otherCondition, partitionNumber, joinType, 500)
}
}
}
2 changes: 1 addition & 1 deletion pkg/executor/join/outer_join_probe.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ func (j *outerJoinProbe) ScanRowTable(joinResult *hashjoinWorkerResult, sqlKille
}

func (j *outerJoinProbe) buildResultForMatchedRowsAfterOtherCondition(chk, joinedChk *chunk.Chunk) {
probeColOffsetInJoinedChunk, buildColOffsetInJoinedChunk := j.currentChunk.NumCols(), 0
probeColOffsetInJoinedChunk, buildColOffsetInJoinedChunk := j.ctx.hashTableMeta.totalColumnNumber, 0
if j.rightAsBuildSide {
probeColOffsetInJoinedChunk, buildColOffsetInJoinedChunk = 0, j.currentChunk.NumCols()
}
Expand Down
8 changes: 7 additions & 1 deletion pkg/executor/join/right_outer_join_probe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,9 @@ func TestRightOuterJoinProbeBasic(t *testing.T) {

lTypes := []*types.FieldType{intTp, stringTp, uintTp, stringTp, tinyTp}
rTypes := []*types.FieldType{intTp, stringTp, uintTp, stringTp, tinyTp}
rTypes = append(rTypes, retTypes...)
rTypes1 := []*types.FieldType{uintTp, stringTp, intTp, stringTp, tinyTp}
rTypes1 = append(rTypes1, rTypes1...)

rightAsBuildSide := []bool{true, false}
partitionNumber := 3
Expand All @@ -129,7 +131,7 @@ func TestRightOuterJoinProbeBasic(t *testing.T) {

testCases := []testCase{
// normal case
{[]int{0}, []int{0}, []*types.FieldType{intTp}, []*types.FieldType{intTp}, lTypes, rTypes, []int{0, 1, 2, 3}, []int{0, 1, 2, 3}, nil, nil, nil},
{[]int{0}, []int{0}, []*types.FieldType{intTp}, []*types.FieldType{intTp}, lTypes, rTypes, nil, nil, nil, nil, nil},
// rightUsed is empty
{[]int{0}, []int{0}, []*types.FieldType{intTp}, []*types.FieldType{intTp}, lTypes, rTypes, []int{0, 1, 2, 3}, []int{}, nil, nil, nil},
// leftUsed is empty
Expand Down Expand Up @@ -261,6 +263,7 @@ func TestRightOuterJoinProbeOtherCondition(t *testing.T) {

lTypes := []*types.FieldType{intTp, intTp, stringTp, uintTp, stringTp}
rTypes := []*types.FieldType{intTp, intTp, stringTp, uintTp, stringTp}
rTypes = append(rTypes, retTypes...)

tinyTp := types.NewFieldType(mysql.TypeTiny)
a := &expression.Column{Index: 1, RetType: nullableIntTp}
Expand All @@ -283,6 +286,7 @@ func TestRightOuterJoinProbeOtherCondition(t *testing.T) {
testJoinProbe(t, false, []int{0}, []int{0}, []*types.FieldType{intTp}, []*types.FieldType{intTp}, lTypes, rTypes, rightBuild, []int{1, 2, 4}, []int{0}, []int{1}, []int{3}, nil, rightFilter, otherCondition, 3, joinType, 200)
testJoinProbe(t, false, []int{0}, []int{0}, []*types.FieldType{intTp}, []*types.FieldType{intTp}, lTypes, rTypes, rightBuild, []int{}, []int{}, []int{1}, []int{3}, nil, rightFilter, otherCondition, 3, joinType, 200)
testJoinProbe(t, false, []int{0}, []int{0}, []*types.FieldType{nullableIntTp}, []*types.FieldType{nullableIntTp}, toNullableTypes(lTypes), toNullableTypes(rTypes), rightBuild, []int{1, 2, 4}, []int{0}, []int{1}, []int{3}, nil, rightFilter, otherCondition, 3, joinType, 200)
testJoinProbe(t, false, []int{0}, []int{0}, []*types.FieldType{nullableIntTp}, []*types.FieldType{nullableIntTp}, toNullableTypes(lTypes), toNullableTypes(rTypes), rightBuild, nil, nil, []int{1}, []int{3}, nil, rightFilter, otherCondition, 3, joinType, 200)
}
}
}
Expand All @@ -301,6 +305,7 @@ func TestRightOuterJoinProbeWithSel(t *testing.T) {

lTypes := []*types.FieldType{intTp, intTp, stringTp, uintTp, stringTp}
rTypes := []*types.FieldType{intTp, intTp, stringTp, uintTp, stringTp}
rTypes = append(rTypes, retTypes...)

tinyTp := types.NewFieldType(mysql.TypeTiny)
a := &expression.Column{Index: 1, RetType: nullableIntTp}
Expand All @@ -323,6 +328,7 @@ func TestRightOuterJoinProbeWithSel(t *testing.T) {
testJoinProbe(t, true, []int{0}, []int{0}, []*types.FieldType{intTp}, []*types.FieldType{intTp}, lTypes, rTypes, rightBuild, []int{1, 2, 4}, []int{0}, []int{1}, []int{3}, nil, rightFilter, otherCondition, 3, joinType, 500)
testJoinProbe(t, true, []int{0}, []int{0}, []*types.FieldType{intTp}, []*types.FieldType{intTp}, lTypes, rTypes, rightBuild, []int{}, []int{}, []int{1}, []int{3}, nil, rightFilter, otherCondition, 3, joinType, 500)
testJoinProbe(t, true, []int{0}, []int{0}, []*types.FieldType{nullableIntTp}, []*types.FieldType{nullableIntTp}, toNullableTypes(lTypes), toNullableTypes(rTypes), rightBuild, []int{1, 2, 4}, []int{0}, []int{1}, []int{3}, nil, rightFilter, otherCondition, 3, joinType, 500)
testJoinProbe(t, true, []int{0}, []int{0}, []*types.FieldType{nullableIntTp}, []*types.FieldType{nullableIntTp}, toNullableTypes(lTypes), toNullableTypes(rTypes), rightBuild, nil, nil, []int{1}, []int{3}, nil, rightFilter, otherCondition, 3, joinType, 500)
}
}
}
2 changes: 1 addition & 1 deletion pkg/executor/test/jointest/hashjoin/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ go_test(
],
flaky = True,
race = "on",
shard_count = 21,
shard_count = 22,
deps = [
"//pkg/config",
"//pkg/executor/join",
Expand Down
37 changes: 37 additions & 0 deletions pkg/executor/test/jointest/hashjoin/hash_join_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -679,3 +679,40 @@ func TestIssue56214(t *testing.T) {
tk.MustQuery("select value, (select t1.id from t1 join t2 on t1.id = t2.id and t1.value < t2.value - t3.value + 3) d from t3 order by value").Check(testkit.Rows("10 1", "20 <nil>"))
}
}

func TestIssue56825(t *testing.T) {
store := testkit.CreateMockStore(t)
tk := testkit.NewTestKit(t, store)
tk.MustExec("use test")
tk.MustExec("drop table if exists t1;")
tk.MustExec("drop table if exists t2;")
tk.MustExec("create table t1(id int, col1 int)")
tk.MustExec("create table t2(id int, col1 int, col2 int, col3 int, col4 int, col5 int)")
tk.MustExec("insert into t1 values(1,2),(2,3)")
tk.MustExec("insert into t2 values(1,2,3,4,5,6),(3,4,5,6,7,8),(4,5,6,7,8,9)")
tk.MustExec("analyze table t1")
tk.MustExec("analyze table t2")
// t1 as build side
for _, hashJoinV2 := range join.HashJoinV2Strings {
tk.MustExec(hashJoinV2)
tk.MustQuery("select * from t1 left join t2 on t1.id = t2.id and t1.col1 <= t2.col1 order by t1.id").Check(testkit.Rows("1 2 1 2 3 4 5 6", "2 3 <nil> <nil> <nil> <nil> <nil> <nil>"))
tk.MustQuery("select * from t1 right join t2 on t1.id = t2.id and t1.col1 <= t2.col1 order by t2.id").Check(testkit.Rows("1 2 1 2 3 4 5 6", "<nil> <nil> 3 4 5 6 7 8", "<nil> <nil> 4 5 6 7 8 9"))
}
tk.MustExec("insert into t1 values(10,20),(11,21),(12,22),(13,23),(14,24),(15,25)")
tk.MustExec("analyze table t1")
// t2 as build side
for _, hashJoinV2 := range join.HashJoinV2Strings {
tk.MustExec(hashJoinV2)
tk.MustQuery("select * from t1 left join t2 on t1.id = t2.id and t1.col1 <= t2.col1 order by t1.id").Check(testkit.Rows(
"1 2 1 2 3 4 5 6",
"2 3 <nil> <nil> <nil> <nil> <nil> <nil>",
"10 20 <nil> <nil> <nil> <nil> <nil> <nil>",
"11 21 <nil> <nil> <nil> <nil> <nil> <nil>",
"12 22 <nil> <nil> <nil> <nil> <nil> <nil>",
"13 23 <nil> <nil> <nil> <nil> <nil> <nil>",
"14 24 <nil> <nil> <nil> <nil> <nil> <nil>",
"15 25 <nil> <nil> <nil> <nil> <nil> <nil>",
))
tk.MustQuery("select * from t1 right join t2 on t1.id = t2.id and t1.col1 <= t2.col1 order by t2.id").Check(testkit.Rows("1 2 1 2 3 4 5 6", "<nil> <nil> 3 4 5 6 7 8", "<nil> <nil> 4 5 6 7 8 9"))
}
}

0 comments on commit 3c8f2f3

Please sign in to comment.