3

How do I post an entity to an OData endpoint while at the same time associating it with other existing entities in the body?


Consider the following class structure (sample):

class Invoice
{
    public int Id { get; set; }

    public Person Issuer { get; set; }

    public Person Recipient { get; set; }

    // other properties
}

class Person
{
    public int Id { get; set; }

    // other properties
}

Both Invoice and Person are entities in my domain (thus the Id property). Imagine that both are exposed in their own entitysets, so:

  • GET http://host/odata/People(1)

    returns Person with Id = 1

  • GET http://host/odata/Invoices(2)?$expand='Issuer, Recipient'

    returns Invoice with Id = 2 and both Issuer and Recipient expanded in the payload

Now consider the following requirement:

I want to create a new invoice in the system that will be associated to an existing issuer and recipient

How do I "tell" the OData framework that I want to associate a given navigation property to an existing entity? How would my controller action be notified that this is the intention?

Ideally, I'd like a POST body to look like this:

  • POST http://host/odata/Invoices

    { "Issuer": "/odata/People(1)", "Recipient": "/odata/People(2)", "Property1": "someValue", "Property2": "100", ... }

Once the server receives this payload, it should:

  1. Load the required "people(1)" Person for the Issuer property. If it does not exist, a bad request should be returned.
  2. Load the required "people(2)" Person for the Recipient property. If it does not exist, a bad request should be returned.
  3. Create a new Invoice instance and assign the Issuer and Recipient from above, then save it to the database.

I know OData has support for configuring the relationships after-the-fact with special PUT/POST URLs using the entity/relation/$ref syntax. With such syntax, I'd be able to do something like this:

  1. POST http://host/odata/Invoices

    { "Property1": "someValue", "Property2": "100" }

  2. PUT http://host/odata/Invoices(x)/Issuer/$ref

    {"@odata.id":"http://host/odata/People(1)"}

  3. PUT http://host/odata/Invoices(x)/Recipient/$ref

    {"@odata.id":"http://host/odata/People(2)"}

However, I want to be able to perform this all in a single POST operation that should atomically create the instance.

I tried a few ideas to see what the server would accept, and this seemed to go through:

{
    "Issuer": { "@odata.id": "/odata/People(1)" },
    "Recipient": { "@odata.id": "/odata/People(2)" },
    "Property1": "someValue",
    "Property2": "100",
    ...
}

But I have no idea how I'd be able to read/parse the IDs from this (like how it is done in a dedicated Ref method), or even if this is supported in the OData standard.

For now, I'll resort to just passing the ID property in the model and in the server assuming this will always mean an existing relationship, but that's far from ideal as it is not general-purpose enough and would make my API inflexible.

julealgon
  • 7,072
  • 3
  • 32
  • 77
  • You can send a batch request using the service batch endpoint. More details about how batch processing works here https://www.odata.org/documentation/odata-version-3-0/batch-processing/ – John Gathogo Apr 16 '20 at 12:13
  • Check out this link if the request is coming from .NET OData client https://learn.microsoft.com/en-us/odata/client/batch-operations – John Gathogo Apr 16 '20 at 12:15
  • @JohnGathogo I'm aware of batching support in OData, but for now, I want the operations on the server to be transactional: i.e., I don't want to insert the `Invoice` entity only to find out that I cannot associate it to an `Issuer` or `Recipient`. Performing all of it inside a single transaction in the POST allows me much stronger data integrity and simplifies the client as it doesn't have to deal with these corner cases. Batching requests are usually independent from one another so I don't think it would be a good fit. – julealgon Apr 16 '20 at 12:28
  • 1
    "Batching requests are usually independent from one another so I don't think it would be a good fit" - this statement is not entirely correct. If you're sending the request from the client and you call `SaveChanges` or `SaveChangesAsync` with `SaveChangesOptions.BatchWithSingleChangeset` as the parameter, it guarantees you a single transaction. – John Gathogo Apr 16 '20 at 12:44
  • 1
    And the single transaction for batch is not restricted to when you're using OData client. If you go through this link https://www.odata.org/documentation/odata-version-3-0/batch-processing/, you'll see in the example how to work with batch and changesets. A changeset is an atomic unit of work. It's something I have worked with practically – John Gathogo Apr 16 '20 at 12:54
  • @JohnGathogo very interesting stuff. Clearly my understanding of OData batch is lacking, I wasn't aware of all that for sure. I'm currently making simple POST calls using a normal `HttpClient` so I'd need to adapt the client side a bit, but as long as it is possible to make everything in a single DB transaction this would be viable. Perhaps you should consider posting your idea as an answer here? It seems like a valid answer to me. However, I'd still like to know if the protocol supports what I want in a single POST request, as that would be substantially less complex. – julealgon Apr 16 '20 at 16:41

