Writing

Mutate

Create and update rows with their relations in one request and one transaction, read the written rows back, and retry on deadlocks.

Every resource answers POST /{resource}/mutate. One request creates and updates rows, writes their relations, and runs as one database transaction: either every entry is written, or none.

app/Rest/Resources/CategoryResource.php
use Lomkit\Rest\Fields\ID;
use Lomkit\Rest\Fields\Text;
use Lomkit\Rest\Relations\BelongsToMany;
use Lomkit\Rest\Relations\HasMany;

class CategoryResource extends Resource
{
    public static string $model = Category::class;

    public function fields(): array
    {
        return [
            ID::make('id'),
            Text::make('name')->rules('max:255')->creationRules('required'),
        ];
    }

    public function relations(): array
    {
        return [
            HasMany::make('products', ProductResource::class),
            BelongsToMany::make('featuredProducts', ProductResource::class),
        ];
    }
}
POST /api/categories/mutate
{
  "mutate": [
    {
      "operation": "create",
      "attributes": {"name": "Lighting"},
      "relations": {
        "products": [
          {"operation": "create", "attributes": {"name": "Desk lamp", "price": 39}},
          {"operation": "attach", "key": [4, 5]}
        ]
      }
    },
    {"operation": "update", "key": 2, "attributes": {"name": "Seating"}}
  ]
}
200 OK
{
  "operations": [
    {
      "operation": "create",
      "key": 3,
      "relations": {
        "products": [
          {"operation": "create", "key": 9},
          {"operation": "attach", "key": [4, 5]}
        ]
      }
    },
    {"operation": "update", "key": 2}
  ]
}

Entries

KeyTaken byValue
operationEvery entrycreate or update at the root; attach, detach, sync, toggle and delete too under relations, see Relations
keyEvery operation but createThe key of the row, typed like the ID field of the resource. A list of keys only in an entry of a to-many relation
attributescreate, update, sync, toggleThe values of the fields that the operation writes
relationsEvery operation but deleteThe entries of each relation, by name: an object for a to-one relation, a list for a to-many relation
versionupdate, on a versioned resourceThe version of the row that the client read, see Versioned rows
pivotEntries of a many-to-many relationThe pivot values, see Pivot fields
without_detachingsynctrue keeps the rows that key does not list
typeEntries of a MorphTo relationThe morph type of the row
  • mutate must be a JSON list: The mutate field must be a list.
  • A key that an entry does not take gives a 422 on that key: The mutate.0.atributes field is prohibited., The mutate.0.key field is prohibited when mutate.0.operation is create.
  • attributes, relations and pivot must be objects.
  • confirmation, next to mutate, carries the token of a confirmation. It must be a string: The confirmation field must be a string.
  • An unknown key at the first level of the body is ignored.

Attributes

attributes accepts the fields that the operation can write, each validated by its field.

ID::make('id'),                                    // refused on create and update
ID::make('uuid')->creatable(),                     // a key that clients send on create
Text::make('title')->rules('max:255')->creationRules('required'),
Number::make('rating')->nullable(),                // a number or null
DateTime::make('created_at')->readonly(),          // refused on create and update
Text::make('slug')->updatable(false),              // written on create only
Text::make('api_token')->writeOnly(),              // written, never returned
  • create accepts the creatable fields, and update, sync and toggle the updatable fields. A field is creatable and updatable by default. ID fields and readonly() fields are not.
  • A virtual() field is refused unless it declares fillUsing(), which runs after the other fields of the entry.
  • Another key gives a 422 under attributes: The created_at key of the mutate.0.attributes field is not writable on create. On update, sync and toggle, the message ends with on update.
  • Each attribute is validated by the base rule of its type, nullable, rules(), and creationRules() on create or updateRules() on the other operations. null is refused unless the field is nullable().
  • A rule that names another attribute, such as required_if:featured,true, compares with that attribute in the same entry.
  • The resource rules, rules(), createRules() and updateRules(), still apply, for rules that compare attributes and for nested keys such as metadata.source.
  • The model is filled with the writable fields of the operation only, after the mutating() hook.

See Fields and Validation for the field declarations and rules.

Rows that a write reaches

  • The keys of mutate, at the root and under relations, are read through the perimeterQuery() of the resource of their row, during validation. A key outside it gives The selected mutate.0.key is invalid., as a key that does not exist, and dispatches the KeysOutOfReach event. See Perimeter.
  • A created row is not checked against perimeterQuery().
  • Each entry that names the same row writes the same model instance, in the order of the entries, so it sees what the entries before it saved.
  • A row that another request deletes between the validation and the write is not written, and the mutation answers 200.

Nested writes

