Skip to content

Commit fefe333

Browse files
davidanthoffclaude
andcommitted
Add the count_by, aggregate_by and chunk operators
Ports Enumerable.CountBy and AggregateBy (.NET 9) and Enumerable.Chunk (.NET 6). count_by and aggregate_by name their key columns through the existing _key_namedtuple, so they agree with summarize: a scalar key becomes a column called `key`, a NamedTuple key contributes one column per field. A test asserts count_by and groupby-plus-summarize produce identical output. summarize remains the more general and more idiomatic way to aggregate; aggregate_by is here for LINQ parity, and its accumulator takes (accumulated, element) to match .NET's argument order. chunk pulls at most n elements per call, so a chunked source is only walked as far as the batches actually consumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 2a6da18 commit fefe333

7 files changed

Lines changed: 314 additions & 0 deletions

File tree

src/QueryOperators.jl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ include("enumerable/enumerable_shuffle.jl")
3636
include("enumerable/enumerable_index.jl")
3737
include("enumerable/enumerable_pivot.jl")
3838
include("enumerable/enumerable_summarize.jl")
39+
include("enumerable/enumerable_countby.jl")
40+
include("enumerable/enumerable_aggregateby.jl")
41+
include("enumerable/enumerable_chunk.jl")
3942
include("enumerable/show.jl")
4043

4144
include("source_iterable.jl")
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
_aggregate_by_row(k, v) = Base.merge(_key_namedtuple(k), (value = v,))
2+
3+
struct EnumerableAggregateBy{T,TKey,TACC,S,Q<:Function,A<:Function} <: Enumerable
4+
source::S
5+
f::Q
6+
seed::TACC
7+
accumulator::A
8+
end
9+
10+
Base.eltype(::Type{EnumerableAggregateBy{T,TKey,TACC,S,Q,A}}) where {T,TKey,TACC,S,Q,A} = T
11+
12+
# Enumerable.AggregateBy (.NET 9): fold the elements of each key into a single
13+
# value, without materialising the intermediate groupings. `accumulator` is
14+
# called as `accumulator(accumulated, element)`, matching .NET's argument order.
15+
#
16+
# `summarize` is the more general and more idiomatic way to aggregate here;
17+
# `aggregate_by` exists for LINQ parity.
18+
function aggregate_by(source::Enumerable, f::Function, f_expr::Expr, seed, accumulator::Function)
19+
TS = eltype(source)
20+
TKey = Base._return_type(f, Tuple{TS,})
21+
TACC = typeof(seed)
22+
T = Base._return_type(_aggregate_by_row, Tuple{TKey,TACC})
23+
24+
return EnumerableAggregateBy{T,TKey,TACC,typeof(source),typeof(f),typeof(accumulator)}(source, f, seed, accumulator)
25+
end
26+
27+
function Base.iterate(iter::EnumerableAggregateBy{T,TKey,TACC,S,Q,A}) where {T,TKey,TACC,S,Q,A}
28+
accumulated = OrderedDict{TKey,TACC}()
29+
for i in iter.source
30+
k = iter.f(i)
31+
accumulated[k] = iter.accumulator(get(accumulated, k, iter.seed), i)
32+
end
33+
34+
rows = T[_aggregate_by_row(k, v) for (k, v) in accumulated]
35+
36+
if length(rows)==0
37+
return nothing
38+
end
39+
40+
return rows[1], (rows, 2)
41+
end
42+
43+
function Base.iterate(iter::EnumerableAggregateBy{T,TKey,TACC,S,Q,A}, state) where {T,TKey,TACC,S,Q,A}
44+
if state[2]>length(state[1])
45+
return nothing
46+
else
47+
return state[1][state[2]], (state[1], state[2]+1)
48+
end
49+
end

