OData客户端为每个字段生成一个扩展和选择。

huangapple go评论73阅读模式
英文:

OData Client generates one expand and select per field

问题

<edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx">
  <edmx:DataServices>
    <Schema Namespace="ODataIssue.Controllers" xmlns="http://docs.oasis-open.org/odata/ns/edm">
      <EntityType Name="Product">
        <Key>
          <PropertyRef Name="Id" />
        </Key>
        <Property Name="Id" Type="Edm.Int32" Nullable="false" />
        <Property Name="Name" Type="Edm.String" Nullable="false" />
        <Property Name="Description" Type="Edm.String" Nullable="false" />
      </EntityType>
      <EntityType Name="Category">
        <Key>
          <PropertyRef Name="Id" />
        </Key>
        <Property Name="Id" Type="Edm.Int32" Nullable="false" />
        <Property Name="Name" Type="Edm.String" Nullable="false" />
        <Property Name="Description" Type="Edm.String" Nullable="false" />
        <NavigationProperty Name="Products" Type="Collection(ODataIssue.Controllers.Product)" />
      </EntityType>
    </Schema>
    <Schema Namespace="Default" xmlns="http://docs.oasis-open.org/odata/ns/edm">
      <EntityContainer Name="Container">
        <EntitySet Name="Products" EntityType="ODataIssue.Controllers.Product" />
        <EntitySet Name="Categories" EntityType="ODataIssue.Controllers.Category">
          <NavigationPropertyBinding Path="Products" Target="Products" />
        </EntitySet>
      </EntityContainer>
    </Schema>
  </edmx:DataServices>
</edmx:Edmx>
var query = container.Categories.Select(c =>
new {
    c.Id, 
    c.Name,
    Products = c.Products.Select(p =>
        new
        {
            p.Name,
            p.Description
        }
    )
});
var list = query.ToList();

It generates the following url http://localhost:5184/odata/Categories?$expand=Products($select=Name),Products($select=Description)&$select=Id,Name.

Repo that can be used to reproduce the issue https://github.com/AnderssonPeter/ODataSelectIssue


<details>
<summary>英文:</summary>

