Skip to content

Commit 7c4b028

Browse files
davidanthoffclaude
andcommitted
Add the left_join, right_join and full_join operators
Ports Enumerable.LeftJoin, Enumerable.RightJoin and Enumerable.FullJoin, which .NET 11 added to System.Linq. The unmatched side of an outer join needs an element to pair with. That element is built by the new `_default_value`, a generated runtime counterpart of the existing `_default_value_expr`, so absent values are DataValue and never Missing. Matched elements on a nullable side are converted to the same all-null-capable type, which keeps a single element type across matched and unmatched rows — the same trick EnumerableDefaultIfEmpty already uses. full_join emits every outer row in source order first, then the inner rows whose key never appeared on the outer side. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent a122944 commit 7c4b028

8 files changed

Lines changed: 440 additions & 0 deletions

src/QueryOperators.jl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ include("enumerable/enumerable.jl")
1414
include("enumerable/enumerable_groupby.jl")
1515
include("enumerable/enumerable_join.jl")
1616
include("enumerable/enumerable_groupjoin.jl")
17+
include("enumerable/enumerable_leftjoin.jl")
18+
include("enumerable/enumerable_rightjoin.jl")
19+
include("enumerable/enumerable_fulljoin.jl")
1720
include("enumerable/enumerable_orderby.jl")
1821
include("enumerable/enumerable_map.jl")
1922
include("enumerable/enumerable_filter.jl")

src/enumerable/enumerable_defaultifempty.jl

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ function _default_value_expr(::Type{T}) where {T<:NamedTuple}
1313
return :( NamedTuple{$(fieldnames(T))}( ($( (_default_value_expr(fieldtype(T,i)) for i in 1:length(fieldnames(T)))... ),)) )
1414
end
1515

