Skip to content

Commit 4389dfa

Browse files
davidanthoffclaude
andcommitted
Add the append, prepend and zip operators
Ports Enumerable.Append, Prepend and Zip. append and zip shadow their Base counterparts. append and prepend convert the extra element to the source's element type, so appending an Int to a sequence of Float64 works rather than erroring. zip stops at the shorter of its two sources, matching both Base.zip and .NET. Nothing is padded, so zip manufactures no null values — a test asserts the result contains neither missing nor DataValue. Without a result selector it yields Tuples, as Base.zip and Enumerable.Zip both do. All three are lazy, and zip pulls at most one element past the end of the shorter source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 99ed76e commit 4389dfa

7 files changed

Lines changed: 269 additions & 0 deletions

File tree

src/QueryOperators.jl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ include("enumerable/enumerable_intersect.jl")
3838
include("enumerable/enumerable_reverse.jl")
3939
include("enumerable/enumerable_shuffle.jl")
4040
include("enumerable/enumerable_index.jl")
41+
include("enumerable/enumerable_append.jl")
42+
include("enumerable/enumerable_prepend.jl")
43+
include("enumerable/enumerable_zip.jl")
4144
include("enumerable/enumerable_pivot.jl")
4245
include("enumerable/enumerable_summarize.jl")
4346
include("enumerable/enumerable_countby.jl")
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
struct EnumerableAppend{T,S} <: Enumerable
2+
source::S
3+
element::T
4+
end
5+
6+
# Enumerable.Append: the source followed by one more element. The element is
7+
# converted to the source's element type, so appending an Int to a sequence of
8+
# Float64 works.
9+
function append(source::Enumerable, element)
10+
T = eltype(source)
11+
return EnumerableAppend{T,typeof(source)}(source, convert(T, element))
12+
end
13+
14+
Base.IteratorSize(::Type{EnumerableAppend{T,S}}) where {T,S} = haslength(S)
15+
16+
Base.eltype(::Type{EnumerableAppend{T,S}}) where {T,S} = T
17+
18+
Base.length(iter::EnumerableAppend) = length(iter.source) + 1
19+
20+
Base.iterate(iter::EnumerableAppend) = _append_next(iter, _NotStarted())
21+
22+
function Base.iterate(iter::EnumerableAppend, state)
23+
# `state.done` marks the appended element as already handed out.
24+
state.done && return nothing
25+
return _append_next(iter, state.state)
26+
end
27+
28+
function _append_next(iter::EnumerableAppend, source_state)
29+
ret = _iterate_from(iter.source, source_state)
30+
ret === nothing && return iter.element, (done=true, state=source_state)
31+
return ret[1], (done=false, state=ret[2])
32+
end
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
struct EnumerablePrepend{T,S} <: Enumerable
2+
source::S
3+
element::T
4+
end
5+
6+
# Enumerable.Prepend: one element followed by the source. As with `append`, the
7+
# element is converted to the source's element type.
8+
function prepend(source::Enumerable, element)
9+
T = eltype(source)
10+
return EnumerablePrepend{T,typeof(source)}(source, convert(T, element))
11+
end
12+
13+
Base.IteratorSize(::Type{EnumerablePrepend{T,S}}) where {T,S} = haslength(S)
14+
15+
Base.eltype(::Type{EnumerablePrepend{T,S}}) where {T,S} = T
16+
17+
Base.length(iter::EnumerablePrepend) = length(iter.source) + 1
18+
19+
Base.iterate(iter::EnumerablePrepend) = (iter.element, (state=_NotStarted(),))
20+
21+
function Base.iterate(iter::EnumerablePrepend, state)
22+
ret = _iterate_from(iter.source, state.state)
23+
ret === nothing && return nothing
24+
return ret[1], (state=ret[2],)
25+
end

