diff --git a/src/Microsoft.AspNetCore.OData/Formatter/ODataOutputFormatterHelper.cs b/src/Microsoft.AspNetCore.OData/Formatter/ODataOutputFormatterHelper.cs index baa00320f..b75d9db40 100644 --- a/src/Microsoft.AspNetCore.OData/Formatter/ODataOutputFormatterHelper.cs +++ b/src/Microsoft.AspNetCore.OData/Formatter/ODataOutputFormatterHelper.cs @@ -159,6 +159,7 @@ internal static async Task WriteToStreamAsync( writeContext.QueryOptions = queryOptions; writeContext.SetComputedProperties(queryOptions?.Compute?.ComputeClause); writeContext.Type = type; + writeContext.IsDelta = serializer.ODataPayloadKind == ODataPayloadKind.Delta; //Set the SelectExpandClause on the context if it was explicitly specified. if (selectExpandDifferentFromQueryOptions != null) diff --git a/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSerializer.cs b/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSerializer.cs index aa6050cd3..39b7b4152 100644 --- a/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSerializer.cs +++ b/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSerializer.cs @@ -134,7 +134,7 @@ private async Task WriteDeltaComplexPropertiesAsync(SelectExpandNode selectExpan IEnumerable complexProperties = selectExpandNode.SelectedComplexProperties.Keys; - if (null != resourceContext.EdmObject && resourceContext.EdmObject.IsDeltaResource()) + if (resourceContext.EdmObject != null && resourceContext.SerializerContext.IsDelta) { IDelta deltaObject = null; if (resourceContext.EdmObject is TypedEdmEntityObject obj) @@ -477,7 +477,7 @@ private async Task WriteResourceAsync(object graph, ODataWriter writer, ODataSer } await writer.WriteStartAsync(odataDeletedResource).ConfigureAwait(false); - await WriteResourceContent(writer, selectExpandNode, resourceContext, /*isDelta*/ true).ConfigureAwait(false); + await WriteResourceContent(writer, selectExpandNode, resourceContext).ConfigureAwait(false); await writer.WriteEndAsync().ConfigureAwait(false); } else @@ -494,9 +494,8 @@ await writer.WriteEntityReferenceLinkAsync(new ODataEntityReferenceLink } else { - bool isDelta = graph is IDelta || graph is IEdmChangedObject; await writer.WriteStartAsync(resource).ConfigureAwait(false); - await WriteResourceContent(writer, selectExpandNode, resourceContext, isDelta).ConfigureAwait(false); + await WriteResourceContent(writer, selectExpandNode, resourceContext).ConfigureAwait(false); await writer.WriteEndAsync().ConfigureAwait(false); } } @@ -512,11 +511,10 @@ await writer.WriteEntityReferenceLinkAsync(new ODataEntityReferenceLink /// The to use to write the resource contents /// The describing the response graph. /// The context for the resource instance being written. - /// Whether to only write changed properties of the resource - private async Task WriteResourceContent(ODataWriter writer, SelectExpandNode selectExpandNode, ResourceContext resourceContext, bool isDelta) + private async Task WriteResourceContent(ODataWriter writer, SelectExpandNode selectExpandNode, ResourceContext resourceContext) { // TODO: These should be aligned; do we need different methods for delta versus non-delta complex/navigation properties? - if (isDelta) + if (resourceContext.SerializerContext.IsDelta) { await WriteUntypedPropertiesAsync(selectExpandNode, resourceContext, writer).ConfigureAwait(false); await WriteStreamPropertiesAsync(selectExpandNode, resourceContext, writer).ConfigureAwait(false); @@ -1130,7 +1128,7 @@ private async Task WriteComplexPropertiesAsync(SelectExpandNode selectExpandNode return; } - if (null != resourceContext.EdmObject && resourceContext.EdmObject.IsDeltaResource()) + if (resourceContext.EdmObject != null && resourceContext.SerializerContext.IsDelta) { IDelta deltaObject = resourceContext.EdmObject as IDelta; IEnumerable changedProperties = deltaObject.GetChangedPropertyNames(); @@ -1400,7 +1398,7 @@ private IEnumerable CreateStructuralPropertyBag(SelectExpandNode { IEnumerable structuralProperties = selectExpandNode.SelectedStructuralProperties; - if (null != resourceContext.EdmObject && resourceContext.EdmObject.IsDeltaResource()) + if (resourceContext.EdmObject != null && resourceContext.SerializerContext.IsDelta) { IDelta deltaObject = null; if (resourceContext.EdmObject is TypedEdmEntityObject obj) @@ -1412,8 +1410,8 @@ private IEnumerable CreateStructuralPropertyBag(SelectExpandNode deltaObject = resourceContext.EdmObject as IDelta; } - if(deltaObject != null) - { + if (deltaObject != null) + { IEnumerable changedProperties = deltaObject.GetChangedPropertyNames(); structuralProperties = structuralProperties.Where(p => changedProperties.Contains(p.Name) || p.IsKey()); } diff --git a/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataSerializerContext.cs b/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataSerializerContext.cs index 626bad565..b2d88a99d 100644 --- a/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataSerializerContext.cs +++ b/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataSerializerContext.cs @@ -44,12 +44,13 @@ public ODataSerializerContext() /// /// Initializes a new instance of the class. /// - /// The resource whose property is being nested. + /// The context for the resource instance being written /// The for the property being nested. /// The complex property being nested or the navigation property being expanded. /// If the resource property is the dynamic complex, the resource property is null. /// /// This constructor is used to construct the serializer context for writing nested and expanded properties. + // TODO: Rename "resource" to "resourceContext" in next major release. public ODataSerializerContext(ResourceContext resource, SelectExpandClause selectExpandClause, IEdmProperty edmProperty) : this(resource, edmProperty, null, null) { @@ -59,22 +60,22 @@ public ODataSerializerContext(ResourceContext resource, SelectExpandClause selec /// /// Initializes a new instance of the class for nested resources. /// - /// The resource whose property is being nested. + /// The context for the resource instance being written. /// The complex property being nested or the navigation property being expanded. /// If the resource property is the dynamic complex, the resource property is null. /// /// The for the property being nested. /// The for the property being nested.> - internal ODataSerializerContext(ResourceContext resource, IEdmProperty edmProperty, ODataQueryContext queryContext, SelectItem currentSelectItem) + internal ODataSerializerContext(ResourceContext resourceContext, IEdmProperty edmProperty, ODataQueryContext queryContext, SelectItem currentSelectItem) { - if (resource == null) + if (resourceContext == null) { - throw Error.ArgumentNull("resource"); + throw Error.ArgumentNull($"{nameof(resourceContext)}"); } // Clone the resource's context. Use a helper function so it can // handle platform-specific differences in ODataSerializerContext. - ODataSerializerContext context = resource.SerializerContext; + ODataSerializerContext context = resourceContext.SerializerContext; this.Request = context.Request; Model = context.Model; @@ -89,7 +90,8 @@ internal ODataSerializerContext(ResourceContext resource, IEdmProperty edmProper QueryContext = queryContext; - ExpandedResource = resource; // parent resource + ExpandedResource = resourceContext; // parent resource + IsDelta = context.IsDelta; CurrentSelectItem = currentSelectItem; @@ -107,7 +109,7 @@ internal ODataSerializerContext(ResourceContext resource, IEdmProperty edmProper if (pathSelectItem != null) { SelectExpandClause = pathSelectItem.SelectAndExpand; - NavigationSource = resource.NavigationSource; // Use it's parent navigation source. + NavigationSource = resourceContext.NavigationSource; // Use it's parent navigation source. SetComputedProperties(pathSelectItem.ComputeOption); } @@ -133,7 +135,7 @@ internal ODataSerializerContext(ResourceContext resource, IEdmProperty edmProper } else { - NavigationSource = resource.NavigationSource; + NavigationSource = resourceContext.NavigationSource; } } } @@ -324,6 +326,11 @@ internal bool IsDeltaOfT } } + /// + /// Gets or sets a value indicating whether a delta payload is being serialized. + /// + internal bool IsDelta { get; set; } + /// /// Gets or sets the . /// diff --git a/src/Microsoft.AspNetCore.OData/Formatter/Value/EdmEntityCollectionObject.cs b/src/Microsoft.AspNetCore.OData/Formatter/Value/EdmEntityObjectCollection.cs similarity index 97% rename from src/Microsoft.AspNetCore.OData/Formatter/Value/EdmEntityCollectionObject.cs rename to src/Microsoft.AspNetCore.OData/Formatter/Value/EdmEntityObjectCollection.cs index c24223c28..37723f62d 100644 --- a/src/Microsoft.AspNetCore.OData/Formatter/Value/EdmEntityCollectionObject.cs +++ b/src/Microsoft.AspNetCore.OData/Formatter/Value/EdmEntityObjectCollection.cs @@ -1,5 +1,5 @@ //----------------------------------------------------------------------------- -// +// // Copyright (c) .NET Foundation and Contributors. All rights reserved. // See License.txt in the project root for license information. // diff --git a/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml b/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml index b4d565bbc..fc98160bd 100644 --- a/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml +++ b/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml @@ -4792,14 +4792,13 @@ The ODataWriter. A task that represents the asynchronous write operation - + Writes the context of a Resource The to use to write the resource contents The describing the response graph. The context for the resource instance being written. - Whether to only write changed properties of the resource @@ -5124,7 +5123,7 @@ Initializes a new instance of the class. - The resource whose property is being nested. + The context for the resource instance being written The for the property being nested. The complex property being nested or the navigation property being expanded. If the resource property is the dynamic complex, the resource property is null. @@ -5135,7 +5134,7 @@ Initializes a new instance of the class for nested resources. - The resource whose property is being nested. + The context for the resource instance being written. The complex property being nested or the navigation property being expanded. If the resource property is the dynamic complex, the resource property is null. @@ -5233,6 +5232,11 @@ Gets or sets the . + + + Gets or sets a value indicating whether a delta payload is being serialized. + + Gets or sets the . @@ -5757,27 +5761,6 @@ Returning DeltaKind of the object within DeltaResourceSet payload - - - Represents an that is a collection of s. - - - - - Initializes a new instance of the class. - - The edm type of the collection. - - - - Initializes a new instance of the class. - - The edm type of the collection. - The list that is wrapped by the new collection. - - - - Represents an with no backing CLR . @@ -5802,6 +5785,27 @@ The of this object. true if this object can be nullable; otherwise, false. + + + Represents an that is a collection of s. + + + + + Initializes a new instance of the class. + + The edm type of the collection. + + + + Initializes a new instance of the class. + + The edm type of the collection. + The list that is wrapped by the new collection. + + + + Represents an with no backing CLR . diff --git a/src/Microsoft.AspNetCore.OData/PublicAPI.Shipped.txt b/src/Microsoft.AspNetCore.OData/PublicAPI.Shipped.txt index 1a905d55c..fa47b92a4 100644 --- a/src/Microsoft.AspNetCore.OData/PublicAPI.Shipped.txt +++ b/src/Microsoft.AspNetCore.OData/PublicAPI.Shipped.txt @@ -470,7 +470,6 @@ Microsoft.AspNetCore.OData.Formatter.Serialization.ODataSerializerContext.Naviga Microsoft.AspNetCore.OData.Formatter.Serialization.ODataSerializerContext.NavigationSource.get -> Microsoft.OData.Edm.IEdmNavigationSource Microsoft.AspNetCore.OData.Formatter.Serialization.ODataSerializerContext.NavigationSource.set -> void Microsoft.AspNetCore.OData.Formatter.Serialization.ODataSerializerContext.ODataSerializerContext() -> void -Microsoft.AspNetCore.OData.Formatter.Serialization.ODataSerializerContext.ODataSerializerContext(Microsoft.AspNetCore.OData.Formatter.ResourceContext resource, Microsoft.OData.UriParser.SelectExpandClause selectExpandClause, Microsoft.OData.Edm.IEdmProperty edmProperty) -> void Microsoft.AspNetCore.OData.Formatter.Serialization.ODataSerializerContext.Path.get -> Microsoft.OData.UriParser.ODataPath Microsoft.AspNetCore.OData.Formatter.Serialization.ODataSerializerContext.Path.set -> void Microsoft.AspNetCore.OData.Formatter.Serialization.ODataSerializerContext.QueryOptions.get -> Microsoft.AspNetCore.OData.Query.ODataQueryOptions @@ -953,7 +952,6 @@ Microsoft.AspNetCore.OData.Query.HandleNullPropagationOption.False = 2 -> Micros Microsoft.AspNetCore.OData.Query.HandleNullPropagationOption.True = 1 -> Microsoft.AspNetCore.OData.Query.HandleNullPropagationOption Microsoft.AspNetCore.OData.Query.HttpRequestODataQueryExtensions Microsoft.AspNetCore.OData.Query.ICountOptionCollection -Microsoft.AspNetCore.OData.Query.ICountOptionCollection.TotalCount.get -> long?Microsoft.AspNetCore.OData.Query.IODataQueryRequestParser Microsoft.AspNetCore.OData.Query.IODataQueryRequestParser.CanParse(Microsoft.AspNetCore.Http.HttpRequest request) -> bool Microsoft.AspNetCore.OData.Query.IODataQueryRequestParser.ParseAsync(Microsoft.AspNetCore.Http.HttpRequest request) -> System.Threading.Tasks.Task Microsoft.AspNetCore.OData.Query.ODataQueryContext diff --git a/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt b/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt index e69de29bb..53fec81f8 100644 --- a/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt +++ b/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt @@ -0,0 +1,3 @@ +Microsoft.AspNetCore.OData.Formatter.Serialization.ODataSerializerContext.ODataSerializerContext(Microsoft.AspNetCore.OData.Formatter.ResourceContext resource, Microsoft.OData.UriParser.SelectExpandClause selectExpandClause, Microsoft.OData.Edm.IEdmProperty edmProperty) -> void +Microsoft.AspNetCore.OData.Query.ICountOptionCollection.TotalCount.get -> long? +Microsoft.AspNetCore.OData.Query.IODataQueryRequestParser \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessController.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessController.cs new file mode 100644 index 000000000..1e966f4d5 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessController.cs @@ -0,0 +1,77 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Linq; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OData.Deltas; +using Microsoft.AspNetCore.OData.Formatter.Value; +using Microsoft.AspNetCore.OData.Routing.Controllers; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.Typeless; + +public class TypelessDeltaController : ODataController +{ + [HttpGet] + public EdmChangedObjectCollection GetChanges() + { + return TypelessDataSource.TypelessChangeSets; + } +} + +public class TypedDeltaController : ODataController +{ + [HttpGet] + public DeltaSet GetChanges() + { + return TypelessDataSource.TypedChangeSets; + } +} + +public class TypelessOrdersController : ODataController +{ + [HttpGet("Orders")] + public EdmEntityObjectCollection Get() + { + Request.Process(); + + return TypelessDataSource.TypelessUnchangedOrders; + } + + [HttpGet("Orders({key})")] + public IEdmEntityObject Get(int key) + { + Request.Process(); + + var orderEntityObject = TypelessDataSource.TypelessUnchangedOrders.FirstOrDefault(d => + { + if (d.TryGetPropertyValue("Id", out object value) && value.Equals(key)) + { + return true; + } + + return false; + }); + + return orderEntityObject; + } + + [HttpGet("Orders/GetChanged()")] + public ActionResult GetChanged() + { + Request.Process(); + + return TypelessDataSource.TypelessChangedOrders; + } + + [HttpGet("Orders/GetUnchanged()")] + public ActionResult GetUnchanged() + { + Request.Process(); + + return TypelessDataSource.TypelessUnchangedOrders; + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessDataModel.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessDataModel.cs new file mode 100644 index 000000000..2084bf46d --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessDataModel.cs @@ -0,0 +1,33 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.Typeless; + +// Types included for comparison with typeless scenario +public class ChangeSet +{ + public int Id { get; set; } + public object Changed { get; set; } +} + +public class Customer +{ + public int Id { get; set; } + public string Name { get; set; } + public decimal CreditLimit { get; set; } + public List Orders { get; set; } +} + +public class Order +{ + public int Id { get; set; } + public decimal Amount { get; set; } + public DateTimeOffset OrderDate { get; set; } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessDataSource.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessDataSource.cs new file mode 100644 index 000000000..b3c0fa2f0 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessDataSource.cs @@ -0,0 +1,212 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using Microsoft.AspNetCore.OData.Deltas; +using Microsoft.AspNetCore.OData.Formatter.Value; +using Microsoft.OData.Edm; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.Typeless; + +internal static class TypelessDataSource +{ + private readonly static EdmChangedObjectCollection typelessChangeSets; + private readonly static DeltaSet typedChangeSets; + private readonly static EdmEntityObjectCollection typelessUnchangedOrders; + private readonly static EdmChangedObjectCollection typelessChangedOrders; + + static TypelessDataSource() + { + typelessChangeSets = CreateTypelessChangeSets(); + typedChangeSets = CreateTypedChangedSets(); + typelessUnchangedOrders = CreateUnchangedOrders(); + typelessChangedOrders = CreateChangedOrders(); + } + + private static EdmChangedObjectCollection CreateTypelessChangeSets() + { + var changeSets = new EdmChangedObjectCollection(TypelessEdmModel.ChangeSetEntityType); + + var changeSetObject1 = new EdmEntityObject(TypelessEdmModel.ChangeSetEntityType); + changeSetObject1.TrySetPropertyValue("Id", 1); + changeSetObject1.TrySetPropertyValue("Changed", CreateTypelessChangedCustomer()); + changeSets.Add(changeSetObject1); + + var changeSetObject2 = new EdmEntityObject(TypelessEdmModel.ChangeSetEntityType); + changeSetObject2.TrySetPropertyValue("Id", 2); + changeSetObject2.TrySetPropertyValue("Changed", CreateTypelessChangedOrder()); + changeSets.Add(changeSetObject2); + + return changeSets; + } + + private static DeltaSet CreateTypedChangedSets() + { + var changeSetDeltaSet = new DeltaSet(); + + var changeSetDeltaItem1 = new Delta(); + changeSetDeltaItem1.TrySetPropertyValue("Id", 1); + changeSetDeltaItem1.TrySetPropertyValue("Changed", CreateTypedChangedCustomer()); + changeSetDeltaSet.Add(changeSetDeltaItem1); + + var changeSetDeltaItem2 = new Delta(); + changeSetDeltaItem2.TrySetPropertyValue("Id", 2); + changeSetDeltaItem2.TrySetPropertyValue("Changed", CreateTypedChangedOrder()); + changeSetDeltaSet.Add(changeSetDeltaItem2); + + return changeSetDeltaSet; + } + + private static EdmDeltaResourceObject CreateTypelessChangedCustomer() + { + var changedOrders = new EdmChangedObjectCollection( + EdmCoreModel.Instance.GetEntityType()); + + var changeOrder1Object = new EdmDeltaResourceObject(TypelessEdmModel.OrderEntityType); + changeOrder1Object.TrySetPropertyValue("Id", 1); + changedOrders.Add(changeOrder1Object); + + var changedOrder2Object = new EdmDeltaDeletedResourceObject(TypelessEdmModel.OrderEntityType); + changedOrder2Object.Id = new Uri("http://tempuri.org/Orders(2)"); + changedOrder2Object.TrySetPropertyValue("Id", 2); + changedOrders.Add(changedOrder2Object); + + var changedCustomerObject = new EdmDeltaResourceObject(TypelessEdmModel.CustomerEntityType); + changedCustomerObject.TrySetPropertyValue("Id", 1); + changedCustomerObject.TrySetPropertyValue("Orders", changedOrders); + + return changedCustomerObject; + } + + private static EdmDeltaResourceObject CreateTypelessChangedOrder() + { + var changedOrderObject = new EdmDeltaResourceObject(TypelessEdmModel.OrderEntityType); + changedOrderObject.TrySetPropertyValue("Id", 1); + changedOrderObject.TrySetPropertyValue("Amount", 310m); + + return changedOrderObject; + } + + private static Delta CreateTypedChangedCustomer() + { + var ordersDeltaSet = new DeltaSet(); + + var order1DeltaObject = new Delta(); + order1DeltaObject.TrySetPropertyValue("Id", 1); + ordersDeltaSet.Add(order1DeltaObject); + + var order2DeltaObject = new DeltaDeletedResource(); + order2DeltaObject.Id = new Uri("http://tempuri.org/Orders(2)"); + order2DeltaObject.TrySetPropertyValue("Id", 2); + ordersDeltaSet.Add(order2DeltaObject); + + var customerDeltaObject = new Delta(); + customerDeltaObject.TrySetPropertyValue("Id", 1); + customerDeltaObject.TrySetPropertyValue("Orders", ordersDeltaSet); + + return customerDeltaObject; + } + + private static Delta CreateTypedChangedOrder() + { + var orderDeltaObject = new Delta(); + orderDeltaObject.TrySetPropertyValue("Id", 1); + orderDeltaObject.TrySetPropertyValue("Amount", 310m); + + return orderDeltaObject; + } + + private static EdmEntityObjectCollection CreateUnchangedOrders() + { + var address1Object = new EdmComplexObject(TypelessEdmModel.AddressComplexType); + address1Object.TrySetPropertyValue("City", "Redmond"); + address1Object.TrySetPropertyValue("State", "Washington"); + + var address2Object = new EdmComplexObject(TypelessEdmModel.AddressComplexType); + address2Object.TrySetPropertyValue("City", "Dallas"); + address2Object.TrySetPropertyValue("State", "Texas"); + + var customer1Object = new EdmEntityObject(TypelessEdmModel.CustomerEntityType); + customer1Object.TrySetPropertyValue("Id", 1); + customer1Object.TrySetPropertyValue("Name", "Sue"); + customer1Object.TrySetPropertyValue("CreditLimit", 1300m); + + var customer2Object = new EdmEntityObject(TypelessEdmModel.CustomerEntityType); + customer2Object.TrySetPropertyValue("Id", 2); + customer2Object.TrySetPropertyValue("Name", "Joe"); + customer2Object.TrySetPropertyValue("CreditLimit", 1700m); + + var order1Object = new EdmEntityObject(TypelessEdmModel.OrderEntityType); + order1Object.TrySetPropertyValue("Id", 1); + order1Object.TrySetPropertyValue("Amount", 310m); + order1Object.TrySetPropertyValue("OrderDate", new DateTimeOffset(2025, 02, 7, 11, 59, 59, TimeSpan.Zero)); + order1Object.TrySetPropertyValue("ShippingAddress", address1Object); + order1Object.TrySetPropertyValue("Customer", customer1Object); + + var order2Object = new EdmEntityObject(TypelessEdmModel.OrderEntityType); + order2Object.TrySetPropertyValue("Id", 2); + order2Object.TrySetPropertyValue("Amount", 290m); + order2Object.TrySetPropertyValue("OrderDate", new DateTimeOffset(2025, 02, 14, 11, 59, 59, TimeSpan.Zero)); + order2Object.TrySetPropertyValue("ShippingAddress", address2Object); + order2Object.TrySetPropertyValue("Customer", customer2Object); + + return new EdmEntityObjectCollection( + new EdmCollectionTypeReference( + new EdmCollectionType( + new EdmEntityTypeReference(TypelessEdmModel.OrderEntityType, false)))) + { + order1Object, + order2Object + }; + } + + private static EdmChangedObjectCollection CreateChangedOrders() + { + // EdmComplexObject used in place of EdmDeltaComplexObject + var address1Object = new EdmComplexObject(TypelessEdmModel.AddressComplexType); + address1Object.TrySetPropertyValue("City", "Redmond"); + + var address2Object = new EdmDeltaComplexObject(TypelessEdmModel.AddressComplexType); + address2Object.TrySetPropertyValue("State", "Texas"); + + // EdmEntityObject used in place of EdmDeltaResourceObject + var customer1Object = new EdmEntityObject(TypelessEdmModel.CustomerEntityType); + customer1Object.TrySetPropertyValue("Id", 1); + customer1Object.TrySetPropertyValue("CreditLimit", 3100m); + + var customer2Object = new EdmDeltaResourceObject(TypelessEdmModel.CustomerEntityType); + customer2Object.TrySetPropertyValue("Id", 2); + customer2Object.TrySetPropertyValue("Name", "Luc"); + + var order1Object = new EdmDeltaResourceObject(TypelessEdmModel.OrderEntityType); + order1Object.TrySetPropertyValue("Id", 1); + order1Object.TrySetPropertyValue("OrderDate", new DateTimeOffset(2025, 02, 7, 11, 59, 59, TimeSpan.Zero)); + order1Object.TrySetPropertyValue("ShippingAddress", address1Object); + order1Object.TrySetPropertyValue("Customer", customer1Object); + + // EdmEntityObject used in place of EdmDeltaResourceObject + var order2Object = new EdmEntityObject(TypelessEdmModel.OrderEntityType); + order2Object.TrySetPropertyValue("Id", 2); + order2Object.TrySetPropertyValue("Amount", 590m); + order2Object.TrySetPropertyValue("ShippingAddress", address2Object); + order2Object.TrySetPropertyValue("Customer", customer2Object); + + return new EdmChangedObjectCollection(TypelessEdmModel.OrderEntityType) + { + order1Object, + order2Object + }; + } + + public static EdmChangedObjectCollection TypelessChangeSets => typelessChangeSets; + + public static DeltaSet TypedChangeSets => typedChangeSets; + + public static EdmEntityObjectCollection TypelessUnchangedOrders => typelessUnchangedOrders; + + public static EdmChangedObjectCollection TypelessChangedOrders => typelessChangedOrders; +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessEdmModel.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessEdmModel.cs new file mode 100644 index 000000000..e622247ef --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessEdmModel.cs @@ -0,0 +1,133 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +namespace Microsoft.AspNetCore.OData.E2E.Tests.Typeless; + +using Microsoft.OData.Edm; +using Microsoft.OData.ModelBuilder; + +internal static class TypelessEdmModel +{ + const string typelessNamespace = "Microsoft.AspNetCore.OData.E2E.Tests.Typeless"; + const string defaultNamespace = "Default"; + + private static readonly EdmModel model; + private static readonly EdmEntityType deltaEntityType; + private static readonly EdmEntityType changeSetEntityType; + private static readonly EdmEntityType customerEntityType; + private static readonly EdmEntityType orderEntityType; + private static readonly EdmComplexType addressComplexType; + + static TypelessEdmModel() + { + model = new EdmModel(); + + deltaEntityType = model.AddEntityType(typelessNamespace, "Delta"); + var deltaChangeIdProperty = deltaEntityType.AddStructuralProperty("Id", EdmPrimitiveTypeKind.Int32); + deltaEntityType.AddKeys(deltaChangeIdProperty); + + changeSetEntityType = model.AddEntityType(typelessNamespace, "ChangeSet"); + var changeSetIdProperty = changeSetEntityType.AddStructuralProperty("Id", EdmPrimitiveTypeKind.Int32); + changeSetEntityType.AddKeys(changeSetIdProperty); + + changeSetEntityType.AddUnidirectionalNavigation(new EdmNavigationPropertyInfo + { + Name = "Changed", + Target = EdmCoreModel.Instance.GetEntityType(), + TargetMultiplicity = EdmMultiplicity.One + }); + + addressComplexType = model.AddComplexType(typelessNamespace, "Address"); + addressComplexType.AddStructuralProperty("City", EdmPrimitiveTypeKind.String); + addressComplexType.AddStructuralProperty("State", EdmPrimitiveTypeKind.String); + + orderEntityType = model.AddEntityType(typelessNamespace, "Order"); + var orderIdProperty = orderEntityType.AddStructuralProperty("Id", EdmPrimitiveTypeKind.Int32); + orderEntityType.AddKeys(orderIdProperty); + orderEntityType.AddStructuralProperty("Amount", EdmPrimitiveTypeKind.Decimal); + orderEntityType.AddStructuralProperty("OrderDate", EdmPrimitiveTypeKind.DateTimeOffset); + orderEntityType.AddStructuralProperty("ShippingAddress", new EdmComplexTypeReference(addressComplexType, false)); + + customerEntityType = model.AddEntityType(typelessNamespace, "Customer"); + var customerIdProperty = customerEntityType.AddStructuralProperty("Id", EdmPrimitiveTypeKind.Int32); + customerEntityType.AddKeys(customerIdProperty); + customerEntityType.AddStructuralProperty("Name", EdmPrimitiveTypeKind.String); + customerEntityType.AddStructuralProperty("CreditLimit", EdmPrimitiveTypeKind.Decimal); + + var ordersNavigationProperty = customerEntityType.AddUnidirectionalNavigation(new EdmNavigationPropertyInfo + { + Name = "Orders", + Target = orderEntityType, + TargetMultiplicity = EdmMultiplicity.Many + }); + + var customerNavigationProperty = orderEntityType.AddUnidirectionalNavigation( + new EdmNavigationPropertyInfo + { + Name = "Customer", + TargetMultiplicity = EdmMultiplicity.One, + Target = customerEntityType + }); + + var getChangesFunction = new EdmFunction( + namespaceName: defaultNamespace, + name: "GetChanges", + returnType: new EdmCollectionTypeReference(new EdmCollectionType(new EdmEntityTypeReference(changeSetEntityType, false))), + isBound: true, + entitySetPathExpression: null, + isComposable: true); + getChangesFunction.AddParameter("bindingParameter", new EdmEntityTypeReference(deltaEntityType, false)); + model.AddElement(getChangesFunction); + + var orderEntityCollectionTypeReference = new EdmCollectionTypeReference(new EdmCollectionType(new EdmEntityTypeReference(orderEntityType, false))); + + var getChangedFunction = new EdmFunction( + namespaceName: defaultNamespace, + name: "GetChanged", + returnType: orderEntityCollectionTypeReference, + isBound: true, + entitySetPathExpression: null, + isComposable: true); + getChangedFunction.AddParameter("bindingParameter", orderEntityCollectionTypeReference); + model.AddElement(getChangedFunction); + + var getUnchangedFunction = new EdmFunction( + namespaceName: defaultNamespace, + name: "GetUnchanged", + returnType: orderEntityCollectionTypeReference, + isBound: true, + entitySetPathExpression: null, + isComposable: true); + getUnchangedFunction.AddParameter("bindingParameter", orderEntityCollectionTypeReference); + model.AddElement(getUnchangedFunction); + + var defaultEntityContainer = model.AddEntityContainer("Default", "Container"); + var customersEntitySet = defaultEntityContainer.AddEntitySet("Customers", customerEntityType); + var ordersEntitySet = defaultEntityContainer.AddEntitySet("Orders", orderEntityType); + + customersEntitySet.AddNavigationTarget(ordersNavigationProperty, ordersEntitySet); + ordersEntitySet.AddNavigationTarget(customerNavigationProperty, customersEntitySet); + + defaultEntityContainer.AddSingleton("TypelessDelta", deltaEntityType); + defaultEntityContainer.AddSingleton("TypedDelta", deltaEntityType); + defaultEntityContainer.AddEntitySet("ChangeSets", changeSetEntityType); + + model.SetAnnotationValue(getChangesFunction, new ReturnedEntitySetAnnotation("ChangeSets")); + } + + public static EdmEntityType DeltaEntityType => deltaEntityType; + + public static EdmEntityType ChangeSetEntityType => changeSetEntityType; + + public static EdmEntityType CustomerEntityType => customerEntityType; + + public static EdmEntityType OrderEntityType => orderEntityType; + + public static EdmComplexType AddressComplexType => addressComplexType; + + public static EdmModel GetModel() => model; +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessExtensions.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessExtensions.cs new file mode 100644 index 000000000..6dd2cf5ba --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessExtensions.cs @@ -0,0 +1,29 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.OData.Extensions; +using Microsoft.AspNetCore.OData.Query; +using Microsoft.OData.Edm; +using Microsoft.OData.UriParser; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.Typeless; + +internal static class TypelessExtensions +{ + public static void Process(this HttpRequest request) + { + var path = request.ODataFeature().Path; + var elementType = path.EdmType().Definition.AsElementType(); + var model = request.ODataFeature().Model; + var queryContext = new ODataQueryContext(model, elementType, path); + var queryOptions = new ODataQueryOptions(queryContext, request); + + request.ODataFeature().SelectExpandClause = queryOptions.SelectExpand?.SelectExpandClause; + request.ODataFeature().ApplyClause = queryOptions.Apply?.ApplyClause; + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessTests.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessTests.cs new file mode 100644 index 000000000..a8def1192 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessTests.cs @@ -0,0 +1,207 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.OData.TestCommon; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.Typeless; + +public class TypelessTests : WebApiTestBase +{ + public TypelessTests(WebApiTestFixture fixture) + : base(fixture) + { + } + + protected static void UpdateConfigureServices(IServiceCollection services) + { + var model = TypelessEdmModel.GetModel(); + + services.ConfigureControllers(typeof(TypelessOrdersController), typeof(TypelessDeltaController), typeof(TypedDeltaController)); + services.AddControllers().AddOData( + options => options.EnableQueryFeatures().AddRouteComponents(TypelessEdmModel.GetModel())); + } + + [Theory] + [InlineData("TypelessDelta/GetChanges()")] + [InlineData("TypedDelta/GetChanges()")] // Typed scenario included for comparison + public async Task TestPropertiesNotSetInDeltaAreNotIncludedInPayloadAsync(string requestUri) + { + // Arrange + 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.EndsWith("/$metadata#ChangeSets/$delta\",\"value\":[" + + "{\"Id\":1," + + "\"Changed\":{" + + "\"@odata.type\":\"#Microsoft.AspNetCore.OData.E2E.Tests.Typeless.Customer\"," + + "\"Id\":1," + + "\"Orders@delta\":[{\"Id\":1},{\"@odata.removed\":{\"reason\":\"deleted\"},\"@odata.id\":\"http://tempuri.org/Orders(2)\",\"Id\":2}]}}," + + "{\"Id\":2," + + "\"Changed\":{" + + "\"@odata.type\":\"#Microsoft.AspNetCore.OData.E2E.Tests.Typeless.Order\"," + + "\"Id\":1," + + "\"Amount\":310}}]}", + content); + } + + [Theory] + [InlineData("Orders", "Orders")] + [InlineData("Orders/GetUnchanged()", "Collection(Microsoft.AspNetCore.OData.E2E.Tests.Typeless.Order)")] + public async Task TestNavigationPropertyNotAutoExpandedAsync(string requestUri, string metadataFragment) + { + // Arrange + 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.EndsWith($"/$metadata#{metadataFragment}\",\"value\":[" + + "{\"Id\":1," + + "\"Amount\":310," + + "\"OrderDate\":\"2025-02-07T11:59:59Z\"," + + "\"ShippingAddress\":{\"City\":\"Redmond\",\"State\":\"Washington\"}}," + + "{\"Id\":2," + + "\"Amount\":290," + + "\"OrderDate\":\"2025-02-14T11:59:59Z\"," + + "\"ShippingAddress\":{\"City\":\"Dallas\",\"State\":\"Texas\"}}]}", + content); + } + + [Theory] + [InlineData("Orders", "Orders(Customer())")] + [InlineData("Orders/GetUnchanged()", "Collection(Microsoft.AspNetCore.OData.E2E.Tests.Typeless.Order)")] + public async Task TestNavigationPropertyExpandedAsync(string resourcePath, string metadataFragment) + { + // Arrange + var requestUri = $"{resourcePath}?$expand=Customer"; + 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.EndsWith($"/$metadata#{metadataFragment}\",\"value\":[" + + "{\"Id\":1," + + "\"Amount\":310," + + "\"OrderDate\":\"2025-02-07T11:59:59Z\"," + + "\"ShippingAddress\":{\"City\":\"Redmond\",\"State\":\"Washington\"}," + + "\"Customer\":{\"Id\":1,\"Name\":\"Sue\",\"CreditLimit\":1300}}," + + "{\"Id\":2," + + "\"Amount\":290," + + "\"OrderDate\":\"2025-02-14T11:59:59Z\"," + + "\"ShippingAddress\":{\"City\":\"Dallas\",\"State\":\"Texas\"}," + + "\"Customer\":{\"Id\":2,\"Name\":\"Joe\",\"CreditLimit\":1700}}]}", + content); + } + + [Theory] + [InlineData("Orders", "Orders(Customer(Name))")] + [InlineData("Orders/GetUnchanged()", "Collection(Microsoft.AspNetCore.OData.E2E.Tests.Typeless.Order)")] + public async Task TestNavigationPropertyExpandedWithNestedSelectAsync(string resourcePath, string metadataFragment) + { + // Arrange + var requestUri = $"{resourcePath}?$expand=Customer($select=Name)"; + 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.EndsWith($"/$metadata#{metadataFragment}\",\"value\":[" + + "{\"Id\":1," + + "\"Amount\":310," + + "\"OrderDate\":\"2025-02-07T11:59:59Z\"," + + "\"ShippingAddress\":{\"City\":\"Redmond\",\"State\":\"Washington\"}," + + "\"Customer\":{\"Name\":\"Sue\"}}," + + "{\"Id\":2," + + "\"Amount\":290," + + "\"OrderDate\":\"2025-02-14T11:59:59Z\"," + + "\"ShippingAddress\":{\"City\":\"Dallas\",\"State\":\"Texas\"}," + + "\"Customer\":{\"Name\":\"Joe\"}}]}", + content); + } + + [Theory] + [InlineData("Orders", "Orders(Id,Amount,Customer(Id,CreditLimit))")] + [InlineData("Orders/GetUnchanged()", "Collection(Microsoft.AspNetCore.OData.E2E.Tests.Typeless.Order)")] + public async Task TestSelectAndNavigationPropertyExpandedWithNestedSelectAsync(string resourcePath, string metadataFragment) + { + // Arrange + var requestUri = $"{resourcePath}?$select=Id,Amount&$expand=Customer($select=Id,CreditLimit)"; + 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.EndsWith($"/$metadata#{metadataFragment}\",\"value\":[" + + "{\"Id\":1,\"Amount\":310,\"Customer\":{\"Id\":1,\"CreditLimit\":1300}}," + + "{\"Id\":2,\"Amount\":290,\"Customer\":{\"Id\":2,\"CreditLimit\":1700}}]}", + content); + } + + [Fact] + public async Task TestSingleResultSelectAndNavigationPropertyExpandedWithNestedSelectAsync() + { + // Arrange + var requestUri = "Orders(1)?$select=Id,Amount&$expand=Customer($select=Id,CreditLimit)"; + 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.EndsWith("/$metadata#Orders(Id,Amount,Customer(Id,CreditLimit))/$entity\"," + + "\"Id\":1,\"Amount\":310,\"Customer\":{\"Id\":1,\"CreditLimit\":1300}}", + content); + } + + [Fact] + public async Task TestEdmEntityObjectAndEdmComplexObjectInChangedObjectCollection() + { + // Arrange + var requestUri = "Orders/GetChanged()"; + 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.EndsWith("/$metadata#Collection(Microsoft.AspNetCore.OData.E2E.Tests.Typeless.Order)/$delta\",\"value\":[" + + "{\"Id\":1," + + "\"OrderDate\":\"2025-02-07T11:59:59Z\"," + + "\"ShippingAddress\":{\"City\":\"Redmond\"}," + + "\"Customer\":{\"Id\":1,\"CreditLimit\":3100}}," + + "{\"Id\":2," + + "\"Amount\":590," + + "\"ShippingAddress\":{\"State\":\"Texas\"}," + + "\"Customer\":{\"Id\":2,\"Name\":\"Luc\"}}]}", + content); + } +} diff --git a/test/Microsoft.AspNetCore.OData.Tests/Formatter/Serialization/ODataSerializerContextTest.cs b/test/Microsoft.AspNetCore.OData.Tests/Formatter/Serialization/ODataSerializerContextTest.cs index 7b3160426..729716069 100644 --- a/test/Microsoft.AspNetCore.OData.Tests/Formatter/Serialization/ODataSerializerContextTest.cs +++ b/test/Microsoft.AspNetCore.OData.Tests/Formatter/Serialization/ODataSerializerContextTest.cs @@ -40,7 +40,7 @@ public void Ctor_ForNestedContext_ThrowsArgumentNull_Resource() // Act & Assert ExceptionAssert.ThrowsArgumentNull( - () => new ODataSerializerContext(resource: null, selectExpandClause: selectExpand, edmProperty: navProp), "resource"); + () => new ODataSerializerContext(resource: null, selectExpandClause: selectExpand, edmProperty: navProp), "resourceContext"); } [Fact]