diff --git a/src/Microsoft.AspNetCore.OData/Formatter/LinkGenerationHelpers.cs b/src/Microsoft.AspNetCore.OData/Formatter/LinkGenerationHelpers.cs index 0cb5b4539..70460e55c 100644 --- a/src/Microsoft.AspNetCore.OData/Formatter/LinkGenerationHelpers.cs +++ b/src/Microsoft.AspNetCore.OData/Formatter/LinkGenerationHelpers.cs @@ -8,6 +8,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Diagnostics; using System.Diagnostics.Contracts; using System.Linq; using Microsoft.AspNetCore.OData.Edm; @@ -69,7 +70,16 @@ public static Uri GenerateNavigationPropertyLink(this ResourceContext resourceCo throw Error.ArgumentNull(nameof(resourceContext)); } - IList navigationPathSegments = resourceContext.GenerateBaseODataPathSegments(); + IList navigationPathSegments; + if (resourceContext.NavigationSource is IEdmContainedEntitySet && + resourceContext.NavigationSource != resourceContext.SerializerContext.Path.NavigationSource()) + { + navigationPathSegments = resourceContext.GenerateContainmentODataPathSegments(); + } + else + { + navigationPathSegments = resourceContext.GenerateBaseODataPathSegments(); + } if (includeCast) { @@ -429,8 +439,7 @@ private static void GenerateBaseODataPathSegments( // the case. odataPath.Clear(); - IEdmContainedEntitySet containmnent = navigationSource as IEdmContainedEntitySet; - if (containmnent != null) + if (navigationSource is IEdmContainedEntitySet) { EdmEntityContainer container = new EdmEntityContainer("NS", "Default"); IEdmEntitySet entitySet = new EdmEntitySet(container, navigationSource.Name, @@ -465,5 +474,107 @@ private static void GenerateBaseODataPathSegmentsForFeed( feedContext.EntitySetBase, odataPath); } + + private static IList GenerateContainmentODataPathSegments(this ResourceContext resourceContext) + { + List navigationPathSegments = new List(); + ResourceContext currentResourceContext = resourceContext; + + // We loop till the base of the $expand expression then use GenerateBaseODataPathSegments to generate the base path segments + // For instance, given $expand=Tabs($expand=Items($expand=Notes($expand=Tips))), we loop until we get to Tabs at the base + while (currentResourceContext != null && currentResourceContext.NavigationSource != resourceContext.SerializerContext.Path.NavigationSource()) + { + if (currentResourceContext.NavigationSource is IEdmContainedEntitySet containedEntitySet) + { + // Type-cast segment for the expanded resource that is passed into the method is added by the caller + if (currentResourceContext != resourceContext && currentResourceContext.StructuredType != containedEntitySet.EntityType()) + { + navigationPathSegments.Add(new TypeSegment(currentResourceContext.StructuredType, currentResourceContext.NavigationSource)); + } + + KeySegment keySegment = new KeySegment( + ConventionsHelpers.GetEntityKey(currentResourceContext), + currentResourceContext.StructuredType as IEdmEntityType, + navigationSource: currentResourceContext.NavigationSource); + navigationPathSegments.Add(keySegment); + + NavigationPropertySegment navPropertySegment = new NavigationPropertySegment( + containedEntitySet.NavigationProperty, + containedEntitySet.ParentNavigationSource); + navigationPathSegments.Add(navPropertySegment); + } + else if (currentResourceContext.NavigationSource is IEdmEntitySet entitySet) + { + // We will get here if there's a non-contained entity set on the $expand expression + if (currentResourceContext.StructuredType != entitySet.EntityType()) + { + navigationPathSegments.Add(new TypeSegment(currentResourceContext.StructuredType, currentResourceContext.NavigationSource)); + } + + KeySegment keySegment = new KeySegment( + ConventionsHelpers.GetEntityKey(currentResourceContext), + currentResourceContext.StructuredType as IEdmEntityType, + currentResourceContext.NavigationSource); + navigationPathSegments.Add(keySegment); + + EntitySetSegment entitySetSegment = new EntitySetSegment(entitySet); + navigationPathSegments.Add(entitySetSegment); + + // Reverse the list such that the segments are in the right order + navigationPathSegments.Reverse(); + return navigationPathSegments; + } + else if (currentResourceContext.NavigationSource is IEdmSingleton singleton) + { + // We will get here if there's a singleton on the $expand expression + if (currentResourceContext.StructuredType != singleton.EntityType()) + { + navigationPathSegments.Add(new TypeSegment(currentResourceContext.StructuredType, currentResourceContext.NavigationSource)); + } + + SingletonSegment singletonSegment = new SingletonSegment(singleton); + navigationPathSegments.Add(singletonSegment); + + // Reverse the list such that the segments are in the right order + navigationPathSegments.Reverse(); + return navigationPathSegments; + } + + currentResourceContext = currentResourceContext.SerializerContext.ExpandedResource; + } + + Debug.Assert(currentResourceContext != null, "currentResourceContext != null"); + // Once we are at the base of the $expand expression, we call GenerateBaseODataPathSegments to generate the base path segments + IList pathSegments = currentResourceContext.GenerateBaseODataPathSegments(); + + Debug.Assert(pathSegments.Count > 0, "pathSegments.Count > 0"); + + ODataPathSegment lastNonKeySegment; + + if (pathSegments.Count == 1) + { + lastNonKeySegment = pathSegments[0]; + Debug.Assert(lastNonKeySegment is SingletonSegment, "lastNonKeySegment is SingletonSegment"); + } + else + { + Debug.Assert(pathSegments[pathSegments.Count - 1] is KeySegment, "pathSegments[pathSegments.Count - 1] is KeySegment"); + // 2nd last segment would be NavigationPathSegment or EntitySetSegment + lastNonKeySegment = pathSegments[pathSegments.Count - 2]; + } + + if (currentResourceContext.StructuredType != lastNonKeySegment.EdmType.AsElementType()) + { + pathSegments.Add(new TypeSegment(currentResourceContext.StructuredType, currentResourceContext.NavigationSource)); + } + + // Add the segments from the $expand expression in reverse order + for (int i = navigationPathSegments.Count - 1; i >= 0; i--) + { + pathSegments.Add(navigationPathSegments[i]); + } + + return pathSegments; + } } } diff --git a/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSetSerializer.cs b/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSetSerializer.cs index 3487fdd68..1dcfc45c2 100644 --- a/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSetSerializer.cs +++ b/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSetSerializer.cs @@ -530,26 +530,12 @@ private IEnumerable CreateODataOperations(IEnumerable> Get() return customers; } } + + public class ContainmentPagingCustomersController : ODataController + { + [EnableQuery(PageSize = 2)] + public ActionResult Get() + { + return Ok(ContainmentPagingDataSource.Customers); + } + + [EnableQuery(PageSize = 2)] + public ActionResult GetOrders(int key) + { + var customer = ContainmentPagingDataSource.Customers.SingleOrDefault(d => d.Id == key); + + if (customer == null) + { + return BadRequest(); + } + + return Ok(customer.Orders); + } + } + + public class ContainmentPagingCompanyController : ODataController + { + private static readonly ContainmentPagingCustomer company = new ContainmentPagingCustomer + { + Id = 1, + Orders = ContainmentPagingDataSource.Orders.Take(ContainmentPagingDataSource.TargetSize).ToList() + }; + + [EnableQuery(PageSize = 2)] + public ActionResult Get() + { + return Ok(company); + } + + [EnableQuery(PageSize = 2)] + public ActionResult GetOrders() + { + return Ok(company.Orders); + } + } + + public class NoContainmentPagingCustomersController : ODataController + { + [EnableQuery(PageSize = 2)] + public ActionResult Get() + { + return Ok(NoContainmentPagingDataSource.Customers); + } + + [EnableQuery(PageSize = 2)] + public ActionResult GetOrders(int key) + { + var customer = NoContainmentPagingDataSource.Customers.SingleOrDefault(d => d.Id == key); + + if (customer == null) + { + return BadRequest(); + } + + return Ok(customer.Orders); + } + } + + public class ContainmentPagingMenusController : ODataController + { + [EnableQuery(PageSize = 2, MaxExpansionDepth = 4)] + public ActionResult Get() + { + return Ok(ContainmentPagingDataSource.Menus); + } + + [EnableQuery(PageSize = 2, MaxExpansionDepth = 4)] + public ActionResult GetFromContainmentPagingExtendedMenu() + { + return Ok(ContainmentPagingDataSource.Menus.OfType()); + } + + [EnableQuery(PageSize = 2, MaxExpansionDepth = 4)] + public ActionResult GetTabsFromContainmentPagingExtendedMenu(int key) + { + var menu = ContainmentPagingDataSource.Menus.OfType().SingleOrDefault(d => d.Id == key); + + if (menu == null) + { + return BadRequest(); + } + + return Ok(menu.Tabs); + } + + [EnableQuery(PageSize = 2, MaxExpansionDepth = 4)] + public ActionResult GetPanelsFromContainmentPagingExtendedMenu(int key) + { + var menu = ContainmentPagingDataSource.Menus.OfType().SingleOrDefault(d => d.Id == key); + + if (menu == null) + { + return BadRequest(); + } + + return Ok(menu.Panels); + } + } + + public class ContainmentPagingRibbonController : ODataController + { + private static readonly ContainmentPagingMenu ribbon = new ContainmentPagingExtendedMenu + { + Id = 1, + Tabs = ContainmentPagingDataSource.Tabs.Take(ContainmentPagingDataSource.TargetSize).ToList() + }; + + [EnableQuery(PageSize = 2, MaxExpansionDepth = 4)] + public ActionResult Get() + { + return Ok(ribbon); + } + + [EnableQuery(PageSize = 2, MaxExpansionDepth = 4)] + public ActionResult GetFromContainmentPagingExtendedMenu() + { + return Ok(ribbon as ContainmentPagingExtendedMenu); + } + + [EnableQuery(PageSize = 2, MaxExpansionDepth = 4)] + [HttpGet("ContainmentPagingRibbon/Microsoft.AspNetCore.OData.E2E.Tests.ServerSidePaging.ContainmentPagingExtendedMenu/Tabs")] + public ActionResult GetTabsFromContainmentPagingExtendedMenu() + { + return Ok((ribbon as ContainmentPagingExtendedMenu).Tabs); + } + } } diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/ServerSidePaging/ServerSidePagingDataModel.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/ServerSidePaging/ServerSidePagingDataModel.cs index 7a4f19c23..9eaffac5f 100644 --- a/test/Microsoft.AspNetCore.OData.E2E.Tests/ServerSidePaging/ServerSidePagingDataModel.cs +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/ServerSidePaging/ServerSidePagingDataModel.cs @@ -8,6 +8,7 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using Microsoft.OData.ModelBuilder; namespace Microsoft.AspNetCore.OData.E2E.Tests.ServerSidePaging { @@ -46,4 +47,91 @@ public class SkipTokenPagingEdgeCase1Customer public int Id { get; set; } public decimal? CreditLimit { get; set; } } + + public class ContainmentPagingCustomer + { + public int Id { get; set; } + [Contained] + public List Orders { get; set; } + } + + public class ContainedPagingOrder + { + public int Id { get; set; } + [Contained] + public List Items { get; set; } + } + + public class ContainedPagingOrderItem + { + public int Id { get; set; } + } + + public class NoContainmentPagingCustomer + { + public int Id { get; set; } + public List Orders { get; set; } + } + + public class NoContainmentPagingOrder + { + public int Id { get; set; } + public List Items { get; set; } + } + + public class NoContainmentPagingOrderItem + { + public int Id { get; set; } + } + + public class ContainmentPagingMenu + { + public int Id { get; set; } + } + + public class ContainmentPagingExtendedMenu : ContainmentPagingMenu + { + [Contained] + public List Tabs { get; set; } + // Non-contained + public List Panels { get; set; } + } + + public class ContainedPagingTab + { + public int Id { get; set; } + } + + public class ContainedPagingExtendedTab : ContainedPagingTab + { + [Contained] + public List Items { get; set; } + } + + public class ContainedPagingItem + { + public int Id { get; set; } + } + + public class ContainedPagingExtendedItem : ContainedPagingItem + { + [Contained] + public List Notes { get; set; } + } + + public class ContainedPagingNote + { + public int Id { get; set; } + } + + public class ContainmentPagingPanel + { + public int Id { get; set; } + } + + public class ContainmentPagingExtendedPanel : ContainmentPagingPanel + { + [Contained] + public List Items { get; set; } + } } diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/ServerSidePaging/ServerSidePagingDataSource.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/ServerSidePaging/ServerSidePagingDataSource.cs new file mode 100644 index 000000000..43d76d6cb --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/ServerSidePaging/ServerSidePagingDataSource.cs @@ -0,0 +1,109 @@ +//----------------------------------------------------------------------------- +// +// 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.Linq; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.ServerSidePaging +{ + public static class ContainmentPagingDataSource + { + internal const int TargetSize = 3; + + private static readonly List orderItems = new List( + Enumerable.Range(1, TargetSize * TargetSize * TargetSize).Select(idx => new ContainedPagingOrderItem + { + Id = idx + })); + + private static readonly List orders = new List( + Enumerable.Range(1, TargetSize * TargetSize).Select(idx => new ContainedPagingOrder + { + Id = idx, + Items = orderItems.Skip((idx - 1) * TargetSize).Take(TargetSize).ToList() + })); + + private static readonly List customers = new List( + Enumerable.Range(1, TargetSize).Select(idx => new ContainmentPagingCustomer + { + Id = idx, + Orders = orders.Skip((idx - 1) * TargetSize).Take(TargetSize).ToList() + })); + + private static readonly List notes = new List( + Enumerable.Range(1, TargetSize * TargetSize * TargetSize * TargetSize).Select(idx => new ContainedPagingNote + { + Id = idx + })); + + private static readonly List items = new List( + Enumerable.Range(1, TargetSize * TargetSize * TargetSize).Select(idx => new ContainedPagingExtendedItem + { + Id = idx, + Notes = notes.Skip((idx - 1) * TargetSize).Take(TargetSize).ToList() + })); + + private static readonly List tabs = new List( + Enumerable.Range(1, TargetSize * TargetSize).Select(idx => new ContainedPagingExtendedTab + { + Id = idx, + Items = items.Skip((idx - 1) * TargetSize).Take(TargetSize).ToList() + })); + + private static readonly List panels = new List( + Enumerable.Range(1, TargetSize * TargetSize).Select(idx => new ContainmentPagingExtendedPanel + { + Id = idx, + Items = items.Skip((idx - 1) * TargetSize).Take(TargetSize).ToList() + })); + + private static readonly List menus = new List( + Enumerable.Range(1, TargetSize).Select(idx => new ContainmentPagingExtendedMenu + { + Id = idx, + Tabs = tabs.Skip((idx - 1) * TargetSize).Take(TargetSize).ToList(), + Panels = panels.Skip((idx - 1) * TargetSize).Take(TargetSize).ToList() + })); + + public static List Customers => customers; + + public static List Orders => orders; + + public static List Menus => menus; + + public static List Tabs => tabs; + } + + public static class NoContainmentPagingDataSource + { + private const int TargetSize = 3; + + private static readonly List orderItems = new List( + Enumerable.Range(1, TargetSize * TargetSize * TargetSize).Select(idx => new NoContainmentPagingOrderItem + { + Id = idx + })); + + private static readonly List orders = new List( + Enumerable.Range(1, TargetSize * TargetSize).Select(idx => new NoContainmentPagingOrder + { + Id = idx, + Items = orderItems.Skip((idx - 1) * TargetSize).Take(TargetSize).ToList() + })); + + private static readonly List customers = new List( + Enumerable.Range(1, TargetSize).Select(idx => new NoContainmentPagingCustomer + { + Id = idx, + Orders = orders.Skip((idx - 1) * TargetSize).Take(TargetSize).ToList() + })); + + public static List Customers => customers; + + 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 e25ce7786..117f6bc71 100644 --- a/test/Microsoft.AspNetCore.OData.E2E.Tests/ServerSidePaging/ServerSidePagingTests.cs +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/ServerSidePaging/ServerSidePagingTests.cs @@ -38,7 +38,12 @@ protected static void UpdateConfigureServices(IServiceCollection services) IEdmModel edmModel = GetEdmModel(); services.ConfigureControllers( typeof(ServerSidePagingCustomersController), - typeof(ServerSidePagingEmployeesController)); + typeof(ServerSidePagingEmployeesController), + typeof(ContainmentPagingCustomersController), + typeof(ContainmentPagingCompanyController), + typeof(NoContainmentPagingCustomersController), + typeof(ContainmentPagingMenusController), + typeof(ContainmentPagingRibbonController)); services.AddControllers().AddOData(opt => opt.Expand().OrderBy().AddRouteComponents("{a}", edmModel)); } @@ -47,6 +52,14 @@ protected static IEdmModel GetEdmModel() ODataModelBuilder builder = new ODataConventionModelBuilder(); builder.EntitySet("ServerSidePagingOrders").EntityType.HasRequired(d => d.ServerSidePagingCustomer); builder.EntitySet("ServerSidePagingCustomers").EntityType.HasMany(d => d.ServerSidePagingOrders); + builder.EntitySet("ContainmentPagingCustomers"); + builder.Singleton("ContainmentPagingCompany"); + builder.EntitySet("NoContainmentPagingCustomers"); + builder.EntitySet("NoContainmentPagingOrders"); + builder.EntitySet("NoContainmentPagingOrderItems"); + builder.EntitySet("ContainmentPagingMenus"); + builder.EntitySet("ContainmentPagingPanels"); + builder.Singleton("ContainmentPagingRibbon"); var getEmployeesHiredInPeriodFunction = builder.EntitySet( "ServerSidePagingEmployees").EntityType.Collection.Function("GetEmployeesHiredInPeriod"); @@ -124,6 +137,307 @@ public async Task VerifyParametersInNextPageLinkInEdmFunctionResponseBodyAreInSa "?%40fromDate=2023-01-07T00%3A00%3A00%2B00%3A00&%40toDate=2023-05-07T00%3A00%3A00%2B00%3A00&$skip=3", content); } + + [Fact] + public async Task VerifyExpectedNextLinksGeneratedForNestedExpandInContainmentScenario() + { + // Arrange + var requestUri = "/prefix/ContainmentPagingCustomers?$expand=Orders($expand=Items)"; + 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/ContainmentPagingCustomers/1/Orders/1/Items?$skip=2", content); + Assert.Contains("/prefix/ContainmentPagingCustomers/1/Orders/2/Items?$skip=2", content); + Assert.Contains("/prefix/ContainmentPagingCustomers/1/Orders?$expand=Items&$skip=2", content); + + Assert.Contains("/prefix/ContainmentPagingCustomers/2/Orders/4/Items?$skip=2", content); + Assert.Contains("/prefix/ContainmentPagingCustomers/2/Orders/5/Items?$skip=2", content); + Assert.Contains("/prefix/ContainmentPagingCustomers/2/Orders?$expand=Items&$skip=2", content); + + Assert.Contains("/prefix/ContainmentPagingCustomers?$expand=Orders", content); + } + + [Fact] + public async Task VerifyExpectedNextLinksGeneratedForContainedNavigationPropertyAsODataPathSegment() + { + // Arrange + var requestUri = "/prefix/ContainmentPagingCustomers/2/Orders?$expand=Items"; + 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/ContainmentPagingCustomers/2/Orders/4/Items?$skip=2", content); + Assert.Contains("/prefix/ContainmentPagingCustomers/2/Orders/5/Items?$skip=2", content); + Assert.Contains("/prefix/ContainmentPagingCustomers/2/Orders?$expand=Items&$skip=2", content); + } + + [Fact] + public async Task VerifyExpectedNextLinksGeneratedForContainedNavigationPropertyInSingletonScenario() + { + // Arrange + var requestUri = "/prefix/ContainmentPagingCompany?$expand=Orders($expand=Items)"; + 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/ContainmentPagingCompany/Orders/1/Items?$skip=2", content); + Assert.Contains("/prefix/ContainmentPagingCompany/Orders/2/Items?$skip=2", content); + Assert.Contains("/prefix/ContainmentPagingCompany/Orders?$expand=Items&$skip=2", content); + } + + [Fact] + public async Task VerifyExpectedNextLinksGeneratedForContainedNavigationPropertyAsODataPathSegmentInSingletonScenario() + { + // Arrange + var requestUri = "/prefix/ContainmentPagingCompany/Orders?$expand=Items"; + 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/ContainmentPagingCompany/Orders/1/Items?$skip=2", content); + Assert.Contains("/prefix/ContainmentPagingCompany/Orders/2/Items?$skip=2", content); + } + + [Fact] + public async Task VerifyExpectedNextLinksGeneratedForNestedExpandInNoContainmentScenario() + { + // Arrange + var requestUri = "/prefix/NoContainmentPagingCustomers?$expand=Orders($expand=Items)"; + 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/NoContainmentPagingOrders/1/Items?$skip=2", content); + Assert.Contains("/prefix/NoContainmentPagingOrders/2/Items?$skip=2", content); + Assert.Contains("/prefix/NoContainmentPagingCustomers/1/Orders?$expand=Items&$skip=2", content); + + Assert.Contains("/prefix/NoContainmentPagingOrders/4/Items?$skip=2", content); + Assert.Contains("/prefix/NoContainmentPagingOrders/5/Items?$skip=2", content); + Assert.Contains("/prefix/NoContainmentPagingCustomers/2/Orders?$expand=Items&$skip=2", content); + + Assert.Contains("/prefix/NoContainmentPagingCustomers?$expand=Orders", content); + } + + [Fact] + public async Task VerifyExpectedNextLinksGeneratedForNonContainedNavigationPropertyAsODataPathSegment() + { + // Arrange + var requestUri = "/prefix/NoContainmentPagingCustomers/2/Orders?$expand=Items"; + 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/NoContainmentPagingOrders/4/Items?$skip=2", content); + Assert.Contains("/prefix/NoContainmentPagingOrders/5/Items?$skip=2", content); + Assert.Contains("/prefix/NoContainmentPagingCustomers/2/Orders?$expand=Items&$skip=2", content); + } + + [Fact] + public async Task VerifyExpectedNextLinksGeneratedForContainedNavigationPropertyDeclaredOnDerivedType() + { + // Arrange + var extendedMenuTypeName = typeof(ContainmentPagingExtendedMenu).FullName; + var extendedTabTypeName = typeof(ContainedPagingExtendedTab).FullName; + var extendedItemTypeName = typeof(ContainedPagingExtendedItem).FullName; + var menusResourcePath = "/prefix/ContainmentPagingMenus"; + var menu1ResourcePath = $"/prefix/ContainmentPagingMenus/1/{extendedMenuTypeName}"; + var menu2ResourcePath = $"/prefix/ContainmentPagingMenus/2/{extendedMenuTypeName}"; + + var requestUri = $"{menusResourcePath}?$expand={extendedMenuTypeName}/Tabs($expand={extendedTabTypeName}/Items($expand={extendedItemTypeName}/Notes))"; + 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($"{menu1ResourcePath}/Tabs/1/{extendedTabTypeName}/Items/1/{extendedItemTypeName}/Notes?$skip=2", content); + Assert.Contains($"{menu1ResourcePath}/Tabs/1/{extendedTabTypeName}/Items/2/{extendedItemTypeName}/Notes?$skip=2", content); + Assert.Contains($"{menu1ResourcePath}/Tabs/1/{extendedTabTypeName}/Items?$expand={extendedItemTypeName}%2FNotes&$skip=2", content); + Assert.Contains($"{menu1ResourcePath}/Tabs/2/{extendedTabTypeName}/Items/4/{extendedItemTypeName}/Notes?$skip=2", content); + Assert.Contains($"{menu1ResourcePath}/Tabs/2/{extendedTabTypeName}/Items/5/{extendedItemTypeName}/Notes?$skip=2", content); + Assert.Contains($"{menu1ResourcePath}/Tabs/2/{extendedTabTypeName}/Items?$expand={extendedItemTypeName}%2FNotes&$skip=2", content); + Assert.Contains($"{menu1ResourcePath}/Tabs?$expand={extendedTabTypeName}%2FItems%28%24expand%3D{extendedItemTypeName}%2FNotes%29&$skip=2", content); + + Assert.Contains($"{menu2ResourcePath}/Tabs/4/{extendedTabTypeName}/Items/10/{extendedItemTypeName}/Notes?$skip=2", content); + Assert.Contains($"{menu2ResourcePath}/Tabs/4/{extendedTabTypeName}/Items/11/{extendedItemTypeName}/Notes?$skip=2", content); + Assert.Contains($"{menu2ResourcePath}/Tabs/4/{extendedTabTypeName}/Items?$expand={extendedItemTypeName}%2FNotes&$skip=2", content); + Assert.Contains($"{menu2ResourcePath}/Tabs/5/{extendedTabTypeName}/Items/13/{extendedItemTypeName}/Notes?$skip=2", content); + Assert.Contains($"{menu2ResourcePath}/Tabs/5/{extendedTabTypeName}/Items/14/{extendedItemTypeName}/Notes?$skip=2", content); + Assert.Contains($"{menu2ResourcePath}/Tabs/5/{extendedTabTypeName}/Items?$expand={extendedItemTypeName}%2FNotes&$skip=2", content); + Assert.Contains($"{menu2ResourcePath}/Tabs?$expand={extendedTabTypeName}%2FItems%28%24expand%3D{extendedItemTypeName}%2FNotes%29&$skip=2", content); + } + + [Fact] + public async Task VerifyExpectedNextLinksGeneratedForContainedNavigationPropertyDeclaredOnDerivedTypeAsODataPathSegment() + { + // Arrange + var extendedMenuTypeName = typeof(ContainmentPagingExtendedMenu).FullName; + var extendedTabTypeName = typeof(ContainedPagingExtendedTab).FullName; + var extendedItemTypeName = typeof(ContainedPagingExtendedItem).FullName; + var menu1ResourcePath = $"/prefix/ContainmentPagingMenus/1/{extendedMenuTypeName}"; + + var requestUri = $"{menu1ResourcePath}/Tabs?$expand={extendedTabTypeName}/Items($expand={extendedItemTypeName}/Notes)"; + 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($"{menu1ResourcePath}/Tabs/1/{extendedTabTypeName}/Items/1/{extendedItemTypeName}/Notes?$skip=2", content); + Assert.Contains($"{menu1ResourcePath}/Tabs/1/{extendedTabTypeName}/Items/2/{extendedItemTypeName}/Notes?$skip=2", content); + Assert.Contains($"{menu1ResourcePath}/Tabs/1/{extendedTabTypeName}/Items?$expand={extendedItemTypeName}%2FNotes&$skip=2", content); + Assert.Contains($"{menu1ResourcePath}/Tabs/2/{extendedTabTypeName}/Items/4/{extendedItemTypeName}/Notes?$skip=2", content); + Assert.Contains($"{menu1ResourcePath}/Tabs/2/{extendedTabTypeName}/Items/5/{extendedItemTypeName}/Notes?$skip=2", content); + Assert.Contains($"{menu1ResourcePath}/Tabs/2/{extendedTabTypeName}/Items?$expand={extendedItemTypeName}%2FNotes&$skip=2", content); + Assert.Contains($"{menu1ResourcePath}/Tabs?$expand={extendedTabTypeName}%2FItems%28%24expand%3D{extendedItemTypeName}%2FNotes%29&$skip=2", content); + } + + [Fact] + public async Task VerifyExpectedNextLinksGeneratedForContainedNavigationPropertyDeclaredOnDerivedTypeInSingletonScenario() + { + // Arrange + var extendedMenuTypeName = typeof(ContainmentPagingExtendedMenu).FullName; + var extendedTabTypeName = typeof(ContainedPagingExtendedTab).FullName; + var extendedItemTypeName = typeof(ContainedPagingExtendedItem).FullName; + var ribbonResourcePath = $"/prefix/ContainmentPagingRibbon"; + + var requestUri = $"{ribbonResourcePath}?$expand={extendedMenuTypeName}/Tabs($expand={extendedTabTypeName}/Items($expand={extendedItemTypeName}/Notes))"; + 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($"{ribbonResourcePath}/{extendedMenuTypeName}/Tabs/1/{extendedTabTypeName}/Items/1/{extendedItemTypeName}/Notes?$skip=2", content); + Assert.Contains($"{ribbonResourcePath}/{extendedMenuTypeName}/Tabs/1/{extendedTabTypeName}/Items/2/{extendedItemTypeName}/Notes?$skip=2", content); + Assert.Contains($"{ribbonResourcePath}/{extendedMenuTypeName}/Tabs/1/{extendedTabTypeName}/Items?$expand={extendedItemTypeName}%2FNotes&$skip=2", content); + Assert.Contains($"{ribbonResourcePath}/{extendedMenuTypeName}/Tabs/2/{extendedTabTypeName}/Items/4/{extendedItemTypeName}/Notes?$skip=2", content); + Assert.Contains($"{ribbonResourcePath}/{extendedMenuTypeName}/Tabs/2/{extendedTabTypeName}/Items/5/{extendedItemTypeName}/Notes?$skip=2", content); + Assert.Contains($"{ribbonResourcePath}/{extendedMenuTypeName}/Tabs/2/{extendedTabTypeName}/Items?$expand={extendedItemTypeName}%2FNotes&$skip=2", content); + Assert.Contains($"{ribbonResourcePath}/{extendedMenuTypeName}/Tabs?$expand={extendedTabTypeName}%2FItems%28%24expand%3D{extendedItemTypeName}%2FNotes%29&$skip=2", content); + } + + [Fact] + public async Task VerifyExpectedNextLinksGeneratedForContainedNavigationPropertyDeclaredOnDerivedTypeAsODataPathSegmentInSingletonScenario() + { + // Arrange + var extendedMenuTypeName = typeof(ContainmentPagingExtendedMenu).FullName; + var extendedTabTypeName = typeof(ContainedPagingExtendedTab).FullName; + var extendedItemTypeName = typeof(ContainedPagingExtendedItem).FullName; + var ribbonResourcePath = $"/prefix/ContainmentPagingRibbon/{extendedMenuTypeName}"; + + var requestUri = $"{ribbonResourcePath}/Tabs?$expand={extendedTabTypeName}/Items($expand={extendedItemTypeName}/Notes)"; + 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($"{ribbonResourcePath}/Tabs/1/{extendedTabTypeName}/Items/1/{extendedItemTypeName}/Notes?$skip=2", content); + Assert.Contains($"{ribbonResourcePath}/Tabs/1/{extendedTabTypeName}/Items/2/{extendedItemTypeName}/Notes?$skip=2", content); + Assert.Contains($"{ribbonResourcePath}/Tabs/1/{extendedTabTypeName}/Items?$expand={extendedItemTypeName}%2FNotes&$skip=2", content); + Assert.Contains($"{ribbonResourcePath}/Tabs/2/{extendedTabTypeName}/Items/4/{extendedItemTypeName}/Notes?$skip=2", content); + Assert.Contains($"{ribbonResourcePath}/Tabs/2/{extendedTabTypeName}/Items/5/{extendedItemTypeName}/Notes?$skip=2", content); + Assert.Contains($"{ribbonResourcePath}/Tabs/2/{extendedTabTypeName}/Items?$expand={extendedItemTypeName}%2FNotes&$skip=2", content); + Assert.Contains($"{ribbonResourcePath}/Tabs?$expand={extendedTabTypeName}%2FItems%28%24expand%3D{extendedItemTypeName}%2FNotes%29&$skip=2", content); + } + + [Fact] + public async Task VerifyExpectedNextLinksGeneratedForNonContainedNavigationPropertyDeclaredOnDerivedType() + { + // Arrange + var extendedMenuTypeName = typeof(ContainmentPagingExtendedMenu).FullName; + var extendedPanelTypeName = typeof(ContainmentPagingExtendedPanel).FullName; + var extendedItemTypeName = typeof(ContainedPagingExtendedItem).FullName; + var menusResourcePath = $"/prefix/ContainmentPagingMenus"; + var menu1ResourcePath = $"/prefix/ContainmentPagingMenus/1/{extendedMenuTypeName}"; + var menu2ResourcePath = $"/prefix/ContainmentPagingMenus/2/{extendedMenuTypeName}"; + + var requestUri = $"{menusResourcePath}?$expand={extendedMenuTypeName}/Panels($expand={extendedPanelTypeName}/Items($expand={extendedItemTypeName}/Notes))"; + 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/ContainmentPagingPanels/1/{extendedPanelTypeName}/Items/1/{extendedItemTypeName}/Notes?$skip=2", content); + Assert.Contains($"/prefix/ContainmentPagingPanels/1/{extendedPanelTypeName}/Items/2/{extendedItemTypeName}/Notes?$skip=2", content); + Assert.Contains($"/prefix/ContainmentPagingPanels/1/{extendedPanelTypeName}/Items?$expand={extendedItemTypeName}%2FNotes&$skip=2", content); + Assert.Contains($"/prefix/ContainmentPagingPanels/2/{extendedPanelTypeName}/Items/4/{extendedItemTypeName}/Notes?$skip=2", content); + Assert.Contains($"/prefix/ContainmentPagingPanels/2/{extendedPanelTypeName}/Items/5/{extendedItemTypeName}/Notes?$skip=2", content); + Assert.Contains($"/prefix/ContainmentPagingPanels/2/{extendedPanelTypeName}/Items?$expand={extendedItemTypeName}%2FNotes&$skip=2", content); + Assert.Contains($"{menu1ResourcePath}/Panels?$expand={extendedPanelTypeName}%2FItems%28%24expand%3D{extendedItemTypeName}%2FNotes%29&$skip=2", content); + + Assert.Contains($"/prefix/ContainmentPagingPanels/4/{extendedPanelTypeName}/Items/10/{extendedItemTypeName}/Notes?$skip=2", content); + Assert.Contains($"/prefix/ContainmentPagingPanels/4/{extendedPanelTypeName}/Items/11/{extendedItemTypeName}/Notes?$skip=2", content); + Assert.Contains($"/prefix/ContainmentPagingPanels/4/{extendedPanelTypeName}/Items?$expand={extendedItemTypeName}%2FNotes&$skip=2", content); + Assert.Contains($"/prefix/ContainmentPagingPanels/5/{extendedPanelTypeName}/Items/13/{extendedItemTypeName}/Notes?$skip=2", content); + Assert.Contains($"/prefix/ContainmentPagingPanels/5/{extendedPanelTypeName}/Items/14/{extendedItemTypeName}/Notes?$skip=2", content); + Assert.Contains($"/prefix/ContainmentPagingPanels/5/{extendedPanelTypeName}/Items?$expand={extendedItemTypeName}%2FNotes&$skip=2", content); + Assert.Contains($"{menu2ResourcePath}/Panels?$expand={extendedPanelTypeName}%2FItems%28%24expand%3D{extendedItemTypeName}%2FNotes%29&$skip=2", content); + } + + [Fact] + public async Task VerifyExpectedNextLinksGeneratedForNonContainedNavigationPropertyDeclaredOnDerivedTypeAsODataPathSegment() + { + // Arrange + var extendedMenuTypeName = typeof(ContainmentPagingExtendedMenu).FullName; + var extendedPanelTypeName = typeof(ContainmentPagingExtendedPanel).FullName; + var extendedItemTypeName = typeof(ContainedPagingExtendedItem).FullName; + var menu1ResourcePath = $"/prefix/ContainmentPagingMenus/1/{extendedMenuTypeName}"; + + var requestUri = $"{menu1ResourcePath}/Panels?$expand={extendedPanelTypeName}/Items($expand={extendedItemTypeName}/Notes)"; + 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/ContainmentPagingPanels/1/{extendedPanelTypeName}/Items/1/{extendedItemTypeName}/Notes?$skip=2", content); + Assert.Contains($"/prefix/ContainmentPagingPanels/1/{extendedPanelTypeName}/Items/2/{extendedItemTypeName}/Notes?$skip=2", content); + Assert.Contains($"/prefix/ContainmentPagingPanels/1/{extendedPanelTypeName}/Items?$expand={extendedItemTypeName}%2FNotes&$skip=2", content); + Assert.Contains($"/prefix/ContainmentPagingPanels/2/{extendedPanelTypeName}/Items/4/{extendedItemTypeName}/Notes?$skip=2", content); + Assert.Contains($"/prefix/ContainmentPagingPanels/2/{extendedPanelTypeName}/Items/5/{extendedItemTypeName}/Notes?$skip=2", content); + Assert.Contains($"/prefix/ContainmentPagingPanels/2/{extendedPanelTypeName}/Items?$expand={extendedItemTypeName}%2FNotes&$skip=2", content); + Assert.Contains($"{menu1ResourcePath}/Panels?$expand={extendedPanelTypeName}%2FItems%28%24expand%3D{extendedItemTypeName}%2FNotes%29&$skip=2", content); + } } public class SkipTokenPagingTests : WebApiTestBase