OData select with complex data type

900 Views Asked by At

I want to retrieve a single property from a complex data type.

Platform: EF Core 6, OData V4, Blazor on Windows 11, VS 2022 on a MS SQL Express database.

Simplified DB / entity structure:

[Owned]
public class FileInfo
{
    [StringLength(255)]
    public string Filename { get; set };
}

public class UserInfo
{
    [StringLength(80)]
    public string UserID { get; set; }

    [StringLength(200)]
    public string Name { get; set; }

    [StringLength(200)]
    public string Email { get; set; }
}

public class Document
{
    [Key]
    public Guid DocumentID { get; set; }

    public FileInfo FileInfo { get; set; }

    [StringLength(80)]
    public string OwnerID { get; set; }

    public virtual UserInfo? Owner { get; set; }
}

public class Request 
{
    [Key]
    public Guid RequestID { get; set; }

    [StringLength(80)]
    public string AuthorID { get; set; }

    [ForeignKey("AuthorID")]
    public virtual UserInfo? Author;

    public Guid DocumentID { get; set; }

    [ForeignKey("DocumentID")]
    public virtual Document? Document;
}

Entities etc.:

public static IEdmModel GetEdmModel()
{
    ODataConventionModelBuilder modelBuilder = new ODataConventionModelBuilder();
    modelBuilder.EntitySet<Document>("Documents");
    modelBuilder.EntitySet<Request>("Requests");
    modelBuilder.ComplexType<FileInfo>();
    return modelBuilder.GetEdmModel();
}

Query #1:

https://localhost:12345/TestApp/Requests?
    $count=true&
    $select=Document&
    $orderby=Document/FileInfo/Filename&
    $expand=Document($select=FileInfo/Filename)

This query returns:

 {"@odata.context":"https://localhost:44393/DocServer2/
  $metadata#Requests(Document,Document(FileInfo/Filename))",
  "@ odata.count":3,"value":[
      {"Document":{"FileInfo":{"Filename":"BST-abc-dd100-04.pdf"}}},
      {"Document":{"FileInfo":{"Filename":"BST-abc-dd100-04.pdf"}}},
      {"Document":{"FileInfo":{"Filename":"BST-DEF-DD100-01.PDF"}}}]}

However, I actually only need a list of strings (the property values).

This is not all though. Things are getting ugly when I apply a filter to the query, requiring me to look at and hence expand more data:

https://localhost:12345/TestApp/Requests?
  $count=true&$orderby=Document/FileInfo/Filename&
  $select=Document&
  $filter=(Document/OwnerID eq 'Testuser') or (AuthorID eq 'TestUser')&
  $expand=Author,Document($expand=Owner;$select=FileInfo/Filename)

The result looks like this:

{"@odata.context":"https://localhost:12345/TestApp/
 $metadata#Requests(
     Document,Author(),
     Document(FileInfo/Filename,
     Owner()))",
 "@odata.count":1,
 "value":[
     {"Author":{"Name":"Test User"},
      "Document":{"FileInfo":{"Filename":"Test.PDF"},
      "Owner":{"Name":"Test User"}}}]}

Note: Using "$select=" instead of "$select=Document" returns all property values of Document (seems to be treated like "select * from Documents").

How do I need to adjust the query to only return Request.Document.FileInfo.Filename?

I did google and also searched SO for an answer, but couldn't find one.

1

There are 1 best solutions below

21
On

Update: Thankyou for updating the post with the platform/vendor version, that changes everything, you are not asking about standards anymore but about a specific implementation, which is the correct approach.

You are correct that to $select a specific property on a ComplexType you should use the / to address it as a descendant of the name of the root property:

https://localhost:12345/TestApp/Requests?
    $count=true&
    $select=Document&
    $orderby=Document/FileInfo/Filename&
    $expand=Document($select=FileInfo/Filename)

NOTE: Some server-side implementations or constraints might require that certain fields are returned, even if you do not request them. This is a server-side configuration and is outside of the scope of the specifications. The specs do not specifically state that non-requested fields cannot be returned, only that the requested fields MUST be included in the response. Custom implementations are allowed to return additional properties as long as they are declared correctly in the $metadata then they will be supported.

