Skip to content

ORM Model

A model is the heart of the Uvicore ORM, the rich Python class that maps to one of your database tables and represents a single entity in your application. This page is the detailed reference for defining one: the model class itself, the Field() options, and the relation types. For the bigger-picture introduction (querying, saving, hooks), start with ORM Basics.


Anatomy of a Model

A model is a class decorated with @uvicore.model() that inherits from Model and uses the ModelMetaclass. It links to its table via __tableclass__, and declares each field with a Field() and a Python type hint.

from __future__ import annotations
from typing import Optional, List

import uvicore
from uvicore.orm import Model, ModelMetaclass, Field, BelongsTo, HasMany
from acme.wiki.database.tables import posts as table


@uvicore.model()
class Post(Model['Post'], metaclass=ModelMetaclass):
    """Wiki Posts"""

    # Link this model to its database table
    __tableclass__ = table.Posts

    id: Optional[int] = Field('id', primary=True, read_only=True)
    slug: str = Field('unique_slug', description='URL friendly title', max_length=100)
    title: str = Field('title')
    creator_id: int = Field('creator_id')

    # Relations (see below)
    creator: Optional[User] = Field(None, relation=BelongsTo('uvicore.auth.models.user.User'))
    comments: Optional[List[Comment]] = Field(None,
        relation=HasMany('acme.wiki.models.comment.Comment', foreign_key='post_id'))


# Import forward-referenced relation types at the bottom of the file
from acme.wiki.models.comment import Comment   # isort:skip

Note

The model field name does not have to match the table column name. Above, the model field slug maps to the table column unique_slug (the first argument of Field()). This is the "column mapper" concept, your model is the entity your API exposes, not a raw dump of the table.

Warning

When a relation references a type that isn't imported yet (a forward reference), import it at the bottom of the file (and keep from __future__ import annotations at the top). This sidesteps circular imports between related models. You do not need to call Model.update_forward_refs() / Model.model_rebuild() — as of 0.4 (Pydantic v2) Uvicore rebuilds every registered model centrally at boot.


The Field() Options

The first positional argument is the database column name (or None for a pure relation or computed field). The rest are keyword options.

Option Description
primary Marks the primary key field
description Field description, shown in the OpenAPI schema
default Default value
read_only Excluded from inserts/updates, marked readOnly in OpenAPI (great for auto-increment ids and timestamps)
write_only Excluded from query results, marked writeOnly in OpenAPI (great for passwords)
sortable / searchable Hints for the automatic Model Router
callback Method name to compute the field's value when the model is built
evaluate A function applied to the row value when mapping a DB row into the model
relation A relation object, see below
min_length / max_length Validation + schema constraints
example Example value for the OpenAPI docs
properties Extra raw properties merged into the field's schema
id: Optional[int] = Field('id', primary=True, read_only=True)
password: str     = Field('password', write_only=True)
slug: str         = Field('unique_slug', max_length=100, example='my-first-post')

See Pydantic for how these options shape validation and the OpenAPI schema.


Computed / Virtual Fields

Because a model is the entity your application exposes, and not a raw dump of table columns, it can carry fields that don't exist on the table at all. These computed (or virtual) fields are declared with a Field() whose column name is None, and they derive their value from the other fields or from the incoming database row. They serialize into your API responses and OpenAPI schema just like a normal column.

There are two flavors, distinguished by when they run: callback runs after the model is built (so it sees self), while evaluate runs before (so it only sees the raw row).

callback — computed after the model is built

A callback names a method on the model that runs after the model has been instantiated, so it has full access to self, every other field, relations included. Think of it as a computed property that is materialized onto the model.

A common example is a full_name assembled from separate first_name and last_name columns:

@uvicore.model()
class User(Model['User'], metaclass=ModelMetaclass):
    """Wiki Users"""
    __tableclass__ = table.Users

    id: Optional[int] = Field('id', primary=True, read_only=True)
    first_name: str = Field('first_name')
    last_name: str = Field('last_name')

    # Computed field — column name is None, so it maps to no database column
    full_name: Optional[str] = Field(None,
        callback='compute_full_name',
        description='First and last name combined',
    )

    def compute_full_name(self):
        return f'{self.first_name} {self.last_name}'

