Models & CRUD¶
Introduction¶
Arvel ships with Arvent, an active-record ORM built on top of SQLAlchemy's async engine. Each database table has a corresponding "model" you use to interact with that table. Models let you query for data in your tables, as well as insert, update, and delete records.
A model is a Python class. Its instances represent rows; its class methods build queries. Because Arvent sits on SQLAlchemy, a model is a real SQLAlchemy mapped class — you get the full power of SQLAlchemy underneath the Eloquent-shaped surface.
Note
Every persistence and read method on a model — create, save, delete, find, all, first — is an async coroutine and must be awaited. The fluent builder methods (where, order_by, with_) are synchronous and return a query builder; only the terminal method touches the database.
Defining Models¶
To get started, let's create a model. The easiest way is the make:model command:
This generates app/models/flight.py. A model declares typed attributes using the column helpers from arvel.database:
from arvel.database import Model, Timestamps, id_, string
class Flight(Model, Timestamps):
__tablename__ = "flights"
id: int = id_()
name: str = string(255)
On its own, make:model only creates the model class. To scaffold the related artifacts in one command, pass companion flags — each is named from the model:
| Flag | Companion |
|---|---|
-m, --migration | A create_<table>_table migration (the table name is pluralized). |
-f, --factory | A model factory. |
-s, --seed | A database seeder. |
-c, --controller | A controller (add --resource / --api to shape it). |
--requests | The Store…Request / Update…Request pair. |
-p, --policy | An authorization policy. |
-o, --observer | A lifecycle observer. |
-R, --json-resource | A JsonResource transformer. |
--test | A feature test. |
-a, --all | The model plus every companion above (resource controller). |
arvel make:model Flight -mfsc # model + migration + factory + seeder + controller
arvel make:model Flight --all # the full set
Companions that already exist are skipped, never overwritten. Short flags combine (-mf, -mfsc).
Table Names¶
Unlike Eloquent, Arvent does not infer the table name from the class name. Every concrete model must declare __tablename__ explicitly:
Warning
Forgetting __tablename__ is the most common first mistake. There is no runtime pluralization fallback on the model — the class will fail to map. (The CLI scaffolders do pluralize a class name into a suggested table name, but that's a generation-time convenience, not a runtime default.)
Primary Keys¶
Use id_() for an auto-incrementing integer primary key, or uuid_id() for a UUID v7 key:
from arvel.database import Model, id_, uuid_id
import uuid
class Flight(Model):
__tablename__ = "flights"
id: int = id_()
class Booking(Model):
__tablename__ = "bookings"
id: uuid.UUID = uuid_id()
Both default to init=False — the value is provided by the database (integer) or a default factory (UUID), so you never pass it to the constructor. Composite primary keys are supported; find accepts a tuple in that case.
Column Helpers¶
The column helpers in arvel.database mirror the migration Blueprint vocabulary so the schema and the model speak the same language. They all return Any, so the plain annotation drives the Python type and the assignment stays clean under strict type checkers.
| Helper | Column type |
|---|---|
id_() | Auto-increment integer primary key |
uuid_id() | UUID v7 primary key |
string(length=255) | VARCHAR(length) |
text() | TEXT |
integer() / big_integer() | INTEGER / BIGINT |
boolean() | BOOLEAN |
decimal(precision=10, scale=2) | NUMERIC |
datetime(timezone=True) | TIMESTAMP |
json() / jsonb() | JSON / JSONB (Postgres) |
uuid() | UUID value column |
enum(EnumOrValues) | ENUM |
foreign_id(references) | Integer FK, e.g. foreign_id("users.id") |
foreign_uuid / foreign_string | FKs for UUID / string primary keys |
column(SAType) | Any SQLAlchemy type (custom TypeDecorator) |
Most helpers accept nullable=, unique=, index=, and default=:
from decimal import Decimal
from arvel.database import Model, Timestamps, boolean, decimal, foreign_id, id_, string, text
class Post(Model, Timestamps):
__tablename__ = "posts"
id: int = id_()
user_id: int = foreign_id("users.id", on_delete="CASCADE")
title: str = string(200, index=True)
body: str = text()
price: Decimal = decimal(10, 2)
is_published: bool = boolean(default=False)
Inferred Columns¶
For the common case you don't need a helper at all. The model metaclass infers a column from a bare annotation or a plain default:
class Post(Model):
__tablename__ = "posts"
id: int = id_()
title: str # → VARCHAR(255), NOT NULL
views: int = 0 # → INTEGER, default 0
published_at: datetime | None = None # → nullable, tz-aware TIMESTAMP
When you need an option a bare annotation can't carry — a unique index, a foreign key, an explicit length — reach for field(...):
from arvel.database import field
handle: str = field(length=64, unique=True, index=True)
team_id: int = field(foreign_key="teams.id", on_delete="CASCADE")
Timestamps¶
Add the Timestamps mixin to get created_at and updated_at columns that Arvent maintains automatically — set on insert, bumped on update:
To bump updated_at without otherwise changing the row, call await model.touch(). To run a block of work without touching timestamps:
Default Attribute Values¶
A column without a default becomes a required keyword argument in the model's generated constructor (models are keyword-only dataclasses). Supply a default= to make it optional:
name: str = string(255) # required: Flight(name=...)
slug: str | None = string(255, default=None) # optional
Retrieving Models¶
Once you have a model and its table, you can start retrieving data. Each model serves as a query builder entry point. The all method retrieves every row:
The model's query methods (where, order_by, …) return a query builder. Add constraints, then run the query with a terminal method like get:
Collections¶
all and get return a ModelCollection — a list subclass enriched with helpers like pluck, map, filter, group_by, key_by, and first_where. Because it's a list, you can iterate it, index it, and pass it anywhere a list is expected:
flights = await Flight.where(active=True).get()
names = flights.pluck("name")
grounded = flights.filter(lambda f: not f.active)
See Collections for the full method list.
Retrieving Single Models¶
In addition to retrieving all rows matching a query, you can fetch single records with find, first, and first_where:
flight = await Flight.find(1) # by primary key, or None
flight = await Flight.where(active=True).first() # first match, or None
flight = await Flight.query().first_where(name="LA") # shorthand
pluck, value, and aggregates pull single columns or scalars:
names = await Flight.query().pluck("name") # list of one column
total = await Flight.where(active=True).count()
Not Found Behavior¶
Sometimes you want to raise instead of receiving None. The *_or_fail methods raise ModelNotFoundError:
Note
If you don't catch ModelNotFoundError in an HTTP handler, the framework's exception handler translates it into a 404 Not Found JSON response automatically.
Inserting & Updating Models¶
Inserts¶
To insert a new record, create an instance, set its attributes, and call save:
Or do it in one step with create, which accepts the attributes as keyword arguments:
created_at and updated_at are set automatically when the model uses Timestamps.
Updates¶
There is no instance-level update(**attrs) method. To update an existing model, set its attributes (or fill them) and call save:
Warning
Flight.update({...}) on the class is a bulk query-builder update over many rows — not an instance update. Don't confuse the two:
To update a single model without firing model events, use update_quietly:
Mass Assignment¶
create and fill go through mass-assignment protection. By default a model is unguarded — any attribute may be set. Lock it down with __fillable__ (an allowlist) or __guarded__ (a denylist):
class Flight(Model):
__tablename__ = "flights"
__fillable__ = ["name", "destination"] # only these may be mass-assigned
id: int = id_()
name: str = string(255)
destination: str = string(255)
is_admin_only: bool = boolean(default=False)
A disallowed key raises MassAssignmentError. To bypass guarding deliberately, use force_fill or the unguarded context manager:
Note
Constructing a model directly — Flight(name=...) — does not run mass-assignment checks. Guarding applies only to create() and fill(). Validate untrusted input with a form request before it reaches the model.
Upserts¶
Get-or-create patterns are available on the query builder:
flight = await Flight.query().first_or_create(
{"name": "London to Paris"}, # match attributes
{"destination": "Paris"}, # extra values if creating
)
flight = await Flight.query().update_or_create(
{"departure": "LHR", "destination": "CDG"},
{"price": Decimal("99.00")},
)
For bulk upserts, see Bulk Writes.
Deleting Models¶
To delete a model, call delete on an instance:
To delete many rows at once, run a delete through the query builder:
Soft Deleting¶
Add the SoftDeletes mixin to keep rows in the table but mark them deleted with a deleted_at timestamp. Soft-deleted rows are hidden from queries by default:
from arvel.database import Model, Timestamps, SoftDeletes, id_, string
class Flight(Model, Timestamps, SoftDeletes):
__tablename__ = "flights"
id: int = id_()
name: str = string(255)
Now delete sets deleted_at instead of removing the row:
await flight.delete() # sets deleted_at; row stays
flight.trashed() # True
await flight.restore() # clears deleted_at
await flight.force_delete() # permanently removes the row
Querying Soft Deleted Models¶
Soft deletes are enforced by a global scope, so ordinary queries exclude trashed rows. Include or isolate them explicitly:
await Flight.with_trashed().get() # include soft-deleted rows
await Flight.only_trashed().get() # only the soft-deleted rows
Bulk restore must pair with only_trashed — otherwise the default scope hides the very rows you mean to restore:
Dirty Tracking¶
After loading a model you can inspect what changed before saving, and what changed after the last save:
flight = await Flight.find_or_fail(1)
flight.name = "New name"
flight.is_dirty() # any unsaved changes?
flight.is_dirty("name") # this attribute specifically?
flight.is_clean("destination") # unchanged?
flight.get_dirty() # {"name": "New name"}
flight.get_original("name") # value as loaded
await flight.save()
flight.was_changed("name") # changed by the save just performed?
flight.get_changes() # {attr: new_value} from the last save
Model Mixins¶
Compose behavior by inheriting mixins alongside Model:
| Mixin | Adds |
|---|---|
Timestamps | created_at / updated_at, maintained automatically |
SoftDeletes | deleted_at column and soft-delete behavior |
HasUuids | Auto-fills a string primary key with a UUID on insert |
HasUlids | Auto-fills a string primary key with a ULID on insert |
Prunable | Marks stale rows for pruning (you implement prunable_query) |
Note
Prunable.prunable_query() raises NotImplementedError by default — you must override it to declare which rows are eligible for pruning.
Model Events¶
Arvent models dispatch lifecycle events you can hook into. The "before" events — creating, updating, deleting, restoring, force_deleting — can cancel the operation by returning False. The "after" and ambient events — saving, saved, created, updated, deleted, trashed, force_deleted, restored, retrieved, replicating — fire around the operation.
Observers¶
Group event handlers into an observer class. Generate one with arvel make:observer, then register it:
from arvel.database import Observer
class FlightObserver(Observer):
def creating(self, flight: Flight) -> bool | None:
if flight.name == "":
return False # cancels the insert
async def created(self, flight: Flight) -> None:
await notify_ops(flight)
Flight.observe(FlightObserver())
You can also register a single callback, or declare observers on the class:
Flight.on("deleted", purge_cache)
class Flight(Model, Timestamps):
__tablename__ = "flights"
__observed_by__ = [FlightObserver]
To dispatch model events onto the application event bus, map them with __dispatches_events__.
Muting Events¶
Suppress all events for a block of work:
The *_quietly helpers (save_quietly, delete_quietly, force_delete_quietly, restore_quietly, update_quietly) do the same for a single operation.
Serializing Models¶
Models convert to dictionaries and JSON for storage or transport:
flight.to_dict() # respects __hidden__ / __visible__ / __appends__
flight.to_json(indent=2)
flight.to_pydantic(FlightOut) # validate into a Pydantic schema
flight.only("id", "name")
flight.except_("internal_notes")
Control which attributes appear with the __hidden__ (denylist), __visible__ (allowlist, applied first), and __appends__ (computed accessor names) class variables, or per instance with make_hidden(...), make_visible(...), and append(...):
from arvel.database import Model, Timestamps, accessor, id_, string
class User(Model, Timestamps):
__tablename__ = "users"
__hidden__ = ["password_hash"] # column dropped from output
__appends__ = ["full_name"] # accessor added to output
id: int = id_()
first_name: str = string(50)
last_name: str = string(50)
# full_name isn't a column — it's a computed accessor. __appends__ tells
# to_dict()/to_json() to include it. See Casts, Accessors & Mutators.
@accessor
def full_name(self) -> str:
return f"{self.first_name} {self.last_name}"
__appends__ only accepts names backed by an accessor (a @accessor method or an Attribute object) — to_dict() resolves each name with getattr(self, name), so an entry with no matching accessor raises AttributeError.
Note
to_dict() serializes columns only — relationships are not included. To embed related data, load it and use to_pydantic(schema) with the relation named in the schema, or shape the output with an API resource, which is the recommended approach for HTTP responses.
Read-Only Models¶
For models that map to a database view (or that you never want written), extend ViewModel. It blocks create, save, delete, and force_delete:
from arvel.database import ViewModel, id_, string
class MonthlyRevenue(ViewModel):
__tablename__ = "monthly_revenue"
id: int = id_()
month: str = string(7)
Generate one with arvel make:model MonthlyRevenue --view (or --materialized-view).