通过IEnumerable隐藏的多对多导航属性进行查询,而无需客户端评估

时间:2019-10-07 14:40:26

标签: c# linq entity-framework-core expression-trees

TL; DR

Multiple authors建议使用私有IEnumerable专用导航属性的只读公共ICollection投影更好地封装导航集合,这在EF Core中受支持。我尝试使用类似的技术,通过只读投影遍历多对多关系-但这会使Linq在遍历时回落到客户端评估(在EF Core 3.0中不允许表现不佳) )。如果这是我选择的公共API,是否有办法使这些只读属性可导航,以使Linq可以将它们转换为SQL,而无需更改API?

完整问题

我正在尝试使用EF Core建立具有以下设计目标的数据模型:

  • 尽可能多的持久性无知-至少从公共API角度来看
  • 限制导航集合通过ICollection界面直接更改
  • 保留我的公共界面只暴露直接多对多关系
  • 隐藏技术ID,因为它们没有域含义(此名称可能会删除)

目前,EF Core的一大局限在于缺少没有联接实体的多对多关系,但这应该会出现-因此,我试图隐藏联接实体,我正在关注in this blog post by ajcvickers(及其他)列出的技术。

例如,我有以下这些(显然是不完整的)一等实体:

    public class Conversation
    {
        private IEnumerable<Tag> Tags => _joinToTags.Select(jt => jt.Tag);
        public ICollection<ConversationTag> _joinToTags = new HashSet<ConversationTag>();
    }
    public class Tag
    {
        public string Label { get; private set; } = null!;
        // No `Conversations` navigation property if I can help it
    }

(假设EF映射代码和琐碎的ConversationTag连接实体存在且正确。除了下面描述的问题外,它们似乎正常工作。)

请注意,Tag可能与5个以上的其他实体相关联...因此,尽管我可以具有从Tag内部回到这些实体的导航属性,正在尝试避免使API混乱。但是,我要做需要能够查询该方向,例如找到Conversations有给定的Tag

我的问题是:无论我何时遍历Tags属性,该设计都不允许我高效地查询,并且尝试回退到客户端执行。

在大多数情况下,它运行良好。但是,我只是改用EF Core 3.0,它可以帮助揭露我针对这些导航属性的查询强制进行客户端评估的地方(这会带来非常差的性能)。

例如,在EF Core 3.0中,此集成测试代码(使用令人敬畏的Shouldly库):

context.Conversations.Count(c => c.Tags.Any(t => t == context.Find<Tag>("In-Person"))).ShouldBe(2);
在EF Core 2.1下运行的

现在爆炸了:

System.InvalidOperationException: 'Processing of the LINQ expression 'Any<Tag>(
    source: NavigationTreeExpression
        Value: EntityReferenceConversation
        Expression: (Unhandled parameter: c).Tags, 
    predicate: (t) => t == (Unhandled parameter: __Find_0))' by 
        'NavigationExpandingExpressionVisitor' failed. This may indicate either 
        a bug or a limitation in EF Core. See 
        https://go.microsoft.com/fwlink/?linkid=2101433 
        for more detailed information.'

这可能是件好事,因为否则我想我会从生产环境中的数据库中检索每个对话,这是EF Core 3.0发生此重大更改的原因。

但是,如何使这种“查询”以一种有效地转换为SQL的方式工作,而又不暴露联接实体,并且通常不破坏设计目标?

似乎应该可行。我认为可能无法解决的许多事情:

// Making it IQueryable instead of IEnumerable...maybe the expression tree will bubble up? Nope...
public IQueryable<Tag> Tags => _joinToTags.AsQueryable().Select(jt => jt.Tag);
// Added this IEnumberable<ConversationTag> extension method:
    public static class ConversationTagExtensions
    {
        public static IEnumerable<Tag> Tags(this IEnumerable<ConversationTag> cts)
        {
            return cts.Select(ct => ct.Tag);
        }
    }
// And changed the Tags property to:
public IEnumerable<Tag> Tags => _joinToTags.Tags(); // Nope!

值得注意的是,这些起作用:

// Changing _joinToTags to public:
context.Conversations.Count(c => c._joinToTags.Any(ct => ct.Tag == context.Find<Tag>("In-Person"))).ShouldBe(2);
// Added Conversation property:
//  public IEnumerable<ConversationTag> ConversationTags => _joinToTags.AsEnumerable();
context.Conversations.Count(c => c.ConversationTags.Any(ct => ct.Tag == context.Find<Tag>("In-Person"))).ShouldBe(2);
// ^ that surprised me a little bit.

