diff --git a/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSetSerializer.cs b/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSetSerializer.cs index 5afccc1ee..46b61d1a1 100644 --- a/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSetSerializer.cs +++ b/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSetSerializer.cs @@ -560,9 +560,9 @@ internal static Func GetNextLinkGenerator(ODataResourceSetBase reso { // nested resourceSet ITruncatedCollection truncatedCollection = resourceSetInstance as ITruncatedCollection; - if (truncatedCollection != null && truncatedCollection.IsTruncated) + if (truncatedCollection != null) { - return (obj) => { return GetNestedNextPageLink(writeContext, truncatedCollection.PageSize, obj); }; + return (obj) => { return GetNestedNextPageLink(writeContext, truncatedCollection, obj); }; } } @@ -599,9 +599,9 @@ internal static Func GetNextLinkGenerator(ODataResourceSetBase reso { // nested resourceSet ITruncatedCollection truncatedCollection = resourceSetInstance as ITruncatedCollection; - if (truncatedCollection != null && truncatedCollection.IsTruncated) + if (truncatedCollection != null) { - return (obj) => { return GetNestedNextPageLink(writeContext, truncatedCollection.PageSize, obj); }; + return (obj) => { return GetNestedNextPageLink(writeContext, truncatedCollection, obj); }; } } @@ -695,32 +695,35 @@ private IEnumerable CreateODataOperations(IEnumerable { { "$expand", "Orders" } }); + + return Ok(queryOptions.ApplyTo(NoContainmentPagingDataSource.UntypedCustomerOrders.AsQueryable(), querySettings, AllowedQueryOptions.All & ~AllowedQueryOptions.SkipToken)); + } + + private ODataQueryOptions CreateQueryOptions(IEdmModel model) + { + ODataQueryContext context = CreateQueryContext(model, Request.ODataFeature().Path); + ODataQueryOptions queryOptions = new ODataQueryOptions(context, Request); + return queryOptions; + } + + private static ODataQueryContext CreateQueryContext(IEdmModel model, ODataPath path) + { + IEdmStructuredType edmStructuredType = null; + foreach (var segment in path) + { + if (segment.EdmType is IEdmCollectionType collectionType) + edmStructuredType = collectionType.ElementType.AsEntity().EntityDefinition(); + if (segment.EdmType is IEdmEntityType entityType) + edmStructuredType = entityType; + if (segment.EdmType is IEdmComplexType complexType) + edmStructuredType = complexType; + } + if (edmStructuredType == null) + { + throw new ArgumentException("No structured type in path"); + } + + return new ODataQueryContext(model, edmStructuredType, path); + } + + private void SetSelectExpandClauseOnODataFeature(IEdmType edmEntityType, IDictionary options = null) + { + if (!Request.IsCountRequest() && Request.ODataFeature().SelectExpandClause == null) + { + SelectExpandClause selectExpand; + + ODataPath odataPath = Request.ODataFeature().Path; + var segment = odataPath.FirstSegment as EntitySetSegment; + IEdmNavigationSource source = segment?.EntitySet; + ODataQueryOptionParser parser = new ODataQueryOptionParser(Request.GetModel(), edmEntityType, source, options, Request.ODataFeature().Services); + selectExpand = parser.ParseSelectAndExpand(); + + //Set the SelectExpand Clause on the ODataFeature otherwise OData formatter won't show the expand and select properties in the response. + Request.ODataFeature().SelectExpandClause = selectExpand; + } + } +} public class ContainmentPagingMenusController : ODataController { diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/ServerSidePaging/ServerSidePagingDataModel.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/ServerSidePaging/ServerSidePagingDataModel.cs index 2b06d42d5..c4217d311 100644 --- a/test/Microsoft.AspNetCore.OData.E2E.Tests/ServerSidePaging/ServerSidePagingDataModel.cs +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/ServerSidePaging/ServerSidePagingDataModel.cs @@ -6,8 +6,10 @@ //------------------------------------------------------------------------------ using System; +using System.Collections; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.OData.Query.Container; using Microsoft.OData.ModelBuilder; namespace Microsoft.AspNetCore.OData.E2E.Tests.ServerSidePaging; @@ -73,6 +75,56 @@ public class NoContainmentPagingCustomer public List Orders { get; set; } } +public class UntypedPagingCustomerOrder +{ + public int Id { get; set; } + + public IEnumerable Orders { get; } = new TruncatedEnumerable(2); + private class TruncatedEnumerable(int pageSize) : IEnumerable, ITruncatedCollection + { + public int PageSize => pageSize; + + public bool IsTruncated => enumerator.Position > pageSize; + + Enumerator enumerator = new(pageSize); + public IEnumerator GetEnumerator() + { + return enumerator; + } + + IEnumerator IEnumerable.GetEnumerator() + { + return enumerator; + } + + private class Enumerator(int pageSize) : IEnumerator + { + public int Position { get; set; } = 0; + + public NoContainmentPagingOrder Current => new(); + + object IEnumerator.Current => Current; + + public void Dispose() + { + } + + public bool MoveNext() + { + // This enumerator simply advances the cursor position and returns false if we have passed the pagesize to stop enumeration + Position++; + return Position <= pageSize; + } + + public void Reset() + { + Position = 0; + } + } + } + +} + public class NoContainmentPagingOrder { public int Id { get; set; } diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/ServerSidePaging/ServerSidePagingDataSource.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/ServerSidePaging/ServerSidePagingDataSource.cs index fdbd86819..f716d54ca 100644 --- a/test/Microsoft.AspNetCore.OData.E2E.Tests/ServerSidePaging/ServerSidePagingDataSource.cs +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/ServerSidePaging/ServerSidePagingDataSource.cs @@ -102,7 +102,14 @@ public static class NoContainmentPagingDataSource Orders = orders.Skip((idx - 1) * TargetSize).Take(TargetSize).ToList() })); + private static readonly List untypedCustomerOrders = new List( + Enumerable.Range(1, TargetSize).Select(idx => new UntypedPagingCustomerOrder + { + Id = idx + })); + public static List Customers => customers; + public static List UntypedCustomerOrders => untypedCustomerOrders; public static List Orders => orders; } diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/ServerSidePaging/ServerSidePagingTests.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/ServerSidePaging/ServerSidePagingTests.cs index 91fe3b22a..de8e40b3c 100644 --- a/test/Microsoft.AspNetCore.OData.E2E.Tests/ServerSidePaging/ServerSidePagingTests.cs +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/ServerSidePaging/ServerSidePagingTests.cs @@ -42,6 +42,7 @@ protected static void UpdateConfigureServices(IServiceCollection services) typeof(ContainmentPagingCustomersController), typeof(ContainmentPagingCompanyController), typeof(NoContainmentPagingCustomersController), + typeof(UntypedPagingCustomerOrdersController), typeof(ContainmentPagingMenusController), typeof(ContainmentPagingRibbonController), typeof(CollectionPagingCustomersController)); @@ -56,6 +57,7 @@ protected static IEdmModel GetEdmModel() builder.EntitySet("ContainmentPagingCustomers"); builder.Singleton("ContainmentPagingCompany"); builder.EntitySet("NoContainmentPagingCustomers"); + builder.EntitySet("UntypedPagingCustomerOrders"); builder.EntitySet("NoContainmentPagingOrders"); builder.EntitySet("NoContainmentPagingOrderItems"); builder.EntitySet("ContainmentPagingMenus"); @@ -241,6 +243,24 @@ public async Task VerifyExpectedNextLinksGeneratedForNestedExpandInNoContainment Assert.Contains("/prefix/NoContainmentPagingCustomers?$expand=Orders", content); } + [Fact] + public async Task VerifyExpectedNextLinksGeneratedForNestedExpandUntypedOrders() + { + // Arrange + var requestUri = "/prefix/UntypedPagingCustomerOrders?$expand=Orders"; + var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + var client = CreateClient(); + + // Act + var response = await client.SendAsync(request); + var content = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Contains("/prefix/UntypedPagingCustomerOrders/1/Orders?$skip=2", content); + Assert.Contains("/prefix/UntypedPagingCustomerOrders/2/Orders?$skip=2", content); + Assert.Contains("/prefix/UntypedPagingCustomerOrders/3/Orders?$skip=2", content); + } + [Fact] public async Task VerifyExpectedNextLinksGeneratedForNonContainedNavigationPropertyAsODataPathSegment() { diff --git a/test/Microsoft.AspNetCore.OData.Tests/Formatter/Serialization/ODataResourceSetSerializerTests.cs b/test/Microsoft.AspNetCore.OData.Tests/Formatter/Serialization/ODataResourceSetSerializerTests.cs index 540f20fb2..d51373e57 100644 --- a/test/Microsoft.AspNetCore.OData.Tests/Formatter/Serialization/ODataResourceSetSerializerTests.cs +++ b/test/Microsoft.AspNetCore.OData.Tests/Formatter/Serialization/ODataResourceSetSerializerTests.cs @@ -20,11 +20,13 @@ using Microsoft.AspNetCore.OData.Formatter.Serialization; using Microsoft.AspNetCore.OData.Formatter.Value; using Microsoft.AspNetCore.OData.Query; +using Microsoft.AspNetCore.OData.Query.Container; using Microsoft.AspNetCore.OData.Results; using Microsoft.AspNetCore.OData.Tests.Commons; using Microsoft.AspNetCore.OData.Tests.Edm; using Microsoft.AspNetCore.OData.Tests.Extensions; using Microsoft.AspNetCore.OData.Tests.Formatter.Models; +using Microsoft.AspNetCore.OData.Tests.Query.Container; using Microsoft.Extensions.DependencyInjection; using Microsoft.OData; using Microsoft.OData.Edm; @@ -612,6 +614,87 @@ public async Task WriteObjectInlineAsync_Sets_NextPageLink_OnWriteEndAsync() mockWriter.Verify(); } + private class TruncatedEnumerable(int pageSize) : IEnumerable, ITruncatedCollection + { + public int PageSize => pageSize; + + public bool IsTruncated => enumerator.Position > pageSize; + + Enumerator enumerator = new(pageSize); + public IEnumerator GetEnumerator() + { + return enumerator; + } + + private class Enumerator(int pageSize) : IEnumerator + { + public int Position { get; set; } = 0; + + public object Current => new(); + + public bool MoveNext() + { + // This enumerator simply advances the cursor position and returns false if we have passed the pagesize to stop enumeration + Position++; + return Position <= pageSize; + } + + public void Reset() + { + throw new NotImplementedException(); + } + } + } + + [Fact] + public async Task WriteObjectInlineAsync_Sets_NextPageLink_OnWriteEndAsync_ForInnerResourceSets() + { + // Arange + IEnumerable instance = new TruncatedEnumerable(2); + + var request = RequestFactory.Create("GET", "http://localhost/Customers?$expand=Orders", opt => opt.AddRouteComponents(_model)); + + IEdmNavigationProperty navProp = _customerSet.EntityType.NavigationProperties().First(); + SelectExpandClause selectExpandClause = new SelectExpandClause(new SelectItem[0], allSelected: true); + ResourceContext entity = new ResourceContext + { + SerializerContext = + new ODataSerializerContext { Request = request, NavigationSource = _customerSet, Model = _model } + }; + ODataSerializerContext nestedContext = new ODataSerializerContext(entity, selectExpandClause, navProp); + nestedContext.ExpandedResource.StructuredType = _customerSet.EntityType; + object idValue = 1; + var edmCustomer = new Mock(_customerSet.EntityType); + edmCustomer.Setup(e => e.TryGetPropertyValue("ID", out idValue)).Returns(true); + nestedContext.ExpandedResource.EdmObject = edmCustomer.Object; + + ODataResourceSet resourceSet = new ODataResourceSet(); + Mock serializerProvider = new Mock(); + Mock resourceSerializer = new Mock(serializerProvider.Object); + serializerProvider.Setup(s => s.GetEdmTypeSerializer(It.IsAny())).Returns(resourceSerializer.Object); + Mock serializer = new Mock(serializerProvider.Object); + serializer.CallBase = true; + serializer.Setup(s => s.CreateResourceSet(instance, _customersType, nestedContext)).Returns(resourceSet); + var mockWriter = new Mock(); + + mockWriter.Setup(m => m.WriteStartAsync(It.Is(f => f.NextPageLink == null))).Verifiable(); + mockWriter + .Setup(m => m.WriteEndAsync()) + .Callback(() => + { + //Assert + Assert.Equal("http://localhost/Customers/1/Orders?$skip=2", resourceSet.NextPageLink?.AbsoluteUri); + }) + .Returns(Task.CompletedTask) + .Verifiable(); + + // Act + await serializer.Object.WriteObjectInlineAsync(instance, _customersType, mockWriter.Object, nestedContext); + + // Assert + mockWriter.Verify(); + } + [Fact] public void CreateResource_Sets_CountValueForPageResult() { diff --git a/test/Microsoft.AspNetCore.OData.Tests/Formatter/Serialization/SerializationTestsHelpers.cs b/test/Microsoft.AspNetCore.OData.Tests/Formatter/Serialization/SerializationTestsHelpers.cs index 27d4417e4..8e6941078 100644 --- a/test/Microsoft.AspNetCore.OData.Tests/Formatter/Serialization/SerializationTestsHelpers.cs +++ b/test/Microsoft.AspNetCore.OData.Tests/Formatter/Serialization/SerializationTestsHelpers.cs @@ -24,7 +24,8 @@ public static IEdmModel SimpleCustomerOrderModel() model.AddElement(sizeType); var customerType = new EdmEntityType("Default", "Customer"); - customerType.AddStructuralProperty("ID", EdmPrimitiveTypeKind.Int32); + var idProperty = customerType.AddStructuralProperty("ID", EdmPrimitiveTypeKind.Int32); + customerType.AddKeys(idProperty); customerType.AddStructuralProperty("FirstName", EdmPrimitiveTypeKind.String); customerType.AddStructuralProperty("LastName", EdmPrimitiveTypeKind.String); customerType.AddStructuralProperty("Size", new EdmComplexTypeReference(sizeType,true)); @@ -105,9 +106,8 @@ public static IEdmModel SimpleCustomerOrderModel() specialOrderType.NavigationProperties().Single(np => np.Name == "SpecialCustomer"), customerSet); - NavigationSourceLinkBuilderAnnotation linkAnnotation = new MockNavigationSourceLinkBuilderAnnotation(); - model.SetNavigationSourceLinkBuilder(customerSet, linkAnnotation); - model.SetNavigationSourceLinkBuilder(orderSet, linkAnnotation); + model.SetNavigationSourceLinkBuilder(customerSet, new NavigationSourceLinkBuilderAnnotation(customerSet, model)); + model.SetNavigationSourceLinkBuilder(orderSet, new NavigationSourceLinkBuilderAnnotation(orderSet, model)); model.AddElement(container); return model;