diff --git a/AspNetCoreOData.sln b/AspNetCoreOData.sln index 64dd16091..a2771489b 100644 --- a/AspNetCoreOData.sln +++ b/AspNetCoreOData.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.0.31815.197 +VisualStudioVersion = 17.3.32901.215 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{2F0E102B-EB33-4025-BE56-7B8F9D2C4B8A}" EndProject diff --git a/src/Microsoft.AspNetCore.OData/Formatter/LinkGenerationHelpers.cs b/src/Microsoft.AspNetCore.OData/Formatter/LinkGenerationHelpers.cs index 8f688e6e0..0cb5b4539 100644 --- a/src/Microsoft.AspNetCore.OData/Formatter/LinkGenerationHelpers.cs +++ b/src/Microsoft.AspNetCore.OData/Formatter/LinkGenerationHelpers.cs @@ -349,13 +349,13 @@ internal static IList GenerateBaseODataPathSegments(this Resou return odataPath; } - private static void GenerateBaseODataPathSegmentsForNonSingletons( + private static void GenerateBaseODataPathSegments( ODataPath path, IEdmNavigationSource navigationSource, IList odataPath) { - // If the navigation is not a singleton we need to walk all of the path segments to generate a - // contextually accurate URI. + // If the navigation is a contained property, we need to walk all of the path segments + // to generate a contextually accurate URI. bool segmentFound = false; bool containedFound = false; if (path != null) @@ -379,6 +379,14 @@ private static void GenerateBaseODataPathSegmentsForNonSingletons( { currentNavigationSource = navigationPathSegment.NavigationSource; } + + var singletonPathSegment = pathSegment as SingletonSegment; + if (singletonPathSegment != null) + { + currentNavigationSource = singletonPathSegment.Singleton; + } + + if (containedFound) { odataPath.Add(pathSegment); @@ -440,9 +448,9 @@ private static void GenerateBaseODataPathSegmentsForEntity( this ResourceContext resourceContext, IList odataPath) { - // If the navigation is not a singleton we need to walk all of the path segments to generate a - // contextually accurate URI. - GenerateBaseODataPathSegmentsForNonSingletons( + // If the navigation is a contained property, we need to walk all of the path segments + // to generate a contextually accurate URI. + GenerateBaseODataPathSegments( resourceContext.SerializerContext.Path, resourceContext.NavigationSource, odataPath); odataPath.Add(new KeySegment(ConventionsHelpers.GetEntityKey(resourceContext), resourceContext.StructuredType as IEdmEntityType, @@ -453,7 +461,7 @@ private static void GenerateBaseODataPathSegmentsForFeed( this ResourceSetContext feedContext, IList odataPath) { - GenerateBaseODataPathSegmentsForNonSingletons(feedContext.Request.ODataFeature().Path, + GenerateBaseODataPathSegments(feedContext.Request.ODataFeature().Path, feedContext.EntitySetBase, odataPath); } diff --git a/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml b/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml index 7a13ad99f..4d0e931a0 100644 --- a/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml +++ b/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml @@ -1488,7 +1488,22 @@ Helper method to check whether the given object is Delta resource set. The given object. - Object (Equivalent of ODataDeletedResource in ODL). + True/False. + + + + The Kind of the object within the DeltaPayload used to distinguish between + Resource/DeletedResource/DeltaDeletedLink/AddedLink. + + + + + Corresponds to EdmEntityObject (Equivalent of ODataResource in ODL). + + + + + Corresponds to EdmDeltaDeletedResourceObject (Equivalent of ODataDeletedResource in ODL). @@ -1569,20 +1584,6 @@ - - - A class the tracnew instance of . - - - - - Initializes a new instance of . - - The derived structural type for which the changes would be tracked. - - - - A class the tracks changes (i.e. the Delta) for a particular . @@ -1637,7 +1638,21 @@ The list of property names that can be updated. - When the list is modified, ttempts to get the value of the nested Property called from the underlying resource. + When the list is modified, any modified properties that were removed from the list are no longer + considered to be changed. + + + + + + + + + + + + + Attempts to get the value of the nested Property called from the underlying resource. Only properties that exist on Entity can be retrieved. Only modified nested properties can be retrieved. @@ -1699,19 +1714,6 @@ The entity to be updated. - - - Attempts to get the property by the specmarks> - - The entity to be updated. - - - - Overwrites the entity with the values stored in this Delta. - The semantics of this operation are equivalent to a HTTP PUT operation, hence the name. - - The entity to be updated. - Attempts to get the property by the specified name. @@ -2669,7 +2671,16 @@ This is how formatters create links to invoke bound actions or functions. - + + + Create a new based on an entity link factory. + + The link factory this should use when building links. + + A value indicating whether the link factory generates links that follow OData conventions. + + + Create a new based on a feed link factory. @@ -7981,18 +7992,6 @@ Enables a controller action to support OData query parameters. - - - Gets or sets a value indicating whether query composition should - alter the original query when necessary to ensure a stable sort order. - - A true value indicates the original query should - be modified when necessary to guarantee a stable sort order. - A false value indicates the sort order can be considered - stable without modifying the query. Query providers that ensure - a stable sort order shoa controller action to support OData query parameters. - - Gets or sets a value indicating whether query composition should @@ -10357,7 +10356,19 @@ - "> + Translate a SingleNavigationNode. + + The node to be translated. + The translated node. + + + + Translate a SingleValueFunctionCallNode. + + The node to be translated. + The translated node. + + Translate a SingleValueOpenPropertyAccessNode. @@ -10423,18 +10434,6 @@ The filter context. - - - Summary: - Called in decreasing Microsoft.AspNetCore.Mvc.Filters.IFilterProvider.Order, - after all Microsoft.AspNetCore.Mvc.Filters.IFilterProviders have executed once. - - The Mica.Query.QueryFilterProvider.OnProvidersExecuting(Microsoft.AspNetCore.Mvc.Filters.FilterProviderContext)"> - - Provides filters to apply to the specified action. - - The filter context. - Summary: @@ -10554,7 +10553,21 @@ Gets the raw $count value. - validation failed. + + + + Gets the value of the $count in a parsed form. + + + + + Gets or sets the $count query validator. + + + + + Validate the count query based on the given . + It throws an ODataException if validation failed. The instance which contains all the validation settings. @@ -11590,20 +11603,6 @@ Gets a list of properties one can orderby the result with. Note, by default this list is empty, it means it can be ordered by any property. - For example, having an empty col: any, all - - - - - - Gets or sets a list of allowed logical operators such as 'eq', 'ne', 'gt', 'ge', 'lt', 'le', 'and', 'or', 'not'. - - - - - Gets a list of properties one can orderby the result with. Note, by default this list is empty, - it means it can be ordered by any property. - For example, having an empty collection means client can order the queryable result by any properties. Adding "Name" to this list means that it only allows queryable result to be ordered by Name property. diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/Singleton/MonstersIncController.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/Singleton/MonstersIncController.cs index 7c6b84eaa..e033540cd 100644 --- a/test/Microsoft.AspNetCore.OData.E2E.Tests/Singleton/MonstersIncController.cs +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/Singleton/MonstersIncController.cs @@ -42,6 +42,36 @@ private static void InitData() Category = CompanyCategory.Electronics, Partners = new List(), Branches = new List() { new Office { City = "Shanghai", Address = "Minhang" }, new Office { City = "Xi'an", Address = "Dayanta" } }, + Projects = new List() + { + new Project + { + Id = 1, + Title = "In Closet Scare", + ProjectDetails = new List() + { + new ProjectDetail { Id = 1, Comment = "The original scare" }, + new ProjectDetail { Id = 2, Comment = "Leaving the door open is the worst mistake any employee can make" }, + new ProjectDetail { Id = 3, Comment = "Leaving the door open could let it not only a draft, but a child" }, + new ProjectDetail { Id = 4, Comment = "Has led to the intrusion of a young girl, Boo" } + }, + }, + new Project + { + Id = 2, + Title = "Under Bed Scare", + ProjectDetails = new List() { + new ProjectDetail { Id = 5, Comment = "Tried and true" }, + new ProjectDetail { Id = 6, Comment = "Tip: grab a foot"} + }, + }, + new Project + { + Id = 3, + Title = "Midnight Snack in Kitchen Scare", + ProjectDetails= new List(), + }, + }, }; } @@ -70,6 +100,26 @@ public IActionResult GetCompanyRevenue() return Ok(MonstersInc.Revenue); } + [HttpGet("Projects")] + [EnableQuery(PageSize = 2)] + public IActionResult GetProjects() + { + return Ok(MonstersInc.Projects); + } + + [HttpGet("Projects/{key}/ProjectDetails")] + [EnableQuery] + public IActionResult GetProjectDetails(int key) + { + var project = MonstersInc.Projects.FirstOrDefault(a => a.Id == key); + if (project == null) + { + return NotFound($"Project with given key {key} does not exist."); + } + + return Ok(project.ProjectDetails); + } + [HttpGet("Branches/$count")] public IActionResult GetBranchesCount(ODataQueryOptions options) { diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/Singleton/SingletonDataModel.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/Singleton/SingletonDataModel.cs index 92f1bacb4..05ab7e1d9 100644 --- a/test/Microsoft.AspNetCore.OData.E2E.Tests/Singleton/SingletonDataModel.cs +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/Singleton/SingletonDataModel.cs @@ -6,6 +6,9 @@ //------------------------------------------------------------------------------ using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; using Microsoft.OData.ModelBuilder; namespace Microsoft.AspNetCore.OData.E2E.Tests.Singleton @@ -48,6 +51,10 @@ public class Company [NotCountable] public IList Partners { get; set; } public IList Branches { get; set; } + + [Contained] + [AutoExpand] + public IList Projects { get; set; } } /// @@ -59,6 +66,28 @@ public class Office public string Address { get; set; } } + /// + /// Present a contained navigation property + /// + public class Project + { + public int Id { get; set; } + public string Title { get; set; } + + [AutoExpand] + [Contained] + public IList ProjectDetails { get; set; } + } + + /// + /// Present a nested contained navigation property + /// + public class ProjectDetail + { + public int Id { get; set; } + public string Comment { get; set; } + } + /// /// EntityType derives from "Company" /// @@ -68,4 +97,4 @@ public class SubCompany : Company public string Description { get; set; } public Office Office { get; set; } } -} +} \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/Singleton/SingletonTest.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/Singleton/SingletonTest.cs index 7b246b6f8..341f7f405 100644 --- a/test/Microsoft.AspNetCore.OData.E2E.Tests/Singleton/SingletonTest.cs +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/Singleton/SingletonTest.cs @@ -73,6 +73,57 @@ public async Task TestRoutes() string contentOfString = await response.Content.ReadAsStringAsync(); } + [Fact] + public async Task SingletonContainerGeneratesCorrectNextLinks() + { + // Arrange + string requestUri = "odata/MonstersInc/Projects"; + string nextLinkUri = "odata/MonstersInc/Projects?$skip=2"; + string nestedNextLinkUri = "odata/MonstersInc/Projects/1/ProjectDetails?$skip=2"; + + using (HttpClient client = CreateClient()) + { + // Act & Assert + string expectedOriginalResult = + "{\"@odata.context\":\"http://localhost/odata/$metadata#MonstersInc/Projects(ProjectDetails())\"," + + "\"value\":[" + + "{\"Id\":1,\"Title\":\"In Closet Scare\",\"ProjectDetails\":[" + + "{\"Id\":1,\"Comment\":\"The original scare\"}," + + "{\"Id\":2,\"Comment\":\"Leaving the door open is the worst mistake any employee can make\"}]," + + "\"ProjectDetails@odata.nextLink\":\"http://localhost/odata/MonstersInc/Projects/1/ProjectDetails?$skip=2\"}," + + "{\"Id\":2,\"Title\":\"Under Bed Scare\",\"ProjectDetails\":[" + + "{\"Id\":5,\"Comment\":\"Tried and true\"}," + + "{\"Id\":6,\"Comment\":\"Tip: grab a foot\"}]}]," + + "\"@odata.nextLink\":\"http://localhost/odata/MonstersInc/Projects?$skip=2\"" + + "}"; + await RequestYieldsExpectedResult(client, requestUri, expectedOriginalResult); + + string expectedNextResult = + "{\"@odata.context\":\"http://localhost/odata/$metadata#MonstersInc/Projects(ProjectDetails())\"," + + "\"value\":[{\"Id\":3,\"Title\":\"Midnight Snack in Kitchen Scare\",\"ProjectDetails\":[]}]}"; + await RequestYieldsExpectedResult(client, nextLinkUri, expectedNextResult); + + string expectedNestedNextResult = + "{\"@odata.context\":\"http://localhost/odata/$metadata#MonstersInc/Projects(1)/ProjectDetails\"," + + "\"value\":[" + + "{\"Id\":3,\"Comment\":\"Leaving the door open could let it not only a draft, but a child\"}," + + "{\"Id\":4,\"Comment\":\"Has led to the intrusion of a young girl, Boo\"}]}"; + await RequestYieldsExpectedResult(client, nestedNextLinkUri, expectedNestedNextResult); + } + } + + private async Task RequestYieldsExpectedResult(HttpClient client, string requestUri, string expectedResult) + { + // Act + using (HttpResponseMessage response = await client.GetAsync(requestUri)) + { + // Assert + string result = await response.Content.ReadAsStringAsync(); + response.EnsureSuccessStatusCode(); + Assert.Equal(expectedResult, result); + } + } + [Fact] public async Task NotCountable() { diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/Singleton/UmbrellaController.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/Singleton/UmbrellaController.cs index d925da21d..d596f04b4 100644 --- a/test/Microsoft.AspNetCore.OData.E2E.Tests/Singleton/UmbrellaController.cs +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/Singleton/UmbrellaController.cs @@ -41,6 +41,7 @@ private static void InitData() Category = CompanyCategory.Communication, Partners = new List(), Branches = new List(), + Projects = new List(), }; } diff --git a/test/Microsoft.AspNetCore.OData.Tests/Formatter/LinkGenerationHelpersTest.cs b/test/Microsoft.AspNetCore.OData.Tests/Formatter/LinkGenerationHelpersTest.cs index 05f3d690c..38c5ff4b6 100644 --- a/test/Microsoft.AspNetCore.OData.Tests/Formatter/LinkGenerationHelpersTest.cs +++ b/test/Microsoft.AspNetCore.OData.Tests/Formatter/LinkGenerationHelpersTest.cs @@ -10,6 +10,7 @@ using System.Linq; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.OData.Formatter; +using Microsoft.AspNetCore.OData.Formatter.Serialization; using Microsoft.AspNetCore.OData.Tests.Commons; using Microsoft.AspNetCore.OData.Tests.Edm; using Microsoft.AspNetCore.OData.Tests.Extensions; @@ -110,6 +111,59 @@ public void GenerateNavigationLink_WorksToGenerateExpectedNavigationLink_ForSing Assert.Equal(expectedNavigationLink, uri.AbsoluteUri); } + private ResourceContext GetOrderLineResourceForNewSingletonContainer() + { + // Arrange + IEdmSingleton myVipOrder = _myOrderModel.FindDeclaredSingleton("VipOrder"); + IEdmEntityType vipOrderType = (IEdmEntityType)myVipOrder.Type; + IEdmNavigationProperty orderLinesProperty = vipOrderType.NavigationProperties().Single(x => x.ContainsTarget && x.Name == "OrderLines"); + IEdmContainedEntitySet orderLines = (IEdmContainedEntitySet)myVipOrder.FindNavigationTarget(orderLinesProperty); + IEdmEntityType orderLine = _myOrderModel.SchemaElements.OfType().First(e => e.Name == "OrderLine"); + IEdmNavigationProperty orderLineDetailsNav = orderLine.NavigationProperties().First(); + + HttpRequest request = RequestFactory.Create(_myOrderModel); + + ODataPath path = new ODataPath( + new SingletonSegment(myVipOrder), + new NavigationPropertySegment(orderLinesProperty, orderLines)); + + ODataSerializerContext orderLineSerializerContext = ODataSerializerContextFactory.Create(_myOrderModel, orderLines, path, request); + orderLineSerializerContext.EdmProperty = orderLineDetailsNav; + ResourceContext orderLineResource = new ResourceContext(orderLineSerializerContext, orderLine.AsReference(), new { ID = 21 }); + orderLineSerializerContext.ExpandedResource = orderLineResource; + + return orderLineResource; + } + + [Fact] + public void GenerateBaseODataPathSegments_WorksToGenerateExpectedPath_ForSingletonContainer() + { + // Arrange + ResourceContext orderLineResource = GetOrderLineResourceForNewSingletonContainer(); + + // Act + IList newPaths = orderLineResource.GenerateBaseODataPathSegments(); + + // Assert + Assert.Equal(3, newPaths.Count); + Assert.IsType(newPaths[0]); // VipOrder + Assert.IsType(newPaths[1]); // OrderLines + Assert.IsType(newPaths[2]); // 21 + } + + [Fact] + public void GenerateSelfLink_WorksToGenerateExpectedSelfLink_ForSingletonContainer() + { + // Arrange + ResourceContext orderLineResource = GetOrderLineResourceForNewSingletonContainer(); + + // Act + Uri selfLink = orderLineResource.GenerateSelfLink(false); + + // Assert + Assert.Equal("http://localhost/VipOrder/OrderLines(21)", selfLink.AbsoluteUri); + } + [Theory] [InlineData(false, "http://localhost/MyOrders(42)/OrderLines(21)/OrderLines")] [InlineData(true, "http://localhost/MyOrders(42)/OrderLines(21)/NS.OrderLine/OrderLines")] @@ -125,8 +179,8 @@ public void GenerateNavigationLink_WorksToGenerateExpectedNavigationLink_ForCont IEdmEntityType myOrder = (IEdmEntityType)_myOrderModel.FindDeclaredType("NS.MyOrder"); IEdmEntityType orderLine = (IEdmEntityType)_myOrderModel.FindDeclaredType("NS.OrderLine"); - IEdmNavigationProperty orderLinesProperty = myOrder.NavigationProperties().Single(x => x.ContainsTarget); - + IEdmNavigationProperty orderLinesProperty = myOrder.NavigationProperties().Single(x => x.ContainsTarget && x.Name == "OrderLines"); + IEdmEntitySet entitySet = _myOrderModel.FindDeclaredEntitySet("MyOrders"); IDictionary parameters = new Dictionary { @@ -689,6 +743,7 @@ private static IEdmModel GetEdmModel2() ODataConventionModelBuilder builder = new ODataConventionModelBuilder(); builder.Namespace = "NS"; builder.EntitySet("MyOrders"); + builder.Singleton("VipOrder"); return builder.GetEdmModel(); } @@ -703,6 +758,14 @@ private class MyOrder } private class OrderLine + { + public int ID { get; set; } + + [Contained] + public IList OrderLineDetails { get; set; } + } + + private class OrderLineDetail { public int ID { get; set; } }