Attributes, Accessors & Mutators¶
How a model's attributes are read, written, transformed, serialized, and change-tracked.
Casts (__casts__) transform values by type — see Casts; this page covers the
per-attribute hooks and the attribute lifecycle around them.
Accessors¶
An accessor transforms a stored value every time you read the attribute. Define a method
that returns an Attribute — the -> Attribute return annotation is what marks it (an
unannotated method stays a plain method):
from arvel import Model
from arvel.database import Attribute
class User(Model):
__fields__ = {"first": str, "last": str}
def first(self) -> Attribute:
return Attribute(get=lambda value, attrs: value.title() if value else value)
user = await User.create(first="ada", last="lovelace")
user.first # "Ada" — the accessor ran
user._attributes # {"first": "ada", ...} — storage is untouched
The get callable receives (value, attributes): the stored value for this key, and the
model's raw stored attributes dict. Raw means post-mutator, pre-accessor/pre-cast — an
accessor reading attrs["first"] above sees "ada", not "Ada". Accessors take precedence
over a cast on the same key.
The defining method is stripped from the class at model creation, so user.first resolves to
the transformed value — you never call it.
Computed attributes¶
An accessor doesn't need a column. Combine values on the fly:
class User(Model):
__fields__ = {"first": str, "last": str}
def full_name(self) -> Attribute:
return Attribute(get=lambda value, attrs: f"{attrs['first']} {attrs['last']}".title())
user.full_name works even though no full_name column exists (value arrives as None).
Computed attributes don't serialize by default — add them to __appends__ for that.
Caching expensive accessors¶
cached=True memoizes the computed value per instance — the get callable runs once:
def gravatar(self) -> Attribute:
return Attribute(get=lambda value, attrs: hash_email(attrs["email"]), cached=True)
Mutators¶
A mutator transforms a value on the way in — assignment and mass-assignment both route through it, and what it returns is what gets stored (and persisted):
class User(Model):
__fields__ = {"first": str}
def first(self) -> Attribute:
return Attribute(
get=lambda value, attrs: value.title() if value else value,
set=lambda value, attrs: value.strip().lower(),
)
user = await User.create(first=" ADA ")
user._attributes["first"] # "ada" — normalized before storage
user.first # "Ada" — accessor on the way back out
A mutator on a key takes precedence over a cast's write path for that key.
Appends¶
__appends__ folds computed accessors into to_dict() / to_json() (they have no stored
value, so serialization would otherwise skip them):
Visibility¶
__hidden__ drops keys from serialization (secrets, internal columns); __visible__ is the
allow-list inverse. Per-instance, make_hidden(*keys) / make_visible(*keys) adjust one
model's serialization without changing the class — make_visible reveals a normally-hidden
key (an admin endpoint showing a field the public API hides):
class User(Model):
__hidden__ = ["password_hash", "email"]
user.to_dict() # neither key present
user.make_visible("email").to_dict() # email included, password_hash still hidden
Both also exist on a query result's ModelCollection, fanning out to every member:
Dirty tracking¶
Every model tracks its attributes against the values it was hydrated (or last saved) with:
user.is_dirty() # any unsaved change?
user.is_clean() # the inverse
user.last = "Byron"
user.get_dirty() # {"last": "Byron"} — changed keys with their NEW values
user.get_original("last") # "Lovelace" — the pre-change value
await user.save() # persists only the dirty keys; the model is clean again
Primary keys¶
__primary_key__ names the key column (default "id", integer, auto-increment). For string
keys generated on insert, mix in one of:
from arvel.database import HasUuids # UUIDv7 — time-ordered
from arvel.database import HasUlids # ULID — lexicographically sortable
class Order(HasUuids, Model):
__fields__ = {"total": int} # id column handled by the mixin
A new Order gets its id generated automatically on insert; both formats sort by creation
time, so they index well.