AspNetCoreOData icon indicating copy to clipboard operation
AspNetCoreOData copied to clipboard

EntitySet aggregation does not work as expected for object collections

Open gathogojr opened this issue 8 months ago • 0 comments

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:

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.

gathogojr avatar Mar 24 '25 13:03 gathogojr