【问题标题】:Navigational DTO properties using Entity Framework with OData Queries使用带有 OData 查询的实体框架的导航 DTO 属性
【发布时间】:2021-02-09 09:03:57
【问题描述】:

开发环境

  • ASP.NET Core 3.1
  • Microsoft.EntityFrameworkCore 3.1.9
  • Microsoft.AspNetCore.OData 7.5.1

型号

public class Computer
{
    public int Id { get; set; }
    public string Name { get; set; }
    
    public ICollection<Disk> Disks { get; set; }
}

public class Disk
{
    public int Id { get; set; }
    public string Letter { get; set; }
    public float Capacity { get; set; }
    
    public int? ComputerId { get; set; }
    public virtual Computer Computer { get; set; }
}

Dtos

public class ComputerDto
{
    public int Id { get; set; }
    public string Name { get; set; }
    public ICollection<DiskDto> Disks { get; set; }
}

public class DiskDto
{
    public string Letter { get; set; }
    public float Capacity { get; set; }
}

EF 核心上下文

public class ComputerContext : DbContext
{
    public DbSet<Computer> Computers { get; set; }
    public DbSet<Disk> Disks { get; set;}
    
    public ComputerContext(DbContextOptions<ComputerContext> options)
        : base(options)
    {
        
    }   
}

OData EDM 模型

private static IEdmModel GetEdmModel()
{
    var builder = new ODataConventionModelBuilder();
    
    builder.EntitySet<Computer>("Computers");   
    builder.EntitySet<Disk>("Disks");
        
    builder.ComplexType<ComputerDto>();
    builder.ComplexType<DiskDto>();

    return builder.GetEdmModel();
}

ASP.NET Core 控制器

[Route("api/[controller]")]
[ApiController]
public class ComputersController : ControllerBase
{
    private readonly ComputerContext context;
    
    public ComputersController(ComputerContext context)
    {
        this.context = context;
    }
    
    [HttpGet]
    [EnableQuery]
    public IQueryable<ComputerDto> GetComputers()
    {
        return this.context.Computers.Select(c => new ComputerDto
        {
            Id = c.Id,
            Name = c.Name,
            Disks = c.Disks.Select(d => new DiskDto
            {
                Letter = d.Letter,
                Capacity = d.Capacity
            }).ToList()
        });
    }
}

此查询有效,但磁盘已展开,因为我正在手动创建列表。

https://localhost:46324/api/computers?$filter=startswith(name,'t')

和输出

{
  "@odata.context": "https://localhost:46324/api/$metadata#Collection(ODataPlayground.Dtos.ComputerDto)",
  "value": [
    {
      "Id": 14,
      "Name": "TestComputer1",
      "Disks": [
        {
          "Letter": "C",
          "Capacity": 234.40
        },
        {
          "Letter": "D",
          "Capacity": 1845.30
        }
      ]
    },
    {
      "Id": 15,
      "Name": "TestComputer2",
      "Disks": [
        {
          "Letter": "C",
          "Capacity": 75.50
        },
        {
          "Letter": "D",
          "Capacity": 499.87
        }
      ]
    }
  ]
}

如果我尝试使用以下查询扩展“磁盘”,则会收到错误消息:

https://localhost:46324/api/computers?$filter=startswith(name,'t')&$expand=disks

错误

{
    "error": {
        "code": "",
        "message": "The query specified in the URI is not valid. Property 'disks' on type 'ODataPlayground.Dtos.ComputerDto' is not a navigation property or complex property. Only navigation properties can be expanded.",
        "details": [],
        "innererror": {
            "message": "Property 'disks' on type 'ODataPlayground.Dtos.ComputerDto' is not a navigation property or complex property. Only navigation properties can be expanded.",
            "type": "Microsoft.OData.ODataException",
            "stacktrace": "...really long stack trace removed for compactness..."
        }
    }
}

问题

  • 我似乎能够将顶级类作为 dto 返回,只公开客户端可能需要的属性,但是否也可以公开并返回 dto 作为导航属性?

非dto输出