I have the following OData model
```xml
&lt;edmx:Edmx Version=&quot;4.0&quot; xmlns:edmx=&quot;http://docs.oasis-open.org/odata/ns/edmx&quot;&gt;
  &lt;edmx:DataServices&gt;
    &lt;Schema Namespace=&quot;ODataIssue.Controllers&quot; xmlns=&quot;http://docs.oasis-open.org/odata/ns/edm&quot;&gt;
      &lt;EntityType Name=&quot;Product&quot;&gt;
        &lt;Key&gt;
          &lt;PropertyRef Name=&quot;Id&quot; /&gt;
        &lt;/Key&gt;
        &lt;Property Name=&quot;Id&quot; Type=&quot;Edm.Int32&quot; Nullable=&quot;false&quot; /&gt;
        &lt;Property Name=&quot;Name&quot; Type=&quot;Edm.String&quot; Nullable=&quot;false&quot; /&gt;
        &lt;Property Name=&quot;Description&quot; Type=&quot;Edm.String&quot; Nullable=&quot;false&quot; /&gt;
      &lt;/EntityType&gt;
      &lt;EntityType Name=&quot;Category&quot;&gt;
        &lt;Key&gt;
          &lt;PropertyRef Name=&quot;Id&quot; /&gt;
        &lt;/Key&gt;
        &lt;Property Name=&quot;Id&quot; Type=&quot;Edm.Int32&quot; Nullable=&quot;false&quot; /&gt;
        &lt;Property Name=&quot;Name&quot; Type=&quot;Edm.String&quot; Nullable=&quot;false&quot; /&gt;
        &lt;Property Name=&quot;Description&quot; Type=&quot;Edm.String&quot; Nullable=&quot;false&quot; /&gt;
        &lt;NavigationProperty Name=&quot;Products&quot; Type=&quot;Collection(ODataIssue.Controllers.Product)&quot; /&gt;
      &lt;/EntityType&gt;
    &lt;/Schema&gt;
    &lt;Schema Namespace=&quot;Default&quot; xmlns=&quot;http://docs.oasis-open.org/odata/ns/edm&quot;&gt;
      &lt;EntityContainer Name=&quot;Container&quot;&gt;
        &lt;EntitySet Name=&quot;Products&quot; EntityType=&quot;ODataIssue.Controllers.Product&quot; /&gt;
        &lt;EntitySet Name=&quot;Categories&quot; EntityType=&quot;ODataIssue.Controllers.Category&quot;&gt;
          &lt;NavigationPropertyBinding Path=&quot;Products&quot; Target=&quot;Products&quot; /&gt;
        &lt;/EntitySet&gt;
      &lt;/EntityContainer&gt;
    &lt;/Schema&gt;
  &lt;/edmx:DataServices&gt;
&lt;/edmx:Edmx&gt;

When using the OData Client against it and running the following query:

var query = container.Categories.Select(c =&gt;
new {
    c.Id, 
    c.Name,
    Products = c.Products.Select(p =&gt;
        new
        {
            p.Name,
            p.Description
        }
    )
});
var list = query.ToList();

It generates the following url http://localhost:5184/odata/Categories?$expand=Products($select=Name),Products($select=Description)&amp;$select=Id,Name.

Is there someway to not repeat Products($select= and just generate http://localhost:5184/odata/Categories?$expand=Products($select=Name,Description)&amp;$select=Id,Name?

Repo that can be used to reproduce the issue https://github.com/AnderssonPeter/ODataSelectIssue

答案1

得分: 1

为了明确意图,OP正在询问此部分是否可以简化为仅包括以下内容(以及如何实现):

?$expand=Products($select=Name,Description)&$select=Id,Name

回答中我们需要实际查看 OData 标准 - URL 约定 - $expand。关于 $expand 类别及其如何使用有相当详细的描述。对我们而言,重要的是以下段落:

路径由斜杠 (/) 分隔的段组成。段可以是单值或集合值复杂属性的名称、实例注释或类型转换段,由前面路径段标识的类型衍生的结构化类型的限定名称,以访问在衍生类型上定义的属性。

路径可以以以下方式结束:

  • 包含流属性的流属性名称,
  • 一个星号 (*),展开已识别结构化实例的所有导航属性,可选择跟随 /$ref 仅展开实体引用,或
  • 一个导航属性以展开相关的实体或实体,可选择跟随类型转换段,以仅展开该派生类型或其子类型的相关实体,或可选择跟随 /$ref 仅展开实体引用。
  • 实体值实例注释以展开相关实体或实体,可选择跟随类型转换段,以仅展开该派生类型或其子类型的相关实体。

简而言之:当你想从对象中仅选择特定属性时(你试图选择仅有的 2 个属性 - NameDescription),OData 标准将它们都视为导航属性,我们必须分别添加它们:

$expand=Products($select=Name),Products($select=Description)

唯一的其他可能解决方案是获取整个对象,这将返回所有数据,查询的部分看起来像这样:

$expand=Products

所以预期的 ?$expand=Products($select=Name,Description) 实际上不符合这个标准 / 会违反标准的定义 ~ 将无效。

并且,为了更加清晰,虽然在 OP 已经使用的方法中手动编辑此 URI 是可能的(显示天真的实现),但这将不符合标准 / 没有意义。

英文:

To make clear the intent, OP is asking whether this part:

?$expand=Products($select=Name),Products($select=Description)&amp;$select=Id,Name

can be shortened to only this (and how to do it):

?$expand=Products($select=Name,Description)&amp;$select=Id,Name

For the answer we need to look actually in the OData standard - URL conventions - $expand. There is quite detailed description about the $expand category and how it can be used. For us is important this paragraph:

> A path consists of segments separated by a forward slash (/). Segments are either names of single- or collection-valued complex properties, instance annotations, or type-cast segments consisting of the qualified name of a structured type that is derived from the type identified by the preceding path segment to reach properties defined on the derived type.

> A path can end with
> - the name of a stream property to include that stream property,
> - a star (*) to expand all navigation properties of the identified structured instance, optionally followed by /$ref to expand only entity references, or
> - a navigation property to expand the related entity or entities, optionally followed by a type-cast segment to expand only related entities of that derived type or one of its sub-types, optionally followed by /$ref to expand only entity references.
> - an entity-valued instance annotation to expand the related entity or entities, optionally followed by a type-cast segment to expand only related entities of that derived type or one of its sub-types.


In normal people language: When you want to select only particular properties from an object (You are trying to select only 2 properties - Name, Description), OData standard considers them both to be navigation properties and we must add them separately:

$expand=Products($select=Name),Products($select=Description)

The only other possible solution is to take whole object, which returns all the data and this bit of query looks like:

$expand=Products

So the intended ?$expand=Products($select=Name,Description) actually doesn't follow this standard / would break the definition of the standard ~ would not be valid.


And just to be super clear, it would be possible to edit this URI by hand inside the method that OP has already used (showing naive implementation), but it would not follow the standard/would not make sense:

var container = new Container(new Uri(&quot;http://localhost:5184/odata&quot;));
container.BuildingRequest += Container_BuildingRequest;

void Container_BuildingRequest(object? sender, Microsoft.OData.Client.BuildingRequestEventArgs e)
{
    var newUri = e.RequestUri.ToString()
       .Replace(&quot;$expand=Products($select=Name),Products($select=Description)&quot;,
                &quot;$expand=Products($select=Name,Description)&quot;);
    e.RequestUri = new Uri(newUri);
}

答案2

得分: 1

I have also struggled with this in the past, in .Net 7 the OData Client does not yet properly resolve projections formulated via LINQ expressions, the resulting URI is wrong if you request more than one property from a related entity.

解决方案由 @tatranskymedved 提供,其中您修改 Uri 是我过去为一次性结构所使用的解决方案之一,但是我经常需要手动解析响应结构,因为 OData 客户端验证响应中的 $metadata。

最终,我选择直接构造查询选项,因为这需要与修改 URI 相同的知识和努力,然后如果需要投影到匿名类型,我们可以在数据从服务器加载后使用 LINQ 对对象进行操作:

var query = container.Categories.AddQueryOption("$expand", "Products($select=Name,Description)")
                                .AddQueryOption("$select", "Id,Name");
var projectedData = query.ToList().Select(c =>
{
    c.Id,
    c.Name,
    Products = c.Products.Select(p =>
        new
        {
            p.Name,
            p.Description
        }
    )
}).ToList();

输出将是预期的形式:

http://localhost:5184/odata/Categories?$expand=Products($select=Name,Description)&$select=Id,Name

这有效是因为 query.ToList() 将响应解析为容器中定义的代理类型的实例,未请求的属性将保持未初始化状态。

更新: 让我们更详细地看一下

// 强制 OData 查询首先解析为具体类型
var odataResult = query.ToList();

// 然后投影为自定义 DTO 以删除未加载的属性
var projectedData = odataResult.Select(c =>
{
c.Id,
c.Name,
Products = c.Products.Select(p =>
new
{
p.Name,
p.Description
}
)
}).ToList();

在这种状态下,如果您将响应直接投影到 DTO 或匿名类型,对于应用程序来说更安全,以避免混淆未初始化字段是否处于该状态,因为它们未从服务器请求或因为它们在服务器上的实际值。

尽管该元数据在编程上是可用的,但大多数开发人员在消耗此输出时不会费心检查它。

最后快速注意一下您的控制器逻辑。因为 Category 类型的 OData 实体集是 Categories

var modelBuilder = new ODataConventionModelBuilder();
modelBuilder.EntitySet<Category>("Categories");

您需要将控制器名称更改为 CategoriesController。标准约定是您的控制器类名称应该采用 $"{EntitySet}Controller" 的形式,以便正确解析。

public class CategoriesController : ODataController
{
    ...

    [EnableQuery]
    public ActionResult<IEnumerable<Category>> Get()
    {  
        return Ok(customers);
    }
}

更新:

关于这仍然是一种解决方法而不是解决方案的问题提出了评论。在 OData 客户端中处理投影的最佳实践解决方案是 投影到嵌套类型结构中。目前支持的嵌套投影旨在支持使用投影来选择导航实体上的单个属性以表示该对象,在一般客户端应用程序开发中,这可能是 TitleNameDescription 属性。

您可以通过复杂类型继承在服务器端促进复杂的常见投影,假设 Product 继承自 DescriptorBase 类,然后您可以使用 .OfType<DescriptorBase>() 来限制导航属性,但是在我撰写此文时,LINQ 到 OData URL 解析器不支持这一点。

这意味着对于所有复杂投影,.AddQueryOption() 解决方案。

我对仅使用 .AddQueryOption() 的唯一问题是,它将初始化的字段留在代码库中可以访问的状态。然而,它确实模仿了 JavaScript 客户端端开发的体验。我经常更改我的脚手架模板,以在访问未初始化的字段值时引发异常,作为一种安全机制,但只有在运行时才会体验到这一点,其他开发人员很容易假设该字段是可用的,并且很难在设计时确定是否应该期望一个值或不期望一个值。

这就是为什么我包括手动投影到预期的 DTO 类型的步骤,它隐藏了调用的复杂性以及关于提供不应访问的值的任何混淆。这不是使 OData 调用工作所必需的,但它确实减少了代码的长期维护成本。

从工程角度来看,您能够从代码库的任何位置随意查询 API 的能力是一种反模式。您应该尽量通过固定合同端点来限制 LINQ 到 OData,因为您正在与外部系统进行交互。LINQ 很有趣,很容易入门,但很难掌握,就像 LINQ 到 SQL 一样。 (虽然在支持对 SQL 进行复杂投影方面已经付出了相当多的社区努力) 此处提供的解决方案封装了 LINQ 和 OData 查询的复杂性,使代码库的其余部分可以消耗它的固定输出。您可以通过局部类或扩展方法将此类查询扩展到容器类中,这将使消耗此类查询的感觉更加自然。

此解决方案还支持无法从 LINQ 转换的复杂 OData 查询和投影,如 $apply 和 `$

