Skip to content

Commit 2a6da18

Browse files
davidanthoffclaude
andcommitted
Add the order, reverse, shuffle and index operators
Ports Enumerable.Order and OrderDescending (.NET 7), Enumerable.Shuffle (.NET 10) and Enumerable.Index (.NET 9), plus Enumerable.Reverse. order and order_descending are expressed in terms of orderby with an identity key selector, so they produce an EnumerableOrderby and thenby can still follow them. index yields (index, item) NamedTuples with 1-based indices, matching Julia rather than .NET's 0-based Index(). It is lazy; reverse and shuffle have to collect their source first. shuffle takes an optional RNG, which makes it reproducible; without one it uses the default RNG and, as in .NET, is not cryptographically secure. This adds Random to the dependencies. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 841d962 commit 2a6da18

9 files changed

Lines changed: 277 additions & 0 deletions

Project.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8"
77
TableShowUtils = "5e66a065-1f0a-5976-b372-e0b8c017ca10"
88
DataValues = "e7dc6d0d-1eca-5fa6-8ad6-5aecde8b7ea5"
99
IteratorInterfaceExtensions = "82899510-4779-5014-852e-03e436cf321d"
10+
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
1011

1112
[extras]
1213
TestItemRunner = "f8b46487-2199-4994-9208-9a1283c18c0a"
@@ -18,6 +19,7 @@ DataStructures = "0.11, 0.12, 0.13, 0.14, 0.15, 0.16, 0.17, 0.18, 0.19"
1819
TableShowUtils = "0.1.1, 0.2, 0.3, 1"
1920
DataValues = "0.4.4, 0.5, 1"
2021
IteratorInterfaceExtensions = "0.1.1, 1"
22+
Random = "1"
2123

2224
[targets]
2325
test = ["Test", "TestItemRunner"]

src/QueryOperators.jl

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ using DataStructures
44
using IteratorInterfaceExtensions
55
using TableShowUtils
66
import DataValues
7+
import Random
78

89
export Grouping, key
910

@@ -30,6 +31,9 @@ include("enumerable/enumerable_concat.jl")
3031
include("enumerable/enumerable_union.jl")
3132
include("enumerable/enumerable_except.jl")
3233
include("enumerable/enumerable_intersect.jl")
34+
include("enumerable/enumerable_reverse.jl")
35+
include("enumerable/enumerable_shuffle.jl")
36+
include("enumerable/enumerable_index.jl")
3337
include("enumerable/enumerable_pivot.jl")
3438
include("enumerable/enumerable_summarize.jl")
3539
include("enumerable/show.jl")

src/enumerable/enumerable_index.jl

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
struct EnumerableIndex{T,TI,S} <: Enumerable
2+
source::S
3+
end
4+
5+
# Enumerable.Index (.NET 9) pairs each element with its position. .NET yields
6+
# (Index, Item) tuples; the Julia equivalent is an (index, item) NamedTuple.
7+
# Indices are 1-based, matching the rest of Julia rather than .NET.
8+
function index(source::Enumerable)
9+
TI = eltype(source)
10+
T = NamedTuple{(:index, :item),Tuple{Int,TI}}
11+
return EnumerableIndex{T,TI,typeof(source)}(source)
12+
end
13+
14+
Base.IteratorSize(::Type{EnumerableIndex{T,TI,S}}) where {T,TI,S} = haslength(S)
15+
16+
Base.eltype(::Type{EnumerableIndex{T,TI,S}}) where {T,TI,S} = T
17+
18+
Base.length(iter::EnumerableIndex) = length(iter.source)
19+
20+
function Base.iterate(iter::EnumerableIndex{T,TI,S}) where {T,TI,S}
21+
ret = iterate(iter.source)
22+
ret === nothing && return nothing
23+
24+
return T((1, ret[1])), (i=1, state=ret[2])
25+
end
26+
27+
function Base.iterate(iter::EnumerableIndex{T,TI,S}, state) where {T,TI,S}
28+
ret = iterate(iter.source, state.state)
29+
ret === nothing && return nothing
30+
31+
i = state.i + 1
32+
return T((i, ret[1])), (i=i, state=ret[2])
33+
end

src/enumerable/enumerable_orderby.jl

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ function orderby_descending(source::Enumerable, f::Function, f_expr::Expr)
2828
return EnumerableOrderby{T,typeof(source),KS,TKS}(source, f, true)
2929
end
3030

