Aster Web Data / Build an API
Write data
Create, update and delete records, deep insert, batch and atomic groups, ETags and concurrent edits.
The product team adds a rake, changes the price of a drill, and removes a product that was discontinued. Two buyers then change prices in the same minute. Each of these is a write request to the same Product set that Query your data reads, checked by the same access rules and the model's validation. Updates and deletes carry the record's ETag, so the second buyer's change, made on an old copy, is refused instead of silently overwriting the first.
Create, update and delete
Create a customer with a POST to the set. When the key is generated, as it is for Customer, the server assigns it. The response is 201 Created with the new record, a Location header and an ETag:
{
"name": "Umber Studio",
"email": "accounts@umber-studio.example.com",
"region": "EU"
}As a URL
POST /shop/CustomerHTTP 201
content-type: application/json;odata.metadata=minimal
etag: W/"54706149"
location: http://localhost:8080/shop/Customer(41)
odata-entityid: http://localhost:8080/shop/Customer(41)
odata-version: 4.01{
"@odata.context": "http://localhost:8080/shop/$metadata#Customer/$entity",
"@odata.etag": "W/\"54706149\"",
"id": 41,
"name": "Umber Studio",
"email": "accounts@umber-studio.example.com",
"region": "EU"
}The database receives these statements:
2 statements on PostgreSQL
PostgreSQL, statement 1 of 2
SELECT n FROM unnest(ARRAY[$1]) AS n WHERE EXISTS (SELECT 1 FROM pg_trigger WHERE tgrelid = n::regclass AND NOT tgisinternal) OR EXISTS (SELECT 1 FROM pg_rewrite WHERE ev_class = n::regclass AND rulename <> '_RETURN')
-- binds: "\"public\".\"customers\""PostgreSQL, statement 2 of 2
WITH m0 AS (INSERT INTO "public"."customers" ("email", "name", "region") VALUES ($1, $2, $3) RETURNING *, CAST(("customers".xmin) AS text) AS "$etag") SELECT 0 AS m, row_number() OVER () AS o, CAST(m0."id" AS text) AS d1, CAST(m0."name" AS text) AS d2, CAST(m0."email" AS text) AS d3, CAST(m0."region" AS text) AS d4, CAST(m0."$etag" AS text) AS d5 FROM m0 ORDER BY m, o
-- binds: "accounts@umber-studio.example.com", "Umber Studio", "EU"Update a product with a PATCH. An update names only the properties that change. It needs an If-Match header with the ETag from the last read, and answers 204 No Content with the new ETag. Without If-Match, the server answers 428 Precondition Required:
PATCH /odata/Product(1)
If-Match: "<ETag from the last GET>"
Content-Type: application/json
{"price": 13.5}
On PostgreSQL the update is one statement: the lock of the record, the write and the record the answer is built from:
1 statement on PostgreSQL
PostgreSQL, statement 1 of 1
WITH l0 AS (SELECT "public"."customers"."id" AS l1, "public"."customers"."id" AS l2 FROM "public"."customers" WHERE ("public"."customers"."id" = $1) FOR NO KEY UPDATE), m0 AS (UPDATE "public"."customers" SET "email" = $2 WHERE ("public"."customers"."id" = $3) RETURNING *, CAST("customers".xmin AS text) AS "$etag") SELECT -1 AS m, row_number() OVER () AS o, CAST(l1 AS text) AS d1, CAST(l2 AS text) AS d2, CAST(NULL AS text) AS d3, CAST(NULL AS text) AS d4, CAST(NULL AS text) AS d5 FROM l0 UNION ALL SELECT 0 AS m, row_number() OVER () AS o, CAST(m0."id" AS text) AS d1, CAST(m0."name" AS text) AS d2, CAST(m0."email" AS text) AS d3, CAST(m0."region" AS text) AS d4, CAST(m0."$etag" AS text) AS d5 FROM m0 ORDER BY m, o
-- binds: 1, "billing@umber-studio.example.com", 1A PUT replaces the whole record. A property the body leaves out is cleared, so send every property the record should keep.
Delete with a DELETE and the same If-Match header. The answer is 204 No Content. A later read of that key is 404 Not Found. Removing the discontinued hand saw:
DELETE /odata/Product(3)
If-Match: "<ETag from the last GET>"
A client that does not need the record back can ask for less. Prefer: return=minimal keeps the status and the OData-EntityId header, and sends no body:
POST /odata/Category
Prefer: return=minimal
Content-Type: application/json
{"name": "Paint"}
The answer is 204 No Content with OData-EntityId pointing at the new record.
A rejected write answers 400 Bad Request with the code ValidationFailed. The details array has one entry for each failed check, and each entry names the property in its target. A form can place each message beside its field.
A server rule can refuse a write, or lock a field once a condition holds. The refusal has the same shape. Rules shows both.
Deep insert
A deep insert creates a parent and its owned children in one request. The children are the navigation properties the model marks as owned. Take a survey with questions, and each question with answers. One request creates all of them:
POST /odata/Surveys
Content-Type: application/json
{"Title": "Deep", "Questions": [
{"Text": "One?", "Position": 1, "Answers": [{"ID": 500, "Label": "yes"}, {"ID": 501, "Label": "no"}]},
{"Text": "Two?", "Position": 2}
]}
The answer is 201 Created, with the new survey and its questions expanded. The server gives each question the survey's key, and each answer the question's key, so the children need not carry them.
The whole request is one transaction. A child that fails leaves nothing written. If the second answer above repeats an existing key, the answer is 409 Conflict with the target Questions[0]/Answers[1], which names the child that failed, and no survey, question or answer is saved. A child that fails validation is found before anything is written, and its target names its path as well.
A PATCH can change owned children by key with @delta. This one updates a question, adds a question with an answer, and removes question 11, all with the parent's title change:
PATCH /odata/Surveys(1)
If-Match: "<ETag from the last GET>"
Content-Type: application/json
{"Title": "Renamed", "Questions@delta": [
{"ID": 10, "Text": "Full name?"},
{"Text": "New?", "Position": 3, "Answers": [{"ID": 700, "Label": "maybe"}]},
{"@removed": {"reason": "deleted"}, "@id": "Questions(11)"}
]}
Without @delta, an owned collection in a PATCH replaces the related set: children with a key are updated, children without one are inserted, and the others are deleted.
Batch and atomic groups
A $batch request sends several requests in one HTTP call. Give related requests the same atomicityGroup name, and the server runs them in one transaction: all of them apply, or none does.
A price change that touches two products belongs together, so the two requests below form one group. Both apply, or neither does:
POST /odata/$batch
Content-Type: application/json
{"requests": [
{"id": "1", "method": "patch", "url": "Product(1)", "atomicityGroup": "prices",
"headers": {"If-Match": "*", "Content-Type": "application/json"}, "body": {"price": 12.00}},
{"id": "2", "method": "patch", "url": "Product(3)", "atomicityGroup": "prices",
"headers": {"If-Match": "*", "Content-Type": "application/json"}, "body": {"price": 17.00}}
]}
If-Match: * matches any ETag, so the members do not need the ETag they read. Use it only when the write should win over a concurrent change to the same record. The response holds one answer for each request in the batch.
If a member of a group fails, the group rolls back, and every other member of that group answers 424 Failed Dependency. A request in a batch that has no group runs on its own, in request order. By default the batch stops at the first request or group that fails, and every later request answers 424 without running. Send Prefer: odata.continue-on-error to run every request that does not depend on the failure.
The batch format is the one used by the JSON $batch of OData 4.01. Each request names its id, method, url, headers and body, and a request may refer to the result of an earlier one.
ETags and concurrent edits
Each record carries an ETag that changes whenever the record changes. A client sends the tag it read in If-Match on an update or delete. If the record has changed since, the server refuses the write. The tag holds the value of the set's concurrency token, and the database compares it in the same statement as the write, so a conditional write costs no more than an unconditional one.
Both buyers read product 1 while its price is 13.50, so both hold the same tag. The first buyer saves a new price, and the server answers 204 with a new ETag. The second buyer still sends the old tag, so the server answers 412 Precondition Failed and returns the current tag in the ETag header. The second buyer reads the record again, sees the new price, and decides whether to save.
PATCH /odata/Product(1)
If-Match: "<the tag from the first buyer's save>"
Content-Type: application/json
{"price": 14}
Nothing is written when the tag is stale. The second buyer's change applies only when it is sent with a current tag.
A tag the server did not write, or one that belongs to another set, is refused like a stale one. A concurrency conflict inside the database answers 409 Conflict with the code TransactionConflict. A 412 therefore always means a stale ETag, so a client can treat it as "read again and retry with the new tag".
A write whose rules decide over the stored record (a field that is read-only for some records, a validation over the merged record, a cascade, a hierarchy) reads that record inside its own transaction and locks it until the write commits. Another writer of the same record waits, so the rules always decide over the record the write changes, whatever If-Match says, and on a set without an ETag too. A cascading delete reads the rows it removes the same way; a child inserted beside it either waits and then fails on its foreign key, or arrives first and is deleted with the rest. Each database page says how it holds the lock. A source without transactions (a virtual set) cannot hold that lock: its concurrency setting says whether the write is guarded or the last write wins, as Virtual data sources describes.
What the tag carries is the set's concurrency token, declared in the model with etag or versionField, as Model your data describes. A set with no token, such as one with etag: none or listed in etag_opt_out, has no ETag: it accepts every update and delete, and the last write wins.
Next
- Rules adds checks that refuse a write, and explains the
ValidationFailedanswer. - Operations in code runs a script inside the write's transaction.