Declaring models¶
A pyvelm model is a Python class. Subclass :class:~pyvelm.models.Model
(Odoo-style from pyvelm import models) or :class:~pyvelm.BaseModel
directly, set a dotted _name, and declare fields as class attributes. The
framework takes care of the schema (a Postgres table named <dotted_to_snake>),
the SQL plumbing, and the recordset machinery.
A first model¶
from pyvelm import Char, Integer, Boolean, Many2one, models
class Partner(models.Model):
_name = "res.partner"
name = Char(required=True, string="Name")
age = Integer()
active = Boolean(default=True)
country_id = Many2one("res.country", ondelete="SET NULL")
Fluent field chains (v1.2+)¶
ORM fields also support method chaining when you call the field type with no constructor kwargs (or, for relations, only the comodel / inverse positional args):
class Partner(models.Model):
_name = "res.partner"
name = Char().required().string("Name").tracking()
age = Integer().default(0)
country_id = Many2one("res.country").required().ondelete("SET NULL")
child_ids = One2many("res.partner").inverse("parent_id").list_view("child.list")
Char().required() works because required is a builder method, not the
required bool on an already-built field (calling .required() on a live
Field instance would hit the bool attribute and fail).
Constructor kwargs remain fully supported and mix cleanly with views that use
pyvelm.builders.Field.make() for list/form archs — that is a different
Field class (view presentation only).
Common chain methods: .string(), .required(), .default(), .readonly(),
.tracking(), .compute(), .store(), .related(), .column();
Char-specific .size(), .choices(); Many2one .ondelete();
One2many .inverse(), .list_view(), .form_view(); Many2many
.relation(), .column1(), .column2().
Outside a model class (unit tests, manual field setup), use Char.bare() to
get a concrete field instance — Char() alone returns a builder.
Char.make() is an alias for Char() when starting a chain.
The class lives inside a module's models/ package; the loader picks
it up when the module installs. See Modules for the
packaging story.
Once loaded, you operate on records through the env:
# create
alice = env["res.partner"].create({"name": "Alice", "age": 30})
# read — field access through the descriptor
print(alice.name, alice.age)
# write
alice.write({"age": 31})
# search — returns a recordset (domain list)
adults = env["res.partner"].search([("age", ">=", 18)])
for r in adults:
print(r.name)
# fluent query builder (Laravel Eloquent-style) — same ACL/rules path as search
posts = (
env["blog.post"]
.query()
.where("active", "=", True)
.where("views", ">", 100)
.order_by("published_at", "desc")
.limit(20)
.get()
)
# env.query("blog.post") is equivalent — always uses the registry class after _inherit
Use env["model.name"].query() or env.query("model.name") so queries hit
the effective merged model, not a stale import from one module file.
| Method | Purpose |
|---|---|
where(field, op, value) / where(field, value) |
AND constraint |
or_where(...) |
OR with the current AND-group |
where_in / where_not_in / where_null |
Common filters |
where_any([leaves…]) |
OR group of leaves |
order_by(field, "asc"\|"desc") |
SQL ORDER BY |
limit / offset |
Pagination |
get() |
Recordset (runs search()) |
first() / find(id) / find_or_fail(id) |
Single row |
count() / exists() |
Aggregates without loading rows |
pluck(field) / value(field) |
Scalar lists |
paginate(page=, per_page=) |
Page with items, total, last_page |
chunk(size) |
Batch recordsets |
All execution methods delegate to search() / search_count() — record rules,
ACL, and company scope still apply.
Collection search paths support universal quantification with a fourth
leaf element: ("tag_ids.name", "!=", "VIP", {"all": True}) — every
related row must match (see Architecture).
Recordsets behave like Python collections — iteration, len(),
in, slicing — and they're always tied to a specific env.
Built-in field types¶
| Field | Stores | Notes |
|---|---|---|
Char(size=…, required=, default=, string=) |
text |
Variable-length string |
Text |
text |
Like Char but no size hint |
Integer(required=, default=) |
integer |
|
Float(required=, default=) |
double precision |
|
Boolean(default=) |
boolean |
|
Many2one(comodel, ondelete=) |
integer (FK) |
Relationship to one record |
One2many(comodel, inverse_name=) |
— | Reverse side of a Many2one |
Many2many(comodel, relation=) |
junction table | Symmetric many-to-many |
Common kwargs across all field types:
string— human label used in views (defaults to the attribute name title-cased).required— declared NOT NULL in the schema; also drives the red*in form views and server-side validation.default— value applied on create when the caller doesn't set the field. Accepts a literal or a callable.column— overrides the SQL column name (rarely needed).compute+store— see Computed fields.
Relationships¶
Many2one¶
Pointer to a single record:
class Partner(BaseModel):
_name = "res.partner"
country_id = Many2one("res.country", ondelete="SET NULL")
The column is an integer FK. ondelete matches the SQL options
("CASCADE", "SET NULL", "RESTRICT").
Reading the field returns a recordset:
alice.country_id # → res.country recordset (one or empty)
alice.country_id.name # → "France"
alice.country_id = france # assign a recordset
alice.write({"country_id": france.id}) # or an id
Dotted-path traversal works in search domains too:
Reads as: partners whose country’s region’s name is "Europe" (two LEFT JOINs,
one query).
Search domains¶
Domains are a list of (field, operator, value) leaves, combined with
implicit AND when no operators are present. Odoo-style prefix operators
are supported:
| Operator | Meaning |
|---|---|
& |
AND (binary) |
\| |
OR (binary) |
! |
NOT (unary) |
Operators appear before their operands (prefix / Polish notation). Read
| A B as “A or B”, & A B as “A and B”, ! A as “not A”. When
several leaves sit next to each other with no operator, they are anded
together.
Example — posted invoices whose name or ref matches the search term:
env["account.move"].search([
("state", "=", "posted"),
"|",
("name", "ilike", term),
("ref", "ilike", term),
])
Reads as: state = 'posted' AND (name ILIKE term OR ref ILIKE term).
The first leaf stands alone; the | takes the next two leaves as its OR group;
implicit AND combines that group with state.
Nested booleans:
[
"&",
("company_id", "=", company.id),
"|",
("name", "ilike", term),
("partner_id.name", "ilike", term),
]
Reads as: company_id = company.id AND (name ILIKE term OR partner_id.name ILIKE term).
Here the leading & explicitly joins two operands: (1) the company leaf and
(2) the |-group built from the name and partner-name leaves. The dotted path
partner_id.name is compiled via a LEFT JOIN — same as in a flat AND domain.
Negation:
Reads as: NOT (active = False) — i.e. partners that are not explicitly
inactive (including rows where active is NULL, depending on your data).
The legacy ("__or__", "=", [sub_leaves…]) leaf still works (list search
emits it); the compiler expands it to | groups.
For example, ("__or__", "=", [("name", "ilike", t), ("code", "ilike", t)])
is the same as "|", ("name", "ilike", t), ("code", "ilike", t).
One2many¶
The reverse side of a Many2one. No column of its own; just a declaration that lets you walk the relationship in the other direction:
class Partner(BaseModel):
_name = "res.partner"
parent_id = Many2one("res.partner", ondelete="SET NULL")
child_ids = One2many("res.partner", inverse_name="parent_id")
Read alice.child_ids to get the recordset of partners whose
parent_id is alice. Write through the inverse: setting
child.parent_id = alice is the canonical way to add a child.
When the field appears on a parent form, you can pin which list
columns and form open in the embedded grid (defaults used to be
“lowest ir.ui.view id” only):
line_ids = One2many(
"account.move.line",
"move_id",
list_view="account.move.line.invoice", # registered list view
form_view="account.move.line.invoice.form",
)
Or set columns=[...] / list_view / form_view on field(...) in the
form arch. See One2many on parent forms.
Many2many¶
A symmetric relationship backed by a junction table:
The framework auto-generates a junction table named
<model1>_<model2>_rel with two FK columns. Reading returns a
recordset; writing replaces the set:
(Incremental add/remove via the Odoo [(4, id)] tuple syntax is
not yet shipped; replace-only for now.)
Related fields¶
Mirror a value from a dotted path (Odoo-style related=). The field
is not stored and reads/writes through the path:
Paths must use Many2one hops only (e.g. company_id.currency_id). The
related field type must match the leaf. Writes update the leaf record.
Field readonly¶
Pass readonly=True on any field declaration to block write() and
form edits (unless the view overrides with field(..., readonly=False)).
Primary key (id)¶
Every model has an implicit readonly id field (the table's SERIAL PRIMARY KEY).
You never declare it in model classes; it is available for domains, ordering, and
compute dependencies (e.g. @depends("id") on the default display_name when
no _rec_name field exists).
Display name¶
Every model gets a computed display_name field automatically (Odoo-style).
By default it reads the model's _rec_name field, which defaults to
"name" when that column exists; otherwise it falls back to
"<model> #<id>".
class Comment(BaseModel):
_name = "my.comment"
_rec_name = "body" # use body instead of name
body = Text()
Override _compute_display_name (with @depends) for richer labels —
you do not need to redeclare the display_name field:
class Partner(BaseModel):
_name = "res.partner"
name = Char()
country_id = Many2one("res.country")
@depends("name", "country_id.code")
def _compute_display_name(self):
for r in self:
code = r.country_id.code if r.country_id else None
r.display_name = f"{r.name} [{code}]" if code else r.name
Automatic timestamps¶
Every :class:~pyvelm.model.BaseModel gets created_at and updated_at
(readonly Datetime columns, UTC stored as naive timestamp). Both are
set on create; only updated_at changes on write.
class Partner(BaseModel):
_name = "res.partner"
_timestamps = False # opt out (legacy tables, import-only models)
name = Char()
Customize column names with _CREATED_AT / _UPDATED_AT. New columns appear
on Apps → Upgrade or pyvelm db migrate (additive DDL). Forms render
timestamp fields as read-only in edit mode.
Use _guarded = ["id", "created_at", "updated_at"] (or _fillable) on
models exposed to HTTP forms so callers cannot spoof system columns.
Mass assignment (_fillable / _guarded)¶
HTTP form writes pass through pyvelm.mass_assignment before they reach
write():
| Policy | Behaviour |
|---|---|
_fillable = ["name", "email"] |
Only listed keys are kept |
_guarded = ["id", "created_at"] |
Listed keys are dropped |
_guarded = ["*"] |
All keys dropped (read-only via forms) |
_strict_fillable = True |
Blocked keys raise ValueError instead of being silently dropped |
Declare either _fillable or _guarded, not both. The registry
validates this at model registration.
Computed fields¶
A field becomes "computed" when you point it at a method via
compute=:
class Partner(BaseModel):
_name = "res.partner"
name = Char()
age = Integer()
age_bucket = Char(compute="_compute_age_bucket", store=True)
@depends("age")
def _compute_age_bucket(self):
...
The @depends decorator declares which fields the compute reads.
The framework invalidates display_name whenever any of them
changes (across recordsets, across writes, across cache layers).
Two flavors:
- Read-time compute (default): the value is recalculated on access; not stored. Cheap, no migration when you add one. Fine for display strings, simple formatting.
- Stored compute (
store=True): the value persists to a SQL column. Cache invalidation triggers a recompute + UPDATE on the next read. Use this when the field appears indomainclauses or needs to participate in indexes.
Dotted-path dependencies work — the same compiler that powers domain traversal walks back through relations:
@depends("country_id.region_id.name")
def _compute_region_label(self):
for r in self:
r.region_label = (
r.country_id.region_id.name if r.country_id else ""
)
A change to Europe.name invalidates region_label on every
partner in a European country, two hops back.
Extending an existing model¶
_inherit lets a downstream module add fields, override methods,
or replace compute implementations on a model someone else owns.
No new table — the existing one gets ALTER TABLE ADD COLUMN.
Declare extensions Odoo-style with from pyvelm import models and
subclass :class:~pyvelm.models.Model (an alias of :class:~pyvelm.BaseModel):
New models with a _name use the same base: class Order(models.Model): _name = "sale.order".
# partners_pro/models/partner.py
from pyvelm import Char, depends, models
class PartnerPro(models.Model):
_inherit = "res.partner"
vip_note = Char()
@depends("name", "vip_note")
def _compute_display_name(self):
# Override the base implementation but still chain to it
# via super() so other modules can stack their own logic.
super()._compute_display_name()
for r in self:
if r.vip_note:
r.display_name = "★ " + r.display_name
The metaclass replaces the registry entry with a proper Python
subclass, so super() works through the MRO the way you'd expect.
Multiple modules can stack _inherit on the same target — each
one becomes another link in the chain.
Super chaining (create / write / custom methods)¶
Override a method and call the next implementation in the stack
with either Python's built-in super() or the Odoo-style recordset
helper:
class PartnerPro(models.Model):
_inherit = "res.partner"
def write(self, vals):
# before hooks …
super().write(vals) # or: self.super().write(vals)
# after hooks …
def button_cancel(self):
# Odoo-style action override on a custom method
res = super().button_cancel()
# post-processing …
return res
Both forms walk the merged model MRO (including mixins such as
MailThread declared before BaseModel). The registry
also records the linear _inherit stack — query it with
env.registry.inherit_chain("res.partner") for introspection or
module tooling.
Stacked extensions run outermost-first: if modules A, B, and C all
override write and call super(), a write on the final class runs
C → B → A → base BaseModel.write.
See the super_chain_demo example modules under
examples/modules/ (install super_chain_demo, super_chain_demo_a,
super_chain_demo_b) for a runnable three-layer button_cancel stack
tested in pyvelm/tests/test_super_chain_example.py.
Multi-company scoping¶
Setting _company_scoped = True on a model adds an implicit
company_id filter to every search:
class Partner(BaseModel):
_name = "res.partner"
_company_scoped = True
company_id = Many2one("res.company", ondelete="SET NULL")
When env.company_id is set, queries are restricted to records
matching that id. Useful for tenant-style isolation. The
pyvelm_company cookie + the company switcher in the topbar drive
the env.
Currencies¶
pyvelm.modules.base ships two collaborating models for money:
res.currency—code,name,symbol,rounding,active, and arate_idsOne2many to its history.res.currency.rate—currency_id(Many2one),date(datetime the rate becomes effective), andrate(units per the implicit reference). A computednameprovides a human-readable label.
The install hook seeds USD / EUR / GBP / JPY with starter rates so a fresh database can do cross-currency math out of the box. Operators replace those rates from Settings → Currencies (or from a scripted seed) when accuracy matters.
Converting amounts¶
USD = env["res.currency"].search([("code", "=", "USD")], limit=1)
EUR = env["res.currency"].search([("code", "=", "EUR")], limit=1)
eur_amount = USD.convert(100.0, EUR) # uses today's rate
back_then = USD.convert(100.0, EUR, date=dt) # uses the rate effective at dt
convert resolves the latest res.currency.rate row whose date <=
date for both the source and target currency, then computes
amount / from_rate * to_rate. Both sides ride the same implicit
reference (USD = 1.0 in the seed), so the reference cancels in the
arithmetic and never has to be named explicitly. If no rate is
effective at or before date, convert raises ValueError.
Currency.convert and _rate_at require ensure_one() — call them
on a single-record recordset (e.g. currency rather than the
results of a multi-record search).
Refreshing rates from the ECB¶
base seeds an ECB rate fetcher server action + ir.cron entry
that calls res.currency.rate.fetch_from_ecb(env). It pulls the
European Central Bank's daily reference rates
(XML, no API key) and writes one res.currency.rate row per
configured currency, rebased against whichever currency carries
rate = 1.0 (USD by default).
The cron is seeded inactive — fresh installs never make outbound
HTTP requests until an admin flips active = True in Settings →
Scheduled Actions. Currencies not in the ECB feed are silently
skipped. Re-running the action on the same ECB publication date is a
no-op (same currency + same date is treated as already-present).
For a one-off refresh without enabling the cron, call the classmethod directly:
Per-company currency¶
res.company carries a currency_id Many2one. The install hook
points the seeded "My Company" at USD; the 0_10_to_0_11 migration
backfills existing companies on upgrade. Slice C's Monetary field
reads this to decide which currency a record's amount lives in by
default:
class Invoice(BaseModel):
_name = "account.invoice"
_company_scoped = True
company_id = Many2one("res.company")
currency_id = Many2one("res.currency") # falls back to company.currency_id
amount = Monetary(currency_field="currency_id")
Operators change a company's currency from Settings → Companies.
Monetary amounts¶
Monetary is a Float subclass that pairs each amount with a sibling
field naming the relevant currency:
from pyvelm import BaseModel, Char, Many2one, Monetary
class Invoice(BaseModel):
_name = "account.invoice"
name = Char(required=True)
currency_id = Many2one("res.currency")
amount = Monetary(currency_field="currency_id") # default
currency_field defaults to "currency_id" to match the convention
that res.company and most domain records already use. The SQL
column is double precision — no rounding is applied on write, the
field stores whatever the caller provides.
Snap an amount to its currency's rounding step (e.g. cents for USD, whole units for JPY) with the static helper:
amount = Monetary.round_with(12.345, invoice.currency_id) # → 12.35 for USD
amount = Monetary.round_with(149.7, jpy) # → 150.0
The display widget reads the sibling currency from the record, prefixes
the configured symbol, and formats with the precision implied by
rounding (0.01 → 2 decimals, 1.0 → 0). The edit widget sets the
HTML step attribute from the same value so the browser's number
input matches the currency's granularity.
Defining a custom field type¶
The built-ins cover most cases. When you need something specific
(a Date, a Json, a Decimal with explicit precision, a validated
Email), subclass Field or one of the existing concrete types.
A Field answers five questions:
| Question | Hook |
|---|---|
| What's my SQL column type? | sql_type class attr, or override column_ddl() |
| Do I have a SQL column at all? | is_stored |
| What's my SQL column name? | column (defaulted from name) |
| How do I normalize for binding / cache? | to_sql_param(value) |
| How do I shape values for Python consumers? | to_python(value) |
Example: a Date field¶
from datetime import date
from pyvelm.fields import Field
class Date(Field):
sql_type = "date"
python_type = date
def to_python(self, value):
if value is None:
return None
if isinstance(value, date):
return value
# psycopg returns date objects; accept ISO strings for
# callers that round-trip JSON.
return date.fromisoformat(value)
def to_sql_param(self, value):
if value is None or value is False:
return None
if isinstance(value, date):
return value
if isinstance(value, str):
return date.fromisoformat(value)
raise TypeError(
f"Date {self.name!r}: cannot bind {type(value).__name__}")
That's the whole field. Field.__init__ already accepts the
common kwargs (required, default, column, compute, store).
Example: a validated Email¶
When you want input-side validation without changing the SQL shape,
subclass an existing concrete type and override to_sql_param:
import re
from pyvelm.fields import Char
_EMAIL_RE = re.compile(r"[^@\s]+@[^@\s]+\.[^@\s]+")
class Email(Char):
def to_sql_param(self, value):
if value is None or value is False:
return None
if not isinstance(value, str) or not _EMAIL_RE.match(value):
raise ValueError(f"{self.name!r}: invalid email: {value!r}")
return value
Every Char kwarg keeps working; the validation runs on every
create/write (and on cache seed, so the cache can never hold a
malformed value).
The cache rule
The value in env.cache is what _read would put there if it
re-read from the database. create and write enforce this
by caching the output of to_sql_param, not the user's input.
If you ever add a write path that bypasses those (don't), apply
the same normalization.
Relational fields¶
Relational types need more than to_sql_param / to_python — they
also override __get__ and __set__ to return recordsets and
accept polymorphic shapes (int, recordset, None, iterables). See
pyvelm/fields.py for the three reference implementations.