31+
# Enumerable.Order/OrderDescending (.NET 7): sort by the elements themselves.
32+
# Expressed in terms of orderby so that `thenby` can still follow them — it
33+
# walks back up the source chain looking for an EnumerableOrderby.
34+
order(source::Enumerable) = orderby(source, identity, :(i -> i))
35+
36+
order_descending(source::Enumerable) = orderby_descending(source, identity, :(i -> i))
37+
3138
function Base.iterate(iter::EnumerableOrderby{T,S,KS,TKS}) where {T,S,KS,TKS}
3239
rows = (Base.IteratorSize(typeof(iter)) isa Base.HasLength || Base.IteratorSize(typeof(iter)) isa Base.HasShape) ? length(iter) : 0
3340

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
struct EnumerableReverse{T,S} <: Enumerable
2+
source::S
3+
end
4+
5+
function reverse(source::Enumerable)
6+
T = eltype(source)
7+
return EnumerableReverse{T,typeof(source)}(source)
8+
end
9+
10+
Base.IteratorSize(::Type{EnumerableReverse{T,S}}) where {T,S} = haslength(S)
11+
12+
Base.eltype(::Type{EnumerableReverse{T,S}}) where {T,S} = T
13+
14+
Base.length(iter::EnumerableReverse) = length(iter.source)
15+
16+
# Reversing needs the whole source, so the elements are collected on the first
17+
# call and then handed out back to front.
18+
function Base.iterate(iter::EnumerableReverse{T,S}) where {T,S}
19+
elements = Base.collect(T, iter.source)
20+
21+
if length(elements)==0
22+
return nothing
23+
end
24+
25+
return elements[end], (elements, length(elements)-1)
26+
end
27+
28+
function Base.iterate(iter::EnumerableReverse{T,S}, state) where {T,S}
29+
if state[2]<1
30+
return nothing
31+
else
32+
return state[1][state[2]], (state[1], state[2]-1)
33+
end
34+
end
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
struct EnumerableShuffle{T,S,R} <: Enumerable
2+
source::S
3+
rng::R
4+
end
5+
6+
function shuffle(source::Enumerable)
7+
return shuffle(source, Random.default_rng())
8+
end
9+
10+
function shuffle(source::Enumerable, rng::Random.AbstractRNG)
11+
T = eltype(source)
12+
return EnumerableShuffle{T,typeof(source),typeof(rng)}(source, rng)
13+
end
14+
15+
Base.IteratorSize(::Type{EnumerableShuffle{T,S,R}}) where {T,S,R} = haslength(S)
16+
17+
Base.eltype(::Type{EnumerableShuffle{T,S,R}}) where {T,S,R} = T
18+
19+
Base.length(iter::EnumerableShuffle) = length(iter.source)
20+
21+
# Like Enumerable.Shuffle, the randomisation is not cryptographically secure.
22+
# Pass an explicit RNG to make a shuffle reproducible.
23+
function Base.iterate(iter::EnumerableShuffle{T,S,R}) where {T,S,R}
24+
elements = Base.collect(T, iter.source)
25+
26+
if length(elements)==0
27+
return nothing
28+
end
29+
30+
Random.shuffle!(iter.rng, elements)
31+
32+
return elements[1], (elements, 2)
33+
end
34+
35+
function Base.iterate(iter::EnumerableShuffle{T,S,R}, state) where {T,S,R}
36+
if state[2]>length(state[1])
37+
return nothing
38+
else
39+
return state[1][state[2]], (state[1], state[2]+1)
40+
end
41+
end

src/operators.jl

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,17 @@ function except_by end
156156
function intersect end
157157

158158
function intersect_by end
159+
160+
# Ordering and row position. `order`/`order_descending` are Enumerable.Order and
161+
# OrderDescending (.NET 7); `shuffle` is Enumerable.Shuffle (.NET 10); `index`
162+
# is Enumerable.Index (.NET 9).
163+
164+
function order end
165+
166+
function order_descending end
167+
168+
function reverse end
169+
170+
function shuffle end
171+
172+
function index end

test/runtests.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ using TestItemRunner
33
include("test_core.jl")
44
include("test_enumerable_outerjoins.jl")
55
include("test_enumerable_setops.jl")
6+
include("test_enumerable_ordering.jl")
67
include("test_enumerable_unique.jl")
78
include("test_enumerable_summarize.jl")
89
include("test_namedtupleutilities.jl")

