OData / OData/AspNetCoreOData

EntitySet aggregation does not work as expected for object collections

Open
#1,447 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug P2
Dominant language
C#
Stars
505
Forks
186
PR merge metrics
No merged PRs in 30d

Description

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.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with src/Microsoft.AspNetCore.OData/Query/Expressions/AggregationBinder.cs around the entity-set aggregation implementation, then run or extend test/Microsoft.AspNetCore.OData.E2E.Tests/EntitySetAggregation/EntitySetAggregationTests.cs. Compare object-collection and EF Core results for the supplied groupby and aggregate query; done means object collections produce the same summed OrdersTotal values as EF Core.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
api
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Stale
Clarity
Clearly specified
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.