Bulk Operations Support in OData Web API

Elizabeth Okerio

Good news! OData Web API 7.x now supports bulk operations.

Install the most recent version of OData Web API 7.x(v7.6.3) to take advantage of bulk operations.

A bulk operation is one that allows you to perform multiple operations (insert, update, delete) on multiple resources (of the same type) and their nested resources (to any level) with a single request.

A bulk operation can either be:  

  1. A deep update
  2. A deep insert
Deep Update

A deep update is a patch request. The following is an example of a deep update request:

PATCH http://localhost:6285/odata/Customers
Host: host 
Content-Type: application/json
Prefer: odata.include-annotations="*"

{
  "@odata.context": "http://localhost:6285/odata/Customers/$delta",
  "value": [
    {
      "Id": 1,
      "Name": "Customer1",
      "Orders@odata.delta": [
        {
          "Id": 1,
          "Price": 10.24,
          "Quantity": 10
        },
        {
          "Id": 2,
          "Price": 11.54,
          "Quantity": 21
        }
      ]
    },
    {
      "Id": 2,
      "Name": "Customer2",
      "Orders@odata.delta": [
        {
          "@odata.id": "Orders(1)",
          "Price": 14
        },
        {
          "@odata.removed": {
            "reason": "changed"
          },
          "Id": 2
        }
      ]
    }
  ]
}

How the above request will be processed:

  1. If the 2 customers exist in the server, they will be updated, otherwise, they will be created.   
  2. The orders for the customers will also be created or updated depending on whether they exist or not.   
  3. If Customer(1) has two orders (Orders(1) and Orders(2)), they will be updated; otherwise, they will be created.
  4. Orders(1) will be linked to Customer(2), and its Price will be updated (notice the use of @odata.id.).
  5. Orders(2) will be delinked from Customer(2) for Customer(2). (Take note of the use of @odata.removed.).
  6. @odata.delta informs OData Web API that the provided orders list is for updates (updates in this case refer to: an update, a create or a delete). If the @odata.delta annotation is not added to the navigation property, a replace operation will be performed. By replace, I mean that the payload will replace all related entities of the type with what is on the payload.

NOTE: The @odata.context annotation with the correct context URL must be included in the request body. The $delta segment must be present at the end of the context URL. Additionally, the request body must include an array-valued property named ‘value’ that contains the objects to be created, updated, or deleted.

Model Classes
public class Customer
{
    [Key]
    public int Id { get; set; }
    public String Name { get; set; }
    public int Age { get; set; }
    public List<Order> Orders { get; set; }
}

public class Order
{
    [Key]
    public int Id { get; set; }
    public int Price { get; set; }
    public int Quantity { get; set; }
    public Customer Customer { get; set; }
}

How will the controller action method look like?

[HttpPatch]
[ODataRoute("Customers")]
public IActionResult Patch([FromBody] DeltaSet<Customer> customers)
{
    var retuncol = customers.Patch(new CustomerPatchHandler(this._db), new APIHandlerFactory(Request.GetModel(), this._db));
    this._db.SaveChanges();
    return Ok(retuncol);
}

Note the use of DeltaSet, CustomerPatchHandler and APIHandlerFactory in the above code segment.

A DeltaSet<T> is a collection of Delta<T>.

CustomerPatchHandler and APIHandlerFactory are OData API Handlers.

OData API Handlers.

OData API Handlers are a new construct introduced in OData Web API 7.x that allows the library to interact with the CRUD operations logic of developers.

How will this happen? 

In OData Web API 7.x, we added two abstract classes from which developers must derive if they want to benefit from the use of OData API Handlers in carrying out various update operations on their resources: These are the classes:

ODataAPIHandlerFactory

A sneak peek at how this class will look:

public abstract class ODataAPIHandlerFactory
{
    protected ODataAPIHandlerFactory(IEdmModel model)
    {
        Model = model;
    }

    public IEdmModel Model { get; }
    public abstract IODataAPIHandler GetHandler(ODataPath odataPath);
}
  1. The GetHandler method accepts an ODataPath and returns an OData API Handler for patching the various resources for that specific type. The developer must return an OData API Handler based on the path segment in the ODataPath that is being processed.