test/test_enumerable_ordering.jl

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
@testitem "order" begin
2+
source = QueryOperators.query([3, 1, 2])
3+
4+
res = QueryOperators.order(source)
5+
6+
@test collect(res) == [1, 2, 3]
7+
@test eltype(res) == Int
8+
@test Base.IteratorSize(typeof(res)) == Base.HasLength()
9+
@test length(res) == 3
10+
end
11+
12+
@testitem "order_descending" begin
13+
source = QueryOperators.query([3, 1, 2])
14+
15+
@test collect(QueryOperators.order_descending(source)) == [3, 2, 1]
16+
end
17+
18+
@testitem "order on an empty source" begin
19+
source = QueryOperators.query(Int[])
20+
21+
@test collect(QueryOperators.order(source)) == Int[]
22+
@test collect(QueryOperators.order_descending(source)) == Int[]
23+
end
24+
25+
@testitem "thenby can follow order" begin
26+
source = QueryOperators.query([(a=1, b=2), (a=1, b=1), (a=0, b=9)])
27+
28+
# order sorts by the whole element; thenby then refines it, which only
29+
# works because order reuses EnumerableOrderby.
30+
res = QueryOperators.@thenby(QueryOperators.order(source), i -> i.b)
31+
32+
@test collect(res) == [(a=0, b=9), (a=1, b=1), (a=1, b=2)]
33+
end
34+
35+
@testitem "reverse" begin
36+
source = QueryOperators.query([1, 2, 3])
37+
38+
res = QueryOperators.reverse(source)
39+
40+
@test collect(res) == [3, 2, 1]
41+
@test eltype(res) == Int
42+
@test Base.IteratorSize(typeof(res)) == Base.HasLength()
43+
@test length(res) == 3
44+
end
45+
46+
@testitem "reverse on empty and single-element sources" begin
47+
@test collect(QueryOperators.reverse(QueryOperators.query(Int[]))) == Int[]
48+
@test collect(QueryOperators.reverse(QueryOperators.query([7]))) == [7]
49+
end
50+
51+
@testitem "reverse of reverse is the identity" begin
52+
source = QueryOperators.query([1, 2, 3, 4])
53+
54+
@test collect(QueryOperators.reverse(QueryOperators.reverse(source))) == [1, 2, 3, 4]
55+
end
56+
57+
@testitem "shuffle is a permutation of its source" begin
58+
using Random
59+
60+
source = QueryOperators.query(collect(1:50))
61+
62+
res = QueryOperators.shuffle(source)
63+
64+
@test sort(collect(res)) == collect(1:50)
65+
@test eltype(res) == Int
66+
@test length(res) == 50
67+
end
68+
69+
@testitem "shuffle with an explicit rng is reproducible" begin
70+
using Random
71+
72+
source = QueryOperators.query(collect(1:50))
73+
74+
a = collect(QueryOperators.shuffle(source, MersenneTwister(42)))
75+
b = collect(QueryOperators.shuffle(source, MersenneTwister(42)))
76+
77+
@test a == b
78+
@test sort(a) == collect(1:50)
79+
end
80+
81+
@testitem "shuffle on an empty source" begin
82+
@test collect(QueryOperators.shuffle(QueryOperators.query(Int[]))) == Int[]
83+
end
84+
85+
@testitem "index" begin
86+
source = QueryOperators.query(["a", "b", "c"])
87+
88+
res = QueryOperators.index(source)
89+
90+
# 1-based, matching Julia rather than .NET's 0-based Index().
91+
@test collect(res) == [(index=1, item="a"), (index=2, item="b"), (index=3, item="c")]
92+
@test eltype(res) == NamedTuple{(:index, :item),Tuple{Int,String}}
93+
@test Base.IteratorSize(typeof(res)) == Base.HasLength()
94+
@test length(res) == 3
95+
end
96+
97+
@testitem "index on an empty source" begin
98+
source = QueryOperators.query(String[])
99+
100+
@test collect(QueryOperators.index(source)) == NamedTuple{(:index, :item),Tuple{Int,String}}[]
101+
end
102+
103+
@testitem "index is lazy" begin
104+
import IteratorInterfaceExtensions
105+
106+
mutable struct CountedIndexSource
107+
data::Vector{Int}
108+
pulled::Int
109+
end
110+
Base.eltype(::Type{CountedIndexSource}) = Int
111+
Base.IteratorSize(::Type{CountedIndexSource}) = Base.HasLength()
112+
Base.length(c::CountedIndexSource) = length(c.data)
113+
function Base.iterate(c::CountedIndexSource, i=1)
114+
i > length(c.data) && return nothing
115+
c.pulled += 1
116+
return c.data[i], i + 1
117+
end
118+
IteratorInterfaceExtensions.isiterable(::CountedIndexSource) = true
119+
IteratorInterfaceExtensions.getiterator(c::CountedIndexSource) = c
120+
121+
src = CountedIndexSource([1, 2, 3, 4, 5], 0)
122+
res = QueryOperators.index(QueryOperators.query(src))
123+
124+
it = iterate(res)
125+
it = iterate(res, it[2])
126+
127+
@test it[1] == (index=2, item=2)
128+
@test src.pulled == 2
129+
end
130+
131+
@testitem "ordering operators work downstream of groupby" begin
132+
source = QueryOperators.query([(k=2, v=1), (k=1, v=2), (k=3, v=3)])
133+
grouped = QueryOperators.@groupby_simple(source, i -> i.k)
134+
135+
reversed = collect(QueryOperators.reverse(grouped))
136+
@test [QueryOperators.key(g) for g in reversed] == [3, 1, 2]
137+
138+
indexed = collect(QueryOperators.index(grouped))
139+
@test [i.index for i in indexed] == [1, 2, 3]
140+
@test [QueryOperators.key(i.item) for i in indexed] == [2, 1, 3]
141+
end

0 commit comments

Comments
 (0)