AspNetCoreOData
AspNetCoreOData copied to clipboard
EntitySet aggregation does not work as expected for object collections
Assemblies affected
- Microsoft.AspNetCore.OData 9.x - 9.2.1 used in investigation
- Microsoft.AspNetCore.OData 8.x - 8.2.7 used in investigation
- Microsoft.AspNetCore.OData 7.x - 7.7.8 used in investigation
Describe the bug
EntitySet aggregation DOES NOT work as expected for object collections but works as expected for Ef Core database provider (InMemory and SqlServer).
- Visit here to familiarize with entity set aggregation implementation
- Visit here to find a test for entityset aggregation
Reproduce steps
To demonstrate how the issue can be repro'ed, let's define a simple OData service that works with both Ef Core InMemory database provider and object collections as data sources
Required packages:
- Microsoft.EntityFrameworkCore.InMemory - 9.0.3 used in investigation
- Microsoft.AspNetCore.OData - (any of the affected version)
Data models
namespace EntitySetAggregation.Models
{
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
public List<Order> Orders { get; set; }
}
public class Order
{
public int Id { get; set; }
public decimal Amount { get; set; }
}
}
Database context and collection objects initialization
namespace EntitySetAggregation.Data
{
// DbContext - Ef Core
public class EntitySetAggregationDbContext : DbContext
{
public EntitySetAggregationDbContext(DbContextOptions<EntitySetAggregationDbContext> options)
: base(options)
{
}
public DbSet<Customer> Customers { get; set; }
public DbSet<Order> Orders { get; set; }
}
// Collection objects data source
internal static class DataSource
{
private static readonly List<Customer> customers;
private static readonly List<Order> orders;
static DataSource()
{
(customers, orders) = Generate();
}
public static List<Customer> Customers => customers;
public static List<Order> Orders => orders;
public static (List<Customer> Customers, List<Order> Orders) Generate()
{
var orders = new List<Order>
{
new Order { Id = 1, Amount = 130 },
new Order { Id = 2, Amount = 190 },
new Order { Id = 3, Amount = 170 },
};
var customers = new List<Customer>
{
new Customer { Id = 1, Name = "Sue", Orders = new List<Order> { orders[0], orders[2] } },
new Customer { Id = 2, Name = "Joe", Orders = new List<Order> { orders[1] } },
};
return (customers, orders);
}
}
// Helper class
internal static class EntitySetAggregationDbContextInitializer
{
public static void SeedDatabase(this EntitySetAggregationDbContext context)
{
context.Database.EnsureCreated();
if (!context.Customers.Any())
{
var (customers, orders) = DataSource.Generate();
context.Customers.AddRange(customers);
context.Orders.AddRange(orders);
context.SaveChanges();
}
}
}
}
Service configuration and Edm model definition
var builder = WebApplication.CreateBuilder(args);
var modelBuilder = new ODataConventionModelBuilder();
modelBuilder.EntitySet<Customer>("Customers");
modelBuilder.EntitySet<Order>("Orders");
builder.Services.AddDbContext<EntitySetAggregationDbContext>(
options => options.UseInMemoryDatabase("EntitySetAggregationDb"));
builder.Services.AddControllers().AddOData(
options =>
{
var model = modelBuilder.GetEdmModel();
options.EnableQueryFeatures();
options.AddRouteComponents("efcore", model);
options.AddRouteComponents("collection", model);
});
var app = builder.Build();
app.UseRouting();
app.MapControllers();
app.Run();
Controllers
namespace EntitySetAggregation.Controllers
{
public class CollectionCustomersController : ODataController
{
[EnableQuery]
[HttpGet("collection/Customers")]
public IQueryable<Customer> Get()
{
return DataSource.Customers.AsQueryable();
}
}
public class EfCoreCustomersController : ODataController
{
private readonly EntitySetAggregationDbContext context;
public EfCoreCustomersController(EntitySetAggregationDbContext context)
{
this.context = context;
this.context.SeedDatabase();
}
[EnableQuery]
[HttpGet("efcore/Customers")]
public IQueryable<Customer> Get()
{
return this.context.Customers;
}
}
}
Request/Response
Ef Core request:
http://localhost:5138/efcore/Customers?$apply=groupby((Name),aggregate(Orders(Amount with sum as OrdersTotal)))
Ef Core response:
{
"@odata.context": "http://localhost:5138/efcore/$metadata#Customers(Name,Orders)",
"value": [
{
"@odata.id": null,
"Name": "Sue",
"Orders": [
{
"@odata.id": null,
"OrdersTotal": 300
}
]
},
{
"@odata.id": null,
"Name": "Joe",
"Orders": [
{
"@odata.id": null,
"OrdersTotal": 190
}
]
}
]
}
Collection objects request:
http://localhost:5138/collection/Customers?$apply=groupby((Name),aggregate(Orders(Amount with sum as OrdersTotal)))
Collection object response:
{
"@odata.context": "http://localhost:5138/collection/$metadata#Customers(Name,Orders)",
"value": [
{
"@odata.id": null,
"Name": "Sue",
"Orders": [
{
"@odata.id": null,
"OrdersTotal": 130
},
{
"@odata.id": null,
"OrdersTotal": 170
}
]
},
{
"@odata.id": null,
"Name": "Joe",
"Orders": [
{
"@odata.id": null,
"OrdersTotal": 190
}
]
}
]
}
What you'll above from the above responses is that the OrdersTotal for customer Sue is the aggregated total of 300 in the case of Ef Core - expected behaviour, but in the case of collections, aggregation didn't work as expected so we end up with a collection of two objects in the Orders collection each with the original Amount.
We should investigate why it doesn't work for object collections.
Expected behavior
Result when object collections are used as a data source should mirror the result when Ef Core database provider is used.
Additional context
Implementing GetHashCode and Equals on Customer and Order models didn't resolve the issue.