Skip to content

Commit 841d962

Browse files
davidanthoffclaude
andcommitted
Add the concat, union, except and intersect set operators
Ports Enumerable.Concat/Union/UnionBy/Except/ExceptBy/Intersect/IntersectBy. union, except and intersect shadow their Base counterparts, as map, filter, count, take, unique and join already do here. concat is fully lazy and reports a length when both sources have one. union is lazy over both sources behind a growing set of seen keys; except and intersect materialise the second source's keys on first iterate and then walk the first source lazily. All three de-duplicate their output, matching .NET. One deliberate deviation: Enumerable.ExceptBy and IntersectBy take a bare sequence of keys as their second argument, while UnionBy takes a sequence of elements. Here the key selector is applied to both sequences in all three cases. That keeps the operators consistent with each other, matches the shape of the equivalent SQL, and is what a table-shaped second argument makes natural. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 7c4b028 commit 841d962

9 files changed

Lines changed: 457 additions & 0 deletions

src/QueryOperators.jl

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ include("enumerable/enumerable_count.jl")
2626
include("enumerable/enumerable_take.jl")
2727
include("enumerable/enumerable_drop.jl")
2828
include("enumerable/enumerable_unique.jl")
29+
include("enumerable/enumerable_concat.jl")
30+
include("enumerable/enumerable_union.jl")
31+
include("enumerable/enumerable_except.jl")
32+
include("enumerable/enumerable_intersect.jl")
2933
include("enumerable/enumerable_pivot.jl")
3034
include("enumerable/enumerable_summarize.jl")
3135
include("enumerable/show.jl")

src/enumerable/enumerable.jl

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,26 @@ Base.IteratorSize(::Type{T}) where {T <: Enumerable} = Base.SizeUnknown()
44
IteratorInterfaceExtensions.isiterable(x::Enumerable) = true
55