src/enumerable/enumerable_chunk.jl

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
struct EnumerableChunk{T,TI,S} <: Enumerable
2+
source::S
3+
n::Int
4+
end
5+
6+
# Enumerable.Chunk (.NET 6): split the source into batches of at most `n`
7+
# elements. The final batch is shorter when the source does not divide evenly.
8+
function chunk(source::Enumerable, n::Integer)
9+
n < 1 && error("The chunk size must be at least 1, got $n.")
10+
11+
TI = eltype(source)
12+
T = Vector{TI}
13+
14+
return EnumerableChunk{T,TI,typeof(source)}(source, Int(n))
15+
end
16+
17+
Base.IteratorSize(::Type{EnumerableChunk{T,TI,S}}) where {T,TI,S} = haslength(S)
18+
19+
Base.eltype(::Type{EnumerableChunk{T,TI,S}}) where {T,TI,S} = T
20+
21+
Base.length(iter::EnumerableChunk) = cld(length(iter.source), iter.n)
22+
23+
Base.iterate(iter::EnumerableChunk) = _chunk_next(iter, _NotStarted())
24+
25+
Base.iterate(iter::EnumerableChunk, state) = _chunk_next(iter, state)
26+
27+
# Pulls at most `n` elements per call, so a chunked source is only walked as
28+
# far as the batches actually consumed.
29+
function _chunk_next(iter::EnumerableChunk{T,TI,S}, source_state) where {T,TI,S}
30+
buffer = TI[]
31+
32+
while length(buffer) < iter.n
33+
ret = _iterate_from(iter.source, source_state)
34+
ret === nothing && break
35+
push!(buffer, ret[1])
36+
source_state = ret[2]
37+
end
38+
39+
length(buffer)==0 && return nothing
40+
41+
return buffer, source_state
42+
end
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Key columns are named exactly as `summarize` names them: a scalar key becomes
2+
# a column called `key`, a NamedTuple key contributes one column per field.
3+
_count_by_row(k, n::Int) = Base.merge(_key_namedtuple(k), (count = n,))
4+
5+
struct EnumerableCountBy{T,TKey,S,Q<:Function} <: Enumerable
6+
source::S
7+
f::Q
8+
end
9+
10+
Base.eltype(::Type{EnumerableCountBy{T,TKey,S,Q}}) where {T,TKey,S,Q} = T
11+
12+
# Enumerable.CountBy (.NET 9): the frequency of each key, without materialising
13+
# the intermediate groupings that `groupby` would build.
14+
function count_by(source::Enumerable, f::Function, f_expr::Expr)
15+
TS = eltype(source)
16+
TKey = Base._return_type(f, Tuple{TS,})
17+
T = Base._return_type(_count_by_row, Tuple{TKey,Int})
18+
19+
return EnumerableCountBy{T,TKey,typeof(source),typeof(f)}(source, f)
20+
end
21+
22+
function Base.iterate(iter::EnumerableCountBy{T,TKey,S,Q}) where {T,TKey,S,Q}
23+
counts = OrderedDict{TKey,Int}()
24+
for i in iter.source
25+
k = iter.f(i)
26+
counts[k] = get(counts, k, 0) + 1
27+
end
28+
29+
rows = T[_count_by_row(k, n) for (k, n) in counts]
30+
31+
if length(rows)==0
32+
return nothing
33+
end
34+
35+
return rows[1], (rows, 2)
36+
end
37+
38+
function Base.iterate(iter::EnumerableCountBy{T,TKey,S,Q}, state) where {T,TKey,S,Q}
39+
if state[2]>length(state[1])
40+
return nothing
41+
else
42+
return state[1][state[2]], (state[1], state[2]+1)
43+
end
44+
end

src/operators.jl

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,3 +170,13 @@ function reverse end
170170
function shuffle end
171171

172172
function index end
173+
174+
# Keyed aggregation and batching. `count_by` and `aggregate_by` are
175+
# Enumerable.CountBy and AggregateBy (.NET 9); `chunk` is Enumerable.Chunk
176+
# (.NET 6).
177+
178+
function count_by end
179+
180+
function aggregate_by end
181+
182+
function chunk end