16+
# Runtime counterpart of `_default_value_expr`, used by the outer join operators
17+
# to build the all-null element that an unmatched side contributes. Generated so
18+
# that the NamedTuple case is constructed at compile time, exactly as
19+
# `default_if_empty` below does.
20+
@generated function _default_value(::Type{T}) where {T}
21+
return _default_value_expr(T)
22+
end
23+
1624
@generated function default_if_empty(source::S) where {S}
1725
T_source = eltype(source)
1826

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
struct EnumerableFullJoin{T,TKey,TOD,TID,SO,SI,OKS<:Function,IKS<:Function,RS<:Function} <: Enumerable
2+
outer::SO
3+
inner::SI
4+
outerKeySelector::OKS
5+
innerKeySelector::IKS
6+
resultSelector::RS
7+
default_outer::TOD
8+
default_inner::TID
9+
end
10+
11+
Base.eltype(::Type{EnumerableFullJoin{T,TKey,TOD,TID,SO,SI,OKS,IKS,RS}}) where {T,TKey,TOD,TID,SO,SI,OKS,IKS,RS} = T
12+
13+
function full_join(outer::Enumerable, inner::Enumerable, f_outerKeySelector::Function, outerKeySelector::Expr, f_innerKeySelector::Function, innerKeySelector::Expr, f_resultSelector::Function, resultSelector::Expr)
14+
TO = eltype(outer)
15+
TI = eltype(inner)
16+
TKeyOuter = Base._return_type(f_outerKeySelector, Tuple{TO,})
17+
TKeyInner = Base._return_type(f_innerKeySelector, Tuple{TI,})
18+
19+
_check_join_key_types("full_join", TKeyOuter, TKeyInner)
20+
21+
default_outer = _default_value(TO)
22+
default_inner = _default_value(TI)
23+
TOD = typeof(default_outer)
24+
TID = typeof(default_inner)
25+
26+
T = Base._return_type(f_resultSelector, Tuple{TOD,TID})
27+
28+
SO = typeof(outer)
29+
SI = typeof(inner)
30+
OKS = typeof(f_outerKeySelector)
31+
IKS = typeof(f_innerKeySelector)
32+
RS = typeof(f_resultSelector)
33+
34+
return EnumerableFullJoin{T,TKeyOuter,TOD,TID,SO,SI,OKS,IKS,RS}(outer, inner, f_outerKeySelector, f_innerKeySelector, f_resultSelector, default_outer, default_inner)
35+
end
36+
37+
function Base.iterate(iter::EnumerableFullJoin{T,TKey,TOD,TID,SO,SI,OKS,IKS,RS}) where {T,TKey,TOD,TID,SO,SI,OKS,IKS,RS}
38+
results = Array{T}(undef, 0)
39+
40+
inner_dict = _outerjoin_lookup(iter.inner, iter.innerKeySelector, TKey, TID)
41+
42+
# All outer elements first, in source order: matched pairs where a key
43+
# matches, otherwise the outer element paired with an all-null inner.
44+
matched_keys = Set{TKey}()
45+
for i in iter.outer
46+
outerKey = iter.outerKeySelector(i)
47+
converted_i = convert(TOD, i)
48+
if haskey(inner_dict, outerKey)
49+
push!(matched_keys, outerKey)
50+
for j in inner_dict[outerKey]
51+
push!(results, iter.resultSelector(converted_i, j))
52+
end
53+
else
54+
push!(results, iter.resultSelector(converted_i, iter.default_inner))
55+
end
56+
end
57+
58+
# Then the inner elements whose key never appeared on the outer side,
59+
# in inner source order, paired with an all-null outer.
60+
for (innerKey, elements) in inner_dict
61+
innerKey in matched_keys && continue
62+
for j in elements
63+
push!(results, iter.resultSelector(iter.default_outer, j))
64+
end
65+
end
66+
67+
if length(results)==0
68+
return nothing
69+
end
70+
71+
return results[1], (results, 2)
72+
end
73+
74+
function Base.iterate(iter::EnumerableFullJoin{T,TKey,TOD,TID,SO,SI,OKS,IKS,RS}, state) where {T,TKey,TOD,TID,SO,SI,OKS,IKS,RS}
75+
if state[2]>length(state[1])
76+
return nothing
77+
else
78+
return state[1][state[2]], (state[1], state[2]+1)
79+
end
80+
end
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Builds the lookup from join key to the list of elements on one side of an
2+
# outer join. Elements are converted to `TD` — the all-null-capable version of
3+
# their own type — so that matched and unmatched rows have a single element
4+
# type, mirroring how `EnumerableDefaultIfEmpty` converts its source.
5+
function _outerjoin_lookup(source, keySelector, ::Type{TKey}, ::Type{TD}) where {TKey,TD}
6+
lookup = OrderedDict{TKey,Array{TD,1}}()
7+
for i in source
8+
key = keySelector(i)
9+
if !haskey(lookup, key)
10+
lookup[key] = Array{TD}(undef, 0)
11+
end
12+
push!(lookup[key], convert(TD, i))
13+
end
14+
return lookup
15+
end
16+
17+
function _check_join_key_types(op, ::Type{TKeyOuter}, ::Type{TKeyInner}) where {TKeyOuter,TKeyInner}
18+
if TKeyOuter != TKeyInner
19+
error("The keys in the $op clause have different types, $TKeyOuter and $TKeyInner.")
20+
end
21+
end
22+
23+
struct EnumerableLeftJoin{T,TKey,TID,SO,SI,OKS<:Function,IKS<:Function,RS<:Function} <: Enumerable
24+
outer::SO
25+
inner::SI
26+
outerKeySelector::OKS
27+
innerKeySelector::IKS
28+
resultSelector::RS
29+
default_inner::TID
30+
end
31+
32+
Base.eltype(::Type{EnumerableLeftJoin{T,TKey,TID,SO,SI,OKS,IKS,RS}}) where {T,TKey,TID,SO,SI,OKS,IKS,RS} = T
33+
34+
function left_join(outer::Enumerable, inner::Enumerable, f_outerKeySelector::Function, outerKeySelector::Expr, f_innerKeySelector::Function, innerKeySelector::Expr, f_resultSelector::Function, resultSelector::Expr)
35+
TO = eltype(outer)
36+
TI = eltype(inner)
37+
TKeyOuter = Base._return_type(f_outerKeySelector, Tuple{TO,})
38+
TKeyInner = Base._return_type(f_innerKeySelector, Tuple{TI,})
39+
40+
_check_join_key_types("left_join", TKeyOuter, TKeyInner)
41+
42+
default_inner = _default_value(TI)
43+
TID = typeof(default_inner)
44+
45+
T = Base._return_type(f_resultSelector, Tuple{TO,TID})
46+
47+
SO = typeof(outer)
48+
SI = typeof(inner)
49+
OKS = typeof(f_outerKeySelector)
50+
IKS = typeof(f_innerKeySelector)
51+
RS = typeof(f_resultSelector)
52+
53+
return EnumerableLeftJoin{T,TKeyOuter,TID,SO,SI,OKS,IKS,RS}(outer, inner, f_outerKeySelector, f_innerKeySelector, f_resultSelector, default_inner)
54+
end
55+
56+
function Base.iterate(iter::EnumerableLeftJoin{T,TKey,TID,SO,SI,OKS,IKS,RS}) where {T,TKey,TID,SO,SI,OKS,IKS,RS}
57+
results = Array{T}(undef, 0)
58+
59+
inner_dict = _outerjoin_lookup(iter.inner, iter.innerKeySelector, TKey, TID)
60+
61+
for i in iter.outer
62+
outerKey = iter.outerKeySelector(i)
63+
if haskey(inner_dict, outerKey)
64+
for j in inner_dict[outerKey]
65+
push!(results, iter.resultSelector(i, j))
66+
end
67+
else
68+
push!(results, iter.resultSelector(i, iter.default_inner))
69+
end
70+
end
71+
72+
if length(results)==0
73+
return nothing
74+
end
75+
76+
return results[1], (results, 2)
77+
end
78+
79+
function Base.iterate(iter::EnumerableLeftJoin{T,TKey,TID,SO,SI,OKS,IKS,RS}, state) where {T,TKey,TID,SO,SI,OKS,IKS,RS}
80+
if state[2]>length(state[1])
81+
return nothing
82+
else
83+
return state[1][state[2]], (state[1], state[2]+1)
84+
end
85+
end
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
struct EnumerableRightJoin{T,TKey,TOD,SO,SI,OKS<:Function,IKS<:Function,RS<:Function} <: Enumerable
2+
outer::SO
3+
inner::SI
4+
outerKeySelector::OKS
5+
innerKeySelector::IKS
6+
resultSelector::RS
7+
default_outer::TOD
8+
end
9+
10+
Base.eltype(::Type{EnumerableRightJoin{T,TKey,TOD,SO,SI,OKS,IKS,RS}}) where {T,TKey,TOD,SO,SI,OKS,IKS,RS} = T
11+
12+
function right_join(outer::Enumerable, inner::Enumerable, f_outerKeySelector::Function, outerKeySelector::Expr, f_innerKeySelector::Function, innerKeySelector::Expr, f_resultSelector::Function, resultSelector::Expr)
13+
TO = eltype(outer)
14+
TI = eltype(inner)
15+
TKeyOuter = Base._return_type(f_outerKeySelector, Tuple{TO,})
16+
TKeyInner = Base._return_type(f_innerKeySelector, Tuple{TI,})
17+
18+
_check_join_key_types("right_join", TKeyOuter, TKeyInner)
19+
20+
default_outer = _default_value(TO)
21+
TOD = typeof(default_outer)
22+
23+
T = Base._return_type(f_resultSelector, Tuple{TOD,TI})
24+
25+
SO = typeof(outer)
26+
SI = typeof(inner)
27+
OKS = typeof(f_outerKeySelector)
28+
IKS = typeof(f_innerKeySelector)
29+
RS = typeof(f_resultSelector)
30+
31+
return EnumerableRightJoin{T,TKeyInner,TOD,SO,SI,OKS,IKS,RS}(outer, inner, f_outerKeySelector, f_innerKeySelector, f_resultSelector, default_outer)
32+
end
33+
34+
function Base.iterate(iter::EnumerableRightJoin{T,TKey,TOD,SO,SI,OKS,IKS,RS}) where {T,TKey,TOD,SO,SI,OKS,IKS,RS}
35+
results = Array{T}(undef, 0)
36+
37+
outer_dict = _outerjoin_lookup(iter.outer, iter.outerKeySelector, TKey, TOD)
38+
39+
for j in iter.inner
40+
innerKey = iter.innerKeySelector(j)
41+
if haskey(outer_dict, innerKey)
42+
for i in outer_dict[innerKey]
43+
push!(results, iter.resultSelector(i, j))
44+
end
45+
else
46+
push!(results, iter.resultSelector(iter.default_outer, j))
47+
end
48+
end
49+
50+
if length(results)==0
51+
return nothing
52+
end
53+
54+
return results[1], (results, 2)
55+
end
56+
57+
function Base.iterate(iter::EnumerableRightJoin{T,TKey,TOD,SO,SI,OKS,IKS,RS}, state) where {T,TKey,TOD,SO,SI,OKS,IKS,RS}
58+
if state[2]>length(state[1])
59+
return nothing
60+
else
61+
return state[1][state[2]], (state[1], state[2]+1)
62+
end
63+
end

src/operators.jl

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,12 @@ function pivot_longer end
129129
function pivot_wider end
130130

131131
function summarize end
132+
133+
# Outer joins, mirroring Enumerable.LeftJoin/RightJoin/FullJoin (.NET 11). The
134+
# unmatched side is supplied as an all-null element built by `_default_value`.
135+
136+
function left_join end
137+
138+
function right_join end
139+
140+
function full_join end

test/runtests.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using TestItemRunner
22

33
include("test_core.jl")
4+
include("test_enumerable_outerjoins.jl")
45
include("test_enumerable_unique.jl")
56
include("test_enumerable_summarize.jl")
67
include("test_namedtupleutilities.jl")

0 commit comments

Comments
 (0)