Unfortunately for your case a key tenant of OData V4 over other APIs is that the structure of the resource will not change. Entities are resources (the R in REST) and OOTB in the .Net implementations this cannot be violated. This means that the response will always be an array of Request objects that have a single Document property that also has a single FileInfo that has a single Filename property.

  • So from a pure OData v4 specification point of view, what you are asking for is totally against the core principle of OData (v4), read on for additional variations and exceptions to this rule...

RE: This is not all though. Things are getting ugly when I apply a filter to the query, requiring me to look at and hence expand more data:

There is no reason that you need to include other $expand or $select properties to evaluate a $filter. $filter is evaluated first and independently from (and before) the $select and $expand. So you are not required to include these properties in your request at all, but if you do include those navigation pathways in the request, it makes sense that those fields and/or navigation properties would be included in the response.

If you query the Requests controller, then according to the OData specification, the response should be in the shape of a Response object. We can used $select and $expand to reduce the bytes transferred over the wire by omitting properties, but relationship structure or general shape of the object graph MUST be maintained to allow the client-side implementations to work correctly.

{
  "@odata.context": "https://localhost:44393/DocServer2/$metadata#Requests(Document,Document(FileInfo/Filename))",
  "@ odata.count": 3,
  "value": [
    {
      "Document": {
        "FileInfo": {
          "Filename": "BST-abc-dd100-04.pdf"
        }
      }
    },
    {
      "Document": {
        "FileInfo": {
          "Filename": "BST-abc-dd100-04.pdf"
        }
      }
    },
    {
      "Document": {
        "FileInfo": {
          "Filename": "BST-DEF-DD100-01.PDF"
        }
      }
    }
  ]
}

If you are expecting a simple OData array of strings like the following, then you will have to write some extra code:

{ 
  "value": [
    "BST-abc-dd100-04.pdf",
    "BST-abc-dd100-04.pdf",
    "BST-DEF-DD100-01.PDF"
  ]
}

or perhaps, if you want a pure custom REST/JSON response you can do that too, but it's not conformant to the OData specification anymore:

[
  "BST-abc-dd100-04.pdf",
  "BST-abc-dd100-04.pdf",
  "BST-DEF-DD100-01.PDF"
]

Previous versions of OData did support direct querying of child resources, but in v4 specification this is only supported by Entity navigation links

You OData controllers are just a great start for whatever you want to add to your OData implementation. If you have a genuine need to return a flattened list, then you can add an additional function to your controller to support this,

There is a feature described in the OData 4.01 amended specification that does allow you to use an alias to reference the result of a $compute query option. However, this was not included in the specification until 2020, not many older implementations are likely to have support for this new option, EF Core (Microsoft.AspNetCore.OData v8.0.12) only has partial support for this syntax.

It is expected to work like this:

https://localhost:12345/TestApp/Requests?
    $count=true&
    $select=File&
    $orderby=File&
    $compute=Document/FileInfo/Filename as File

Should result in something similar to this:

{"@odata.context":"...",
 "@odata.count":3,"value":
 [{"File":"test1.pdf"},
  {"File":"test2.pdf"},
  {"File":"test3.PDF"}
 ]}

Unfortunately as I test this I encounter a bug in Microsoft.AspNetCore.OData v8.0.12 that does not allow you to $select the aliased column, you can see the column included if you use $select=* but I cannot scope the response to just that column.

Please try it on your API to confirm, but until $compute works if you have need of a specific shape of data, then you should add a function or action endpoint to return that desired data. OData is just a tool to help you get there, using OData does not preclude you from adding custom endpoints, as long as you define them correctly, they will still be exposed through the metadata and can be easily consumed by clients that implement code generators.

To implement a custom function to retrieve this data, you can use a controller method similar to this:

[HttpGet]
[EnableQuery]
public async Task<IActionResult> Filenames()
{
    IQueryable<Request> query = GetRequestsQuery();
    return Ok(query.Select(x => x.Document.FileInfo.Filename).ToArray());
}
...
        
builder.EntitySet<Request>("Requests").EntityType.Collection.Function(nameof(Filenames)).ReturnsCollection<string>();

Then you could query this via the following URL:

https://localhost:12345/TestApp/Requests/Filenames

However OData Query Options can only be enforced on the response type of the method, so even with [EnableQuery] OOTB you can only $filter or $orderby the values in the Filename property.

There are other workarounds, including Open Type support, but if you are interested in the $compute solution but cannot get it to work, then we should raise an issue with https://github.com/OData/odata.net/issues?q=compute to get the wider community involved.