test/runtests.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ include("test_core.jl")
44
include("test_enumerable_outerjoins.jl")
55
include("test_enumerable_setops.jl")
66
include("test_enumerable_ordering.jl")
7+
include("test_enumerable_keyed_aggregation.jl")
78
include("test_enumerable_unique.jl")
89
include("test_enumerable_summarize.jl")
910
include("test_namedtupleutilities.jl")
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
@testitem "count_by with a scalar key" begin
2+
source = QueryOperators.query([(k="a", v=1), (k="b", v=2), (k="a", v=3)])
3+
4+
res = QueryOperators.count_by(source, i -> i.k, :(i -> i.k))
5+
6+
# A scalar key becomes a column called `key`, as summarize names it.
7+
@test collect(res) == [(key="a", count=2), (key="b", count=1)]
8+
@test eltype(res) == NamedTuple{(:key, :count),Tuple{String,Int}}
9+
end
10+
11+
@testitem "count_by with a NamedTuple key splats the key columns" begin
12+
source = QueryOperators.query([(a=1, b=1), (a=1, b=1), (a=1, b=2)])
13+
14+
res = QueryOperators.count_by(source, i -> (a=i.a, b=i.b), :(i -> (a=i.a, b=i.b)))
15+
16+
@test collect(res) == [(a=1, b=1, count=2), (a=1, b=2, count=1)]
17+
end
18+
19+
@testitem "count_by preserves first-seen key order" begin
20+
source = QueryOperators.query([3, 1, 3, 2, 1])
21+
22+
res = QueryOperators.count_by(source, i -> i, :(i -> i))
23+
24+
@test collect(res) == [(key=3, count=2), (key=1, count=2), (key=2, count=1)]
25+
end
26+
27+
@testitem "count_by on an empty source" begin
28+
source = QueryOperators.query(Int[])
29+
30+
@test collect(QueryOperators.count_by(source, i -> i, :(i -> i))) == NamedTuple{(:key, :count),Tuple{Int,Int}}[]
31+
end
32+
33+
@testitem "count_by agrees with groupby plus summarize" begin
34+
using Statistics
35+
36+
data = [(k=1, v=10), (k=2, v=20), (k=1, v=30), (k=3, v=40), (k=1, v=50)]
37+
38+
by_count = collect(QueryOperators.count_by(QueryOperators.query(data), i -> i.k, :(i -> i.k)))
39+
40+
grouped = QueryOperators.@groupby_simple(QueryOperators.query(data), i -> i.k)
41+
by_summarize = collect(QueryOperators.summarize(
42+
grouped,
43+
g -> Base.merge(QueryOperators._key_namedtuple(QueryOperators.key(g)), (count = length(g),)),
44+
:(g -> (count = length(g),))))
45+
46+
@test by_count == by_summarize
47+
end
48+
49+
@testitem "aggregate_by" begin
50+
source = QueryOperators.query([(id="0", score=42), (id="1", score=5), (id="2", score=4), (id="1", score=10), (id="0", score=25)])
51+
52+
res = QueryOperators.aggregate_by(
53+
source,
54+
i -> i.id, :(i -> i.id),
55+
0,
56+
(total, cur) -> total + cur.score)
57+
58+
# The example from the .NET 9 release notes.
59+
@test collect(res) == [(key="0", value=67), (key="1", value=15), (key="2", value=4)]
60+
@test eltype(res) == NamedTuple{(:key, :value),Tuple{String,Int}}
61+
end
62+
63+
@testitem "aggregate_by with a NamedTuple key" begin
64+
source = QueryOperators.query([(a=1, b=1, v=2), (a=1, b=1, v=3), (a=2, b=1, v=5)])
65+
66+
res = QueryOperators.aggregate_by(
67+
source,
68+
i -> (a=i.a, b=i.b), :(i -> (a=i.a, b=i.b)),
69+
1,
70+
(acc, cur) -> acc * cur.v)
71+
72+
@test collect(res) == [(a=1, b=1, value=6), (a=2, b=1, value=5)]
73+
end
74+
75+
@testitem "aggregate_by on an empty source" begin
76+
source = QueryOperators.query(Int[])
77+
78+
res = QueryOperators.aggregate_by(source, i -> i, :(i -> i), 0, (acc, cur) -> acc + cur)
79+
80+
@test collect(res) == NamedTuple{(:key, :value),Tuple{Int,Int}}[]
81+
end
82+
83+
@testitem "aggregate_by never shares the seed between keys" begin
84+
source = QueryOperators.query([1, 1, 2, 2, 2])
85+
86+
res = QueryOperators.aggregate_by(source, i -> i, :(i -> i), Int[], (acc, cur) -> vcat(acc, cur))
87+
88+
@test collect(res) == [(key=1, value=[1, 1]), (key=2, value=[2, 2, 2])]
89+
end
90+
91+
@testitem "chunk" begin
92+
source = QueryOperators.query([1, 2, 3, 4, 5])
93+
94+
res = QueryOperators.chunk(source, 2)
95+
96+
# The final chunk is short when the source does not divide evenly.
97+
@test collect(res) == [[1, 2], [3, 4], [5]]
98+
@test eltype(res) == Vector{Int}
99+
@test Base.IteratorSize(typeof(res)) == Base.HasLength()
100+
@test length(res) == 3
101+
end
102+
103+
@testitem "chunk when the size divides evenly" begin
104+
source = QueryOperators.query([1, 2, 3, 4])
105+
106+
@test collect(QueryOperators.chunk(source, 2)) == [[1, 2], [3, 4]]
107+
@test length(QueryOperators.chunk(source, 2)) == 2
108+
end
109+
110+
@testitem "chunk with a size larger than the source" begin
111+
source = QueryOperators.query([1, 2])
112+
113+
@test collect(QueryOperators.chunk(source, 10)) == [[1, 2]]
114+
end
115+
116+
@testitem "chunk on an empty source" begin
117+
source = QueryOperators.query(Int[])
118+
119+
@test collect(QueryOperators.chunk(source, 3)) == Vector{Int}[]
120+
end
121+
122+
@testitem "chunk rejects a size below 1" begin
123+
source = QueryOperators.query([1, 2, 3])
124+
125+
@test_throws ErrorException QueryOperators.chunk(source, 0)
126+
@test_throws ErrorException QueryOperators.chunk(source, -1)
127+
end
128+
129+
@testitem "chunk only walks the source as far as the batches consumed" begin
130+
import IteratorInterfaceExtensions
131+
132+
mutable struct CountedChunkSource
133+
data::Vector{Int}
134+
pulled::Int
135+
end
136+
Base.eltype(::Type{CountedChunkSource}) = Int
137+
Base.IteratorSize(::Type{CountedChunkSource}) = Base.HasLength()
138+
Base.length(c::CountedChunkSource) = length(c.data)
139+
function Base.iterate(c::CountedChunkSource, i=1)
140+
i > length(c.data) && return nothing
141+
c.pulled += 1
142+
return c.data[i], i + 1
143+
end
144+
IteratorInterfaceExtensions.isiterable(::CountedChunkSource) = true
145+
IteratorInterfaceExtensions.getiterator(c::CountedChunkSource) = c
146+
147+
src = CountedChunkSource(collect(1:100), 0)
148+
res = QueryOperators.chunk(QueryOperators.query(src), 3)
149+
150+
it = iterate(res)
151+
152+
@test it[1] == [1, 2, 3]
153+
@test src.pulled == 3
154+
end
155+
156+
@testitem "chunk works downstream of groupby" begin
157+
source = QueryOperators.query([(k=1, v=1), (k=2, v=2), (k=3, v=3)])
158+
grouped = QueryOperators.@groupby_simple(source, i -> i.k)
159+
160+
chunks = collect(QueryOperators.chunk(grouped, 2))
161+
162+
@test length(chunks) == 2
163+
@test [QueryOperators.key(g) for g in chunks[1]] == [1, 2]
164+
@test [QueryOperators.key(g) for g in chunks[2]] == [3]
165+
end

0 commit comments

Comments
 (0)