Skip to content

Bulk & collection mutations

Two ServiceSpec shapes cover the bulk cases a single-instance spec can't. They're mutually exclusive. Neither takes an instance_selector_spec: one declared beside a collection_selector_spec or many=True is refused with ImproperlyConfigured when the ServiceSpec is constructed, because dispatch would never call it.

many=True — a list body in, a list out

Validate the request body as a list, hand the validated list to the service, and render the result list. The service loops itself — one call, one round-trip where the ORM allows it.

from rest_framework_services import ServiceSpec, SelectorKind, SelectorSpec


@dataclass
class BookIn:
    title: str


def bulk_create_books(*, data: list[BookIn]) -> list[Book]:
    return Book.objects.bulk_create([Book(title=item.title) for item in data])


class BulkCreateBooksView(ServiceCreateView):
    spec = ServiceSpec(
        service=bulk_create_books,
        input_serializer=BookIn,
        many=True,
        output_selector_spec=SelectorSpec(
            kind=SelectorKind.RETRIEVE, output_serializer=BookSerializer
        ),
    )

POST a JSON array; you get a 201 with the rendered array. Under atomic=True (the default) any item's ServiceError rolls the whole batch back.

A refused body is answered with the list serializer's own errors, in the shape the installed DRF gives (a mapping of the failing rows' indexes from DRF 3.18, a list below it). Over a live view the ListSerializer alone bounds the body, through its own allow_empty, min_length and max_length, and a body it sets no max_length for takes any number of rows.

Off HTTP, and on an OfflineServiceView, the kernel checks the list first and takes its bounds from the same ListSerializer; where it sets no max_length, at most 1000 rows are accepted, or what the SERVICE_SPECS_MAX_ARRAY_LENGTH setting says (None bounds nothing), and a longer list is refused under non_field_errors. A ListSerializer that cannot be built without a request, such as one whose item serializer reads context["request"] in __init__, is held to that default whatever max_length it sets, because its bounds cannot be read.

Off HTTP: the list as one named argument

A tool call's arguments are always a JSON object, so a caller that only sends named arguments can never send the bare array an HTTP body is. For that caller the list travels under one argument instead, items unless the spec names another:

spec = ServiceSpec(
    service=bulk_create_books,
    input_serializer=BookIn,
    many=True,
    many_argument="books",  # optional; the default is "items"
)

schema = spec_to_json_schema(spec, phase="input")
result = dispatch_spec(
    spec, user=user, params={"books": [{"title": "Dune"}]}, many_as_argument=True
)

The two halves describe the same input. spec_to_json_schema wraps the item schema in an object with that one required property and nothing else, carrying the list's own bounds from allow_empty, min_length and max_length as minItems / maxItems:

{
  "type": "object",
  "properties": {"books": {"type": "array", "items": {"...": "the BookIn schema"}}},
  "required": ["books"],
  "additionalProperties": false
}

many_as_argument=True makes dispatch_spec read the list out of that argument. It is a no-op on a single-item spec and on a selector, so a transport passes it on every call rather than branching on spec.many. HTTP never passes it: a view still validates the request body as the bare array.

Every validation error the list raises comes back keyed under the argument, in the shape a wrapper serializer declaring books = BookIn(many=True) produces on current DRF. Item errors name only the invalid items, by index, on every DRF this package supports, where DRF below 3.18 would otherwise report a list with an empty entry for each valid item:

{"books": {"1": {"title": ["This field may not be blank."]}}}

The argument missing answers {"books": ["This field is required."]}. null or a value that is not a list is refused under the argument in the kernel's words, such as {"books": ["Expected an array."]}, and so, with no view or an OfflineServiceView, is a list outside its length bounds. Where a transport dispatches with its own live view, the ListSerializer refuses that list, as {"books": {"non_field_errors": ["Ensure this field has no more than 2 elements."]}}, before it reads a row. A row that is not an object is refused at its index in DRF's words, {"books": {"1": {"non_field_errors": ["Invalid data. Expected a dictionary, but got int."]}}}, and that refusal leaves the list's own checks to the rows sent, as DRF's do: its length bounds count that row, and its list-level validate() does not run. Arguments that are not an object are refused as {"non_field_errors": ["Invalid data. Expected a dictionary, but got list."]}. An argument sent beside the list that the spec declares no reads entry for is refused under every unknown_arguments policy, as {"non_field_errors": ["Unexpected argument(s): 'zzz'."]}, because the service receives only the list and the argument would have nowhere to go. Under REJECT it is refused before the items are read; under IGNORE, once every item is valid, so an item's refusal comes first. A reserved pool seed sent there, such as user, is not refused. The policy decides an undeclared key inside an item: dropped under IGNORE, refused inside that item under REJECT. A refusal the service or a precondition raises is passed on as raised.

