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:
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_classesplus the scoped selector authorize the action; there is no per-rowcheck_object_permissions(a per-row opt-in is a planned follow-up). - All-or-nothing —
atomic=Truemakes 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.