Skip to content

Fixes #1494: Enable DeltaSet<T> for minimal API parameter binding #1495

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions sample/ODataMiniApi/CustomersAndOrders.http
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,43 @@ Content-Type: application/json
}

###

# The following is the test case for patch deltaset (changes) to an entity set.
# You will get the following response:
#
#{
# "@odata.context": "http://localhost:5177/$metadata#Edm.String",
# "value": "Patch : '4' to customers"
#}

PATCH {{ODataMiniApi_HostAddress}}/v1/customers
Content-Type: application/json

{
"@odata.context":"http://localhost/v1/$metadata#Customers/$delta",
"value":[
{
"@odata.id":"Customers(42)",
"Name":"Microsoft"
},
{
"@odata.context":"http://localhost/v1/$metadata#Customers/$deletedLink",
"source":"Customers(32)",
"relationship":"Orders",
"target":"Orders(12)"
},
{
"@odata.context":"http://localhost/v1/$metadata#Customers/$link",
"source":"Customers(22)",
"relationship":"Orders",
"target":"Orders(2)"
},
{
"@odata.context":"http://localhost/v1/$metadata#Customers/$deletedEntity",
"id":"Customers(12)",
"reason":"deleted"
}
]
}

###
14 changes: 14 additions & 0 deletions sample/ODataMiniApi/Endpoints/CustomersEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,20 @@ public static IEndpointRouteBuilder MapCustomersEndpoints(this IEndpointRouteBui
new OperationSegment(action, null)
);
});

// DeltaSet<T>
app.MapPatch("v1/customers", (AppDb db, DeltaSet<Customer> changes) =>
{
return $"Patch : '{changes.Count}' to customers";
})
.WithODataResult()
.WithODataModel(model)
.WithODataPathFactory(
(h, t) =>
{
IEdmEntitySet customers = model.FindDeclaredEntitySet("Customers");
return new ODataPath(new EntitySetSegment(customers));
});
return app;
}

Expand Down
17 changes: 16 additions & 1 deletion src/Microsoft.AspNetCore.OData/Deltas/DeltaSetOfT.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@
using System;
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.OData.Abstracts;
using Microsoft.AspNetCore.OData.Extensions;

namespace Microsoft.AspNetCore.OData.Deltas;

Expand All @@ -29,6 +33,17 @@ public class DeltaSet<T> : Collection<IDeltaSetItem>, IDeltaSet, ITypedDelta whe
/// </summary>
public Type ExpectedClrType => typeof(T);

/// <summary>
/// Binds the <see cref="HttpContext"/> and <see cref="ParameterInfo"/> to generate the <see cref="DeltaSet{T}"/>.
/// </summary>
/// <param name="context">The HttpContext.</param>
/// <param name="parameter">The parameter info.</param>
/// <returns>The built <see cref="DeltaSet{T}"/></returns>
public static async ValueTask<DeltaSet<T>> BindAsync(HttpContext context, ParameterInfo parameter)
{
return await context.BindODataParameterAsync<DeltaSet<T>>(parameter);
}

#region Exclude unfinished APIs
#if false
/// <summary>
Expand Down Expand Up @@ -100,5 +115,5 @@ protected virtual T GetOriginal(IDeltaSetItem deltaItem, IEnumerable originalSet
return null;
}
#endif
#endregion
#endregion
}
8 changes: 8 additions & 0 deletions src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1837,6 +1837,14 @@
Gets the expected type of the entity for which the changes are tracked.
</summary>
</member>
<member name="M:Microsoft.AspNetCore.OData.Deltas.DeltaSet`1.BindAsync(Microsoft.AspNetCore.Http.HttpContext,System.Reflection.ParameterInfo)">
<summary>
Binds the <see cref="T:Microsoft.AspNetCore.Http.HttpContext"/> and <see cref="T:System.Reflection.ParameterInfo"/> to generate the <see cref="T:Microsoft.AspNetCore.OData.Deltas.DeltaSet`1"/>.
</summary>
<param name="context">The HttpContext.</param>
<param name="parameter">The parameter info.</param>
<returns>The built <see cref="T:Microsoft.AspNetCore.OData.Deltas.DeltaSet`1"/></returns>
</member>
<member name="T:Microsoft.AspNetCore.OData.Deltas.IDelta">
<summary>
<see cref="T:Microsoft.AspNetCore.OData.Deltas.IDelta" /> allows and tracks changes to an object.
Expand Down
1 change: 1 addition & 0 deletions src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ Microsoft.AspNetCore.OData.Results.IODataResult.Value.get -> object
override Microsoft.AspNetCore.OData.Query.Wrapper.SelectExpandWrapperConverter.CanConvert(System.Type typeToConvert) -> bool
override Microsoft.AspNetCore.OData.Query.Wrapper.SelectExpandWrapperConverter.CreateConverter(System.Type type, System.Text.Json.JsonSerializerOptions options) -> System.Text.Json.Serialization.JsonConverter
static Microsoft.AspNetCore.OData.Deltas.Delta<T>.BindAsync(Microsoft.AspNetCore.Http.HttpContext context, System.Reflection.ParameterInfo parameter) -> System.Threading.Tasks.ValueTask<Microsoft.AspNetCore.OData.Deltas.Delta<T>>
static Microsoft.AspNetCore.OData.Deltas.DeltaSet<T>.BindAsync(Microsoft.AspNetCore.Http.HttpContext context, System.Reflection.ParameterInfo parameter) -> System.Threading.Tasks.ValueTask<Microsoft.AspNetCore.OData.Deltas.DeltaSet<T>>
static Microsoft.AspNetCore.OData.Formatter.ODataActionParameters.BindAsync(Microsoft.AspNetCore.Http.HttpContext context, System.Reflection.ParameterInfo parameter) -> System.Threading.Tasks.ValueTask<Microsoft.AspNetCore.OData.Formatter.ODataActionParameters>
static Microsoft.AspNetCore.OData.Formatter.ODataUntypedActionParameters.BindAsync(Microsoft.AspNetCore.Http.HttpContext context, System.Reflection.ParameterInfo parameter) -> System.Threading.Tasks.ValueTask<Microsoft.AspNetCore.OData.Formatter.ODataUntypedActionParameters>
static Microsoft.AspNetCore.OData.ODataEndpointConventionBuilderExtensions.AddODataQueryEndpointFilter(this Microsoft.AspNetCore.Builder.RouteHandlerBuilder builder, Microsoft.AspNetCore.OData.Query.IODataQueryEndpointFilter queryFilter) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,19 @@
// </copyright>
//------------------------------------------------------------------------------

