Skip to content

Mutators & Casts

Accessors and mutators let you transform attribute values as you read or write them on a model. Casts do the same thing declaratively — telling Articulate that votes is an integer or meta is JSON, without writing a transform for every column.

Declare an accessor and its mutator together as an Attribute:

app/models/user.py
from almasix.orm import Attribute, Model
class User(Model):
fillable = ("name",)
name = Attribute(
get=lambda value: value.title(),
set=lambda value: value.strip(),
)

get receives the stored value and returns what the model should hand back. set receives the assigned value and returns what should be stored:

user = User(name=" ada lovelace ")
user.name # "Ada Lovelace"
user.get_raw_attribute("name") # "ada lovelace"

Both callbacks may take no arguments, just the value, or the value plus the model’s raw attributes — Articulate passes whichever your callback accepts:

email = Attribute(get=lambda value, attributes: f"{value} <{attributes['name']}>")

When the accessor needs more than a lambda, use @attribute on a method that returns an Attribute. This is the shape Laravel uses:

from almasix.orm import Attribute, Model, attribute
class User(Model):
@attribute
def full_name(self) -> Attribute:
return Attribute(
get=lambda _value, attributes: f"{attributes['first']} {attributes['last']}",
)

full_name needs no column of its own. Add it to appends to include it in to_dict().

Writing several columns from one attribute

Section titled “Writing several columns from one attribute”

A set callback that returns a mapping writes each key, which is how a value object spans columns:

class Place(Model):
position = Attribute(
get=lambda _value, attributes: (attributes["lat"], attributes["lng"]),
set=lambda value: {"lat": value[0], "lng": value[1]},
)

Accessors run on every read. When one is expensive, pass cache=True and it computes once per instance, until that attribute is written again:

summary = Attribute(get=lambda value: expensive(value), cache=True)

The get_<name>_attribute / set_<name>_attribute form also works, and is often the shortest route when you only need one direction:

def get_display_attribute(self, value=None) -> str:
return f"{self.name} <{self.email}>"
def set_name_attribute(self, value: str) -> str:
return value.strip()

A mutator returning None suppresses the write entirely.

Declare casts as a dict:

class User(Model):
casts = {"votes": "int", "active": "bool", "meta": "json"}

Or as a casts() method, when a cast needs to be constructed:

class User(Model):
@classmethod
def casts(cls) -> dict:
return {"labels": EnumCollection.of(Status)}
Cast Reads as
int / integer int
float / double / real float
str / string str
bool / boolean bool"1", "true", "yes", "on" are true
decimal:<places> Decimal, quantized to places
json / array / dict / object / collection dict or list
date / immutable_date date
datetime / immutable_datetime datetime
time time
timestamp int epoch seconds
encrypted decrypted str
encrypted:array (or :object / :collection) decrypted, JSON-decoded value
hashed the stored digest
An Enum subclass that enum member
EnumCollection.of(Enum) a list of enum members

encrypted encrypts on write and decrypts on read, using the same key as Encryption:

class Account(Model):
casts = {"token": "encrypted", "recovery": "encrypted:array"}

The stored column holds ciphertext, so it must be a text column long enough to hold it. Encrypted values cannot be queried with a where clause.

hashed hashes the value on write using Hashing, and leaves an already-hashed value alone — so re-saving a model does not double-hash:

class User(Model):
casts = {"password": "hashed"}
user.password = "hunter2" # stored as a bcrypt digest

A date cast may carry a strftime format, which sets how the attribute serializes. Storage stays ISO-8601:

class Event(Model):
casts = {"day": "date:%d/%m/%Y", "at": "datetime:%Y-%m-%d %H:%M"}

To change the default for every date on a model, set date_format, or override serialize_date:

class Event(Model):
date_format = "%Y-%m-%d"
def serialize_date(self, value) -> str:
return value.strftime("%d %b %Y")

with_casts applies casts to one query, which is useful for computed columns and raw selects:

readings = await (
Reading.query()
.select_raw("sum(amount) as total")
.with_casts({"total": "decimal:2"})
.get()
)

merge_casts does the same for a single model instance.

Subclass CastsAttributes when a cast needs real logic. get transforms a stored value on read, set on write:

from almasix.orm import CastsAttributes
class AsJsonLines(CastsAttributes):
def get(self, model, key, value, attributes):
return (value or "").splitlines()
def set(self, model, key, value, attributes):
return "\n".join(value)
class Report(Model):
casts = {"lines": AsJsonLines}

Declare the class or an instance — casts = {"lines": AsJsonLines()} also works, which is how you pass constructor arguments.

A set returning a mapping writes several columns, and get composes them back:

class AsCoordinates(CastsAttributes):
def get(self, model, key, value, attributes):
return Point(attributes["lat"], attributes["lng"])
def set(self, model, key, value, attributes):
return {"lat": value.lat, "lng": value.lng}

The attribute needs no column of its own.

When a transform cannot be reversed, subclass CastsInboundAttributes. Reads pass the stored value through untouched:

from almasix.orm import CastsInboundAttributes
class AsSlug(CastsInboundAttributes):
def set(self, model, key, value, attributes):
return str(value).lower().replace(" ", "-")

A class may name its own cast with a cast_using classmethod, so the value type and its cast travel together:

class Money:
@classmethod
def cast_using(cls):
return AsMoney

casts = {"price": Money} then resolves to AsMoney.