ODataAPIHandler<TStructuralType> 

A sneak peek at how this class will look:

public abstract class ODataAPIHandler<TStructuralType>: IODataAPIHandler where TStructuralType : class
{
    public abstract ODataAPIResponseStatus TryCreate(IDictionary<string, object> keyValues, out TStructuralType createdObject, out string errorMessage);

    public abstract ODataAPIResponseStatus TryGet(IDictionary<string, object> keyValues, out TStructuralType originalObject, out string errorMessage);

    public abstract ODataAPIResponseStatus TryDelete(IDictionary<string, object> keyValues, out string errorMessage);

    public abstract ODataAPIResponseStatus TryAddRelatedObject(TStructuralType resource, out string errorMessage);

    public abstract IODataAPIHandler GetNestedHandler(TStructuralType parent, string navigationPropertyName);
}

Let’s take a look at some of the methods in this class and how developers may implement them:

  • TryCreate – This method accepts key-value pairs representing the key property values of the object to be created. If the key values are not included in the payload, then create an empty object and return it. The library will populate the object’s properties with payload values.
    public override ODataAPIResponseStatus TryCreate(
         IDictionary<string, object> keyValues, 
         out Customer createdObject, 
         out string errorMessage)
     {
         createdObject = null;
         errorMessage = string.Empty;

         try
         {
              createdObject = new Employee();
              dbContext.Customers.Add(createdObject);

              return ODataAPIResponseStatus.Success;
          }
          catch (Exception ex)
          {
              errorMessage = ex.Message;

              return ODataAPIResponseStatus.Failure;
          }
      }
  • TryGet – This method accepts key-value pairs that represent the object’s key property values. Use these key values to retrieve and return the object from your data store. If the object is found, the returned object will be updated with any new values from the payload. If no object is returned, the TryCreate method is called by the library.
    public override ODataAPIResponseStatus TryGet(
        IDictionary<string, object> keyValues,
        out Customer originalObject,
        out string errorMessage)
    {
        ODataAPIResponseStatus status = ODataAPIResponseStatus.Success;
        errorMessage = string.Empty;
        originalObject = null;

        try
        {
            var id = keyValues["ID"].ToString();
            originalObject = dbContext.Customers.First(x => x.ID == Int32.Parse(id));

            if (originalObject == null)
            {
                status = ODataAPIResponseStatus.NotFound;
            }

        }
        catch (Exception ex)
        {
            status = ODataAPIResponseStatus.Failure;
            errorMessage = ex.Message;
        }

        return status;
    }
  • TryDelete – This method accepts key-value pairs that represent the object’s key property values. The developer provides logic for deleting the object using the provided keys.
    public override ODataAPIResponseStatus TryDelete(
        IDictionary<string, object> keyValues,
        out string errorMessage)
    {
        errorMessage = string.Empty;

        try
        {
            var id = keyValues.First().Value.ToString();
            var customer = dbContext.Customers.First(x => x.ID == Int32.Parse(id));

            dbContext.Customers.Remove(customer);

            return ODataAPIResponseStatus.Success;
        }
        catch (Exception ex)
        {
            errorMessage = ex.Message;

            return ODataAPIResponseStatus.Failure;
        }
    }
  • TryAddRelatedObject –This method will include logic for linking one object to another. Assume we want to associate an order with a specific customer.
    public override ODataAPIResponseStatus TryAddRelatedObject(
        Order resource, 
        out string errorMessage)
    {
        errorMessage = string.Empty;

        try
        {
            parent.Orders.Add(resource);

            return ODataAPIResponseStatus.Success;
        }
        catch (Exception ex)
        {
            errorMessage = ex.Message;

            return ODataAPIResponseStatus.Failure;
        }
    }
  • GetNestedHandler – This method will include logic for returning the appropriate handler for processing the nested resources.
    public override IODataAPIHandler GetNestedHandler(
        Customer parent,
        string navigationPropertyName)
    {
        switch (navigationPropertyName)
        {
            case "Orders":
                return new OrdersHandler(parent);
            default:
                return null;
        }
    }