relations holds the entries of each relation of the row, by relation name. An entry can hold its own relations.

  • A BelongsTo or MorphTo entry is written before the row that holds it. The other relations are written after it.
  • The keys that the parent relation sets win over the attributes of the same name: the key of the parent on HasOne, HasMany, MorphOne, MorphMany and their of-many forms, and the morph type on the morph relations. On BelongsTo and MorphTo, the foreign key and the morph type that the relation sets win over the attributes of the entry.
  • An entry under an entry with a list of keys is written once for each of these keys.
  • relations nest up to rest.limits.depths.mutations levels, 3 by default. A deeper relations gives The mutate.0.relations.products.0.relations.category.relations.products.0.relations field is prohibited.
  • requiredOnCreation(), prohibitedOnCreation(), requiredOnUpdate() and prohibitedOnUpdate() on a relation require or refuse its entries: The mutate.0.relations.products field is required.
  • HasOneThrough and HasManyThrough take no entry: The mutate.0.relations.replies field is prohibited. Write through the intermediate relation instead.

The operations of each relation family are listed on Relations.

The response

The response holds operations: one entry for each entry of mutate, at the same index, and one for each entry of its relations.

  • operation is the operation of the entry.
  • key is the key of the row that a create wrote. On another operation, it is the key that the entry sent, typed like the ID field: "2" answers 2 on an integer key.
  • An entry that sends relations answers relations, under the names that it sent, in the same order: an object for a to-one relation, a list for a to-many relation. An entry that sends no relation answers no relations key.
  • An entry with a list of keys answers once, with the list. A create under such an entry answers the list of the keys that it created, in the order of the keys above:
{"operation": "attach", "key": [1, 2], "relations": {"category": {"operation": "create", "key": [7, 8]}}}
  • An entry of a MorphTo relation also answers type, the morph type of its row: {"operation": "attach", "type": "book", "key": 2}.
  • sync and toggle answer the keys that they sent. delete answers the keys that it deleted, as sent.

Reading the written rows

Send response next to mutate to get the written rows back, rendered as a search renders its rows:

POST /api/books/mutate
{
  "mutate": [
    {"operation": "update", "key": 2, "attributes": {"title": "Dune Messiah"}},
    {"operation": "create", "attributes": {"title": "Ivanhoe"}}
  ],
  "response": {
    "selects": [{"field": "title"}],
    "includes": [{"relation": "labels", "selects": [{"field": "text"}]}],
    "gates": ["update"]
  }
}
200 OK
{
  "operations": [
    {"operation": "update", "key": 2},
    {"operation": "create", "key": 3}
  ],
  "records": [
    {"title": "Dune Messiah", "gates": {"authorized_to_update": {"allowed": true, "message": null}}, "labels": [{"text": "Classic"}], "@more": []},
    {"title": "Ivanhoe", "gates": {"authorized_to_update": {"allowed": true, "message": null}}, "labels": [], "@more": []}
  ]
}
  • records holds one record for each entry of mutate, at the same index. Two entries on the same row give the same record. The entries under relations give no record: request their rows through includes.
  • response takes selects, aggregates, includes and gates, with the rules of a search at every level. An include keeps its limit for each record, and a record with a to-many include ends with @more.
  • Another key of a search gives a 422, such as The response.filters field is prohibited., and so does create in gates: The response.gates.0 field is prohibited. A response that is not an object gives The response field must be an array.
  • The records are read after the commit, through the search of the resource: its perimeterQuery() applies, and a row that it leaves out, or that is gone, gives null.
  • The gates answer for the rows as the mutation left them. The records go through the map() of the resource's Response.
  • response needs viewAny on the resource and on each resource that its includes and aggregates reach, checked before any write: a denial answers 403, and nothing is written.

Transactions

The mutation runs in one database transaction, on the connection of the model of the endpoint's resource:

  1. The class checks (viewAny, create) run before validation.
  2. Validation reads the rows that the keys name, through the perimeter.
  3. In the transaction, the row checks of every entry run, before the first write, then the version check of a versioned resource.
  4. The resource's mutateConfirmation() can ask for a confirmation: the endpoint then answers 428 and writes nothing.
  5. mutatingAll(), the writes with their mutating() and mutated() hooks, then mutatedAll().
  6. After the commit, the records are read and the ResourceMutated event is dispatched.
  • A failure anywhere, a denied row, a hook that throws or a database error, rolls the whole mutation back, and the response is the error alone.
  • Rows of models on another connection are written outside the transaction.
  • mutating() and mutated() run for each row that an entry writes, at every level, on the resource of that row: a create, an update, an entry with attributes, and an attach, detach, sync or toggle entry whose own relations change.
  • An entry that writes no attribute and no relation, such as the target of an attach, runs no hook and is not saved: only its link is written.
{"operation": "attach", "key": 7, "relations": {"tags": [{"operation": "attach", "key": [3]}]}}

