Mutation helpers¶
The library doesn't run your services for you, but it does ship the
helpers that DRF's serializer.save() quietly performs — minus the
surprises and plus a typed change record.
| Helper | What it does |
|---|---|
apply_input(instance, data, ...) |
set attributes in memory; does not save |
create_from_input(Model, data, ...) |
build, save, optional M2M |
update_from_input(instance, data, ...) |
diff in-memory state vs. input, save(update_fields=[...]) only the fields that actually changed |
acreate_from_input |
async sibling of create_from_input |
aupdate_from_input |
async sibling of update_from_input |
All sync helpers wrap the same apply_input; the async helpers use
Django 4.2+ asave() / aset() and are otherwise identical in
contract.
Common signature¶
data— a dataclass instance, plain dict, or any object with__dict__. The helper iterates the public attributes / keys.field_map: dict[str, str] | None— translate input keys to model attribute names. Useful when input contracts diverge from column names.exclude_fields: list[str] | None— fields to drop from the input before applying. Use it to keep server-controlled columns (created_by,updated_at) out of write paths.m2m: dict[str, Any] | None— many-to-many assignments applied post-save (create / update only). Each value is passed to the manager'sset().
apply_input — set attributes, don't save¶
from rest_framework_services import apply_input
def update_author(*, instance, data):
apply_input(instance, data, exclude_fields=["created_by"])
instance.full_clean()
instance.save()
return instance
Returns the same ChangeResult shape as the others — created is
always False, changes is the diff against the in-memory state at
call time.
create_from_input¶
from rest_framework_services import create_from_input
from myapp.models import Author
def create_author(*, data):
result = create_from_input(Author, data)
return result.instance
Builds a fresh Author, calls save(), then applies any m2m
assignments. result.created is True.
update_from_input¶
from rest_framework_services import update_from_input
def update_author(*, instance, data):
result = update_from_input(instance, data, exclude_fields=["created_by"])
if result.get_field_change("email"):
send_email_changed_notice(instance)
return result.instance
Behaviour worth knowing:
- The diff is computed against the in-memory instance state, not the database. Reload first if you need to see what's actually stored.
save()runs only when at least one field changed — and is called withupdate_fields=[changed columns], not the whole row.m2massignments are applied unconditionally and tracked separately inchanges.
ChangeResult and FieldChange¶
@dataclass(frozen=True)
class FieldChange:
field: str
old: Any
new: Any
@dataclass(frozen=True)
class ChangeResult:
instance: Model
created: bool
changes: tuple[FieldChange, ...]
@property
def changed_fields(self) -> tuple[str, ...]: ...
def get_field_change(self, field_name: str) -> FieldChange | None: ...
def __bool__(self) -> bool: ... # True iff any change
Common patterns:
result = update_from_input(instance, data)
if not result: # nothing changed
return result.instance
if "status" in result.changed_fields:
audit.log("status changed", result.get_field_change("status"))
for change in result.changes:
metrics.incr(f"author.changed.{change.field}")
UNSET — distinguish omitted from None¶
from dataclasses import dataclass
from rest_framework_services import UNSET
@dataclass
class UpdateAuthorInput:
name: str | None = None
bio: str | None = UNSET # type: ignore[assignment]
def update_author(*, instance, data):
if data.bio is UNSET:
# client did not send bio — leave whatever's there
...
elif data.bio is None:
instance.bio = ""
instance.save(update_fields=["bio"])
else:
instance.bio = data.bio
instance.save(update_fields=["bio"])
Critical for correct PATCH semantics: a missing key and null mean
different things. The mutation helpers themselves treat UNSET as
"skip this field"; if you use them, you don't have to special-case it
yourself.
Async siblings¶
acreate_from_input / aupdate_from_input exist purely to honour
async ORM calls. The contract is identical:
from rest_framework_services import aupdate_from_input
async def update_author(*, instance, data):
result = await aupdate_from_input(instance, data)
return result.instance
Use them inside async def services. See Async.