最后,这种方法确实有效-非常感谢this post为我指明了正确的方向-但需要我知道使用表达式进行查询:< / p>

    public class Tag
    {
        public string Label { get; private set; } = null!;

    // Added EF mapping for _joinToConversations, but at least it's not part of the public API...
        private ICollection<ConversationTag> _joinToConversations = new HashSet<ConversationTag>();
        public static Expression<Func<Tag, IEnumerable<Conversation>>> ConversationsExpression
        {
            get { return t => t._joinToConversations.Select(jt => jt.Conversation); }
        }
    }
// And now the unit test code is translated to:
context.Tags.Where(t => t.Label == "In-Person").SelectMany(Tag.ConversationsExpression).Count().ShouldBe(2);

最后产生了这个不错的,正确的SQL查询:

[Parameters=[@__entity_equality_Find_0_Label='In-Person' (Size = 9)], CommandType='Text', CommandTimeout='30']
SELECT COUNT(*)
FROM "Conversations" AS "c"
WHERE EXISTS (
    SELECT 1
    FROM "ConversationTag" AS "c0"
    INNER JOIN "Tags" AS "t" ON "c0"."TagLabel" = "t"."Label"
    WHERE (("c"."Id" = "c0"."ConversationId1") AND "c0"."ConversationId1" IS NOT NULL) 
      AND (("t"."Label" = @__entity_equality_Find_0_Label) AND @__entity_equality_Find_0_Label IS NOT NULL))

该解决方案,或者涉及表达式或可能公开技术ID的另一种解决方案都是可以接受的-但是对于客户端开发人员而言,它不是很“流畅”,而且似乎应该存在更好的解决方案。似乎可以将这种带有Expression的方法应用于另一个方向,并直接绑定到返回直接表示投影的结果的东西的标准属性中,但是我无法确定正确的咒语。相反,似乎无论何时我使用标准的Tags属性,无论它返回什么,它都将返回到客户端评估。

是否有一种方法来构造一个更自然可用的Tags属性,该属性返回Linq可以将其作为表达式树进行处理?

附录:这里还有一些其他设计目标不是直接相关的,但可能会引起疑问。例如。上面的context实际上不是直接的EF DbContext,而是一个工作单元对象,其接口为每种实体类型提供IQueryable……在EF之上的薄壳……可能不切合实际,但到目前为止已经为我提供了足够的服务。因此,如果您对我的设计目标感到好奇,为什么我的测试代码直接使用EF,实际上却不是。尽管它旨在行使持久性层来查找与我要询问的问题完全相同的问题。

第2天更新

这很疯狂...但这是另一个可行的设计,或者至少成功创建了SQL代码:

    public class Conversation
    {
        internal IEnumerable<Tag> Tags => _joinToTags.Select(jt => jt.Tag); 
        //   ^-- NOTE: now internal instead of private... not ideal
        public ICollection<ConversationTag> _joinToTags = new HashSet<ConversationTag>();
    }

    public static class ConversationExtensions
    {
        public static IQueryable<Conversation> WhereTag(this IQueryable<Conversation> conversations, Expression<Func<Tag, bool>> expression)
        {
            return conversations.Where(c => c._joinToTags.AsQueryable().Select<ConversationTag, Tag>(ct => ct.Tag).Any<Tag>(expression));
        }
    }

// With test code:
context.Conversations.WhereTag(t => t == context.Find<Tag>("In-Person") ).Count().ShouldBe(2);

我通过使用以下思想扩展IQueryable扩展方法(found many places的概念而到达那里提供一个过滤器表达式作为参数...让我惊讶的是,它非常容易工作。仍然不是我的目标,但可能比上面的解决方法更好。 (为公平起见,在之后我发现我发现了an article that does something similar。)

我现在还在探索我发现的一些其他工具-特别是DelegateDecompiler,我同意是mind blowing!看来此工具可能是使用Plain Old Properties回答的最大希望:Decompile()我的属性获取器返回到表达式中-但我仍无法使其与集合一起使用...仍在尝试

我现在也更好地了解了问题的根源,as explained here

  

出现问题是因为无法翻译这些属性,并且   发送到服务器,因为它们已经被编译为中间   语言(IL)而不是所需的LINQ表达式树   通过IQueryable实现进行翻译。没有可用的   在.NET中,我们可以将IL反向工程回方法和   允许我们将预期的操作转换为   远程查询。

...除了2009年编写的内容,现在DelegateDecompiler确实存在,听起来确实像它所做的那样。

我还发现了其他一些工具,例如Linq.TranslationsPropertyTranslator(尽管看起来已经不存在了),ExpressMapper(以及相关的blog post ),甚至是AutoMapper,因为我以前都不了解Queryable Extensions功能。仍然没有找到神奇的词。

0 个答案:

没有答案