The entry above links row 7 to its parent and attaches tag 3 to row 7: the hooks of its resource run for row 7, and tag 3, only linked, runs none.

See Events and hooks.

Deadlock retries

config/rest.php
'transactions' => [
    'attempts' => env('REST_TRANSACTION_ATTEMPTS', 1),
],
  • When the database reports a deadlock or a lock timeout, the package runs the transaction again, up to attempts times in all. 1, the default, runs it once.
  • This applies to the transaction of a mutation, of destroy, restore and forceDestroy, and of each chunk, row or whole run of an action.
  • Each attempt rolls the previous one back, gives the rows read before the transaction their attributes as read, and runs everything inside the transaction again: the row checks, the hooks, the events and the handle() or handleRow() of an action.
  • Send mails, HTTP calls and jobs after the commit, with afterCommit(), ShouldDispatchAfterCommit or a listener of the package events, so that they run once.
  • Laravel runs only the outermost transaction again: inside a transaction of your application, a deadlock throws Illuminate\Database\DeadlockException at once.

Recording each written row

To keep a history row for each written row, write it from the row hooks. They run inside the transaction of the endpoint: a throw in a hook, or later in the request, rolls back the write and the history together, and a deadlock retry runs the hooks again after it rolls back what the failed attempt wrote.

use Illuminate\Database\Eloquent\Model;
use Lomkit\Rest\Mutations\MutationNode;

public function mutated(MutationNode $entry, Model $model): void
{
    History::create([
        'subject_type' => $model->getMorphClass(),
        'subject_id' => $model->getKey(),
        'note' => trim($entry->operation->value.' '.implode(',', array_keys($entry->relations ?? []))),
    ]);
}
POST /api/loans/mutate
{
  "mutate": [
    {
      "operation": "update",
      "key": 101,
      "attributes": {"borrower": "Alan"},
      "relations": {
        "book": {
          "operation": "attach",
          "key": 102,
          "relations": {
            "shelves": [
              {"operation": "create", "attributes": {"name": "Essays"}},
              {"operation": "attach", "key": [101]}
            ]
          }
        }
      }
    }
  ]
}

The hooks run from the deepest row up, and the history gets three rows in the transaction of the mutation: the new shelf with create, book 102 with attach shelves, then loan 101 with update book. Shelf 101, only linked, runs no hook.

  • On mutate, use mutated(): the row and its relations are written by then. $entry->attributes and $entry->relations say what the request changed for $model. Read a relation in mutated() for the rows now attached.
  • A target that is only linked, a moved child and a child delete run no hook of their own: record them from the entry of the parent row, under $entry->relations.
  • On destroy, restore and forceDestroy, use destroyed(), restored() and forceDestroyed(), once per row.
  • In an action, write the history inside handle() or handleRow(), in the transaction of the chunk, the row or the run. ActionTransaction::None opens no transaction.
  • The Laravel events run after the commit: keep them for work outside the database.

Authorization

  • viewAny on the resource of the endpoint is checked before validation.
  • create is checked once per created resource class, before validation, then per created row with its draft.
  • update is checked per updated row, with the changes of the entry as a third argument.
  • A row that an entry names without writing it, such as the target of an attach on a many-to-many relation, is checked with view. A row that an entry moves under a child relation is checked with update.
  • The links of attach, detach, sync and toggle check the abilities that the relation declares, or else update on the parent.
  • A denial answers 403 with the message of the first denial: {"message": "This action is unauthorized."}, or the message of the policy's Response::deny().

See Policies for the checks and for the denials list.

Limits

LimitDefaultCounts
rest.limits.sizes.mutate100The entries of mutate
rest.limits.sizes.relation_entries100The entries of each to-many relation, each deleted key counting as one
rest.limits.sizes.values100The keys of one key list
rest.limits.depths.mutations3The levels of relations
rest.limits.totals.entries1000Every list entry of the request
rest.limits.sizes.rows10000The rows that response can render: one record for each entry of mutate, times the limit of each to-many include. See Limits

Errors

422 Unprocessable Content
{
  "message": "The created_at key of the mutate.0.attributes field is not writable on create.",
  "errors": {
    "mutate.0.attributes": ["The created_at key of the mutate.0.attributes field is not writable on create."]
  }
}
StatusExample
403A policy refuses a row: {"message": "This action is unauthorized."}
409A versioned row changed since the client read it, see Versioned rows
428The resource asks for a confirmation, see Confirmation
422The selected mutate.0.operation is invalid.
422The selected mutate.0.key is invalid.
422The mutate.0.attributes.name field must be a string.
422The mutate.0.key field is prohibited when mutate.0.operation is create.
422The mutate.0.without_detaching field is prohibited unless mutate.0.operation is in sync.