NOTE: The code samples provided above show how to implement the various methods. You are free to provide your own logic for implementing these methods.

Important things to note: 

If one of the operations in a bulk-operation fails, the processing does not stop. It continues on error, but a DataModificationException is created for the failed operation, with a compensating action. In such a case, the returned response will contain the DataModificationExceptions for the failed operations, along with their compensating actions, and the created or updated objects for the successful operations.

Compensating actions ensure that the server state is always in sync with the local state.

  1. A delete compensating action will be created if a create operation fails. This ensures that if the object was created locally, it must be deleted because it was not created on the server.
  2. A create compensating action is created if a delete operation fails.
  3. If an update operation fails, the response will contain the original object that was to be updated on the server.

NOTE: Prefer: odata.include-annotations=”*” must be included in the request headers for the DataModificationExceptions annotations to be included in the response.

An example of a response to a bulk operation request:

{
  "@context": "http://localhost:6285/odata/$metadata#Customers/$delta",
  "value": [
    {
      "Id": 1,
      "Name": "Customer1",
      "Age": 0,
      "Orders@delta": [
        {
          "Id": 1005,
          "Price": 40,
          "Quantity": 1005
        },
        {
          "Id": 2000,
          "Price": 200,
          "Quantity": 90
        }
      ]
    },
    {
      "Id": 2,
      "Name": "Customer2",
      "Age": 0,
      "Orders@delta": [
        {
          "@removed": {
            "reason": "changed"
          },
          "@id": "http://localhost:6285/odata/Orders(10)",
          "@Core.DataModificationException": {
            "@type": "#Org.OData.Core.V1.DataModificationExceptionType"
          },
          "Id": 10,
          "Price": 0,
          "Quantity": 0
        },
        {
          "@removed": {
            "reason": "changed"
          },
          "@id": "http://localhost:6285/odata/Orders(1004)",
          "Id": 1004,
          "Price": 0,
          "Quantity": 0
        },
        {
          "Id": 12,
          "Price": 12,
          "Quantity": 12
        },
        {
          "Id": 13,
          "Price": 13,
          "Quantity": 13
        },
        {
          "Id": 1003,
          "Price": 999,
          "Quantity": 99
        }
      ]
    }
  ]
}
Deep Insert 

A deep insert is a post request. Here’s an example of a deep insert request:

POST http://localhost:6285/odata/Customers
Host: host 
Content-Type: application/json
{
  "Id": 1,
  "Name": "Customer1",
  "Orders": [
    {
      "@odata.id": "Customers(3)/Orders(1005)"
    },
    {
      "Id": 2000,
      "Price": 200,
      "Quantity": 90
    }
  ]
}

In the above request we want to:

  1. Create a new Customer with an id of 1.
  2. Link an existing Order(1005) to customer 1.
  3. Create a new Order with id 2000 and link it to customer with id 1.
Important things to note: 
  1. In a deep insert, the @odata.id is only used to reference a related object. We cannot update the related object’s properties as in a deep update.
  2. In order to get the referenced object in the controller action method when using @odata.id in a deep insert, you must include an ODataIdContainer property in your model class. Example:
public class Order
{
    [Key]
    public int Id { get; set; }
    public int Price { get; set; }
    public int Quantity { get; set; }
    public Customer Customers { get; set; }
    public ODataIdContainer Container { get; set; }
}

The Container property will hold the referenced object.

How will the controller look like?

[HttpPost]
[ODataRoute("Customers")]
public IActionResult Post([FromBody] Customer customer)
{
    return Created(customer);
}
Known Issues: 

The nested navigation properties that were included in the request payload are not present in the response from a deep insert. There is still some work to be done in order to return the correct response for a deep insert. The response should include the top-level resource’s nested navigation properties up to the level of nesting specified in the request payload. In the preceding example, the response should include the newly created customer as well as the two orders that were either linked or created and linked to the newly created customer. This is not the case right now.

Users should be able to use the OData API Handlers to perform insertion and linking of related objects operations in a deep insert, but that part is currently missing. It will be taken into account in our next release. In the meantime, developers can implement their own logic.