From 4ad1d87db2418354abd30e4cbe4a24ab5adbfb9f Mon Sep 17 00:00:00 2001 From: Samuel Wanjohi Date: Wed, 25 Jun 2025 07:51:24 +0300 Subject: [PATCH 1/7] trying optimize TruncatedCollectionOfT and add support for IAsyncEnumerable --- .../Microsoft.AspNetCore.OData.csproj | 3 +- .../Query/Container/TruncatedCollectionOfT.cs | 295 +++++++++++++----- 2 files changed, 211 insertions(+), 87 deletions(-) diff --git a/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.csproj b/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.csproj index 507f2d72..7b06a384 100644 --- a/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.csproj +++ b/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.csproj @@ -1,4 +1,4 @@ - + net8.0 @@ -35,6 +35,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedCollectionOfT.cs b/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedCollectionOfT.cs index fc62d83f..ba39a7dc 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedCollectionOfT.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedCollectionOfT.cs @@ -6,8 +6,11 @@ //------------------------------------------------------------------------------ using System; +using System.Collections; using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Threading.Tasks; namespace Microsoft.AspNetCore.OData.Query.Container; @@ -15,161 +18,281 @@ namespace Microsoft.AspNetCore.OData.Query.Container; /// Represents a class that truncates a collection to a given page size. /// /// The collection element type. -public class TruncatedCollection : List, ITruncatedCollection, IEnumerable, ICountOptionCollection +public class TruncatedCollection : IReadOnlyList, ITruncatedCollection, ICountOptionCollection, IAsyncEnumerable { - // The default capacity of the list. - // https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Collections/Generic/List.cs#L23 - private const int DefaultCapacity = 4; private const int MinPageSize = 1; + private const int DefaultCapacity = 4; - private bool _isTruncated; - private int _pageSize; - private long? _totalCount; + private readonly List _items; + private readonly IAsyncEnumerable _asyncSource; + private readonly bool _isTruncated; /// - /// Initializes a new instance of the class. + /// Private constructor used by static Create methods and public constructors. /// + /// The list of items in the collection. + /// The maximum number of items per page. + /// The total number of items in the source collection, if known. + /// Indicates whether the collection is truncated. + private TruncatedCollection(List items, int pageSize, long? totalCount, bool isTruncated) + { + _items = items; + _isTruncated = isTruncated; + PageSize = pageSize; + TotalCount = totalCount; + } + + /// + /// Private constructor used by static Create methods and public constructors. + /// + /// The asynchronous source of items (pageSize + 1) in the collection. + /// The maximum number of items per page. + /// The total number of items in the source collection, if known. + /// Indicates whether the collection is truncated. + private TruncatedCollection(IAsyncEnumerable asyncSource, int pageSize, long? totalCount, bool isTruncated) + { + _asyncSource = asyncSource; + _isTruncated = isTruncated; + PageSize = pageSize; + TotalCount = totalCount; + } + + #region Constructors for Backward Compatibility + + /// + /// Initializes a new instance of the class. /// The collection to be truncated. /// The page size. public TruncatedCollection(IEnumerable source, int pageSize) - : base(checked(pageSize + 1)) - { - var items = source.Take(Capacity); - AddRange(items); - Initialize(pageSize); - } + : this(CreateInternal(source, pageSize, totalCount: null)) { } /// /// Initializes a new instance of the class. /// /// The queryable collection to be truncated. /// The page size. - // NOTE: The queryable version calls Queryable.Take which actually gets translated to the backend query where as - // the enumerable version just enumerates and is inefficient. - public TruncatedCollection(IQueryable source, int pageSize) : this(source, pageSize, false) - { - } + /// The total count. + public TruncatedCollection(IEnumerable source, int pageSize, long? totalCount) + : this(CreateInternal(source, pageSize, totalCount)) { } /// /// Initializes a new instance of the class. /// /// The queryable collection to be truncated. /// The page size. - /// Flag indicating whether constants should be parameterized - // NOTE: The queryable version calls Queryable.Take which actually gets translated to the backend query where as - // the enumerable version just enumerates and is inefficient. - public TruncatedCollection(IQueryable source, int pageSize, bool parameterize) - : base(checked(pageSize + 1)) - { - var items = Take(source, pageSize, parameterize); - AddRange(items); - Initialize(pageSize); - } + public TruncatedCollection(IQueryable source, int pageSize) + : this(CreateInternal(source, pageSize, false)) { } /// /// Initializes a new instance of the class. /// /// The queryable collection to be truncated. /// The page size. - /// The total count. - public TruncatedCollection(IEnumerable source, int pageSize, long? totalCount) - : base(pageSize > 0 - ? checked(pageSize + 1) - : (totalCount > 0 ? (totalCount < int.MaxValue ? (int)totalCount : int.MaxValue) : DefaultCapacity)) + /// Flag indicating whether constants should be parameterized + public TruncatedCollection(IQueryable source, int pageSize, bool parameterize) + : this(CreateInternal(source, pageSize, parameterize)) { } + + /// + /// Wrapper used internally by the backward-compatible constructors. + /// + /// An instance of . + private TruncatedCollection(TruncatedCollection other) + : this(other._items, other.PageSize, other.TotalCount, other._isTruncated) { - if (pageSize > 0) - { - AddRange(source.Take(Capacity)); - } - else - { - AddRange(source); - } + } - if (pageSize > 0) - { - Initialize(pageSize); - } + #endregion + + #region Static Create Methods - _totalCount = totalCount; + /// + /// Create a truncated collection from an . + /// + /// The collection to be truncated. + /// The page size. + /// The total count. Default is null. + /// An instance of the + public static TruncatedCollection Create(IEnumerable source, int pageSize, long? totalCount = null) + { + return CreateInternal(source, pageSize, totalCount); } /// - /// Initializes a new instance of the class. + /// Create a truncated collection from an . /// - /// The queryable collection to be truncated. + /// The collection to be truncated. /// The page size. - /// The total count. - // NOTE: The queryable version calls Queryable.Take which actually gets translated to the backend query where as - // the enumerable version just enumerates and is inefficient. + /// The total count. Default is null. + /// An instance of the [Obsolete("should not be used, will be marked internal in the next major version")] - public TruncatedCollection(IQueryable source, int pageSize, long? totalCount) : this(source, pageSize, - totalCount, false) + public static TruncatedCollection Create(IQueryable source, int pageSize, long? totalCount = null) { + return CreateInternal(source, pageSize, false, totalCount); } /// - /// Initializes a new instance of the class. + /// Create a truncated collection from an . /// - /// The queryable collection to be truncated. + /// The collection to be truncated. /// The page size. - /// The total count. /// Flag indicating whether constants should be parameterized - // NOTE: The queryable version calls Queryable.Take which actually gets translated to the backend query where as - // the enumerable version just enumerates and is inefficient. + /// An instance of the + public static TruncatedCollection Create(IQueryable source, int pageSize, bool parameterize) + { + return CreateInternal(source, pageSize, parameterize); + } + + /// + /// Create a truncated collection from an . + /// + /// The collection to be truncated. + /// The page size. + /// The total count. Default is null. + /// Flag indicating whether constants should be parameterized + /// An instance of the [Obsolete("should not be used, will be marked internal in the next major version")] - public TruncatedCollection(IQueryable source, int pageSize, long? totalCount, bool parameterize) - : base(pageSize > 0 ? Take(source, pageSize, parameterize) : source) + public static TruncatedCollection Create(IQueryable source, int pageSize, long? totalCount, bool parameterize) { - if (pageSize > 0) - { - Initialize(pageSize); - } + return CreateInternal(source, pageSize, parameterize, totalCount); + } - _totalCount = totalCount; + /// + /// Create an async truncated collection from an . + /// + /// The AsyncEnumerable to be truncated. + /// The page size. + /// The total count. Default is null. + /// Cancellation token for async operations. Default is + /// An instance of the + public static async Task> CreateAsync(IAsyncEnumerable source, int pageSize, long? totalCount = null, CancellationToken cancellationToken = default) + { + return await CreateInternalAsync(source, pageSize, totalCount, cancellationToken).ConfigureAwait(false); } - private void Initialize(int pageSize) + #endregion + + #region Core Internal (Sync/Async) + + private static TruncatedCollection CreateInternal(IEnumerable source, int pageSize, long? totalCount) { - if (pageSize < MinPageSize) + ValidateArgs(source, pageSize); + + int capacity = pageSize > 0 ? checked(pageSize + 1) : (totalCount > 0 ? (totalCount < int.MaxValue ? (int)totalCount : int.MaxValue) : DefaultCapacity); + var items = source.Take(capacity); + + var smallPossibleCount = capacity < items.Count() ? items.Count() : capacity; + var buffer = new List(smallPossibleCount); + buffer.AddRange(items); + + bool isTruncated = buffer.Count > pageSize; + if (isTruncated) { - throw Error.ArgumentMustBeGreaterThanOrEqualTo("pageSize", pageSize, MinPageSize); + buffer.RemoveAt(buffer.Count - 1); } - _pageSize = pageSize; + return new TruncatedCollection(buffer, pageSize, totalCount, isTruncated: isTruncated); + } + + private static TruncatedCollection CreateInternal(IQueryable source, int pageSize, bool parameterize = false, long? totalCount = null) + { + ValidateArgs(source, pageSize); - if (Count > pageSize) + int capacity = pageSize > 0 ? pageSize : (totalCount > 0 ? (totalCount < int.MaxValue ? (int)totalCount : int.MaxValue) : DefaultCapacity); + var items = Take(source, capacity, parameterize); + + int count = 0; + var buffer = new List(pageSize); + using IEnumerator enumerator = items.GetEnumerator(); + while (count < pageSize && enumerator.MoveNext()) { - _isTruncated = true; - RemoveAt(Count - 1); + buffer.Add(enumerator.Current); + count++; } + + return new TruncatedCollection(buffer, pageSize, totalCount, isTruncated: enumerator.MoveNext()); } - private static IQueryable Take(IQueryable source, int pageSize, bool parameterize) + private static async Task> CreateInternalAsync(IAsyncEnumerable source, int pageSize, long? totalCount, CancellationToken cancellationToken) { - if (source == null) + ValidateArgs(source, pageSize); + + int capacity = pageSize > 0 ? pageSize : (totalCount > 0 ? (totalCount < int.MaxValue ? (int)totalCount : int.MaxValue) : DefaultCapacity); + var buffer = new List(capacity); + + bool isTruncated = false; + int count = 0; + await foreach (var item in source.Take(checked(capacity + 1)).WithCancellation(cancellationToken).ConfigureAwait(false)) { - throw Error.ArgumentNull("source"); + if (count < pageSize) + { + buffer.Add(item); + count++; + } + else + { + isTruncated = true; + break; + } } - return ExpressionHelpers.Take(source, checked(pageSize + 1), typeof(T), parameterize) as IQueryable; + // Create a new async enumerable from the buffer + IAsyncEnumerable truncatedSource = GetAsyncEnumerable(buffer); + + return new TruncatedCollection(truncatedSource, pageSize, totalCount, isTruncated); + + static async IAsyncEnumerable GetAsyncEnumerable(IEnumerable items) + { + foreach (var item in items) + yield return item; + + await Task.CompletedTask; + } } - /// - public int PageSize + private static IQueryable Take(IQueryable source, int pageSize, bool parameterize) { - get { return _pageSize; } + // This uses existing ExpressionHelpers from OData to apply Take(pageSize + 1) + return (IQueryable)ExpressionHelpers.Take(source, checked(pageSize + 1), typeof(T), parameterize); } - /// - public bool IsTruncated + private static void ValidateArgs(object source, int pageSize) { - get { return _isTruncated; } + ArgumentNullException.ThrowIfNull(source); + + if (pageSize < MinPageSize) + throw new ArgumentOutOfRangeException(nameof(pageSize), $"Page size must be >= {MinPageSize}."); } - /// - public long? TotalCount + #endregion + + /// + public int PageSize { get; } + /// + public long? TotalCount { get; } + /// + public bool IsTruncated => _isTruncated; + + /// + public int Count => _items != null ? _items.Count : 0; + + public T this[int index] => _items[index]; + //public List AsList() => new List(_items); + + /// + public IEnumerator GetEnumerator() => _items.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => _items.GetEnumerator(); + + /// + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) { - get { return _totalCount; } + return GetPagedAsyncEnumerator(cancellationToken); + + async IAsyncEnumerator GetPagedAsyncEnumerator(CancellationToken ct) + { + await foreach (var item in _asyncSource.WithCancellation(ct).ConfigureAwait(false)) + { + yield return item; + } + } } } From a3b8eb7123c923264ff864c390a623b3c452bc25 Mon Sep 17 00:00:00 2001 From: Samuel Wanjohi Date: Wed, 25 Jun 2025 20:08:37 +0300 Subject: [PATCH 2/7] refactor --- .../Query/Container/TruncatedCollectionOfT.cs | 35 +++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedCollectionOfT.cs b/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedCollectionOfT.cs index ba39a7dc..b520ec9f 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedCollectionOfT.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedCollectionOfT.cs @@ -25,6 +25,7 @@ public class TruncatedCollection : IReadOnlyList, ITruncatedCollection, IC private readonly List _items; private readonly IAsyncEnumerable _asyncSource; + private readonly bool _isTruncated; /// @@ -61,6 +62,7 @@ private TruncatedCollection(IAsyncEnumerable asyncSource, int pageSize, long? /// /// Initializes a new instance of the class. + /// /// The collection to be truncated. /// The page size. public TruncatedCollection(IEnumerable source, int pageSize) @@ -110,9 +112,20 @@ private TruncatedCollection(TruncatedCollection other) /// /// The collection to be truncated. /// The page size. - /// The total count. Default is null. - /// An instance of the - public static TruncatedCollection Create(IEnumerable source, int pageSize, long? totalCount = null) + /// An instance of the + public static TruncatedCollection Create(IEnumerable source, int pageSize) + { + return CreateInternal(source, pageSize, null); + } + + /// + /// Create a truncated collection from an . + /// + /// The collection to be truncated. + /// The page size. + /// The total count. + /// An instance of the + public static TruncatedCollection Create(IEnumerable source, int pageSize, long? totalCount) { return CreateInternal(source, pageSize, totalCount); } @@ -122,10 +135,21 @@ public static TruncatedCollection Create(IEnumerable source, int pageSize, /// /// The collection to be truncated. /// The page size. - /// The total count. Default is null. + /// An instance of the + public static TruncatedCollection Create(IQueryable source, int pageSize) + { + return CreateInternal(source, pageSize, false, null); + } + + /// + /// Create a truncated collection from an . + /// + /// The collection to be truncated. + /// The page size. + /// The total count. /// An instance of the [Obsolete("should not be used, will be marked internal in the next major version")] - public static TruncatedCollection Create(IQueryable source, int pageSize, long? totalCount = null) + public static TruncatedCollection Create(IQueryable source, int pageSize, long? totalCount) { return CreateInternal(source, pageSize, false, totalCount); } @@ -276,7 +300,6 @@ private static void ValidateArgs(object source, int pageSize) public int Count => _items != null ? _items.Count : 0; public T this[int index] => _items[index]; - //public List AsList() => new List(_items); /// public IEnumerator GetEnumerator() => _items.GetEnumerator(); From 359c2f940e1b3fed88f1a5f273ccd025cea67cbc Mon Sep 17 00:00:00 2001 From: Samuel Wanjohi Date: Wed, 25 Jun 2025 20:37:02 +0300 Subject: [PATCH 3/7] Add some tests --- .../Microsoft.AspNetCore.OData.xml | 92 ++++++++++++++++--- .../PublicAPI.Unshipped.txt | 12 +++ .../Query/Container/TruncatedCollectionOfT.cs | 51 ++++++++-- .../Container/TruncatedCollectionOfTTest.cs | 50 +++++++++- 4 files changed, 180 insertions(+), 25 deletions(-) diff --git a/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml b/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml index 14483fa3..abbb3fe9 100644 --- a/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml +++ b/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml @@ -8914,6 +8914,24 @@ The collection element type. + + + Private constructor used by static Create methods and public constructors. + + The list of items in the collection. + The maximum number of items per page. + The total number of items in the source collection, if known. + Indicates whether the collection is truncated. + + + + Private constructor used by static Create methods and public constructors. + + The asynchronous source of items (pageSize + 1) in the collection. + The maximum number of items per page. + The total number of items in the source collection, if known. + Indicates whether the collection is truncated. + Initializes a new instance of the class. @@ -8921,6 +8939,14 @@ The collection to be truncated. The page size. + + + Initializes a new instance of the class. + + The queryable collection to be truncated. + The page size. + The total count. + Initializes a new instance of the class. @@ -8936,39 +8962,75 @@ The page size. Flag indicating whether constants should be parameterized - + - Initializes a new instance of the class. + Wrapper used internally by the backward-compatible constructors. - The queryable collection to be truncated. + An instance of . + + + + Create a truncated collection from an . + + The collection to be truncated. The page size. - The total count. + An instance of the - + - Initializes a new instance of the class. + Create a truncated collection from an . - The queryable collection to be truncated. + The collection to be truncated. The page size. The total count. + An instance of the - + - Initializes a new instance of the class. + Create a truncated collection from an . - The queryable collection to be truncated. + The collection to be truncated. + The page size. + An instance of the + + + + Create a truncated collection from an . + + The collection to be truncated. The page size. The total count. - Flag indicating whether constants should be parameterized + An instance of the + + + + + + Create an async truncated collection from an . + + The AsyncEnumerable to be truncated. + The page size. + The total count. Default is null. + Cancellation token for async operations. Default is + An instance of the - + + + + - + - - + + + + + + + + diff --git a/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt b/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt index 18bac592..579ce601 100644 --- a/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt +++ b/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt @@ -44,6 +44,11 @@ Microsoft.AspNetCore.OData.ODataMiniOptions.SetVersion(Microsoft.OData.ODataVers Microsoft.AspNetCore.OData.ODataMiniOptions.SkipToken() -> Microsoft.AspNetCore.OData.ODataMiniOptions Microsoft.AspNetCore.OData.ODataMiniOptions.TimeZone.get -> System.TimeZoneInfo Microsoft.AspNetCore.OData.ODataMiniOptions.Version.get -> Microsoft.OData.ODataVersion +Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection.Count.get -> int +Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection.CountAsync() -> System.Threading.Tasks.Task +Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection.GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerator +Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection.GetEnumerator() -> System.Collections.Generic.IEnumerator +Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection.this[int index].get -> T Microsoft.AspNetCore.OData.Query.IODataQueryEndpointFilter Microsoft.AspNetCore.OData.Query.IODataQueryEndpointFilter.OnFilterExecutedAsync(object responseValue, Microsoft.AspNetCore.OData.Extensions.ODataQueryFilterInvocationContext context) -> System.Threading.Tasks.ValueTask Microsoft.AspNetCore.OData.Query.IODataQueryEndpointFilter.OnFilterExecutingAsync(Microsoft.AspNetCore.OData.Extensions.ODataQueryFilterInvocationContext context) -> System.Threading.Tasks.ValueTask @@ -78,6 +83,13 @@ static Microsoft.AspNetCore.OData.ODataEndpointConventionBuilderExtensions.WithO static Microsoft.AspNetCore.OData.ODataEndpointConventionBuilderExtensions.WithODataVersion(this TBuilder builder, Microsoft.OData.ODataVersion version) -> TBuilder static Microsoft.AspNetCore.OData.ODataServiceCollectionExtensions.AddOData(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection static Microsoft.AspNetCore.OData.ODataServiceCollectionExtensions.AddOData(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Action setupAction) -> Microsoft.Extensions.DependencyInjection.IServiceCollection +static Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection.Create(System.Collections.Generic.IEnumerable source, int pageSize) -> Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection +static Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection.Create(System.Collections.Generic.IEnumerable source, int pageSize, long? totalCount) -> Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection +static Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection.Create(System.Linq.IQueryable source, int pageSize) -> Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection +static Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection.Create(System.Linq.IQueryable source, int pageSize, bool parameterize) -> Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection +static Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection.Create(System.Linq.IQueryable source, int pageSize, long? totalCount) -> Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection +static Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection.Create(System.Linq.IQueryable source, int pageSize, long? totalCount, bool parameterize) -> Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection +static Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection.CreateAsync(System.Collections.Generic.IAsyncEnumerable source, int pageSize, long? totalCount = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task> static Microsoft.AspNetCore.OData.Query.ODataQueryOptions.BindAsync(Microsoft.AspNetCore.Http.HttpContext context, System.Reflection.ParameterInfo parameter) -> System.Threading.Tasks.ValueTask> static Microsoft.AspNetCore.OData.Query.ODataQueryOptions.PopulateMetadata(System.Reflection.ParameterInfo parameter, Microsoft.AspNetCore.Builder.EndpointBuilder builder) -> void static readonly Microsoft.AspNetCore.OData.Query.Wrapper.SelectExpandWrapperConverter.MapperProvider -> System.Func diff --git a/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedCollectionOfT.cs b/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedCollectionOfT.cs index b520ec9f..13a373ae 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedCollectionOfT.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedCollectionOfT.cs @@ -135,7 +135,7 @@ public static TruncatedCollection Create(IEnumerable source, int pageSize, /// /// The collection to be truncated. /// The page size. - /// An instance of the + /// An instance of the public static TruncatedCollection Create(IQueryable source, int pageSize) { return CreateInternal(source, pageSize, false, null); @@ -147,8 +147,7 @@ public static TruncatedCollection Create(IQueryable source, int pageSize) /// The collection to be truncated. /// The page size. /// The total count. - /// An instance of the - [Obsolete("should not be used, will be marked internal in the next major version")] + /// An instance of the public static TruncatedCollection Create(IQueryable source, int pageSize, long? totalCount) { return CreateInternal(source, pageSize, false, totalCount); @@ -160,7 +159,7 @@ public static TruncatedCollection Create(IQueryable source, int pageSize, /// The collection to be truncated. /// The page size. /// Flag indicating whether constants should be parameterized - /// An instance of the + /// An instance of the public static TruncatedCollection Create(IQueryable source, int pageSize, bool parameterize) { return CreateInternal(source, pageSize, parameterize); @@ -173,7 +172,7 @@ public static TruncatedCollection Create(IQueryable source, int pageSize, /// The page size. /// The total count. Default is null. /// Flag indicating whether constants should be parameterized - /// An instance of the + /// An instance of the [Obsolete("should not be used, will be marked internal in the next major version")] public static TruncatedCollection Create(IQueryable source, int pageSize, long? totalCount, bool parameterize) { @@ -186,7 +185,7 @@ public static TruncatedCollection Create(IQueryable source, int pageSize, /// The AsyncEnumerable to be truncated. /// The page size. /// The total count. Default is null. - /// Cancellation token for async operations. Default is + /// Cancellation token for async operations. Default. /// An instance of the public static async Task> CreateAsync(IAsyncEnumerable source, int pageSize, long? totalCount = null, CancellationToken cancellationToken = default) { @@ -284,7 +283,9 @@ private static void ValidateArgs(object source, int pageSize) ArgumentNullException.ThrowIfNull(source); if (pageSize < MinPageSize) - throw new ArgumentOutOfRangeException(nameof(pageSize), $"Page size must be >= {MinPageSize}."); + { + throw Error.ArgumentMustBeGreaterThanOrEqualTo("pageSize", pageSize, MinPageSize); + } } #endregion @@ -297,8 +298,42 @@ private static void ValidateArgs(object source, int pageSize) public bool IsTruncated => _isTruncated; /// - public int Count => _items != null ? _items.Count : 0; + public int Count + { + get + { + if (_items != null) + { + return _items.Count; + } + else if (_asyncSource != null) + { + throw new InvalidOperationException("Count cannot be accessed synchronously for an asynchronous source. Use CountAsync instead."); + } + return 0; + } + } + /// + public async Task CountAsync() + { + if (_items != null) + { + return await Task.FromResult(_items.Count); + } + else if (_asyncSource != null) + { + int count = 0; + await foreach (var _ in _asyncSource.ConfigureAwait(false)) + { + count++; + } + return count; + } + return 0; + } + + /// public T this[int index] => _items[index]; /// diff --git a/test/Microsoft.AspNetCore.OData.Tests/Query/Container/TruncatedCollectionOfTTest.cs b/test/Microsoft.AspNetCore.OData.Tests/Query/Container/TruncatedCollectionOfTTest.cs index 2868e724..2c36c0b1 100644 --- a/test/Microsoft.AspNetCore.OData.Tests/Query/Container/TruncatedCollectionOfTTest.cs +++ b/test/Microsoft.AspNetCore.OData.Tests/Query/Container/TruncatedCollectionOfTTest.cs @@ -6,10 +6,11 @@ //------------------------------------------------------------------------------ using System; -using Microsoft.AspNetCore.OData.Query.Container; -using Microsoft.AspNetCore.OData.Tests.Commons; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.OData.Query.Container; +using Microsoft.AspNetCore.OData.Tests.Commons; using Xunit; namespace Microsoft.AspNetCore.OData.Tests.Query.Container; @@ -50,6 +51,51 @@ public void CtorTruncatedCollection_SetsProperties() Assert.Equal(new[] { 1, 2, 3 }, collection); } + [Fact] + public void TruncatedCollectionCreateForIEnumerable_SetsProperties() + { + // Arrange & Act + IEnumerable source = new[] { 1, 2, 3, 5, 7 }; + var collection = TruncatedCollection.Create(source, 3, 5); + + // Assert + Assert.Equal(3, collection.PageSize); + Assert.Equal(5, collection.TotalCount); + Assert.True(collection.IsTruncated); + Assert.Equal(3, collection.Count); + Assert.Equal(new[] { 1, 2, 3 }, collection); + } + + [Fact] + public async Task TruncatedCollectionCreateForIAsyncEnumerable_SetsProperties() + { + // Arrange & Act + IAsyncEnumerable source = new[] { 1, 2, 3, 5, 7 }.ToAsyncEnumerable(); + var collection = await TruncatedCollection.CreateAsync(source, 3, 5); + + // Assert + Assert.Equal(3, collection.PageSize); + Assert.Equal(5, collection.TotalCount); + Assert.True(collection.IsTruncated); + Assert.Equal(3, await collection.CountAsync()); + Assert.Equal(new[] { 1, 2, 3 }, await collection.ToArrayAsync()); + } + + [Fact] + public void TruncatedCollectionCreateForIQueryable_SetsProperties() + { + // Arrange & Act + IQueryable source = new[] { 1, 2, 3, 5, 7 }.AsQueryable(); + var collection = TruncatedCollection.Create(source, 3, 5); + + // Assert + Assert.Equal(3, collection.PageSize); + Assert.Equal(5, collection.TotalCount); + Assert.True(collection.IsTruncated); + Assert.Equal(3, collection.Count); + Assert.Equal(new[] { 1, 2, 3 }, collection); + } + [Fact] [Obsolete] public void CtorTruncatedCollection_WithQueryable_SetsProperties() From dec21262e6df3685f205d8175679dd19cd03d07b Mon Sep 17 00:00:00 2001 From: Samuel Wanjohi Date: Wed, 25 Jun 2025 20:38:12 +0300 Subject: [PATCH 4/7] Refactor --- .../Query/Container/TruncatedCollectionOfT.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedCollectionOfT.cs b/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedCollectionOfT.cs index 13a373ae..6bbfdd12 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedCollectionOfT.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedCollectionOfT.cs @@ -308,8 +308,9 @@ public int Count } else if (_asyncSource != null) { - throw new InvalidOperationException("Count cannot be accessed synchronously for an asynchronous source. Use CountAsync instead."); + throw Error.InvalidOperation("Count cannot be accessed synchronously for an asynchronous source. Use CountAsync instead."); } + return 0; } } From e54873dfa882f4f23fed84995b539d61ef94496d Mon Sep 17 00:00:00 2001 From: Samuel Wanjohi Date: Thu, 26 Jun 2025 13:25:01 +0300 Subject: [PATCH 5/7] Try handling IAsyncEnumerable --- .../Microsoft.AspNetCore.OData.xml | 92 +++---------------- .../PublicAPI.Unshipped.txt | 12 --- .../Container/TruncatedAsyncEnumerableOfT.cs | 71 ++++++++++++++ .../Query/Container/TruncatedCollectionOfT.cs | 76 ++++----------- .../Container/TruncatedCollectionOfTTest.cs | 4 +- 5 files changed, 107 insertions(+), 148 deletions(-) create mode 100644 src/Microsoft.AspNetCore.OData/Query/Container/TruncatedAsyncEnumerableOfT.cs diff --git a/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml b/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml index abbb3fe9..14483fa3 100644 --- a/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml +++ b/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml @@ -8914,24 +8914,6 @@ The collection element type. - - - Private constructor used by static Create methods and public constructors. - - The list of items in the collection. - The maximum number of items per page. - The total number of items in the source collection, if known. - Indicates whether the collection is truncated. - - - - Private constructor used by static Create methods and public constructors. - - The asynchronous source of items (pageSize + 1) in the collection. - The maximum number of items per page. - The total number of items in the source collection, if known. - Indicates whether the collection is truncated. - Initializes a new instance of the class. @@ -8939,14 +8921,6 @@ The collection to be truncated. The page size. - - - Initializes a new instance of the class. - - The queryable collection to be truncated. - The page size. - The total count. - Initializes a new instance of the class. @@ -8962,75 +8936,39 @@ The page size. Flag indicating whether constants should be parameterized - - - Wrapper used internally by the backward-compatible constructors. - - An instance of . - - - - Create a truncated collection from an . - - The collection to be truncated. - The page size. - An instance of the - - + - Create a truncated collection from an . + Initializes a new instance of the class. - The collection to be truncated. + The queryable collection to be truncated. The page size. The total count. - An instance of the - + - Create a truncated collection from an . - - The collection to be truncated. - The page size. - An instance of the - - - - Create a truncated collection from an . + Initializes a new instance of the class. - The collection to be truncated. + The queryable collection to be truncated. The page size. The total count. - An instance of the - - - + - Create an async truncated collection from an . + Initializes a new instance of the class. - The AsyncEnumerable to be truncated. + The queryable collection to be truncated. The page size. - The total count. Default is null. - Cancellation token for async operations. Default is - An instance of the + The total count. + Flag indicating whether constants should be parameterized - - - - + - - - - - - - + - - + + diff --git a/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt b/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt index 579ce601..18bac592 100644 --- a/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt +++ b/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt @@ -44,11 +44,6 @@ Microsoft.AspNetCore.OData.ODataMiniOptions.SetVersion(Microsoft.OData.ODataVers Microsoft.AspNetCore.OData.ODataMiniOptions.SkipToken() -> Microsoft.AspNetCore.OData.ODataMiniOptions Microsoft.AspNetCore.OData.ODataMiniOptions.TimeZone.get -> System.TimeZoneInfo Microsoft.AspNetCore.OData.ODataMiniOptions.Version.get -> Microsoft.OData.ODataVersion -Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection.Count.get -> int -Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection.CountAsync() -> System.Threading.Tasks.Task -Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection.GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerator -Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection.GetEnumerator() -> System.Collections.Generic.IEnumerator -Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection.this[int index].get -> T Microsoft.AspNetCore.OData.Query.IODataQueryEndpointFilter Microsoft.AspNetCore.OData.Query.IODataQueryEndpointFilter.OnFilterExecutedAsync(object responseValue, Microsoft.AspNetCore.OData.Extensions.ODataQueryFilterInvocationContext context) -> System.Threading.Tasks.ValueTask Microsoft.AspNetCore.OData.Query.IODataQueryEndpointFilter.OnFilterExecutingAsync(Microsoft.AspNetCore.OData.Extensions.ODataQueryFilterInvocationContext context) -> System.Threading.Tasks.ValueTask @@ -83,13 +78,6 @@ static Microsoft.AspNetCore.OData.ODataEndpointConventionBuilderExtensions.WithO static Microsoft.AspNetCore.OData.ODataEndpointConventionBuilderExtensions.WithODataVersion(this TBuilder builder, Microsoft.OData.ODataVersion version) -> TBuilder static Microsoft.AspNetCore.OData.ODataServiceCollectionExtensions.AddOData(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection static Microsoft.AspNetCore.OData.ODataServiceCollectionExtensions.AddOData(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Action setupAction) -> Microsoft.Extensions.DependencyInjection.IServiceCollection -static Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection.Create(System.Collections.Generic.IEnumerable source, int pageSize) -> Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection -static Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection.Create(System.Collections.Generic.IEnumerable source, int pageSize, long? totalCount) -> Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection -static Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection.Create(System.Linq.IQueryable source, int pageSize) -> Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection -static Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection.Create(System.Linq.IQueryable source, int pageSize, bool parameterize) -> Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection -static Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection.Create(System.Linq.IQueryable source, int pageSize, long? totalCount) -> Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection -static Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection.Create(System.Linq.IQueryable source, int pageSize, long? totalCount, bool parameterize) -> Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection -static Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection.CreateAsync(System.Collections.Generic.IAsyncEnumerable source, int pageSize, long? totalCount = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task> static Microsoft.AspNetCore.OData.Query.ODataQueryOptions.BindAsync(Microsoft.AspNetCore.Http.HttpContext context, System.Reflection.ParameterInfo parameter) -> System.Threading.Tasks.ValueTask> static Microsoft.AspNetCore.OData.Query.ODataQueryOptions.PopulateMetadata(System.Reflection.ParameterInfo parameter, Microsoft.AspNetCore.Builder.EndpointBuilder builder) -> void static readonly Microsoft.AspNetCore.OData.Query.Wrapper.SelectExpandWrapperConverter.MapperProvider -> System.Func diff --git a/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedAsyncEnumerableOfT.cs b/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedAsyncEnumerableOfT.cs new file mode 100644 index 00000000..54c24120 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedAsyncEnumerableOfT.cs @@ -0,0 +1,71 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.OData.Query.Container; + +public class TruncatedAsyncEnumerable : IAsyncEnumerable +{ + private readonly IAsyncEnumerable _source; + private readonly int _pageSize; + private readonly TruncationState _state; + + /// + /// Initializes a new instance of the class, which provides an + /// asynchronous enumerable that limits the number of items returned per page and tracks truncation state. + /// + /// The source asynchronous enumerable to be paginated and truncated. + /// The maximum number of items to include in each page. Must be greater than zero. + /// The truncation state object used to track whether the enumeration was truncated. + public TruncatedAsyncEnumerable(IAsyncEnumerable source, int pageSize, TruncationState state) + { + _source = source; + _pageSize = pageSize; + _state = state; + } + + /// + /// Returns an asynchronous enumerator that iterates through the items in the source collection, up to a specified page size. + /// + /// The enumerator yields items from the source collection until the specified page size is reached. + /// If the number of items exceeds the page size, the enumeration is truncated, and the state is updated to true. Otherwise, the state is updated to false. + /// A token to monitor for cancellation requests. If the token is canceled, the enumeration is stopped. + /// An asynchronous enumerator that yields items from the source collection, up to the specified page size. + public async IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + int count = 0; + await foreach (var item in _source.WithCancellation(cancellationToken)) + { + if (count < _pageSize) + { + yield return item; + count++; + } + else + { + // More items exist than pageSize, so mark as truncated and stop yielding. + _state.IsTruncated = true; + yield break; + } + } + + // If we didn't hit the limit, not truncated. + _state.IsTruncated = false; + } +} + + +/// +/// Used to track the truncation state of an async enumerable. +/// +public class TruncationState +{ + public bool IsTruncated { get; set; } +} diff --git a/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedCollectionOfT.cs b/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedCollectionOfT.cs index 6bbfdd12..dbe054e7 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedCollectionOfT.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedCollectionOfT.cs @@ -28,6 +28,8 @@ public class TruncatedCollection : IReadOnlyList, ITruncatedCollection, IC private readonly bool _isTruncated; + private readonly TruncationState _isTruncatedState; + /// /// Private constructor used by static Create methods and public constructors. /// @@ -49,11 +51,11 @@ private TruncatedCollection(List items, int pageSize, long? totalCount, bool /// The asynchronous source of items (pageSize + 1) in the collection. /// The maximum number of items per page. /// The total number of items in the source collection, if known. - /// Indicates whether the collection is truncated. - private TruncatedCollection(IAsyncEnumerable asyncSource, int pageSize, long? totalCount, bool isTruncated) + /// State to indicate whether the collection is truncated. + private TruncatedCollection(IAsyncEnumerable asyncSource, int pageSize, long? totalCount, TruncationState isTruncatedState) { _asyncSource = asyncSource; - _isTruncated = isTruncated; + _isTruncatedState = isTruncatedState; PageSize = pageSize; TotalCount = totalCount; } @@ -184,12 +186,12 @@ public static TruncatedCollection Create(IQueryable source, int pageSize, /// /// The AsyncEnumerable to be truncated. /// The page size. - /// The total count. Default is null. + /// /// The total count. Default null. /// Cancellation token for async operations. Default. /// An instance of the - public static async Task> CreateAsync(IAsyncEnumerable source, int pageSize, long? totalCount = null, CancellationToken cancellationToken = default) + public static TruncatedCollection CreateForAsyncSource(IAsyncEnumerable source, int pageSize, long? totalCount = null, CancellationToken cancellationToken = default) { - return await CreateInternalAsync(source, pageSize, totalCount, cancellationToken).ConfigureAwait(false); + return CreateInternal(source, pageSize, totalCount, cancellationToken); } #endregion @@ -235,41 +237,15 @@ private static TruncatedCollection CreateInternal(IQueryable source, int p return new TruncatedCollection(buffer, pageSize, totalCount, isTruncated: enumerator.MoveNext()); } - private static async Task> CreateInternalAsync(IAsyncEnumerable source, int pageSize, long? totalCount, CancellationToken cancellationToken) + private static TruncatedCollection CreateInternal(IAsyncEnumerable source, int pageSize, long? totalCount, CancellationToken cancellationToken = default) { ValidateArgs(source, pageSize); int capacity = pageSize > 0 ? pageSize : (totalCount > 0 ? (totalCount < int.MaxValue ? (int)totalCount : int.MaxValue) : DefaultCapacity); - var buffer = new List(capacity); - - bool isTruncated = false; - int count = 0; - await foreach (var item in source.Take(checked(capacity + 1)).WithCancellation(cancellationToken).ConfigureAwait(false)) - { - if (count < pageSize) - { - buffer.Add(item); - count++; - } - else - { - isTruncated = true; - break; - } - } - - // Create a new async enumerable from the buffer - IAsyncEnumerable truncatedSource = GetAsyncEnumerable(buffer); - - return new TruncatedCollection(truncatedSource, pageSize, totalCount, isTruncated); - static async IAsyncEnumerable GetAsyncEnumerable(IEnumerable items) - { - foreach (var item in items) - yield return item; - - await Task.CompletedTask; - } + var state = new TruncationState(); + var truncatedSource = new TruncatedAsyncEnumerable(source, capacity, state); + return new TruncatedCollection(truncatedSource, pageSize, totalCount, state); } private static IQueryable Take(IQueryable source, int pageSize, bool parameterize) @@ -295,7 +271,7 @@ private static void ValidateArgs(object source, int pageSize) /// public long? TotalCount { get; } /// - public bool IsTruncated => _isTruncated; + public bool IsTruncated => _isTruncatedState?.IsTruncated ?? _isTruncated; /// public int Count @@ -324,13 +300,9 @@ public async Task CountAsync() } else if (_asyncSource != null) { - int count = 0; - await foreach (var _ in _asyncSource.ConfigureAwait(false)) - { - count++; - } - return count; + return await _asyncSource.CountAsync().ConfigureAwait(false); } + return 0; } @@ -338,20 +310,10 @@ public async Task CountAsync() public T this[int index] => _items[index]; /// - public IEnumerator GetEnumerator() => _items.GetEnumerator(); - IEnumerator IEnumerable.GetEnumerator() => _items.GetEnumerator(); + public IEnumerator GetEnumerator() => _items?.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => _items?.GetEnumerator(); /// - public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) - { - return GetPagedAsyncEnumerator(cancellationToken); - - async IAsyncEnumerator GetPagedAsyncEnumerator(CancellationToken ct) - { - await foreach (var item in _asyncSource.WithCancellation(ct).ConfigureAwait(false)) - { - yield return item; - } - } - } + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) => + _asyncSource?.GetAsyncEnumerator(cancellationToken); } diff --git a/test/Microsoft.AspNetCore.OData.Tests/Query/Container/TruncatedCollectionOfTTest.cs b/test/Microsoft.AspNetCore.OData.Tests/Query/Container/TruncatedCollectionOfTTest.cs index 2c36c0b1..1ac59cb2 100644 --- a/test/Microsoft.AspNetCore.OData.Tests/Query/Container/TruncatedCollectionOfTTest.cs +++ b/test/Microsoft.AspNetCore.OData.Tests/Query/Container/TruncatedCollectionOfTTest.cs @@ -71,14 +71,14 @@ public async Task TruncatedCollectionCreateForIAsyncEnumerable_SetsProperties() { // Arrange & Act IAsyncEnumerable source = new[] { 1, 2, 3, 5, 7 }.ToAsyncEnumerable(); - var collection = await TruncatedCollection.CreateAsync(source, 3, 5); + var collection = TruncatedCollection.CreateForAsyncSource(source, 3, 5); // Assert Assert.Equal(3, collection.PageSize); Assert.Equal(5, collection.TotalCount); - Assert.True(collection.IsTruncated); Assert.Equal(3, await collection.CountAsync()); Assert.Equal(new[] { 1, 2, 3 }, await collection.ToArrayAsync()); + Assert.True(collection.IsTruncated); } [Fact] From f2ef42198557a7d57f550d8851babc5563ee3575 Mon Sep 17 00:00:00 2001 From: Samuel Wanjohi Date: Thu, 26 Jun 2025 14:10:24 +0300 Subject: [PATCH 6/7] Update src/Microsoft.AspNetCore.OData/Query/Container/TruncatedCollectionOfT.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Query/Container/TruncatedCollectionOfT.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedCollectionOfT.cs b/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedCollectionOfT.cs index dbe054e7..1dddf25f 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedCollectionOfT.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedCollectionOfT.cs @@ -314,6 +314,12 @@ public async Task CountAsync() IEnumerator IEnumerable.GetEnumerator() => _items?.GetEnumerator(); /// - public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) => - _asyncSource?.GetAsyncEnumerator(cancellationToken); + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + if (_asyncSource == null) + { + throw new InvalidOperationException("Async enumeration is not supported for sync-only instances."); + } + return _asyncSource.GetAsyncEnumerator(cancellationToken); + } } From 9a885d9110c6e4f405f9efa3cfd87e371107eb69 Mon Sep 17 00:00:00 2001 From: Samuel Wanjohi Date: Fri, 4 Jul 2025 09:49:46 +0300 Subject: [PATCH 7/7] some refactor --- .../Query/Container/TruncatedAsyncEnumerableOfT.cs | 2 +- .../Query/Container/TruncatedCollectionOfT.cs | 2 +- .../IAsyncEnumerableTests/IAsyncEnumerableController.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedAsyncEnumerableOfT.cs b/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedAsyncEnumerableOfT.cs index 54c24120..0ce6a4fb 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedAsyncEnumerableOfT.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedAsyncEnumerableOfT.cs @@ -32,7 +32,7 @@ public TruncatedAsyncEnumerable(IAsyncEnumerable source, int pageSize, Trunca } /// - /// Returns an asynchronous enumerator that iterates through the items in the source collection, up to a specified page size. + /// Returns an asynchronous enumerator that iterates through the items in the source collection, up to a specified page size. /// /// The enumerator yields items from the source collection until the specified page size is reached. /// If the number of items exceeds the page size, the enumeration is truncated, and the state is updated to true. Otherwise, the state is updated to false. diff --git a/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedCollectionOfT.cs b/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedCollectionOfT.cs index dbe054e7..413ce373 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedCollectionOfT.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Container/TruncatedCollectionOfT.cs @@ -189,7 +189,7 @@ public static TruncatedCollection Create(IQueryable source, int pageSize, /// /// The total count. Default null. /// Cancellation token for async operations. Default. /// An instance of the - public static TruncatedCollection CreateForAsyncSource(IAsyncEnumerable source, int pageSize, long? totalCount = null, CancellationToken cancellationToken = default) + public static TruncatedCollection CreateForAsync(IAsyncEnumerable source, int pageSize, long? totalCount = null, CancellationToken cancellationToken = default) { return CreateInternal(source, pageSize, totalCount, cancellationToken); } diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/IAsyncEnumerableTests/IAsyncEnumerableController.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/IAsyncEnumerableTests/IAsyncEnumerableController.cs index e73e29c1..dced0d04 100644 --- a/test/Microsoft.AspNetCore.OData.E2E.Tests/IAsyncEnumerableTests/IAsyncEnumerableController.cs +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/IAsyncEnumerableTests/IAsyncEnumerableController.cs @@ -55,7 +55,7 @@ public ActionResult> CustomersDataNew() return Ok(_context.Customers.AsAsyncEnumerable()); } - [EnableQuery] + [EnableQuery(PageSize = 2)] [HttpGet("v3/Customers")] public IActionResult SearchCustomersForV3Route([FromQuery] Variant variant = Variant.None) {