1 Answers1

1

The simplest solution is to expose the ForeignKey properties in your Model directly. Even the Models used in the MS Doc Entity Relations in OData v4 Using ASP.NET Web API 2.2 explaining $ref expose the FKs.

class Invoice
{
    public int Id { get; set; }

    public int Issuer_Person_Id { get; set; }
    [ForeignKey(nameof(Issuer_Person_Id)]
    public Person Issuer { get; set; }

    public int Recipient_Person_Id { get; set; }
    [ForeignKey(nameof(Recipient_Person_Id)]
    public Person Recipient { get; set; }

    // other properties
}

This doesn't make your API inflexible, rather it makes your API MORE flexible. This also grants you greater control over the DataBase Implementation of your Model, whilst still being database engine agnostic.

In environments where Lazy Loading is enabled, including FKs has some added performance benefits if you need to check the existence of a related entity without requiring it to be loaded into memory.

NOTE: By including the FKs in the Model the $ref syntax and batching can still be used, but we now have access to the more practical FK Id values that can be easily validated in the server side code, just as it is easier to send the values.

Now in the PATCH or POST we can simply use the Ids directly to link the Person records.

The same level of information/understanding is required at both the client and server sides to achieve this, so it is still general purpose, the $metadata document fully describes which FK fields link the related entities but a good naming convention as demonstrated here can help

{
    "Issuer_Person_Id": 1,
    "Recipient_Person_Id": 2,
    "Property1": "someValue",
    "Property2": "100",
    ...
}

Be Careful:
One of the reasons that many Model designers choose NOT to expose ForeignKey properties is that ambiguity exists when or if you try to send or process both the ForeignKey and the related Entity.
For a PATCH there is no confusion, the v4.0 specification tells use specifically to ignore the related entity and that it shouldn't be sent at all.

11.4.3 Update an Entity
If an update specifies both a binding to a single-valued navigation property and a dependent property that is tied to a key property of the principal entity according to the same navigation property, then the dependent property is ignored and the relationship is updated according to the value specified in the binding.

For a POST however if the related entity is provided in the request as well as the FK, the related entity is assumed to be a deep insert and the FK is ignored.

11.4.2.2 Create Related Entities when Creating an Entity
Each included related entity is processed observing the rules for creating an entity as if it was posted against the original target URL extended with the navigation path to this related entity.

With FKs enabled My advice therefore is to take steps on the client side to make sure you don't try to send both the FK and the related entities in requests back to the API.

I agree that the @odata.id in the post as you have suggested is a logical conclusion, however it raises other potential implementation issues which is why the protocol provides the concept of direct CRUD operations against the $ref endpoint that represents the ForeignKey reference.

OData V4.0 was specifically declarative and designed such that operations against a single resource should only affect that resource. This is why we cannot PATCH related properties in a single query, as with this referencing issue, there are too many potential implementation variations and interpretations of how it might work that they kept the specification concise and constrained in the way that it is.

Basically a consensus between interested parties could not be reached on the protocol specifics and guidance on how to handle deep updates before the specification was drafted. The ASP.Net FX and Core implementations (as of this post) are only OData 4.0 Minimal Conformance Level OOTB. There is a lot you need to do to increase the level of conformance.

Batching is the preferred mechanism to perform operations that "might" affect multiple resources in a single transacted query, however it is a more involved solution than if you just expose the FKs!


Whilst it's nice that we can use complicated syntax and batching to achieve this in other ways, there are many other practical benefits to exposing the FK Ids in the Model and making them accessible to the client side, not just in the server logic, IMO these can be really big benefits in the right scenarios:

  • Optimised data retrieval in Grids
    If many rows have a link to the same record in another table you only need to download the linked value from the common table once. For some types of data it will be more efficient to download all the possible lookup values from the common tables and then in your presentation layer you can join in the results based on the Ids. These lookup values may only need to be downloaded once in the whole session in some use cases.

  • ComboBox relationship assignments
    There is a time and a place, but by including the FKs in your Model it is very simple to bind to ComboBox or DropDownList implemenations in the presentation layer to change or assign the related entities, the implementation is almost identical to the grid presentation, bind the control the FK, and show the related entities in the drop down list.

UPDATE 2022

OData v4.01 Minimal Conformance level SHOULD support deep updates
But the current version of ODataLib (v8) used by the .Net 5 runtime does not support this feature OOTB and is still only minimally compliant to v4.0, albeit with some of the more advanced features than before.

11.4.3.1 Update Related Entities When Updating an Entity
Payloads with an OData-Version header with a value of 4.01 or greater MAY include nested entities and entity references that specify the full set of to be related entities, or a nested delta payload representing the related entities that have been added, removed, or changed. Such a request is referred to as a “deep update”. If the nested collection is represented identical to an expanded navigation property, then the set of nested entities and entity references specified in a successful update request represents the full set of entities to be related according to that relationship and MUST NOT include added links, deleted links, or deleted entities.

The json payload implementation is similar to your suggestion:

{
    "@type":"#container.Invoice",
    "Issuer": { "@id": "People(1)" },
    "Recipient": { "@id": "People(2)" },
    "Property1": "someValue",
    "Property2": "100",
    ...
}

There is now also a nested delta representation to Add, Remove or Update links as well as nested values in a single request, but these advanced mechanisms are not yet implemented in the ODataLib runtimes.

Chris Schaller
  • 13,704
  • 3
  • 43
  • 81
  • Would you mind citing a source for this claim: "OData V4 is specifically declarative and designed such that operations against a single resource should only affect that resource. This is why we cannot PATCH related properties in a single query". Is it really not supported as per the spec, or are you just saying that's what you personally believe is the better practice? I'm asking as I don't remember seeing the restrictions you cite there regarding not being able to update related entities. My understanding is that if the payload was valid, it should be respected by the server one way or anothe – julealgon Jan 12 '22 at 22:05
  • Deep updates or nested patch is a new concept introduced in OData v4.01: http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_UpdateRelatedEntitiesWhenUpdatinganE , it is one of the key defining differences between the 4.0 and 4.01 conformance levels. The original discussion around deep updates could not find consensus on how to remove the ambiguity between patching the properties on the existing related entity, or if a new entity been assigned. The ASP.Net Core implementation is only minimally conformant to v4.0 OOTB. – Chris Schaller Jan 13 '22 at 03:28
  • thanks @julealgon I've rewritten a large chunk of my response now that I am more familiar with the latest version of the protocol specification. – Chris Schaller Jan 13 '22 at 04:48
  • Very insightful update Chris, much appreciated. Do you mind if I ask you how you know all these details? Are you part of the MS OData team or the spec team? – julealgon Jan 13 '22 at 17:22
  • 1
    I have been an active observer of the OData github project from its inception and have used OData v4 in my commercial solutions since 2014, before that I was a massive fan of WCF services but at the time utilised Self Tracking Entity based API or RPCs instead. OData was my github cherry and I have been fascinated by the process and evolution of the dev support tools and often recall comments from the dev and design teams in the git issues lists as if they were face to face conversations. Makes it hard to find specific quotes to cite, but it gives me a lot of confidence in the product. – Chris Schaller Jan 14 '22 at 02:58