66
haslength(S) = Base.IteratorSize(S) isa Union{Base.HasLength, Base.HasShape} ? Base.HasLength() : Base.IteratorSize(S)
7+
8+
# Operators that walk more than one source keep "which source, how far into it"
9+
# in their iteration state. `_NotStarted` marks a source that has not been
10+
# iterated yet, so that `_iterate_from` can pick the right `iterate` method
11+
# without conflating it with a source whose own state happens to be `nothing`.
12+
struct _NotStarted end
13+
14+
_iterate_from(source, ::_NotStarted) = iterate(source)
15+
_iterate_from(source, state) = iterate(source, state)
16+
17+
# Element types of two sources that will be emitted into a single stream have
18+
# to agree, the same requirement `default_if_empty` places on its default value.
19+
function _check_same_eltype(op, ::Type{T1}, ::Type{T2}) where {T1,T2}
20+
if T1 != T2
21+
error("The two sequences passed to $op have different element types, $T1 and $T2.")
22+
end
23+
end
24+
25+
function _check_same_keytype(op, ::Type{TKey1}, ::Type{TKey2}) where {TKey1,TKey2}
26+
if TKey1 != TKey2
27+
error("The keys of the two sequences passed to $op have different types, $TKey1 and $TKey2.")
28+
end
29+
end
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
struct EnumerableConcat{T,S1,S2} <: Enumerable
2+
first::S1
3+
second::S2
4+
end
5+
6+
function concat(first::Enumerable, second::Enumerable)
7+
T1 = eltype(first)
8+
T2 = eltype(second)
9+
10+
_check_same_eltype("concat", T1, T2)
11+
12+
return EnumerableConcat{T1,typeof(first),typeof(second)}(first, second)
13+
end
14+
15+
Base.eltype(::Type{EnumerableConcat{T,S1,S2}}) where {T,S1,S2} = T
16+
17+
function Base.IteratorSize(::Type{EnumerableConcat{T,S1,S2}}) where {T,S1,S2}
18+
return haslength(S1) isa Base.HasLength && haslength(S2) isa Base.HasLength ?
19+
Base.HasLength() : Base.SizeUnknown()
20+
end
21+
22+
Base.length(iter::EnumerableConcat) = length(iter.first) + length(iter.second)
23+
24+
Base.iterate(iter::EnumerableConcat) = _concat_next(iter, 1, _NotStarted())
25+
26+
function Base.iterate(iter::EnumerableConcat, state)
27+
return _concat_next(iter, state.side, state.state)
28+
end
29+
30+
function _concat_next(iter::EnumerableConcat, side, source_state)
31+
if side == 1
32+
ret = _iterate_from(iter.first, source_state)
33+
if ret !== nothing
34+
return ret[1], (side=1, state=ret[2])
35+
end
36+
# First source exhausted — fall through to the second.
37+
side = 2
38+
source_state = _NotStarted()
39+
end
40+
41+
ret = _iterate_from(iter.second, source_state)
42+
ret === nothing && return nothing
43+
return ret[1], (side=2, state=ret[2])
44+
end
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
struct EnumerableExcept{T,TKEY,S1,S2,Q<:Function} <: Enumerable
2+
first::S1
3+
second::S2
4+
f::Q
5+
end
6+
7+
Base.eltype(::Type{EnumerableExcept{T,TKEY,S1,S2,Q}}) where {T,TKEY,S1,S2,Q} = T
8+
9+
function except(first::Enumerable, second::Enumerable)
10+
T1 = eltype(first)
11+
T2 = eltype(second)
12+
13+
_check_same_eltype("except", T1, T2)
14+
15+
return EnumerableExcept{T1,T1,typeof(first),typeof(second),typeof(identity)}(first, second, identity)
16+
end
17+
18+
# Unlike Enumerable.ExceptBy, whose second argument is a sequence of keys, the
19+
# key selector here is applied to both sequences. That keeps `except_by`
20+
# consistent with `union_by`, matches the shape of the equivalent SQL
21+
# (`WHERE k NOT IN (SELECT k FROM b)`), and is what a table-shaped second
22+
# argument makes natural.
23+
function except_by(first::Enumerable, second::Enumerable, f::Function, f_expr::Expr)
24+
T1 = eltype(first)
25+
T2 = eltype(second)
26+
27+
TKEY1 = Base._return_type(f, Tuple{T1,})
28+
TKEY2 = Base._return_type(f, Tuple{T2,})
29+
30+
_check_same_keytype("except_by", TKEY1, TKEY2)
31+
32+
return EnumerableExcept{T1,TKEY1,typeof(first),typeof(second),typeof(f)}(first, second, f)
33+
end
34+
35+
function Base.iterate(iter::EnumerableExcept{T,TKEY,S1,S2,Q}) where {T,TKEY,S1,S2,Q}
36+
excluded = Set{TKEY}()
37+
for i in iter.second
38+
push!(excluded, iter.f(i))
39+
end
40+
41+
return _except_next(iter, excluded, Set{TKEY}(), _NotStarted())
42+
end
43+
44+
function Base.iterate(iter::EnumerableExcept, state)
45+
return _except_next(iter, state.excluded, state.observed, state.state)
46+
end
47+
48+
# Yields the distinct elements of the first source whose key does not occur in
49+
# the second, matching Enumerable.Except's de-duplicating behaviour.
50+
function _except_next(iter::EnumerableExcept, excluded, observed, source_state)
51+
while true
52+
ret = _iterate_from(iter.first, source_state)
53+
ret === nothing && return nothing
54+
55+
element, source_state = ret
56+
k = iter.f(element)
57+
if !(k in excluded) && !(k in observed)
58+
push!(observed, k)
59+
return element, (excluded=excluded, observed=observed, state=source_state)
60+
end
61+
end
62+
end
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
struct EnumerableIntersect{T,TKEY,S1,S2,Q<:Function} <: Enumerable
2+
first::S1
3+
second::S2
4+
f::Q
5+
end
6+
7+
Base.eltype(::Type{EnumerableIntersect{T,TKEY,S1,S2,Q}}) where {T,TKEY,S1,S2,Q} = T
8+
9+
function intersect(first::Enumerable, second::Enumerable)
10+
T1 = eltype(first)
11+
T2 = eltype(second)
12+
13+
_check_same_eltype("intersect", T1, T2)
14+
15+
return EnumerableIntersect{T1,T1,typeof(first),typeof(second),typeof(identity)}(first, second, identity)
16+
end
17+
18+
# As with `except_by`, the key selector is applied to both sequences rather than
19+
# the second being a bare sequence of keys as in Enumerable.IntersectBy.
20+
function intersect_by(first::Enumerable, second::Enumerable, f::Function, f_expr::Expr)
21+
T1 = eltype(first)
22+
T2 = eltype(second)
23+
24+
TKEY1 = Base._return_type(f, Tuple{T1,})
25+
TKEY2 = Base._return_type(f, Tuple{T2,})
26+
27+
_check_same_keytype("intersect_by", TKEY1, TKEY2)
28+
29+
return EnumerableIntersect{T1,TKEY1,typeof(first),typeof(second),typeof(f)}(first, second, f)
30+
end
31+
32+
function Base.iterate(iter::EnumerableIntersect{T,TKEY,S1,S2,Q}) where {T,TKEY,S1,S2,Q}
33+
required = Set{TKEY}()
34+
for i in iter.second
35+
push!(required, iter.f(i))
36+
end
37+
38+
return _intersect_next(iter, required, Set{TKEY}(), _NotStarted())
39+
end
40+
41+
function Base.iterate(iter::EnumerableIntersect, state)
42+
return _intersect_next(iter, state.required, state.observed, state.state)
43+
end
44+
45+
# Yields the distinct elements of the first source whose key also occurs in the
46+
# second, matching Enumerable.Intersect's de-duplicating behaviour.
47+
function _intersect_next(iter::EnumerableIntersect, required, observed, source_state)
48+
while true
49+
ret = _iterate_from(iter.first, source_state)
50+
ret === nothing && return nothing
51+
52+
element, source_state = ret
53+
k = iter.f(element)
54+
if k in required && !(k in observed)
55+
push!(observed, k)
56+
return element, (required=required, observed=observed, state=source_state)
57+
end
58+
end
59+
end

