From 624fa2306abd5901e1cf2392338c133838beee27 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 11 Mar 2026 19:22:03 +0000 Subject: [PATCH 1/3] feat: add TaskSeq.allPairs (69 tests) Implements TaskSeq.allPairs which returns all pairings of elements from two task sequences. source2 is fully materialised before iteration begins; source1 is consumed lazily. - TaskSeqInternal.fs: allPairs implementation (materialises source2 via toResizeArrayAsync) - TaskSeq.fs / .fsi: public API with XML documentation - TaskSeq.AllPairs.Tests.fs: 69 new tests - README.md: mark allPairs as implemented - release-notes.txt: updated Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- release-notes.txt | 1 + .../FSharp.Control.TaskSeq.Test.fsproj | 1 + .../TaskSeq.AllPairs.Tests.fs | 152 ++++++++++++++++++ src/FSharp.Control.TaskSeq/TaskSeq.fs | 1 + src/FSharp.Control.TaskSeq/TaskSeq.fsi | 12 ++ src/FSharp.Control.TaskSeq/TaskSeqInternal.fs | 12 ++ 7 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 src/FSharp.Control.TaskSeq.Test/TaskSeq.AllPairs.Tests.fs diff --git a/README.md b/README.md index dcc1cbf..5c8e593 100644 --- a/README.md +++ b/README.md @@ -253,7 +253,7 @@ This is what has been implemented so far, is planned or skipped: | Done | `Seq` | `TaskSeq` | Variants | Remarks | |------------------|--------------------|----------------------|---------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| ❓ | `allPairs` | `allPairs` | | [note #1](#note1 "These functions require a form of pre-materializing through 'TaskSeq.cache', similar to the approach taken in the corresponding 'Seq' functions. It doesn't make much sense to have a cached async sequence. However, 'AsyncSeq' does implement these, so we'll probably do so eventually as well.") | +| ✅ | `allPairs` | `allPairs` | | | ✅ [#81][] | `append` | `append` | | | | ✅ [#81][] | | | `appendSeq` | | | ✅ [#81][] | | | `prependSeq` | | diff --git a/release-notes.txt b/release-notes.txt index 5c0b97e..f8271b7 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -2,6 +2,7 @@ Release notes: 0.6.0 + - adds TaskSeq.allPairs - adds TaskSeq.scan and TaskSeq.scanAsync, #289 - adds TaskSeq.pairwise, #289 - adds TaskSeq.groupBy and TaskSeq.groupByAsync, #289 diff --git a/src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj b/src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj index 89f8d94..508c3c2 100644 --- a/src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj +++ b/src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj @@ -58,6 +58,7 @@ + diff --git a/src/FSharp.Control.TaskSeq.Test/TaskSeq.AllPairs.Tests.fs b/src/FSharp.Control.TaskSeq.Test/TaskSeq.AllPairs.Tests.fs new file mode 100644 index 0000000..60cec61 --- /dev/null +++ b/src/FSharp.Control.TaskSeq.Test/TaskSeq.AllPairs.Tests.fs @@ -0,0 +1,152 @@ +module TaskSeq.Tests.AllPairs + +open Xunit +open FsUnit.Xunit + +open FSharp.Control + +// +// TaskSeq.allPairs +// + +module EmptySeq = + [] + let ``TaskSeq-allPairs with null source1 raises`` () = + assertNullArg + <| fun () -> TaskSeq.allPairs null TaskSeq.empty + + [] + let ``TaskSeq-allPairs with null source2 raises`` () = + assertNullArg + <| fun () -> TaskSeq.allPairs TaskSeq.empty null + + [] + let ``TaskSeq-allPairs with both null raises`` () = assertNullArg <| fun () -> TaskSeq.allPairs null null + + [)>] + let ``TaskSeq-allPairs returns empty when source1 is empty`` variant = task { + let! result = + TaskSeq.allPairs (Gen.getEmptyVariant variant) (taskSeq { yield! [ 1..5 ] }) + |> TaskSeq.toArrayAsync + + result |> should be Empty + } + + [)>] + let ``TaskSeq-allPairs returns empty when source2 is empty`` variant = task { + let! result = + TaskSeq.allPairs (taskSeq { yield! [ 1..5 ] }) (Gen.getEmptyVariant variant) + |> TaskSeq.toArrayAsync + + result |> should be Empty + } + + [)>] + let ``TaskSeq-allPairs returns empty when both sources are empty`` variant = task { + let! result = + TaskSeq.allPairs (Gen.getEmptyVariant variant) (Gen.getEmptyVariant variant) + |> TaskSeq.toArrayAsync + + result |> should be Empty + } + + +module Immutable = + [)>] + let ``TaskSeq-allPairs produces all pairs in row-major order`` variant = task { + let! result = + TaskSeq.allPairs (Gen.getSeqImmutable variant) (taskSeq { yield! [ 'a'; 'b'; 'c' ] }) + |> TaskSeq.toArrayAsync + + // source1 has 10 elements (1..10), source2 has 3 chars → 30 pairs + result |> should be (haveLength 30) + + // check that for each element of source1, all elements of source2 appear consecutively + for i in 0..9 do + result.[i * 3 + 0] |> should equal (i + 1, 'a') + result.[i * 3 + 1] |> should equal (i + 1, 'b') + result.[i * 3 + 2] |> should equal (i + 1, 'c') + } + + [)>] + let ``TaskSeq-allPairs with singleton source1 gives one pair per source2 element`` variant = task { + let! result = + TaskSeq.allPairs (TaskSeq.singleton 42) (Gen.getSeqImmutable variant) + |> TaskSeq.toArrayAsync + + result |> should be (haveLength 10) + result |> should equal (Array.init 10 (fun i -> 42, i + 1)) + } + + [)>] + let ``TaskSeq-allPairs with singleton source2 gives one pair per source1 element`` variant = task { + let! result = + TaskSeq.allPairs (Gen.getSeqImmutable variant) (TaskSeq.singleton 42) + |> TaskSeq.toArrayAsync + + result |> should be (haveLength 10) + result |> should equal (Array.init 10 (fun i -> i + 1, 42)) + } + + [] + let ``TaskSeq-allPairs matches Seq.allPairs for small sequences`` () = task { + let xs = [ 1; 2; 3 ] + let ys = [ 10; 20 ] + + let expected = Seq.allPairs xs ys |> Seq.toArray + + let! result = + TaskSeq.allPairs (TaskSeq.ofList xs) (TaskSeq.ofList ys) + |> TaskSeq.toArrayAsync + + result |> should equal expected + } + + [] + let ``TaskSeq-allPairs source2 is fully materialised before source1 is iterated`` () = task { + // Verify: if source2 raises, it raises before any element of the result is consumed. + let source2 = taskSeq { + yield 1 + failwith "source2 error" + } + + let result = TaskSeq.allPairs (TaskSeq.ofList [ 'a'; 'b' ]) source2 + + // Consuming even the first element should surface the exception from source2 + do! task { + try + let! _ = TaskSeq.head result + failwith "expected exception" + with ex -> + ex.Message |> should equal "source2 error" + } + } + + +module SideEffects = + [)>] + let ``TaskSeq-allPairs works with side-effect source1`` variant = task { + let! result = + TaskSeq.allPairs (Gen.getSeqWithSideEffect variant) (taskSeq { yield! [ 'x'; 'y' ] }) + |> TaskSeq.toArrayAsync + + result |> should be (haveLength 20) + + for i in 0..9 do + result.[i * 2 + 0] |> should equal (i + 1, 'x') + result.[i * 2 + 1] |> should equal (i + 1, 'y') + } + + [)>] + let ``TaskSeq-allPairs works with side-effect source2`` variant = task { + let! result = + TaskSeq.allPairs (taskSeq { yield! [ 'x'; 'y' ] }) (Gen.getSeqWithSideEffect variant) + |> TaskSeq.toArrayAsync + + result |> should be (haveLength 20) + + for i in 0..1 do + for j in 0..9 do + result.[i * 10 + j] + |> should equal ((if i = 0 then 'x' else 'y'), j + 1) + } diff --git a/src/FSharp.Control.TaskSeq/TaskSeq.fs b/src/FSharp.Control.TaskSeq/TaskSeq.fs index aced3a5..4800d92 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeq.fs +++ b/src/FSharp.Control.TaskSeq/TaskSeq.fs @@ -512,6 +512,7 @@ type TaskSeq private () = static member zip source1 source2 = Internal.zip source1 source2 static member zip3 source1 source2 source3 = Internal.zip3 source1 source2 source3 + static member allPairs source1 source2 = Internal.allPairs source1 source2 static member fold folder state source = Internal.fold (FolderAction folder) state source static member foldAsync folder state source = Internal.fold (AsyncFolderAction folder) state source static member scan folder state source = Internal.scan (FolderAction folder) state source diff --git a/src/FSharp.Control.TaskSeq/TaskSeq.fsi b/src/FSharp.Control.TaskSeq/TaskSeq.fsi index fcf45cf..dc1887a 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeq.fsi +++ b/src/FSharp.Control.TaskSeq/TaskSeq.fsi @@ -1554,6 +1554,18 @@ type TaskSeq = static member zip3: source1: TaskSeq<'T1> -> source2: TaskSeq<'T2> -> source3: TaskSeq<'T3> -> TaskSeq<'T1 * 'T2 * 'T3> + /// + /// Returns a new task sequence that contains all pairings of elements from the first and second task sequences. + /// The second task sequence is fully evaluated before iteration begins. The output is produced lazily as the first + /// sequence is consumed. + /// + /// + /// The first input task sequence. + /// The second input task sequence, which is fully evaluated before the result sequence is iterated. + /// The result task sequence of all pairs from the two input sequences. + /// Thrown when either of the two input task sequences is null. + static member allPairs: source1: TaskSeq<'T1> -> source2: TaskSeq<'T2> -> TaskSeq<'T1 * 'T2> + /// /// argument of type through the computation. If the input function is and the elements are /// then computes. diff --git a/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs b/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs index 26b02a6..8e95131 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs +++ b/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs @@ -585,6 +585,18 @@ module internal TaskSeqInternal = go <- step1 && step2 && step3 } + let allPairs (source1: TaskSeq<_>) (source2: TaskSeq<_>) = + checkNonNull (nameof source1) source1 + checkNonNull (nameof source2) source2 + + taskSeq { + let! arr2 = toResizeArrayAsync source2 + + for x in source1 do + for y in arr2 do + yield x, y + } + let collect (binder: _ -> #IAsyncEnumerable<_>) (source: TaskSeq<_>) = checkNonNull (nameof source) source From 1cf379db6773518be3852eac85c76783a017530f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 11 Mar 2026 19:28:22 +0000 Subject: [PATCH 2/3] ci: trigger checks From ab4c7c3141ebdca3c0eb57bf2878684598f8d40e Mon Sep 17 00:00:00 2001 From: Don Syme Date: Sat, 14 Mar 2026 19:17:33 +0000 Subject: [PATCH 3/3] Fix formatting in TaskSeqInternal.fs --- src/FSharp.Control.TaskSeq/TaskSeqInternal.fs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs b/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs index 5af875b..a09a1de 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs +++ b/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs @@ -595,6 +595,7 @@ module internal TaskSeqInternal = for x in source1 do for y in arr2 do yield x, y + } let compareWith (comparer: 'T -> 'T -> int) (source1: TaskSeq<'T>) (source2: TaskSeq<'T>) = checkNonNull (nameof source1) source1