A spec that took a list through that wrapper can declare many=True instead and keep its error wire, as long as the wrapper's field was named items or the spec names it with many_argument. Below DRF 3.18 the wrapper itself reported item errors as a list, so there the index mapping is new. One call the wrapper let through is refused: under REJECT, an undeclared key inside an item, which the wrapper never looked at.

collection_selector_spec — operate on a filtered set

The LIST-kind twin of instance_selector_spec. It resolves a scoped set (via the selector + filter_set) and seeds it into the service as collection. Use it for instance-less bulk delete / update — no single pk in the URL.

from rest_framework_services import delete_collection


def published_books(*, user) -> QuerySet[Book]:
    return Book.objects.for_user(user)  # owner-scoped


class BulkDeleteBooksView(ServiceDeleteView):
    spec = ServiceSpec(
        service=delete_collection(Book),  # collection.delete()
        collection_selector_spec=SelectorSpec(
            kind=SelectorKind.LIST,
            selector=published_books,
            filter_set=BookFilterSet,  # ?status=draft&… narrows the set
        ),
    )

DELETE /books/?status=draft deletes the draft set. The filter comes from the query string; an empty set is a harmless no-op (idempotent). A bulk update is the same shape with a service that calls collection.update(...) (return a summary like {"updated": n} to get a 200 body instead of 204).

The two channels stay apart here exactly as they do on a single-instance mutation: the query string feeds filter_set and the body feeds input_serializer. A repeated parameter therefore survives whole, so a MultipleChoiceFilter or an id__in filter reads every value of ?id=1&id=2&id=3; and a query parameter cannot stand in for a serializer field it happens to share a name with. Selector arguments come from the body and the route captures — ?status= reaches filter_set, not a status= keyword on the selector.

delete_collection / adelete_collection are batteries-included; pass soft_delete=lambda qs: qs.update(is_archived=True) to archive instead.

Return the affected set

By default a collection mutation renders whatever the service returns — a summary like {"updated": n}, or an empty 204. To render the set itself, add an output_selector_spec with kind=SelectorKind.LIST: it re-fetches and renders a list — the output twin of the bulk input. Capture the affected pks before the write, since the collection's own filter may no longer match afterwards.

def publish_drafts(*, collection: QuerySet[Post]) -> list[int]:
    ids = list(collection.values_list("id", flat=True))
    Post.objects.filter(id__in=ids).update(published=True)
    return ids  # the service result → `result`


def published_by_ids(*, result: list[int]) -> QuerySet[Post]:
    return Post.objects.filter(id__in=result).order_by("id")


class PublishDraftsView(ServiceUpdateView):
    spec = ServiceSpec(
        service=publish_drafts,
        collection_selector_spec=SelectorSpec(
            kind=SelectorKind.LIST,
            selector=all_posts,
            filter_set=PostFilterSet,
        ),
        output_selector_spec=SelectorSpec(
            kind=SelectorKind.LIST,  # ← renders a list, not one row
            selector=published_by_ids,
            output_serializer=PostSerializer,
        ),
    )

PUT /posts/?published=false publishes the drafts and responds 200 with the JSON array of the now-published rows. kind=LIST on output_selector_spec is valid only alongside collection_selector_spec — a single-instance mutation returns one representation (kind=RETRIEVE, the default). The re-fetch runs inside the same transport-neutral dispatch_spec, so the MCP server renders the list identically.

Permissions & failures

  • Per-set — the view / spec permission_classes plus the scoped selector authorize the action; there is no per-row check_object_permissions (a per-row opt-in is a planned follow-up).
  • All-or-nothing — atomic=True makes the batch a single transaction. Per-item partial-success responses are a tracked follow-up.

A bulk spec runs through the same transport-neutral dispatch_spec the MCP server uses, so the rules are identical on and off HTTP.