Minimal repro: GetAsync (Obsolete) vs GetQueryAsync (Bug or expected behaviour?) #252
Description
GetAsync()is marked [Obsolete] and is replaced by GetQueryAsync().
The behaviour however is different and we are missing 1:1 expands that were present with the Obsolete method. This breaks our application.
Please see attached repro project.
Summary
In AutoMapper.AspNetCore.OData.EFCore 9.1.0 the extension method
GetAsync(...) was marked [Obsolete] (see
PR #250)
in favor of GetQueryAsync(...). In 10.0.0 the method is removed
entirely.
The problem — the two methods do not produce the same result. The
recommended GetQueryAsync fails to expand an optional 1:1 navigation
property (same class of issue as
AutoMapper.Extensions.OData#150).
Project
- .NET 10 minimal Web API
AutoMapper.AspNetCore.OData.EFCore9.1.0 (last version where both
methods coexist so they can be compared in one project)- EF Core InMemory
- Single
Categoryentity with a self-referencing optional 1:1
ParentCategory(nullable FKParentId) - One AutoMapper profile:
Category→CategoryDtowith
ForAllMembers(o => o.ExplicitExpansion()) - Two OData controllers hitting the exact same
DbSet, mapper and
ODataQueryOptions:GET /odata/CategoriesObsolete→ usesGetAsync(Obsolete)GET /odata/CategoriesQuery→ usesGetQueryAsync
Reproduce
dotnet runThen issue the same OData query against both endpoints:
GET /odata/CategoriesObsolete?$filter=parentId ne null&$expand=parentCategory
GET /odata/CategoriesQuery?$filter=parentId ne null&$expand=parentCategory
You can use the provided requests in OData.AutoMapper.Repro.http.
Please see that expanded ParentCategory is missing in the second response.
Actual output
GetAsync (Obsolete) — works:
{
"value": [
{ "Id": 2, "Name": "Child", "ParentId": 1, "ParentCategory": { "Id": 1, "Name": "Root", "ParentId": null } },
{ "Id": 3, "Name": "Grandchild", "ParentId": 2, "ParentCategory": { "Id": 2, "Name": "Child", "ParentId": 1 } }
]
}GetQueryAsync — broken, expanded ParentCategory is null:
{
"value": [
{ "Id": 2, "Name": "Child", "ParentId": 1, "ParentCategory": null },
{ "Id": 3, "Name": "Grandchild", "ParentId": 2, "ParentCategory": null }
]
}Impact
Because the replacement method silently drops optional 1:1 expansions,
we cannot follow AutoMapper.OData.Extensions's guidance to migrate to GetQueryAsync,
and cannot upgrade to 10.0.0 (10.0.0 removes GetAsync).
Source/destination types
public class Category
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public int? ParentId { get; set; }
public Category? ParentCategory { get; set; }
public ICollection<Category> ChildrenCategories { get; set; } = new List<Category>();
}
public class CategoryDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public int? ParentId { get; set; }
public CategoryDto? ParentCategory { get; set; }
public ICollection<CategoryDto> ChildrenCategories { get; set; } = new List<CategoryDto>();
}Mapping configuration
public class MappingProfile : Profile
{
public MappingProfile()
{
CreateMap<Category, CategoryDto>()
.ForAllMembers(o => o.ExplicitExpansion());
}
}
// registered via:
builder.Services.AddAutoMapper(cfg => cfg.AddProfile<MappingProfile>());Version: 9.1.0
Expected behavior
GetQueryAsync should expand the optional 1:1 navigation property ParentCategory just like the obsolete GetAsync does, since both are given the same DbSet , IMapper , and ODataQueryOptions with $expand=parentCategory .
Actual behavior
GetAsync (Obsolete) correctly returns the expanded ParentCategory :
{
"value": [
{ "Id": 2, "Name": "Child", "ParentId": 1, "ParentCategory": { "Id": 1, "Name": "Root", "ParentId": null } },
{ "Id": 3, "Name": "Grandchild", "ParentId": 2, "ParentCategory": { "Id": 2, "Name": "Child", "ParentId": 1 } }
]
}GetQueryAsync (recommended replacement) returns ParentCategory: null for every row, silently dropping the expand:
{
"value": [
{ "Id": 2, "Name": "Child", "ParentId": 1, "ParentCategory": null },
{ "Id": 3, "Name": "Grandchild", "ParentId": 2, "ParentCategory": null }
]
}Steps to reproduce
// Seed data
var root = new Category { Id = 1, Name = "Root", ParentId = null };
var child = new Category { Id = 2, Name = "Child", ParentId = 1 };
var grand = new Category { Id = 3, Name = "Grandchild", ParentId = 2 };
db.Categories.AddRange(root, child, grand);
db.SaveChanges();
// Controller A (works): GetAsync
var result = await _db.Categories.AsNoTracking().GetAsync(_mapper, options,
new QuerySettings { AsyncSettings = new AsyncSettings { CancellationToken = ct } });
// Controller B (broken): GetQueryAsync
var result = await _db.Categories.AsNoTracking().GetQueryAsync(_mapper, options,
new QuerySettings { AsyncSettings = new AsyncSettings { CancellationToken = ct } });
Issue the same OData request against both endpoints and compare:
GET /odata/CategoriesObsolete?$filter=parentId ne null&$expand=parentCategory
GET /odata/CategoriesQuery?$filter=parentId ne null&$expand=parentCategory
Activity
[-]Minimal repro: `GetAsync` (Obsolete) vs `GetQueryAsync` in `AutoMapper.AspNetCore.OData.EFCore`[/-][+]Minimal repro: `GetAsync` (Obsolete) vs `GetQueryAsync`[/+][-]Minimal repro: `GetAsync` (Obsolete) vs `GetQueryAsync`[/-][+]Minimal repro: `GetAsync` (Obsolete) vs `GetQueryAsync` (Bug?)[/+][-]Minimal repro: `GetAsync` (Obsolete) vs `GetQueryAsync` (Bug?)[/-][+]Minimal repro: `GetAsync` (Obsolete) vs `GetQueryAsync` (Bug or expected behaviour?)[/+]BlaiseD commentedon Sep 1, 2026
This thread recommends separate DTO types for self referencing entities.
GetAsyncreferenced code (old ideas) which have been removed from the expression mapping library which this one depends on. If you get the same results using AutoMapper'sProjectTo(without this library) then it's probably the same issue. If you get it to succeed then we'll have a bug to address. In short we want to stay away fromGetAsyncand improveGetQueryAsyncif we have to.leoerlandsson commentedon Sep 2, 2026
Hi,
Thanks for you response. I can confirm that this is upstream in AutoMapper.
I've attached an updated repro project.
However, switching from [Obsolete]
GetAsynctoGetQueryAsyncis not a 1:1 update. It introduces breaking changes (like this one), both for this example and some others we've found (where Includes() and included data are dropped from the result, also probably because the newGetQueryAsyncusesProjectTo).If this is intended behaviour, the Issue can be closed, and we'll have to find some kind of workarounds switching from
GetAsynctoGetQueryAsyncin our application.odata_repro_with_projectto.zip
BlaiseD commentedon Sep 2, 2026
Removing the
MapIncludesbehavior from the expression mapping library andGetAsyncas a consequence is intended.MapIncludesderived the mapped parent of flattened DTO members for expansion.ProjectToexpansions handle flattened members without further mapping and has millions more users i.e. continuing to maintainMapIncludesisn't worth the trouble ifProjectTocan achieve the same behavior even though code changes will be needed for some use cases.leoerlandsson commentedon Sep 2, 2026
Ok, and thanks. The removal of
MapIncludesalso explains why we are missing the (on backend manually) included entities.I understand the behaviour of
GetQueryAsyncwill not be changed and we'll have to find a way around that going forward switching from[Obsolete] GetAsynctoGetQueryAsync.Thanks again for your prompt response.
leoerlandsson commentedon Sep 2, 2026
Workaround / rewrite of code implemented for the removed
MapIncludes.The rewrite involves, in this case, separately loading the data and adding the
Includesneeded in a separate query and then populating the OData response with it before responding. Sure, two hits to the database, but for us an acceptable compromise.If anyone needs an example, just give me a ping.
You can close this Issue @BlaiseD. Thanks again.