Skip to content

Commit 329fd86

Browse files
authored
Merge pull request #307 from fsprojects/repo-assist/feat-groupby-countby-partition-2026-03-7606207c355db1fa
[Repo Assist] feat: add TaskSeq.groupBy, countBy, and partition (195 tests)
2 parents 78a1d50 + cef7a88 commit 329fd86

File tree

6 files changed

+583
-0
lines changed

6 files changed

+583
-0
lines changed

release-notes.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ Release notes:
44
0.6.0
55
- adds TaskSeq.scan and TaskSeq.scanAsync, #289
66
- adds TaskSeq.pairwise, #289
7+
- adds TaskSeq.groupBy and TaskSeq.groupByAsync, #289
8+
- adds TaskSeq.countBy and TaskSeq.countByAsync, #289
9+
- adds TaskSeq.partition and TaskSeq.partitionAsync, #289
710
- adds TaskSeq.reduce and TaskSeq.reduceAsync, #289
811
- adds TaskSeq.unfold and TaskSeq.unfoldAsync, #289
912
- adds TaskSeq.distinct, TaskSeq.distinctBy, TaskSeq.distinctByAsync

src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
<Compile Include="TaskSeq.Scan.Tests.fs" />
3131
<Compile Include="TaskSeq.MapFold.Tests.fs" />
3232
<Compile Include="TaskSeq.Reduce.Tests.fs" />
33+
<Compile Include="TaskSeq.GroupBy.Tests.fs" />
3334
<Compile Include="TaskSeq.Forall.Tests.fs" />
3435
<Compile Include="TaskSeq.Head.Tests.fs" />
3536
<Compile Include="TaskSeq.Indexed.Tests.fs" />
Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
module TaskSeq.Tests.GroupBy
2+
3+
open Xunit
4+
open FsUnit.Xunit
5+
6+
open FSharp.Control
7+
8+
//
9+
// TaskSeq.groupBy
10+
// TaskSeq.groupByAsync
11+
// TaskSeq.countBy
12+
// TaskSeq.countByAsync
13+
// TaskSeq.partition
14+
// TaskSeq.partitionAsync
15+
//
16+
17+
module EmptySeq =
18+
[<Fact>]
19+
let ``TaskSeq-groupBy with null source raises`` () =
20+
assertNullArg <| fun () -> TaskSeq.groupBy id null
21+
22+
assertNullArg
23+
<| fun () -> TaskSeq.groupByAsync (fun x -> Task.fromResult x) null
24+
25+
[<Fact>]
26+
let ``TaskSeq-countBy with null source raises`` () =
27+
assertNullArg <| fun () -> TaskSeq.countBy id null
28+
29+
assertNullArg
30+
<| fun () -> TaskSeq.countByAsync (fun x -> Task.fromResult x) null
31+
32+
[<Fact>]
33+
let ``TaskSeq-partition with null source raises`` () =
34+
assertNullArg
35+
<| fun () -> TaskSeq.partition (fun _ -> true) null
36+
37+
assertNullArg
38+
<| fun () -> TaskSeq.partitionAsync (fun _ -> Task.fromResult true) null
39+
40+
[<Theory; ClassData(typeof<TestEmptyVariants>)>]
41+
let ``TaskSeq-groupBy on empty sequence returns empty array`` variant = task {
42+
let! result =
43+
Gen.getEmptyVariant variant
44+
|> TaskSeq.groupBy (fun x -> x % 2)
45+
46+
result |> should be Empty
47+
}
48+
49+
[<Theory; ClassData(typeof<TestEmptyVariants>)>]
50+
let ``TaskSeq-groupByAsync on empty sequence returns empty array`` variant = task {
51+
let! result =
52+
Gen.getEmptyVariant variant
53+
|> TaskSeq.groupByAsync (fun x -> task { return x % 2 })
54+
55+
result |> should be Empty
56+
}
57+
58+
[<Theory; ClassData(typeof<TestEmptyVariants>)>]
59+
let ``TaskSeq-countBy on empty sequence returns empty array`` variant = task {
60+
let! result =
61+
Gen.getEmptyVariant variant
62+
|> TaskSeq.countBy (fun x -> x % 2)
63+
64+
result |> should be Empty
65+
}
66+
67+
[<Theory; ClassData(typeof<TestEmptyVariants>)>]
68+
let ``TaskSeq-countByAsync on empty sequence returns empty array`` variant = task {
69+
let! result =
70+
Gen.getEmptyVariant variant
71+
|> TaskSeq.countByAsync (fun x -> task { return x % 2 })
72+
73+
result |> should be Empty
74+
}
75+
76+
[<Theory; ClassData(typeof<TestEmptyVariants>)>]
77+
let ``TaskSeq-partition on empty sequence returns two empty arrays`` variant = task {
78+
let! trueItems, falseItems =
79+
Gen.getEmptyVariant variant
80+
|> TaskSeq.partition (fun _ -> true)
81+
82+
trueItems |> should be Empty
83+
falseItems |> should be Empty
84+
}
85+
86+
[<Theory; ClassData(typeof<TestEmptyVariants>)>]
87+
let ``TaskSeq-partitionAsync on empty sequence returns two empty arrays`` variant = task {
88+
let! trueItems, falseItems =
89+
Gen.getEmptyVariant variant
90+
|> TaskSeq.partitionAsync (fun _ -> Task.fromResult true)
91+
92+
trueItems |> should be Empty
93+
falseItems |> should be Empty
94+
}
95+
96+
97+
module Immutable =
98+
[<Theory; ClassData(typeof<TestImmTaskSeq>)>]
99+
let ``TaskSeq-groupBy groups by even/odd`` variant = task {
100+
let! result =
101+
Gen.getSeqImmutable variant
102+
|> TaskSeq.groupBy (fun x -> x % 2 = 0)
103+
104+
// should have exactly two groups
105+
result |> Array.length |> should equal 2
106+
107+
let falseKey, oddItems = result[0] // 1 is first, so 'false' (odd) comes first
108+
let trueKey, evenItems = result[1]
109+
falseKey |> should equal false
110+
trueKey |> should equal true
111+
oddItems |> should equal [| 1; 3; 5; 7; 9 |]
112+
evenItems |> should equal [| 2; 4; 6; 8; 10 |]
113+
}
114+
115+
[<Theory; ClassData(typeof<TestImmTaskSeq>)>]
116+
let ``TaskSeq-groupByAsync groups by even/odd`` variant = task {
117+
let! result =
118+
Gen.getSeqImmutable variant
119+
|> TaskSeq.groupByAsync (fun x -> task { return x % 2 = 0 })
120+
121+
result |> Array.length |> should equal 2
122+
123+
let falseKey, oddItems = result[0]
124+
let trueKey, evenItems = result[1]
125+
falseKey |> should equal false
126+
trueKey |> should equal true
127+
oddItems |> should equal [| 1; 3; 5; 7; 9 |]
128+
evenItems |> should equal [| 2; 4; 6; 8; 10 |]
129+
}
130+
131+
[<Theory; ClassData(typeof<TestImmTaskSeq>)>]
132+
let ``TaskSeq-groupBy with identity projection produces one group per element`` variant = task {
133+
let! result = Gen.getSeqImmutable variant |> TaskSeq.groupBy id
134+
135+
result |> Array.length |> should equal 10
136+
137+
for key, items in result do
138+
items |> should equal [| key |]
139+
}
140+
141+
[<Theory; ClassData(typeof<TestImmTaskSeq>)>]
142+
let ``TaskSeq-groupBy preserves first-occurrence key ordering`` variant = task {
143+
let! result =
144+
Gen.getSeqImmutable variant
145+
|> TaskSeq.groupBy (fun x -> x % 3)
146+
147+
// 1 % 3 = 1 → first key is 1
148+
// 2 % 3 = 2 → second key is 2
149+
// 3 % 3 = 0 → third key is 0
150+
let keys = result |> Array.map fst
151+
keys |> should equal [| 1; 2; 0 |]
152+
153+
let _, group1 = result[0] // remainder 1: 1, 4, 7, 10
154+
let _, group2 = result[1] // remainder 2: 2, 5, 8
155+
let _, group0 = result[2] // remainder 0: 3, 6, 9
156+
group1 |> should equal [| 1; 4; 7; 10 |]
157+
group2 |> should equal [| 2; 5; 8 |]
158+
group0 |> should equal [| 3; 6; 9 |]
159+
}
160+
161+
[<Theory; ClassData(typeof<TestImmTaskSeq>)>]
162+
let ``TaskSeq-groupBy with constant key produces single group`` variant = task {
163+
let! result =
164+
Gen.getSeqImmutable variant
165+
|> TaskSeq.groupBy (fun _ -> "same")
166+
167+
result |> Array.length |> should equal 1
168+
let key, items = result[0]
169+
key |> should equal "same"
170+
items |> should equal [| 1; 2; 3; 4; 5; 6; 7; 8; 9; 10 |]
171+
}
172+
173+
[<Theory; ClassData(typeof<TestImmTaskSeq>)>]
174+
let ``TaskSeq-countBy counts by even/odd`` variant = task {
175+
let! result =
176+
Gen.getSeqImmutable variant
177+
|> TaskSeq.countBy (fun x -> x % 2 = 0)
178+
179+
result |> Array.length |> should equal 2
180+
181+
let falseKey, oddCount = result[0]
182+
let trueKey, evenCount = result[1]
183+
falseKey |> should equal false
184+
trueKey |> should equal true
185+
oddCount |> should equal 5
186+
evenCount |> should equal 5
187+
}
188+
189+
[<Theory; ClassData(typeof<TestImmTaskSeq>)>]
190+
let ``TaskSeq-countByAsync counts by even/odd`` variant = task {
191+
let! result =
192+
Gen.getSeqImmutable variant
193+
|> TaskSeq.countByAsync (fun x -> task { return x % 2 = 0 })
194+
195+
result |> Array.length |> should equal 2
196+
197+
let falseKey, oddCount = result[0]
198+
let trueKey, evenCount = result[1]
199+
falseKey |> should equal false
200+
trueKey |> should equal true
201+
oddCount |> should equal 5
202+
evenCount |> should equal 5
203+
}
204+
205+
[<Theory; ClassData(typeof<TestImmTaskSeq>)>]
206+
let ``TaskSeq-countBy preserves first-occurrence key ordering`` variant = task {
207+
let! result =
208+
Gen.getSeqImmutable variant
209+
|> TaskSeq.countBy (fun x -> x % 3)
210+
211+
let keys = result |> Array.map fst
212+
keys |> should equal [| 1; 2; 0 |]
213+
214+
let _, count1 = result[0] // remainder 1: 1, 4, 7, 10 → 4 items
215+
let _, count2 = result[1] // remainder 2: 2, 5, 8 → 3 items
216+
let _, count0 = result[2] // remainder 0: 3, 6, 9 → 3 items
217+
count1 |> should equal 4
218+
count2 |> should equal 3
219+
count0 |> should equal 3
220+
}
221+
222+
[<Theory; ClassData(typeof<TestImmTaskSeq>)>]
223+
let ``TaskSeq-countBy with constant key counts all`` variant = task {
224+
let! result =
225+
Gen.getSeqImmutable variant
226+
|> TaskSeq.countBy (fun _ -> "same")
227+
228+
result |> should equal [| "same", 10 |]
229+
}
230+
231+
[<Theory; ClassData(typeof<TestImmTaskSeq>)>]
232+
let ``TaskSeq-partition splits by even`` variant = task {
233+
let! evens, odds =
234+
Gen.getSeqImmutable variant
235+
|> TaskSeq.partition (fun x -> x % 2 = 0)
236+
237+
evens |> should equal [| 2; 4; 6; 8; 10 |]
238+
odds |> should equal [| 1; 3; 5; 7; 9 |]
239+
}
240+
241+
[<Theory; ClassData(typeof<TestImmTaskSeq>)>]
242+
let ``TaskSeq-partitionAsync splits by even`` variant = task {
243+
let! evens, odds =
244+
Gen.getSeqImmutable variant
245+
|> TaskSeq.partitionAsync (fun x -> task { return x % 2 = 0 })
246+
247+
evens |> should equal [| 2; 4; 6; 8; 10 |]
248+
odds |> should equal [| 1; 3; 5; 7; 9 |]
249+
}
250+
251+
[<Theory; ClassData(typeof<TestImmTaskSeq>)>]
252+
let ``TaskSeq-partition with always-true predicate puts all in first array`` variant = task {
253+
let! trueItems, falseItems =
254+
Gen.getSeqImmutable variant
255+
|> TaskSeq.partition (fun _ -> true)
256+
257+
trueItems
258+
|> should equal [| 1; 2; 3; 4; 5; 6; 7; 8; 9; 10 |]
259+
260+
falseItems |> should be Empty
261+
}
262+
263+
[<Theory; ClassData(typeof<TestImmTaskSeq>)>]
264+
let ``TaskSeq-partition with always-false predicate puts all in second array`` variant = task {
265+
let! trueItems, falseItems =
266+
Gen.getSeqImmutable variant
267+
|> TaskSeq.partition (fun _ -> false)
268+
269+
trueItems |> should be Empty
270+
271+
falseItems
272+
|> should equal [| 1; 2; 3; 4; 5; 6; 7; 8; 9; 10 |]
273+
}
274+
275+
[<Theory; ClassData(typeof<TestImmTaskSeq>)>]
276+
let ``TaskSeq-partition preserves element order within each partition`` variant = task {
277+
let! trueItems, falseItems =
278+
Gen.getSeqImmutable variant
279+
|> TaskSeq.partition (fun x -> x <= 5)
280+
281+
trueItems |> should equal [| 1; 2; 3; 4; 5 |]
282+
falseItems |> should equal [| 6; 7; 8; 9; 10 |]
283+
}
284+
285+
[<Theory; ClassData(typeof<TestImmTaskSeq>)>]
286+
let ``TaskSeq-partitionAsync preserves element order within each partition`` variant = task {
287+
let! trueItems, falseItems =
288+
Gen.getSeqImmutable variant
289+
|> TaskSeq.partitionAsync (fun x -> task { return x <= 5 })
290+
291+
trueItems |> should equal [| 1; 2; 3; 4; 5 |]
292+
falseItems |> should equal [| 6; 7; 8; 9; 10 |]
293+
}
294+
295+
296+
module SideEffects =
297+
[<Theory; ClassData(typeof<TestSideEffectTaskSeq>)>]
298+
let ``TaskSeq-groupBy groups side-effecting sequence`` variant = task {
299+
let ts = Gen.getSeqWithSideEffect variant
300+
301+
let! result = ts |> TaskSeq.groupBy (fun x -> x % 2 = 0)
302+
303+
result |> Array.length |> should equal 2
304+
// re-evaluating yields new side-effects (next 10 items: 11..20)
305+
let! result2 = ts |> TaskSeq.groupBy (fun x -> x % 2 = 0)
306+
result2 |> Array.length |> should equal 2
307+
let _, group2 = result2[0]
308+
group2 |> Array.sum |> should equal (11 + 13 + 15 + 17 + 19) // odd items from 11–20
309+
}
310+
311+
[<Theory; ClassData(typeof<TestSideEffectTaskSeq>)>]
312+
let ``TaskSeq-countBy counts side-effecting sequence`` variant = task {
313+
let ts = Gen.getSeqWithSideEffect variant
314+
315+
let! result = ts |> TaskSeq.countBy (fun x -> x % 2 = 0)
316+
317+
// 5 odd, 5 even from 1..10
318+
let falseKey, oddCount = result[0]
319+
let trueKey, evenCount = result[1]
320+
falseKey |> should equal false
321+
trueKey |> should equal true
322+
oddCount |> should equal 5
323+
evenCount |> should equal 5
324+
}
325+
326+
[<Theory; ClassData(typeof<TestSideEffectTaskSeq>)>]
327+
let ``TaskSeq-partition splits side-effecting sequence`` variant = task {
328+
let ts = Gen.getSeqWithSideEffect variant
329+
330+
let! evens, odds = ts |> TaskSeq.partition (fun x -> x % 2 = 0)
331+
332+
evens |> should equal [| 2; 4; 6; 8; 10 |]
333+
odds |> should equal [| 1; 3; 5; 7; 9 |]
334+
335+
// second call picks up side effects
336+
let! evens2, odds2 = ts |> TaskSeq.partition (fun x -> x % 2 = 0)
337+
evens2 |> should equal [| 12; 14; 16; 18; 20 |]
338+
odds2 |> should equal [| 11; 13; 15; 17; 19 |]
339+
}

src/FSharp.Control.TaskSeq/TaskSeq.fs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,5 +516,16 @@ type TaskSeq private () =
516516
static member scanAsync folder state source = Internal.scan (AsyncFolderAction folder) state source
517517
static member reduce folder source = Internal.reduce (FolderAction folder) source
518518
static member reduceAsync folder source = Internal.reduce (AsyncFolderAction folder) source
519+
520+
//
521+
// groupBy/countBy/partition
522+
//
523+
524+
static member groupBy projection source = Internal.groupBy (ProjectorAction projection) source
525+
static member groupByAsync projection source = Internal.groupBy (AsyncProjectorAction projection) source
526+
static member countBy projection source = Internal.countBy (ProjectorAction projection) source
527+
static member countByAsync projection source = Internal.countBy (AsyncProjectorAction projection) source
528+
static member partition predicate source = Internal.partition (Predicate predicate) source
529+
static member partitionAsync predicate source = Internal.partition (PredicateAsync predicate) source
519530
static member mapFold mapping state source = Internal.mapFold (MapFolderAction mapping) state source
520531
static member mapFoldAsync mapping state source = Internal.mapFold (AsyncMapFolderAction mapping) state source

0 commit comments

Comments
 (0)