AutoMapper / AutoMapper.Extensions.OData

Creates LINQ expressions from ODataQueryOptions and executes the query.
MIT License
140 stars 38 forks source link

Expanding a parent entity from a child collection seems to be ignored. #102

Closed maboivin closed 3 years ago

maboivin commented 3 years ago

Hi, I'm trying to expand a child collection and from that child collection, expand a parent. It works with EF Core and EnableQuery out of the box but the expansion of the last parent seems to be ignored by AutoMapper (using AutoMapper.Extensions.OData v2.2.0-preview.1).

I was also able to reproduce the issue with the AutoMapper.OData.EFCore.Tests.

First, I added a simple child collection property to the TCity entity.

[Table("TCities")]
public class TCity
{
    [Column("Id")]
    [Required, Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public Int32 Id { get; set; }

    [Column("Name")]
    public String Name { get; set; }

+    public ICollection<TBuilder> Builders { get; set; }
}

Then, I did the same for the OpsCity entity.

public class OpsCity
{
    public Int32 Id { get; set; }
    public String Name { get; set; }

+    public ICollection<OpsBuilder> Builders { get; set; }
}

Then. I added a unit test in GetQueryTests.

[Fact]
public async void ExpandChildCollection_ExpandParent()
{
    Test(Get<OpsCity, TCity>("/opscity?$expand=Builders($expand=City)"));
    Test(await GetAsync<OpsCity, TCity>("/opscity?$expand=Builders($expand=City)"));

    void Test(ICollection<OpsCity> collection)
    {
        Assert.Equal(2, collection.Count);
        Assert.Equal(2, collection.ElementAt(0).Builders.Count);

        // This is where it fails.
        Assert.NotNull(collection.ElementAt(0).Builders.ElementAt(0).City);
    }
}

I expected that the City property would have been loaded.

Now, I was able to reproduce the issue with the WebAPI.OData.EFCore project as well.

I added an OData controller to query the OpsCity entity set.

public class OpsCityController : ODataController
{
    private readonly IMapper _mapper;

    public OpsCityController(MyDbContext repository, IMapper mapper)
    {
        Repository = repository;
        _mapper = mapper;
    }

    MyDbContext Repository { get; set; }

    [HttpGet]
    public async Task<IActionResult> Get(ODataQueryOptions<OpsCity> options)
    {
        return Ok(await Repository.City.GetQueryAsync(_mapper, options, new QuerySettings { ODataSettings = new ODataSettings { HandleNullPropagation = HandleNullPropagationOption.False } }));
    }
}

When querying http://localhost:16324/opscity?$expand=builders($expand=city), it returns the following data:

{
  "@odata.context": "http://localhost:16324/$metadata#OpsCity(Builders(City()))",
  "value": [
    {
      "Id": 1,
      "Name": "London",
      "Builders": [
        {
          "Id": 1,
          "Name": "John",
          "Parameter": 0,
          "City": null
        },
        {
          "Id": 2,
          "Name": "Sam",
          "Parameter": 0,
          "City": null
        }
      ]
    },
    {
      "Id": 2,
      "Name": "Leeds",
      "Builders": [
        {
          "Id": 3,
          "Name": "Mark",
          "Parameter": 0,
          "City": null
        }
      ]
    }
  ]
}

using the following SQL query:

SELECT [t].[Id], [t].[Name], [t0].[Id], [t0].[Name]
FROM [TCities] AS [t]
LEFT JOIN [TBuilders] AS [t0] ON [t].[Id] = [t0].[CityId]
ORDER BY [t].[Id], [t0].[Id]

Then, I added the TCity and TBuilder entites to the OData model and an OData controller to query the TCity entity set directly from EF Core using the EnableQuery attribute.

public class TCityController : ODataController
{
    public TCityController(MyDbContext repository)
    {
        Repository = repository;
    }

    MyDbContext Repository { get; set; }

    [HttpGet]
    [EnableQuery]
    public IQueryable<TCity> Get()
    {
        return Repository.City;
    }
}

When querying http://localhost:16324/tcity?$expand=builders($expand=city), it returns the following data:

{
  "@odata.context": "http://localhost:16324/$metadata#TCity(Builders(City()))",
  "value": [
    {
      "Id": 1,
      "Name": "London",
      "Builders": [
        {
          "Id": 1,
          "Name": "John",
          "CityId": 1,
          "City": {
            "Id": 1,
            "Name": "London"
          }
        },
        {
          "Id": 2,
          "Name": "Sam",
          "CityId": 1,
          "City": {
            "Id": 1,
            "Name": "London"
          }
        }
      ]
    },
    {
      "Id": 2,
      "Name": "Leeds",
      "Builders": [
        {
          "Id": 3,
          "Name": "Mark",
          "CityId": 2,
          "City": {
            "Id": 2,
            "Name": "Leeds"
          }
        }
      ]
    }
  ]
}

using the following SQL query:

SELECT [t].[Id], [t].[Name], [t2].[Id], [t2].[CityId], [t2].[Name], [t2].[Id0], [t2].[Name0], [t2].[c]
FROM [TCities] AS [t]
LEFT JOIN (
    SELECT [t0].[Id], [t0].[CityId], [t0].[Name], [t1].[Id] AS [Id0], [t1].[Name] AS [Name0], CAST(0 AS bit) AS [c]
    FROM [TBuilders] AS [t0]
    INNER JOIN [TCities] AS [t1] ON [t0].[CityId] = [t1].[Id]
) AS [t2] ON [t].[Id] = [t2].[CityId]
ORDER BY [t].[Id], [t2].[Id], [t2].[Id0]

My first thought is that I think AutoMapper should return the City as well. Is there a setting or a configuration that needs to be changed? Or is it possible that it's a bug when translating the expand clause into the select expression?

BlaiseD commented 3 years ago

So Tenant -> Buildings -> Builder -> City works but not City -> Builder -> City. It could be a bug.

BlaiseD commented 3 years ago

Your test will work if you set up your configuration as follows:

            AutoMapper.IConfigurationProvider configurationProvider = new MapperConfiguration(cfg =>
            {
                cfg.AddMaps(typeof(Program).Assembly);
                cfg.Advanced.RecursiveQueriesMaxDepth = 1;
            });
            IMapper mapper = new Mapper(configurationProvider);

Or in AutoMapper.v11:

            AutoMapper.IConfigurationProvider configurationProvider = new MapperConfiguration(cfg =>
            {
                cfg.AddMaps(typeof(Program).Assembly);
                cfg.Internal().RecursiveQueriesMaxDepth = 1;
            });
            IMapper mapper = new Mapper(configurationProvider);

RecursiveQueriesMaxDepth is the number of times you want an already existing object to be included in the object graph.

AutoMapper takes an opt-in approach to configurations affecting performance. RecursiveQueriesMaxDepth is not documented i.e. its use is discouraged.