英文:

I have also struggled with this in the past, in .Net 7 the OData Client does not yet properly resolve projections formulated via LINQ expressions, the resulting URI is wrong if you request more than one property from a related entity.

The solution provided by @tatranskymedved where you modify the Uri in-flight is one that I myself have used in the past for one-off structures, however I often have to manually parse the response structure because the OData Client validates the $metadata in the response.

In the end I settle with constructing the query options directly, seeing that this requires the same level of knowledge and effort as modifying the URI, then if a projection into an anonymous type is required we can use LINQ to objects after the data has been loaded from the server:

var query = container.Categories.AddQueryOption(&quot;$expand&quot;,&quot;Products($select=Name,Description)&quot;)
                                .AddQueryOption(&quot;$select&quot;, &quot;Id,Name&quot;);
var projectedData = query.ToList().Select(c =&gt; new
{
    c.Id,
    c.Name,
    Products = c.Products.Select(p =&gt;
        new
        {
            p.Name,
            p.Description
        }
    )
}).ToList();

The output will be the expected form:

http://localhost:5184/odata/Categories?$expand=Products($select=Name,Description)&amp;$select=Id,Name

This works because the query.ToList() will resolve the response into instances from the proxy types defined in the container, with the omitted properties left uninitialized.
> Update: Lets see that in more detail
>
> // force OData query to resolve into the concrete types first
> var odataResult = query.ToList();
>
> // then project into custom DTO to remove the properties that were not loaded
> var projectedData = odataResult.Select(c => new
> {
> c.Id,
> c.Name,
> Products = c.Products.Select(p =>
> new
> {
> p.Name,
> p.Description
> }
> )
> }).ToList();