src/enumerable/enumerable_zip.jl

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
struct EnumerableZip{T,S1,S2,RS<:Function} <: Enumerable
2+
first::S1
3+
second::S2
4+
resultSelector::RS
5+
end
6+
7+
Base.eltype(::Type{EnumerableZip{T,S1,S2,RS}}) where {T,S1,S2,RS} = T
8+
9+
# Enumerable.Zip. Like Base.zip and .NET, the result stops at the shorter of
10+
# the two sources — nothing is padded, so no null values are manufactured.
11+
function zip(first::Enumerable, second::Enumerable)
12+
return _zip(first, second, tuple)
13+
end
14+
15+
function zip(first::Enumerable, second::Enumerable, f_resultSelector::Function, resultSelector::Expr)
16+
return _zip(first, second, f_resultSelector)
17+
end
18+
19+
function _zip(first::Enumerable, second::Enumerable, f_resultSelector::Function)
20+
T1 = eltype(first)
21+
T2 = eltype(second)
22+
T = Base._return_type(f_resultSelector, Tuple{T1,T2})
23+
24+
return EnumerableZip{T,typeof(first),typeof(second),typeof(f_resultSelector)}(first, second, f_resultSelector)
25+
end
26+
27+
function Base.IteratorSize(::Type{EnumerableZip{T,S1,S2,RS}}) where {T,S1,S2,RS}
28+
return haslength(S1) isa Base.HasLength && haslength(S2) isa Base.HasLength ?
29+
Base.HasLength() : Base.SizeUnknown()
30+
end
31+
32+
Base.length(iter::EnumerableZip) = min(length(iter.first), length(iter.second))
33+
34+
Base.iterate(iter::EnumerableZip) = _zip_next(iter, _NotStarted(), _NotStarted())
35+
36+
Base.iterate(iter::EnumerableZip, state) = _zip_next(iter, state.s1, state.s2)
37+
38+
function _zip_next(iter::EnumerableZip, s1, s2)
39+
r1 = _iterate_from(iter.first, s1)
40+
r1 === nothing && return nothing
41+
42+
r2 = _iterate_from(iter.second, s2)
43+
r2 === nothing && return nothing
44+
45+
return iter.resultSelector(r1[1], r2[1]), (s1=r1[2], s2=r2[2])
46+
end

src/operators.jl

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,3 +191,11 @@ function drop_while end
191191
function take_last end
192192

193193
function drop_last end
194+
195+
# Combining sequences. `append` and `zip` shadow their Base counterparts.
196+
197+
function append end
198+
199+
function prepend end
200+
201+
function zip end

test/runtests.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ include("test_enumerable_setops.jl")
66
include("test_enumerable_ordering.jl")
77
include("test_enumerable_keyed_aggregation.jl")
88
include("test_enumerable_partitioning.jl")
9+
include("test_enumerable_combining.jl")
910
include("test_enumerable_unique.jl")
1011
include("test_enumerable_summarize.jl")
1112
include("test_namedtupleutilities.jl")

