User Guides: Defining Models¶
Models are the heart of your application's data layer. In DuoORM, a model is a Python class that inherits from the db.Model base class provided by your Database instance. Each model maps to a table in your database.
Basic Model Definition¶
To define a model, create a class that inherits from db.Model and include a __tablename__ attribute. Columns are defined using type annotations with Mapped and mapped_column from duo_orm (which re-exports them from SQLAlchemy).
from duo_orm import Mapped, mapped_column
from ..database import db
class User(db.Model):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str]
age: Mapped[int]
DuoORM uses modern SQLAlchemy 2.0 syntax, which relies on Mapped to indicate which attributes are part of the database mapping.
Tip
All your models should inherit from the same db.Model object. This is how DuoORM associates your models with the correct database connection and metadata.
Column Configuration¶
You can configure columns by passing arguments to mapped_column. This is where you set primary keys, default values, constraints, and more.
from datetime import datetime
from duo_orm import Mapped, mapped_column, String, DateTime, func
from ..database import db
class Product(db.Model):
__tablename__ = "products"
# A primary key that auto-increments
id: Mapped[int] = mapped_column(primary_key=True)
# A string column with a max length
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
# A column with a server-side default value
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now()
)
# A nullable column
description: Mapped[str | None]
DuoORM re-exports all of SQLAlchemy's standard types and constraints, so you can import them directly from duo_orm.
Dialect Notes (SQLAlchemy-level)¶
These behaviors are imposed by SQLAlchemy dialects and the database itself, not DuoORM.
- Oracle and UUID: SQLAlchemy's
UUIDtype is not accepted by Oracle DDL. If you target Oracle, use aString(36)orRAW(16)style column and handle conversion in your app layer. Example:id: Mapped[str] = mapped_column(String(36), primary_key=True). - Oracle auto-increment: For integer primary keys on Oracle, use
Identity()withInteger. On other dialects, a plain integer primary key is sufficient. Example:id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True). - JSON and ARRAY operators: The
json()andarray()helpers rely on PostgreSQL operator support. Other dialects do not implement the same operators. Example:User.where(json(User.profile)["flags"]["beta"].is_true())orArticle.where(array(Article.tags).includes("python")).
Security: Guarded Fields (Mass Assignment)¶
When you pass payloads to helpers like create, update, create_bulk, or update_bulk, DuoORM protects sensitive columns with a simple guard list.
- Add
__guarded__ = ["field1", "field2"]to your model to block those fields from mass assignment. - Primary keys are always guarded for updates.
- Guarded fields are always blocked, even on create.
from duo_orm import Mapped, mapped_column, Boolean, String
from ..database import db
class User(db.Model):
__tablename__ = "users"
__guarded__ = ["role", "is_admin"]
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True)
role: Mapped[str] = mapped_column(String(50), default="user")
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
# These payloads cannot overwrite guarded fields:
# await User.create({"email": "a@x.com", "role": "admin"})
# await user.update({"is_admin": True})
Automatic Timestamps¶
DuoORM provides a declarative, Django-style way to automatically manage creation and update timestamps. You can add info flags to a mapped_column to control this behavior.
info={"set_on": "create"}: The field will be set to the current UTC timestamp only when the record is first inserted.info={"set_on": {"create", "update"}}: The field will be set on insert and also every time the record is updated via.save().
from datetime import datetime
from duo_orm import Mapped, mapped_column, DateTime
from ..database import db
class Order(db.Model):
__tablename__ = "orders"
id: Mapped[int] = mapped_column(primary_key=True)
# Set once on creation
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
info={"set_on": "create"}
)
# Set on creation AND on every update
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
info={"set_on": {"create", "update"}}
)
# --- Usage ---
# order = Order()
# await order.save()
#
# # order.created_at is now set
# # order.updated_at is now set
#
# # ... later ...
# await order.save()
#
# # order.created_at remains the same
# # order.updated_at is updated to the new current time
Warning
These timestamp hooks are executed in Python code right before the INSERT or UPDATE statement is sent to the database. For this reason, they are not compatible with server_default. Use one or the other.
Tip
Pydantic is optional. You can pass dicts to all CRUD helpers. If you want schema validation/serialization, see the Pydantic Integration guide.
Model Validation¶
You can enforce custom business logic by overriding the .validate() method on your model. This method is automatically called before any .save() or .create_bulk() operation.
If validation fails, raise a ValidationError from duo_orm.exceptions.
from duo_orm import Mapped, mapped_column, ValidationError
from ..database import db
class User(db.Model):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str]
age: Mapped[int]
def validate(self):
"""Custom validation logic."""
if self.age < 18:
raise ValidationError(
"Users must be at least 18 years old.",
field="age"
)
# --- Usage ---
#
# # This will succeed
# user_ok = User(name="Alice", age=30)
# await user_ok.save()
#
# # This will fail
# user_invalid = User(name="Bob", age=15)
# try:
# await user_invalid.save()
# except ValidationError as e:
# print(f"Validation failed for field '{e.field}': {e}")
Defining Relationships¶
You can define relationships between your models using relationship from duo_orm. This allows you to navigate between related objects in your code.
Many-to-One¶
A Post belongs to a User. This requires a foreign key column on the "many" side.
from duo_orm import Mapped, mapped_column, relationship, ForeignKey
from .user import User
from ..database import db
class Post(db.Model):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str]
# Foreign key to the users table
author_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
# Relationship to the User object
author: Mapped["User"] = relationship(back_populates="posts")
One-to-Many¶
A User can have many Posts. The back_populates argument is crucial because it links the two sides of the relationship together.
from typing import List
from duo_orm import Mapped, mapped_column, relationship
from .post import Post
from ..database import db
class User(db.Model):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str]
# Relationship to the list of Post objects
posts: Mapped[List["Post"]] = relationship(back_populates="author")
With this setup, you can access user.posts to get a list of post objects, and post.author to get the user object. To learn how to efficiently query and load these relationships, see the Querying Data guide.