src/enumerable/enumerable_union.jl

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
struct EnumerableUnion{T,TKEY,S1,S2,Q<:Function} <: Enumerable
2+
first::S1
3+
second::S2
4+
f::Q
5+
end
6+
7+
Base.eltype(::Type{EnumerableUnion{T,TKEY,S1,S2,Q}}) where {T,TKEY,S1,S2,Q} = T
8+
9+
function union(first::Enumerable, second::Enumerable)
10+
T1 = eltype(first)
11+
T2 = eltype(second)
12+
13+
_check_same_eltype("union", T1, T2)
14+
15+
return EnumerableUnion{T1,T1,typeof(first),typeof(second),typeof(identity)}(first, second, identity)
16+
end
17+
18+
function union_by(first::Enumerable, second::Enumerable, f::Function, f_expr::Expr)
19+
T1 = eltype(first)
20+
T2 = eltype(second)
21+
22+
_check_same_eltype("union_by", T1, T2)
23+
24+
TKEY = Base._return_type(f, Tuple{T1,})
25+
26+
return EnumerableUnion{T1,TKEY,typeof(first),typeof(second),typeof(f)}(first, second, f)
27+
end
28+
29+
function Base.iterate(iter::EnumerableUnion{T,TKEY,S1,S2,Q}) where {T,TKEY,S1,S2,Q}
30+
return _union_next(iter, Set{TKEY}(), 1, _NotStarted())
31+
end
32+
33+
function Base.iterate(iter::EnumerableUnion, state)
34+
return _union_next(iter, state.observed, state.side, state.state)
35+
end
36+
37+
# Walks the first source and then the second, yielding each element whose key
38+
# has not been seen before, so that the result is distinct across both sources.
39+
function _union_next(iter::EnumerableUnion{T,TKEY,S1,S2,Q}, observed, side, source_state) where {T,TKEY,S1,S2,Q}
40+
while true
41+
source = side == 1 ? iter.first : iter.second
42+
ret = _iterate_from(source, source_state)
43+
44+
if ret === nothing
45+
side == 2 && return nothing
46+
side = 2
47+
source_state = _NotStarted()
48+
continue
49+
end
50+
51+
element, source_state = ret
52+
k = iter.f(element)
53+
if !(k in observed)
54+
push!(observed, k)
55+
return element, (observed=observed, side=side, state=source_state)
56+
end
57+
end
58+
end

src/operators.jl

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,21 @@ function left_join end
138138
function right_join end
139139

140140
function full_join end
141+
142+
# Set operations. `union`, `except` and `intersect` shadow their Base
143+
# counterparts, as `map`, `filter`, `count`, `take`, `unique` and `join`
144+
# already do in this module.
145+
146+
function concat end
147+
148+
function union end
149+
150+
function union_by end
151+
152+
function except end
153+
154+
function except_by end
155+
156+
function intersect end
157+
158+
function intersect_by end

test/runtests.jl

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

33
include("test_core.jl")
44
include("test_enumerable_outerjoins.jl")
5+
include("test_enumerable_setops.jl")
56
include("test_enumerable_unique.jl")
67
include("test_enumerable_summarize.jl")
78
include("test_namedtupleutilities.jl")

0 commit comments

Comments
 (0)