In this state it is safer for your application if you then project into a DTO or anonymous type to avoid confusion over the uninitialized fields being in that state because they were not requested from the server or because that is their actual value on the server.

  • Even though that metadata is programmatically available, most developers consuming this output would not bother to check for it in their consuming logic.

A final quick note in your controller logic. Because the OData entity set for the Category type is Categories:

var modelBuilder = new ODataConventionModelBuilder();
modelBuilder.EntitySet&lt;Category&gt;(&quot;Categories&quot;);

you need to change your controller name to CategoriesController. The standard convention is that your controller class name should be in the form $&quot;{EntitySet}Controller&quot; for it to be correctly resolved.

public class CategoriesController : ODataController
{
    ...

    [EnableQuery]
    public ActionResult&lt;IEnumerable&lt;Category&gt;&gt; Get()
    {  
        return Ok(customers);
    }
}

Update:

A comment was raised about how this is still a workaround and not a solution. The best practise solution to projections in OData client is to NOT project into nested type structures. The current support for nested projections is intended to support using a projection to select a single property on the navigation entity to represent that object, in general client application development this might be a Title, Name or Description property.

You can facilitate complex common projections on the server-side through complex type inheritance, lets say that Product inherited from a DescriptorBase class then you could us .OfType&lt;DescriptorBase&gt;() to constrain the navigation properties, but at the time of me writing this, there is no support for this in the LINQ to OData url resolver.

That means that for all complex projections .AddQueryOption() IS the solution.

My only issue with using .AddQueryOption() alone is that it leaves the initialized fields in an accessible state in the codebase. It does however mimic the experience of javascript client-side development. I often change my scaffolding templates to throw an exception if an un-initialised field value is accessed as a safety mechanism, but that is only experienced at runtime and it's too easy for other developers to assume that the field is usable and hard to identify at design time if we should be expecting a value or not.

That is why I include the step of manually projecting to the expected DTO type, it hides the complexity of the call and any confusion about providing values that should not be accessed. It is not necessary to make the OData call work, but it does reduce long term maintenance of the code.

From an engineering point of view, the ability for you to query your API at free will from any point in your codebase is an anti-pattern. You should try to constrain the LINQ to OData through fixed contract endpoints because you are interfacing with an external system. LINQ is fun and is very easy to get started with and a lot harder to master, much like LINQ to SQL. (although significantly more community effort has gone into supporting complex projections to SQL) The solution offered here encapsulates the complexity of the LINQ and the OData query into a fixed output that the rest of your codebase can consume. You could extend queries like this into your container class either through partial classes or extension methods, that would make consuming this type of query feel more natural to consuming code.

This solution also supports complex OData queries and projections that cannot be translated from LINQ, like $apply and $compute so even if you start to write your own LINQ to OData URI resolver, there will always be exceptions and this still is going to present a viable solution to those exceptions.

Constraining the calls to your external API through pre-defined expressions/queries and DTOs makes it easier to decouple and test your code-base. It feels like more effort than it is worth for small apps or single dev teams, but it is non-negotiable for successfull management of larger teams or applications. Getting into the habit now will prepare you for the future.

huangapple
  • 本文由 发表于 2023年5月22日 19:22:48
  • 转载请务必保留本文链接:https://go.coder-hub.com/76305655.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定