test/test_enumerable_combining.jl

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
@testitem "append" begin
2+
source = QueryOperators.query([1, 2, 3])
3+
4+
res = QueryOperators.append(source, 4)
5+
6+
@test collect(res) == [1, 2, 3, 4]
7+
@test eltype(res) == Int
8+
@test Base.IteratorSize(typeof(res)) == Base.HasLength()
9+
@test length(res) == 4
10+
end
11+
12+
@testitem "append to an empty source" begin
13+
source = QueryOperators.query(Int[])
14+
15+
@test collect(QueryOperators.append(source, 1)) == [1]
16+
end
17+
18+
@testitem "append converts the element to the source element type" begin
19+
source = QueryOperators.query([1.0, 2.0])
20+
21+
res = QueryOperators.append(source, 3)
22+
23+
@test collect(res) == [1.0, 2.0, 3.0]
24+
@test eltype(res) == Float64
25+
end
26+
27+
@testitem "append rejects an element that cannot be converted" begin
28+
source = QueryOperators.query([1, 2])
29+
30+
@test_throws MethodError QueryOperators.append(source, "three")
31+
end
32+
33+
@testitem "append of NamedTuple rows" begin
34+
source = QueryOperators.query([(a=1, b="x")])
35+
36+
res = QueryOperators.append(source, (a=2, b="y"))
37+
38+
@test collect(res) == [(a=1, b="x"), (a=2, b="y")]
39+
end
40+
41+
@testitem "prepend" begin
42+
source = QueryOperators.query([2, 3])
43+
44+
res = QueryOperators.prepend(source, 1)
45+
46+
@test collect(res) == [1, 2, 3]
47+
@test eltype(res) == Int
48+
@test Base.IteratorSize(typeof(res)) == Base.HasLength()
49+
@test length(res) == 3
50+
end
51+
52+
@testitem "prepend to an empty source" begin
53+
source = QueryOperators.query(Int[])
54+
55+
@test collect(QueryOperators.prepend(source, 1)) == [1]
56+
end
57+
58+
@testitem "append and prepend compose" begin
59+
source = QueryOperators.query([2, 3])
60+
61+
res = QueryOperators.append(QueryOperators.prepend(source, 1), 4)
62+
63+
@test collect(res) == [1, 2, 3, 4]
64+
@test length(res) == 4
65+
end
66+
67+
@testitem "zip" begin
68+
a = QueryOperators.query([1, 2, 3])
69+
b = QueryOperators.query(["a", "b", "c"])
70+
71+
res = QueryOperators.zip(a, b)
72+
73+
@test collect(res) == [(1, "a"), (2, "b"), (3, "c")]
74+
@test eltype(res) == Tuple{Int,String}
75+
@test Base.IteratorSize(typeof(res)) == Base.HasLength()
76+
@test length(res) == 3
77+
end
78+
79+
@testitem "zip truncates to the shorter source" begin
80+
a = QueryOperators.query([1, 2, 3, 4])
81+
b = QueryOperators.query(["a", "b"])
82+
83+
@test collect(QueryOperators.zip(a, b)) == [(1, "a"), (2, "b")]
84+
@test length(QueryOperators.zip(a, b)) == 2
85+
@test collect(QueryOperators.zip(b, a)) == [("a", 1), ("b", 2)]
86+
end
87+
88+
@testitem "zip with an empty source" begin
89+
a = QueryOperators.query([1, 2])
90+
empty = QueryOperators.query(String[])
91+
92+
@test collect(QueryOperators.zip(a, empty)) == Tuple{Int,String}[]
93+
@test collect(QueryOperators.zip(empty, a)) == Tuple{String,Int}[]
94+
end
95+
96+
@testitem "zip with a result selector" begin
97+
a = QueryOperators.query([1, 2, 3])
98+
b = QueryOperators.query([10, 20, 30])
99+
100+
res = QueryOperators.zip(a, b, (x, y) -> (sum=x + y,), :((x, y) -> (sum=x + y,)))
101+
102+
@test collect(res) == [(sum=11,), (sum=22,), (sum=33,)]
103+
@test eltype(res) == NamedTuple{(:sum,),Tuple{Int}}
104+
end
105+
106+
@testitem "zip pads nothing, so it manufactures no null values" begin
107+
using DataValues
108+
109+
a = QueryOperators.query([1, 2, 3])
110+
b = QueryOperators.query([10])
111+
112+
res = collect(QueryOperators.zip(a, b))
113+
114+
@test length(res) == 1
115+
@test !any(r -> any(ismissing, r), res)
116+
@test !any(r -> any(x -> x isa DataValue, r), res)
117+
end
118+
119+
@testitem "zip does not over-read the longer source" begin
120+
import IteratorInterfaceExtensions
121+
122+
mutable struct CountedZipSource
123+
data::Vector{Int}
124+
pulled::Int
125+
end
126+
Base.eltype(::Type{CountedZipSource}) = Int
127+
Base.IteratorSize(::Type{CountedZipSource}) = Base.HasLength()
128+
Base.length(c::CountedZipSource) = length(c.data)
129+
function Base.iterate(c::CountedZipSource, i=1)
130+
i > length(c.data) && return nothing
131+
c.pulled += 1
132+
return c.data[i], i + 1
133+
end
134+
IteratorInterfaceExtensions.isiterable(::CountedZipSource) = true
135+
IteratorInterfaceExtensions.getiterator(c::CountedZipSource) = c
136+
137+
long = CountedZipSource(collect(1:100), 0)
138+
res = QueryOperators.zip(QueryOperators.query(long), QueryOperators.query([1, 2]))
139+
140+
@test length(collect(res)) == 2
141+
# Three pulls: two matched, plus the one that had no partner.
142+
@test long.pulled == 3
143+
end
144+
145+
@testitem "combining operators work downstream of groupby" begin
146+
source = QueryOperators.query([(k=1, v=1), (k=2, v=2)])
147+
grouped = QueryOperators.@groupby_simple(source, i -> i.k)
148+
149+
zipped = collect(QueryOperators.zip(grouped, QueryOperators.query(["first", "second"])))
150+
151+
@test length(zipped) == 2
152+
@test [QueryOperators.key(g) for (g, _) in zipped] == [1, 2]
153+
@test [s for (_, s) in zipped] == ["first", "second"]
154+
end

0 commit comments

Comments
 (0)