Skip to content

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

helper(target, data, *, field_map=None, exclude_fields=None, m2m=None)
  • 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's set().

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 with update_fields=[changed columns], not the whole row.
  • m2m assignments are applied unconditionally and tracked separately in changes.

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.

update_from_input(instance, data)   # bio==UNSET is skipped automatically

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.