From c077586cb83597528b2e0e83378bd60624bd5b4f Mon Sep 17 00:00:00 2001 From: Noah Treuhaft Date: Tue, 25 Aug 2026 21:09:35 -0400 Subject: [PATCH] fix null join key handling in hash join The hash join implementation considers join keys to be matching if both are null. Per SQL they do not match so fix this. --- runtime/vam/op/hashjoin.go | 19 +++++++++++------ runtime/ztests/op/join-first-key-is-null.yaml | 4 +--- runtime/ztests/op/join-kinds.yaml | 21 +++++++++++++++++++ 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/runtime/vam/op/hashjoin.go b/runtime/vam/op/hashjoin.go index 69f2655447..6346f6dd95 100644 --- a/runtime/vam/op/hashjoin.go +++ b/runtime/vam/op/hashjoin.go @@ -192,9 +192,12 @@ func (j *hashJoin) probeLeft() (vector.Any, error) { if keyVal.IsMissing() { continue } - key := hashKey(keyVal) + var rightVals []super.Value + var ok bool + if !keyVal.IsNull() { + rightVals, ok = j.table[hashKey(keyVal)] + } leftVal := vector.ValueAt(&sb, vec, i) - rightVals, ok := j.table[key] if !ok { if j.style != "inner" { b.Write(j.wrap(leftVal.Ptr(), nil)) @@ -235,10 +238,14 @@ func (j *hashJoin) probeRight() (vector.Any, error) { if keyVal.IsMissing() { continue } - key := hashKey(keyVal) - leftVals, ok := j.table[key] - if ok { - j.hits[key] = true + var leftVals []super.Value + if !keyVal.IsNull() { + key := hashKey(keyVal) + var ok bool + leftVals, ok = j.table[key] + if ok { + j.hits[key] = true + } } if j.style == "anti" { continue diff --git a/runtime/ztests/op/join-first-key-is-null.yaml b/runtime/ztests/op/join-first-key-is-null.yaml index f6cabd3a0a..e1bd95a78b 100644 --- a/runtime/ztests/op/join-first-key-is-null.yaml +++ b/runtime/ztests/op/join-first-key-is-null.yaml @@ -3,7 +3,5 @@ spq: | | join using (a) | values left -input: &input | +input: | {a:null} - -output: *input diff --git a/runtime/ztests/op/join-kinds.yaml b/runtime/ztests/op/join-kinds.yaml index d05048ef1d..7f6c93895d 100644 --- a/runtime/ztests/op/join-kinds.yaml +++ b/runtime/ztests/op/join-kinds.yaml @@ -30,6 +30,7 @@ inputs: {a:40,sa:"a3"} {a:45,sa:"a5"} {a:50,sa:"a4"} + {a:null,sa:"anull"} {anti_a:"Anti hash join output must not contain this record."} - name: B.sup data: | @@ -38,6 +39,7 @@ inputs: {b:40,sb:"b40"} {b:40,sb:"b40.2"} {b:60,sb:"b60"} + {b:null,sb:"bnull"} - name: C.sup data: | {c:15,sc:"c8"} @@ -46,6 +48,7 @@ inputs: {c:30,sc:"c2"} {c:35,sc:"c6"} {c:40,sc:"c3"} + {c:null,sc:"cnull"} outputs: - name: stdout @@ -58,10 +61,12 @@ outputs: {a:35,sa:"a6"} {a:45,sa:"a5"} {a:50,sa:"a4"} + {a:null,sa:"anull"} === anti nested loop {a:10,sa:"a0"} {a:15,sa:"a8"} {a:20,sa:"a1"} + {a:null,sa:"anull"} {anti_a:"Anti hash join output must not contain this record."} === left hash {a:10,sa:"a0",hit:error("missing")} @@ -75,6 +80,7 @@ outputs: {a:40,sa:"a3",hit:"b40.2"} {a:45,sa:"a5",hit:error("missing")} {a:50,sa:"a4",hit:error("missing")} + {a:null,sa:"anull",hit:error("missing")} === left nested loop {a:10,sa:"a0",hit:error("missing")} {a:15,sa:"a8",hit:error("missing")} @@ -95,6 +101,7 @@ outputs: {a:50,sa:"a4",hit:"b20.2"} {a:50,sa:"a4",hit:"b40"} {a:50,sa:"a4",hit:"b40.2"} + {a:null,sa:"anull",hit:error("missing")} {anti_a:"Anti hash join output must not contain this record.",hit:error("missing")} === inner hash {a:20,sa:"a1",hit:"b20.1"} @@ -127,6 +134,7 @@ outputs: {c:35,sc:"c6",hit:error("missing")} {c:40,sc:"c3",hit:"b40"} {c:40,sc:"c3",hit:"b40.2"} + {c:null,sc:"cnull",hit:error("missing")} === right nested loop {c:15,sc:"c8",hit:error("missing")} {c:20,sc:"c1",hit:error("missing")} @@ -138,6 +146,7 @@ outputs: {c:35,sc:"c6",hit:"b20.2"} {c:40,sc:"c3",hit:"b20.1"} {c:40,sc:"c3",hit:"b20.2"} + {c:null,sc:"cnull",hit:error("missing")} === cross {b:20,sb:"b20.1",c:15,sc:"c8"} {b:20,sb:"b20.1",c:20,sc:"c1"} @@ -145,27 +154,39 @@ outputs: {b:20,sb:"b20.1",c:30,sc:"c2"} {b:20,sb:"b20.1",c:35,sc:"c6"} {b:20,sb:"b20.1",c:40,sc:"c3"} + {b:20,sb:"b20.1",c:null,sc:"cnull"} {b:20,sb:"b20.2",c:15,sc:"c8"} {b:20,sb:"b20.2",c:20,sc:"c1"} {b:20,sb:"b20.2",c:25,sc:"c7"} {b:20,sb:"b20.2",c:30,sc:"c2"} {b:20,sb:"b20.2",c:35,sc:"c6"} {b:20,sb:"b20.2",c:40,sc:"c3"} + {b:20,sb:"b20.2",c:null,sc:"cnull"} {b:40,sb:"b40",c:15,sc:"c8"} {b:40,sb:"b40",c:20,sc:"c1"} {b:40,sb:"b40",c:25,sc:"c7"} {b:40,sb:"b40",c:30,sc:"c2"} {b:40,sb:"b40",c:35,sc:"c6"} {b:40,sb:"b40",c:40,sc:"c3"} + {b:40,sb:"b40",c:null,sc:"cnull"} {b:40,sb:"b40.2",c:15,sc:"c8"} {b:40,sb:"b40.2",c:20,sc:"c1"} {b:40,sb:"b40.2",c:25,sc:"c7"} {b:40,sb:"b40.2",c:30,sc:"c2"} {b:40,sb:"b40.2",c:35,sc:"c6"} {b:40,sb:"b40.2",c:40,sc:"c3"} + {b:40,sb:"b40.2",c:null,sc:"cnull"} {b:60,sb:"b60",c:15,sc:"c8"} {b:60,sb:"b60",c:20,sc:"c1"} {b:60,sb:"b60",c:25,sc:"c7"} {b:60,sb:"b60",c:30,sc:"c2"} {b:60,sb:"b60",c:35,sc:"c6"} {b:60,sb:"b60",c:40,sc:"c3"} + {b:60,sb:"b60",c:null,sc:"cnull"} + {b:null,sb:"bnull",c:15,sc:"c8"} + {b:null,sb:"bnull",c:20,sc:"c1"} + {b:null,sb:"bnull",c:25,sc:"c7"} + {b:null,sb:"bnull",c:30,sc:"c2"} + {b:null,sb:"bnull",c:35,sc:"c6"} + {b:null,sb:"bnull",c:40,sc:"c3"} + {b:null,sb:"bnull",c:null,sc:"cnull"}