Every User you query or build now exposes user.full_name even though there is no full_name column, and it shows up in the JSON response and OpenAPI schema. Because the callback runs after instantiation, it can read any field on the model, including eager-loaded relations (for example, summing the totals of a HasMany to expose an order_count).

Note

A callback field has no underlying column, so it is read-only by nature, it is computed on the way out and never written back to the database on .save().

evaluate — transform the row before the model is built

An evaluate function runs before the model is instantiated, while the raw database row (a dict) is being mapped into the model. It receives the row and returns the value for that field, so use it to reshape, cast or extract a value as it comes out of the database (it has the row, but no self).

# Pull a nested value out of a JSON column, with a fallback
name: Optional[str] = Field(None,
    evaluate=lambda row: row['data']['name'] if 'data' in row else row['name']
)

# A standalone function instead of a lambda
def decode_status(row):
    return 'active' if row['status'] == 1 else 'inactive'

status: Optional[str] = Field(None, evaluate=decode_status)

# A function that also takes extra parameters, pass a (func, *args) tuple
flag: Optional[str] = Field(None, evaluate=(decode_status, 'Data'))

Tip

Reach for evaluate when the value depends only on the raw row (casting, extracting from a JSON blob, choosing between columns). Reach for callback when the value depends on the finished model, other computed fields, or eager-loaded relations.


Relations

Relations are declared as a Field(None, relation=...). The first argument to every relation is the import path of the related model. Import the relation types from uvicore.orm.

from uvicore.orm import (
    BelongsTo, HasOne, HasMany, BelongsToMany,
    MorphOne, MorphMany, MorphToMany,
)
Relation Constructor Foreign key lives on
BelongsTo BelongsTo(model, foreign_key='id', local_key='{field}_id') this table
HasOne HasOne(model, foreign_key=...) the related table
HasMany HasMany(model, foreign_key=...) the related table
BelongsToMany BelongsToMany(model, join_tablename=..., left_key=..., right_key=...) a pivot table
MorphOne MorphOne(model, polyfix='imageable') the related (polymorphic) table
MorphMany MorphMany(model, polyfix='attributable') the related (polymorphic) table
MorphToMany MorphToMany(model, join_tablename=..., polyfix=..., right_key=...) a polymorphic pivot table
# One Post BelongsTo one creator (creator_id FK is on the posts table)
creator: Optional[User] = Field(None, relation=BelongsTo('uvicore.auth.models.user.User'))

# One Post HasMany comments (post_id FK is on the comments table)
comments: Optional[List[Comment]] = Field(None,
    relation=HasMany('acme.wiki.models.comment.Comment', foreign_key='post_id'))

# Many-To-Many via a pivot table
tags: Optional[List[Tag]] = Field(None,
    relation=BelongsToMany('acme.wiki.models.tag.Tag',
                           join_tablename='post_tags', left_key='post_id', right_key='tag_id'))

# Polymorphic one-to-many
attributes: Optional[Dict] = Field(None,
    relation=MorphMany('acme.wiki.models.attribute.Attribute', polyfix='attributable',
                       dict_key='key', dict_value='value'))

Eager-load relations with .include(), and manage them with .create(), .add(), .set(), .link(), .unlink() and .delete() (see ORM Basics).

How local_key and foreign_key are derived

Every non-pivot relation joins on two columns, and the JOIN is always:

this_table.local_key  =  related_table.foreign_key
  • local_key is the column on this model's table (the model you're declaring the relation on).
  • foreign_key is the column on the related model's table.

In the examples above both keys are omitted on BelongsTo and local_key is omitted on HasMany. That's perfectly fine — Uvicore derives the missing key for you so the common case stays terse. The derivation rules differ per relation type:

Relation local_key (this table) foreign_key (related table)
BelongsTo derives to '{field_name}_id' derives to 'id'
HasOne derives to 'id' required — you must pass it
HasMany derives to 'id' required — you must pass it
MorphOne / MorphMany derives to 'id' derives to '{polyfix}_id'

{field_name} is the name of the relation field itself (e.g. the creator field derives creator_id). HasOne/HasMany cannot derive foreign_key — they don't know this model's name — so it is a required argument; only local_key is optional for them.

The examples below are written fully explicit so you can see exactly what the terse versions resolve to. Each pair is equivalent:

# BelongsTo — terse (both keys derived):
creator: Optional[User] = Field(None, relation=BelongsTo('uvicore.auth.models.user.User'))
# BelongsTo — explicit equivalent (join: posts.creator_id = users.id):
creator: Optional[User] = Field(None,
    relation=BelongsTo('uvicore.auth.models.user.User', local_key='creator_id', foreign_key='id'))

# HasMany — terse (local_key derived):
comments: Optional[List[Comment]] = Field(None,
    relation=HasMany('acme.wiki.models.comment.Comment', foreign_key='post_id'))
# HasMany — explicit equivalent (join: posts.id = comments.post_id):
comments: Optional[List[Comment]] = Field(None,
    relation=HasMany('acme.wiki.models.comment.Comment', foreign_key='post_id', local_key='id'))

Pass the keys explicitly whenever your columns don't match the derived names — for example a BelongsTo whose FK column is author_id rather than {field_name}_id, or a self-referential relation:

# A 'parent' BelongsTo on the same table, FK column is parent_id (matches default), PK is id:
parent: Optional[Post] = Field(None,
    relation=BelongsTo('acme.wiki.models.post.Post', local_key='parent_id', foreign_key='id'))

# A BelongsTo whose FK column name does NOT match the field name (field 'owner', column 'user_id'):
owner: Optional[User] = Field(None,
    relation=BelongsTo('uvicore.auth.models.user.User', local_key='user_id', foreign_key='id'))

Composite (multi-column) keys

foreign_key and local_key accept an ordered list of columns as well as a single string, producing a multi-column JOIN ON clause (e.g. foreign_key=['tenant_id', 'workspace_id', 'post_id']). The two lists are positional and must be the same length (local_key[i] pairs with foreign_key[i], AND-ed in declared order); composite keys are not derived, so list every column explicitly on both sides. This is needed for sharded backends (Vitess / PlanetScale) that must join on the shard key — list it first. See Composite Relation Keys for full details.

Tip

*Many relations (HasMany, BelongsToMany, MorphMany, MorphToMany) accept dict_key, dict_value and list_value to shape the output as a dict or a flat list instead of a list of full model objects. When you set dict_key, type the field as Optional[Dict]; otherwise type it Optional[List[RelatedModel]].


OpenAPI Example Override

The OpenAPI docs auto-generate an example for request and response bodies from your model schema. Override it by leaning on the Pydantic Config class.

@uvicore.model()
class Post(Model['Post'], metaclass=ModelMetaclass):
    """Wiki Posts"""

    class Config:
        schema_extra = {
            "example": {
                "id": 1,
                "slug": "title-as-a-slug",
                # ...
            },
        }
    # ...

Tables

Most ORM models have a corresponding database table. You can attach one in three ways: point __tableclass__ at a Table class in a separate file, define that Table class in the same file as the model, or define the schema fully inline on the model with __connection__ + __tablename__ + a raw __table__ column list. See Database Tables for all three styles.


Tableless Models

A model does not require a database table. Perhaps you're building an API passthrough with a custom schema, wrapping a remote service, or shaping data that never touches your database. Simply omit __tableclass__ and declare your fields. You won't be able to run database queries against such a model, but it still works beautifully as a Pydantic schema for your API request and response bodies.

@uvicore.model()
class Forecast(Model['Forecast'], metaclass=ModelMetaclass):
    """A weather forecast assembled from a remote API (no database table)."""
    city: str = Field('city')
    temperature: float = Field('temperature')
    summary: str = Field('summary')