using System;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.OData.Deltas;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we usually place System directives first

using Microsoft.AspNetCore.OData.Query;
using Microsoft.AspNetCore.OData.TestCommon;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OData.Edm;
using Microsoft.OData.UriParser;
using System;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Xunit;

namespace Microsoft.AspNetCore.OData.E2E.Tests.MinimalApis;
Expand Down Expand Up @@ -64,6 +67,19 @@ protected static void ConfigureAPIs(WebApplication app)
.WithODataVersion(Microsoft.OData.ODataVersion.V401)
.WithODataBaseAddressFactory(h => new Uri("http://localhost/v2"))
.WithODataOptions(opt => opt.EnableAll().SetCaseInsensitive(true));

// DeltaSet<T> endpoint
app.MapPatch("v2/todos", (IMiniTodoTaskRepository db, DeltaSet<MiniTodo> changes) => $"Patch : '{changes.Count}' to todos")
.WithODataResult()
.WithODataModel(model)
.WithODataVersion(Microsoft.OData.ODataVersion.V401)
.WithODataBaseAddressFactory(h => new Uri("http://localhost/v2"))
.WithODataPathFactory(
(h, t) =>
{
IEdmEntitySet todos = model.FindDeclaredEntitySet("Todos");
return new ODataPath(new EntitySetSegment(todos));
});
}

[Fact]
Expand Down Expand Up @@ -151,4 +167,25 @@ public async Task QueryTodos_WithODataResult_WithModel_UsingFilter_ReturnsODataJ
"\"Tasks\":[{\"Description\":\"Clean bathroom\"},{\"Description\":\"Clean carpet\"}]}",
content);
}

[Fact]
public async Task PatchChangesToTodos_WithODataResult_WithModel_WithPath_ReturnsODataJsonPayload()
{
// Arrange & Act
var payload = @"{
'@context':'http://localhost/v2/$metadata#Todos/$delta',
'value':[
{ '@odata.id': 'Todos(42)','Title':'No 42 Todo'},
{ '@odata.context': 'http://localhost/v2/$metadata#Todos/$deletedEntity', 'Id': 'Todos(12)', 'reason':'deleted'}
]}";

StringContent stringContent = new StringContent(payload, Encoding.UTF8, "application/json");

var result = await _client.PatchAsync("/v2/todos", stringContent);
var content = await result.Content.ReadAsStringAsync();

// Assert
Assert.Equal(HttpStatusCode.OK, result.StatusCode);
Assert.Equal("{\"@context\":\"http://localhost/v2/$metadata#Edm.String\",\"value\":\"Patch : '2' to todos\"}", content);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,11 @@ public class Microsoft.AspNetCore.OData.Deltas.DeltaSet`1 : System.Collections.O

System.Type ExpectedClrType { public virtual get; }
System.Type StructuredType { public virtual get; }

[
AsyncStateMachineAttribute(),
]
public static ValueTask`1 BindAsync (Microsoft.AspNetCore.Http.HttpContext context, System.Reflection.ParameterInfo parameter)
}

public interface Microsoft.AspNetCore.OData.Edm.IODataModelConfiguration {
Expand Down