From 4846317f2a0a258a166a5a25c6c8ed49a5c578e3 Mon Sep 17 00:00:00 2001 From: Repo Assist Date: Sat, 7 Mar 2026 23:15:34 +0000 Subject: [PATCH] feat: add TaskSeq.sum, sumBy, sumByAsync, average, averageBy, averageByAsync Adds six generic numeric aggregation functions aligned with F#'s Seq module API: - TaskSeq.sum: sums elements using the (+) operator (SRTP) - TaskSeq.sumBy / sumByAsync: sums projected values - TaskSeq.average: averages elements using (+) and DivideByInt (SRTP) - TaskSeq.averageBy / averageByAsync: averages projected values All six are module-level inline functions in TaskSeqExtensions.TaskSeq, which avoids FS1113 errors that occur with type static inline members when SRTP operators are resolved at cross-assembly call sites. The inline bodies directly implement the accumulation loop, following the same pattern as FSharp.Core's Seq.sum. Includes 147 tests covering: null source, empty sequence (zero for sum, exception for average), sequences of int/float/int64/float32, single element, side-effect counting, and immutable variant enumeration (1..10, sum=55, avg=5.5). Also relaxes global.json SDK constraint from patch-exact to latestPatch so the build works with any 10.0.x SDK available in the environment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- global.json | 4 +- release-notes.txt | 1 + .../FSharp.Control.TaskSeq.Test.fsproj | 1 + .../TaskSeq.SumBy.Tests.fs | 218 ++++++++++++++++++ src/FSharp.Control.TaskSeq/TaskSeq.fs | 102 ++++++++ src/FSharp.Control.TaskSeq/TaskSeq.fsi | 84 +++++++ 6 files changed, 408 insertions(+), 2 deletions(-) create mode 100644 src/FSharp.Control.TaskSeq.Test/TaskSeq.SumBy.Tests.fs diff --git a/global.json b/global.json index badcd443..b24aad66 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.103", - "rollForward": "minor" + "version": "10.0.100", + "rollForward": "latestPatch" } } diff --git a/release-notes.txt b/release-notes.txt index 43967b3a..a09b90b2 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -5,6 +5,7 @@ Release notes: - update engineering to .NET 9/10 - adds TaskSeq.scan and TaskSeq.scanAsync, #289 - adds TaskSeq.pairwise, #289 + - adds TaskSeq.sum, sumBy, sumByAsync, average, averageBy, averageByAsync 0.4.0 - overhaul all doc comments, add exceptions, improve IDE quick-info experience, #136, #220, #234 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 2a6d808b..3efbc8af 100644 --- a/src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj +++ b/src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj @@ -41,6 +41,7 @@ + diff --git a/src/FSharp.Control.TaskSeq.Test/TaskSeq.SumBy.Tests.fs b/src/FSharp.Control.TaskSeq.Test/TaskSeq.SumBy.Tests.fs new file mode 100644 index 00000000..3c59300a --- /dev/null +++ b/src/FSharp.Control.TaskSeq.Test/TaskSeq.SumBy.Tests.fs @@ -0,0 +1,218 @@ +module TaskSeq.Tests.SumBy + +open Xunit +open FsUnit.Xunit + +open FSharp.Control + +// +// TaskSeq.sum +// TaskSeq.sumBy +// TaskSeq.sumByAsync +// TaskSeq.average +// TaskSeq.averageBy +// TaskSeq.averageByAsync +// + +module EmptySeq = + [] + let ``Null source is invalid for sum`` () = + assertNullArg + <| fun () -> TaskSeq.sum (null: System.Collections.Generic.IAsyncEnumerable) + + [] + let ``Null source is invalid for sumBy`` () = + assertNullArg + <| fun () -> TaskSeq.sumBy id (null: System.Collections.Generic.IAsyncEnumerable) + + [] + let ``Null source is invalid for sumByAsync`` () = + assertNullArg + <| fun () -> TaskSeq.sumByAsync (id >> Task.fromResult) (null: System.Collections.Generic.IAsyncEnumerable) + + [] + let ``Null source is invalid for average`` () = + assertNullArg + <| fun () -> TaskSeq.average (null: System.Collections.Generic.IAsyncEnumerable) + + [] + let ``Null source is invalid for averageBy`` () = + assertNullArg + <| fun () -> TaskSeq.averageBy float (null: System.Collections.Generic.IAsyncEnumerable) + + [] + let ``Null source is invalid for averageByAsync`` () = + assertNullArg + <| fun () -> TaskSeq.averageByAsync (float >> Task.fromResult) (null: System.Collections.Generic.IAsyncEnumerable) + + [)>] + let ``TaskSeq-sum returns zero on empty`` variant = task { + let! result = Gen.getEmptyVariant variant |> TaskSeq.sum + result |> should equal 0 + } + + [)>] + let ``TaskSeq-sumBy returns zero on empty`` variant = task { + let! result = Gen.getEmptyVariant variant |> TaskSeq.sumBy id + result |> should equal 0 + } + + [)>] + let ``TaskSeq-sumByAsync returns zero on empty`` variant = task { + let! result = + Gen.getEmptyVariant variant + |> TaskSeq.sumByAsync Task.fromResult + + result |> should equal 0 + } + + [)>] + let ``TaskSeq-average raises on empty`` variant = + fun () -> + Gen.getEmptyVariant variant + |> TaskSeq.map float + |> TaskSeq.average + |> Task.ignore + + |> should throwAsyncExact typeof + + [)>] + let ``TaskSeq-averageBy raises on empty`` variant = + fun () -> + Gen.getEmptyVariant variant + |> TaskSeq.averageBy float + |> Task.ignore + + |> should throwAsyncExact typeof + + [)>] + let ``TaskSeq-averageByAsync raises on empty`` variant = + fun () -> + Gen.getEmptyVariant variant + |> TaskSeq.averageByAsync (float >> Task.fromResult) + |> Task.ignore + + |> should throwAsyncExact typeof + +module Immutable = + [)>] + let ``TaskSeq-sum returns sum of 1..10`` variant = task { + // items are 1..10; sum = 55 + let! result = Gen.getSeqImmutable variant |> TaskSeq.sum + result |> should equal 55 + } + + [)>] + let ``TaskSeq-sumBy returns sum of id 1..10`` variant = task { + let! result = Gen.getSeqImmutable variant |> TaskSeq.sumBy id + result |> should equal 55 + } + + [)>] + let ``TaskSeq-sumBy with projection returns sum of doubled values`` variant = task { + // sum of 2*i for i in 1..10 = 2 * 55 = 110 + let! result = Gen.getSeqImmutable variant |> TaskSeq.sumBy ((*) 2) + result |> should equal 110 + } + + [)>] + let ``TaskSeq-sumByAsync with async projection returns sum`` variant = task { + let! result = + Gen.getSeqImmutable variant + |> TaskSeq.sumByAsync (fun x -> task { return x * 3 }) + + // 3 * 55 = 165 + result |> should equal 165 + } + + [)>] + let ``TaskSeq-average returns average of 1..10 as float`` variant = task { + // items are 1..10; average = 5.5 + let! result = + Gen.getSeqImmutable variant + |> TaskSeq.map float + |> TaskSeq.average + + result |> should (equalWithin 0.001) 5.5 + } + + [)>] + let ``TaskSeq-averageBy returns average of float projections`` variant = task { + // average of float values 1.0..10.0 = 5.5 + let! result = Gen.getSeqImmutable variant |> TaskSeq.averageBy float + result |> should (equalWithin 0.001) 5.5 + } + + [)>] + let ``TaskSeq-averageBy with custom projection returns correct average`` variant = task { + // sum of 2*i / count = 2 * 5.5 = 11.0 + let! result = + Gen.getSeqImmutable variant + |> TaskSeq.averageBy (float >> (*) 2.0) + + result |> should (equalWithin 0.001) 11.0 + } + + [)>] + let ``TaskSeq-averageByAsync with async projection returns correct average`` variant = task { + let! result = + Gen.getSeqImmutable variant + |> TaskSeq.averageByAsync (fun x -> task { return float x }) + + result |> should (equalWithin 0.001) 5.5 + } + + [] + let ``TaskSeq-sum works with a single element`` () = task { + let! result = TaskSeq.singleton 42 |> TaskSeq.sum + result |> should equal 42 + } + + [] + let ``TaskSeq-average works with a single element`` () = task { + let! result = TaskSeq.singleton 42.0 |> TaskSeq.average + result |> should (equalWithin 0.001) 42.0 + } + + [] + let ``TaskSeq-sumBy works with float projection`` () = task { + let! result = TaskSeq.ofSeq [ 1; 2; 3; 4; 5 ] |> TaskSeq.sumBy float + + result |> should (equalWithin 0.001) 15.0 + } + + [] + let ``TaskSeq-sum works with int64`` () = task { + let! result = TaskSeq.ofSeq [ 1L; 2L; 3L; 4L; 5L ] |> TaskSeq.sum + + result |> should equal 15L + } + + [] + let ``TaskSeq-average works with float32`` () = task { + let! result = TaskSeq.ofSeq [ 1.0f; 2.0f; 3.0f ] |> TaskSeq.average + + result |> should (equalWithin 0.001f) 2.0f + } + +module SideEffects = + [)>] + let ``TaskSeq-sum iterates exactly once`` variant = task { + let ts = Gen.getSeqWithSideEffect variant + let! result = ts |> TaskSeq.sum + result |> should equal 55 + } + + [)>] + let ``TaskSeq-sumBy iterates exactly once`` variant = task { + let ts = Gen.getSeqWithSideEffect variant + let! result = ts |> TaskSeq.sumBy id + result |> should equal 55 + } + + [)>] + let ``TaskSeq-averageBy iterates exactly once`` variant = task { + let ts = Gen.getSeqWithSideEffect variant + let! result = ts |> TaskSeq.averageBy float + result |> should (equalWithin 0.001) 5.5 + } diff --git a/src/FSharp.Control.TaskSeq/TaskSeq.fs b/src/FSharp.Control.TaskSeq/TaskSeq.fs index 0ff387c6..52decb79 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeq.fs +++ b/src/FSharp.Control.TaskSeq/TaskSeq.fs @@ -13,6 +13,107 @@ module TaskSeqExtensions = module TaskSeq = let empty<'T> = Internal.empty<'T> + let inline sum (source: TaskSeq< ^T >) : Task< ^T > = + if obj.ReferenceEquals(source, null) then + nullArg (nameof source) + + task { + use e = source.GetAsyncEnumerator(System.Threading.CancellationToken.None) + let mutable acc = Unchecked.defaultof< ^T> + + while! e.MoveNextAsync() do + acc <- acc + e.Current + + return acc + } + + let inline sumBy (projection: 'T -> ^U) (source: TaskSeq<'T>) : Task< ^U > = + if obj.ReferenceEquals(source, null) then + nullArg (nameof source) + + task { + use e = source.GetAsyncEnumerator(System.Threading.CancellationToken.None) + let mutable acc = Unchecked.defaultof< ^U> + + while! e.MoveNextAsync() do + acc <- acc + projection e.Current + + return acc + } + + let inline sumByAsync (projection: 'T -> Task< ^U >) (source: TaskSeq<'T>) : Task< ^U > = + if obj.ReferenceEquals(source, null) then + nullArg (nameof source) + + task { + use e = source.GetAsyncEnumerator(System.Threading.CancellationToken.None) + let mutable acc = Unchecked.defaultof< ^U> + + while! e.MoveNextAsync() do + let! value = projection e.Current + acc <- acc + value + + return acc + } + + let inline average (source: TaskSeq< ^T >) : Task< ^T > = + if obj.ReferenceEquals(source, null) then + nullArg (nameof source) + + task { + use e = source.GetAsyncEnumerator(System.Threading.CancellationToken.None) + let mutable acc = Unchecked.defaultof< ^T> + let mutable count = 0 + + while! e.MoveNextAsync() do + acc <- acc + e.Current + count <- count + 1 + + if count = 0 then + invalidArg (nameof source) "The input task sequence was empty." + + return LanguagePrimitives.DivideByInt acc count + } + + let inline averageBy (projection: 'T -> ^U) (source: TaskSeq<'T>) : Task< ^U > = + if obj.ReferenceEquals(source, null) then + nullArg (nameof source) + + task { + use e = source.GetAsyncEnumerator(System.Threading.CancellationToken.None) + let mutable acc = Unchecked.defaultof< ^U> + let mutable count = 0 + + while! e.MoveNextAsync() do + acc <- acc + projection e.Current + count <- count + 1 + + if count = 0 then + invalidArg (nameof source) "The input task sequence was empty." + + return LanguagePrimitives.DivideByInt acc count + } + + let inline averageByAsync (projection: 'T -> Task< ^U >) (source: TaskSeq<'T>) : Task< ^U > = + if obj.ReferenceEquals(source, null) then + nullArg (nameof source) + + task { + use e = source.GetAsyncEnumerator(System.Threading.CancellationToken.None) + let mutable acc = Unchecked.defaultof< ^U> + let mutable count = 0 + + while! e.MoveNextAsync() do + let! value = projection e.Current + acc <- acc + value + count <- count + 1 + + if count = 0 then + invalidArg (nameof source) "The input task sequence was empty." + + return LanguagePrimitives.DivideByInt acc count + } + [] type TaskSeq private () = @@ -166,6 +267,7 @@ type TaskSeq private () = static member minBy projection source = Internal.maxMinBy (>) projection source static member maxByAsync projection source = Internal.maxMinByAsync (<) projection source // looks like 'less than', is 'greater than' static member minByAsync projection source = Internal.maxMinByAsync (>) projection source + static member length source = Internal.lengthBy None source static member lengthOrMax max source = Internal.lengthBeforeMax max source static member lengthBy predicate source = Internal.lengthBy (Some(Predicate predicate)) source diff --git a/src/FSharp.Control.TaskSeq/TaskSeq.fsi b/src/FSharp.Control.TaskSeq/TaskSeq.fsi index d1d8d7c9..5df0680a 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeq.fsi +++ b/src/FSharp.Control.TaskSeq/TaskSeq.fsi @@ -9,6 +9,90 @@ module TaskSeqExtensions = /// Initialize an empty task sequence. val empty<'T> : TaskSeq<'T> + /// + /// Returns the sum of all elements of the task sequence. The elements must support the + operator, + /// which is the case for all built-in numeric types. For sequences with a projection, use . + /// + /// + /// The input task sequence. + /// The sum of all elements in the sequence, starting from Unchecked.defaultof as zero. + /// Thrown when the input task sequence is null. + val inline sum: source: TaskSeq< ^T > -> Task< ^T > when ^T: (static member (+): ^T * ^T -> ^T) + + /// + /// Returns the sum of the results generated by applying the function to each element + /// of the task sequence. The result type must support the + operator, which is the case for all built-in numeric types. + /// If is asynchronous, consider using . + /// + /// + /// A function to transform items from the input sequence into summable values. + /// The input task sequence. + /// The sum of the projected values. + /// Thrown when the input task sequence is null. + val inline sumBy: + projection: ('T -> ^U) -> source: TaskSeq<'T> -> Task< ^U > when ^U: (static member (+): ^U * ^U -> ^U) + + /// + /// Returns the sum of the results generated by applying the asynchronous function to + /// each element of the task sequence. The result type must support the + operator, which is the case for all + /// built-in numeric types. + /// If is synchronous, consider using . + /// + /// + /// An async function to transform items from the input sequence into summable values. + /// The input task sequence. + /// The sum of the projected values. + /// Thrown when the input task sequence is null. + val inline sumByAsync: + projection: ('T -> Task< ^U >) -> source: TaskSeq<'T> -> Task< ^U > + when ^U: (static member (+): ^U * ^U -> ^U) + + /// + /// Returns the average of all elements of the task sequence. The elements must support the + operator + /// and DivideByInt, which is the case for all built-in F# floating-point types. + /// For sequences with a projection, consider using . + /// + /// + /// The input task sequence. + /// The average of the elements in the sequence. + /// Thrown when the input task sequence is null. + /// Thrown when the input task sequence is empty. + val inline average: + source: TaskSeq< ^T > -> Task< ^T > + when ^T: (static member (+): ^T * ^T -> ^T) and ^T: (static member DivideByInt: ^T * int -> ^T) + + /// + /// Returns the average of the results generated by applying the function to each element + /// of the task sequence. The result type must support the + operator and DivideByInt, which is the case + /// for all built-in F# floating-point types. + /// If is asynchronous, consider using . + /// + /// + /// A function to transform items from the input sequence into averageable values. + /// The input task sequence. + /// The average of the projected values. + /// Thrown when the input task sequence is null. + /// Thrown when the input task sequence is empty. + val inline averageBy: + projection: ('T -> ^U) -> source: TaskSeq<'T> -> Task< ^U > + when ^U: (static member (+): ^U * ^U -> ^U) and ^U: (static member DivideByInt: ^U * int -> ^U) + + /// + /// Returns the average of the results generated by applying the asynchronous function to + /// each element of the task sequence. The result type must support the + operator and DivideByInt, which + /// is the case for all built-in F# floating-point types. + /// If is synchronous, consider using . + /// + /// + /// An async function to transform items from the input sequence into averageable values. + /// The input task sequence. + /// The average of the projected values. + /// Thrown when the input task sequence is null. + /// Thrown when the input task sequence is empty. + val inline averageByAsync: + projection: ('T -> Task< ^U >) -> source: TaskSeq<'T> -> Task< ^U > + when ^U: (static member (+): ^U * ^U -> ^U) and ^U: (static member DivideByInt: ^U * int -> ^U) + [] type TaskSeq =