{
  "@odata.context": "https://localhost:46324/api/$metadata#Collection(ODataPlayground.Dtos.ComputerDto)",
  "value": [
    {
      "Id": 14,
      "Name": "TestComputer1",
      "Disks": [
        {
          "Id": 16,
          "ComputerId": 14,
          "Letter": "C",
          "Capacity": 234.40
        },
        {
          "Id": 17,
          "ComputerId": 14,
          "Letter": "D",
          "Capacity": 1845.30
        }
      ]
    }
  ]
}

所需的输出(使用上面的 $filter 和 $expand 查询)

{
  "@odata.context": "https://localhost:46324/api/$metadata#Collection(ODataPlayground.Dtos.ComputerDto)",
  "value": [
    {
      "Id": 14,
      "Name": "TestComputer1",
      "Disks": [
        {
          "Letter": "C",
          "Capacity": 234.40
        },
        {
          "Letter": "D",
          "Capacity": 1845.30
        }
      ]
    }
  ]
}

更新 #1

如果我将 Automapper 添加到组合中并尝试使用带有以下代码的 ProjectTo 方法:

    //// Inject context and mapper
    public ComputersController(ComputerContext context, IMapper mapper)
    {
        this.context = context;
        this.mapper = mapper;
    }

    [HttpGet]
    [EnableQuery]
    public IQueryable<ComputerDto> GetComputers()
    {
        return this.context.Computers.ProjectTo<ComputerDto>(mapper.ConfigurationProvider);
    }

我得到一个不同的错误:

    InvalidOperationException: When called from 'VisitLambda', rewriting a node of type
    'System.Linq.Expressions.ParameterExpression' must return a non - null value of the same type.
    Alternatively, override 'VisitLambda' and change it to not visit children of this type.

【问题讨论】:

  • disks != Disks,可能是错误?
  • 很遗憾,$expand=Disks 不会产生不同的结果。
  • OData 无法识别 ComputerDto > Disks 作为导航属性,因为这是一个复杂类型,您需要使用 builder.ComplexType() 告诉 OData Disks 是一个关系(可扩展)属性.HasMany(c => c.Disks); // 这只有在 DiskDto 有 Id 时才有效
  • @RodrigoGRodrigues 是否无法将 OData 查询应用到 Computer,然后将结果映射到 ComputerDto?
  • 您可能需要考虑为此使用存储过程。

标签: c# asp.net-core entity-framework-core odata


【解决方案1】:

我似乎能够将顶级类作为 dto 返回,只公开客户端可能需要的属性,但是否也可以公开并返回 dto 作为导航属性?

这是可能的,但您需要解决一些建模和实现特定的问题。

首先,建模。 OData 仅支持 实体类型 的集合导航属性。因此,为了将ComputerDto.Disks 属性映射为导航属性,您需要创建DiskDto 实体类型。这反过来又要求它有一把钥匙。所以要么给它添加Id属性,要么给它关联一些其他的属性(例如,Letter):

//builder.ComplexType<DiskDto>();
builder.EntityType<DiskDto>().HasKey(e => e.Letter);

现在,Disks 属性将不会包含在没有 $expand 选项的情况下,并且还将消除原始 OData 异常。

这完全是关于 OData Edm 模型和为 Disks 启用 $expand 选项。

接下来要解决的问题与 OData 和 EF Core 查询实现细节有关。运行过滤后的查询(不带 $expand)会产生所需的 JSON 输出(不包括 Disks),但生成的 EF Core SQL 查询是

SELECT [c].[Id], [c].[Name], [d].[Letter], [d].[Capacity], [d].[Id]
FROM [Computers] AS [c]
LEFT JOIN [Disks] AS [d] ON [c].[Id] = [d].[ComputerId]
WHERE (@__TypedProperty_0 = N'') OR ([c].[Name] IS NOT NULL AND (LEFT([c].[Name], LEN(@__TypedProperty_0)) = @__TypedProperty_0))
ORDER BY [c].[Id], [d].[Id]

如您所见,它包含不必要的连接和列,效率低下。

如果使用$expand 选项,您会得到VisitLambda 异常,该异常来自EF Core 3.1 查询翻译管道,由Disks 成员投影中的ToList() 调用引起,而这又是必需的,因为目标属性类型是 ICollection&lt;DiskDto&gt; 并且没有它你会得到编译时错误。可以通过将属性类型设置为IEnumerable&lt;DiskDto&gt; 并从投影中删除ToList() 来解决,这将消除异常,但又会产生效率更低的SQL 查询

SELECT [c].[Id], [c].[Name], [d].[Letter], [d].[Capacity], [d].[Id], @__TypedProperty_2, [d0].[Letter], [d0].[Capacity], CAST(1 AS bit), [d0].[Id]
FROM [Computers] AS [c]
LEFT JOIN [Disks] AS [d] ON [c].[Id] = [d].[ComputerId]
LEFT JOIN [Disks] AS [d0] ON [c].[Id] = [d0].[ComputerId]
WHERE (@__TypedProperty_0 = N'') OR ([c].[Name] IS NOT NULL AND (LEFT([c].[Name], LEN(@__TypedProperty_0)) = @__TypedProperty_0))
ORDER BY [c].[Id], [d].[Id], [d0].[Id]

这意味着尝试直接通过 EF Core 投影查询使用 OData 查询是有问题的。

因此,作为实现问题的解决方案,我建议AutoMapper.Extensions.OData 扩展:

ODataQueryOptions 创建 LINQ 表达式并执行查询。

你需要安装包AutoMapper.AspNetCore.OData.EFCore,使用类似这样的AutoMapper配置(本质是启用空集合和显式扩展)

cfg.AllowNullCollections = true;
cfg.CreateMap<Computer, ComputerDto>()
    .ForAllMembers(opt => opt.ExplicitExpansion());
cfg.CreateMap<Disk, DiskDto>()
    .ForAllMembers(opt => opt.ExplicitExpansion());

(注意:使用这种方法,属性类型可以保持ICollection&lt;DiskDto&gt;

并更改与此类似的控制器方法(本质是使用EnableQuery,添加选项参数并返回IEnumerable/ICollection而不是IQueryable

using AutoMapper.AspNet.OData;

[HttpGet]
public async Task<IEnumerable<ComputerDto>> GetComputers(
    ODataQueryOptions<ComputerDto> options) =>
    await context.Computers.GetAsync(mapper, options, HandleNullPropagationOption.False);

现在两个输出以及生成的 SQL 查询都将如期而至:

  • (不展开)

输出:

{
    "@odata.context": "https://localhost:5001/api/$metadata#Collection(ODataTest.Dtos.ComputerDto)",
    "value": [
        {
            "Id": 1,
            "Name": "TestComputer1"
        },
        {
            "Id": 2,
            "Name": "TestComputer2"
        }
    ]
}

SQL 查询:

SELECT [c].[Id], [c].[Name]
FROM [Computers] AS [c]
WHERE [c].[Name] IS NOT NULL AND ([c].[Name] LIKE N't%')
  • $expand=disks

输出:

{
    "@odata.context": "https://localhost:5001/api/$metadata#Collection(ODataTest.Dtos.ComputerDto)",
    "value": [
        {
            "Id": 1,
            "Name": "TestComputer1",
            "Disks": [
                {
                    "Letter": "C",
                    "Capacity": 234.4
                },
                {
                    "Letter": "D",
                    "Capacity": 1845.3
                }
            ]
        },
        {
            "Id": 2,
            "Name": "TestComputer2",
            "Disks": [
                {
                    "Letter": "C",
                    "Capacity": 75.5
                },
                {
                    "Letter": "D",
                    "Capacity": 499.87
                }
            ]
        }
    ]
}

SQL 查询:

SELECT [c].[Id], [c].[Name], [d].[Id], [d].[Capacity], [d].[ComputerId], [d].[Letter]
FROM [Computers] AS [c]
LEFT JOIN [Disks] AS [d] ON [c].[Id] = [d].[ComputerId]
WHERE [c].[Name] IS NOT NULL AND ([c].[Name] LIKE N't%')
ORDER BY [c].[Id], [d].[Id]

【讨论】:

  • 哇,总是你想念的那个库正是你想要的。您的解释非常有道理,甚至帮助我了解了有关 EFCore 优化的更多信息。感谢您花时间写出如此详细的答案。
  • 现在我们只需要更新 OData nuget 以支持 .NET 5。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2013-10-19
相关资源
最近更新 更多