Defining Entities¶
All persistent objects in NextORM are subclasses of Entity.
The metaclass EntityMeta processes type annotations at class
definition time, so no runtime magic is required — just annotate and go.
Entity registration¶
Every Entity subclass registers itself in a global
registry the moment its class body is executed (i.e. when the module is
imported). You never have to pass entities to a database explicitly — unless
you want to.
Single database, auto-discovery (most common):
# models.py — entities register automatically on import
class User(Entity):
name: Req[str]
class Post(Entity):
title: Req[str]
# app.py
import models # ensures models.py is executed before generate_mapping
db = Database()
db.bind("sqlite", "app.db")
db.generate_mapping(create_tables=True) # discovers User and Post automatically
Scoped database — pass an explicit list so only these entities are mapped:
db = Database(entities=[User, Post]) # only User and Post, nothing else
db.bind("sqlite", ":memory:")
db.generate_mapping(create_tables=True)
This is useful when multiple Database instances
point to different databases and each should own only a subset of entities.
Adding entities after construction with register():
db = Database(entities=[User])
db.register(Post, Comment) # added to db's scope after the fact
db.bind("sqlite", ":memory:")
db.generate_mapping(create_tables=True)
Note
register()accepts one or more entity classes as positional arguments.Calling
register()on an entity that is already in the list is a no-op.If you never pass
entities=and never callregister(), the database will discover all globally registered entities. This auto-discovery is convenient in single-database apps but can be surprising when twoDatabaseinstances exist — both would try to map all entities. Useentities=[...]orregister()to partition them.
The entities property returns the current
mapping as a {name: class} dict:
for name, cls in db.entities.items():
print(name, cls._table_name_)
Field types¶
There are four core field markers:
Annotation |
Meaning |
SQL |
|---|---|---|
|
Primary key, auto-generated (int by default) |
|
|
Required — not nullable |
|
|
Optional — may be |
nullable column |
|
Local / transient — never persisted |
(no column) |
from nextorm import Entity, PK, Req, Opt, Local
class Product(Entity):
id: PK[int]
name: Req[str]
price: Req[float]
description: Opt[str]
_cache: Local[dict] # in-memory only
Python types map to SQL column types as follows:
Python type |
SQLite |
PostgreSQL |
MariaDB |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Primary keys¶
Integer auto-increment (default)¶
class Article(Entity):
id: PK[int] # AUTOINCREMENT integer, assigned by the DB
String primary keys¶
class Country(Entity):
code: PK[str] # must be set manually before saving
UUID / ULID primary keys¶
NextORM can auto-generate time-ordered UUIDs (v7), random UUIDs (v4), or ULIDs. Import the sentinel types:
from nextorm import Entity, PK
from nextorm.fields import uuid7, uuid4, ulid
class Event(Entity):
id: PK[uuid7] # time-ordered UUID, auto-generated before INSERT
class File(Entity):
id: PK[uuid4] # random UUID, auto-generated before INSERT
class Log(Entity):
id: PK[ulid] # 26-char ULID string, auto-generated before INSERT
Composite primary keys¶
Use the PrimaryKey() helper as a class attribute:
from nextorm import Entity, Req
from nextorm.fields import PrimaryKey
class OrderLine(Entity):
order_id: Req[int]
product_id: Req[int]
quantity: Req[int]
pk = PrimaryKey("order_id", "product_id")
Default values¶
Assign a plain value or a callable as the default by calling the marker with
the default= option:
from datetime import datetime
from nextorm import Entity, Req
class Comment(Entity):
text: Req[str]
created_at: Req[datetime] = Req(default=datetime.utcnow)
score: Req[int] = Req(default=0)
Note
Callables (like datetime.utcnow) are invoked fresh for each new instance.
Plain values are used as-is; avoid mutable defaults like [] — use
a factory callable instead.
Custom column names¶
By default the column name is the attribute name. Override it with the
column= option on the marker:
class User(Entity):
first_name: Req[str] = Req(column="fname")
Custom table names¶
By default the table name is the class name lower-cased. Override with
_table_name_:
class UserAccount(Entity):
_table_name_ = "user_account"
id: PK[int]
email: Req[str]
Unique constraints and indexes¶
Single-column unique constraint:
class User(Entity):
email: Req[str] = Req(unique=True)
Multi-column constraints use the helpers at class level:
from nextorm.fields import composite_key, composite_index
class Booking(Entity):
user_id: Req[int]
event_id: Req[int]
seat: Req[str]
# unique (user_id, event_id, seat) combination
unq = composite_key("user_id", "event_id", "seat")
# non-unique index on (user_id, event_id) for fast lookups
idx = composite_index("user_id", "event_id")
Special column types¶
Sentinel |
Description |
|---|---|
|
Large text; lazy-loaded by default ( |
|
JSON/JSONB column; Python dict/list auto-serialised |
|
Timezone-aware datetime ( |
|
Fixed-dimension vector (pgvector on PostgreSQL) |
|
Auto-generated UUID/ULID primary keys (see above) |
from nextorm.fields import LongStr, Json, DateTimeTz, Vec
class Article(Entity):
body: Req[LongStr] # loaded lazily
metadata: Opt[Json]
published: Opt[DateTimeTz]
embedding: Req[Vec[384]] # pgvector column
Local (transient) fields¶
Use Local[T] to attach computed or cached state to an instance.
Local fields are never written to or read from the database — they live in
instance.__dict__ only. They are safe to initialise in lifecycle hooks:
from nextorm import Entity, Req, Local
class User(Entity):
name: Req[str]
_full_name: Local[str] # computed value
_cache: Local[dict] = Local[dict](default=dict) # factory default
user = User(name="Alice")
user._full_name = "Dr. Alice" # set manually
user._cache["key"] = "value"
Options (passed as Local[T](…)):
default: Value or callable; invoked onEntity()constructionpy_check: Callable that validates the value; receives the value, should raise or return falsy on failure
class Task(Entity):
priority: Req[int]
_validated_priority: Local[int] = Local[int](py_check=lambda x: 1 <= x <= 5)
Lifetime hooks¶
Override these methods on your entity to react to persistence events:
class Order(Entity):
total: Req[float]
confirmed: Req[bool]
def before_insert(self) -> None:
"""Called just before the row is INSERTed for the first time."""
self.confirmed = False
def after_load(self) -> None:
"""Called after a row is SELECTed and mapped to this instance."""
...
Available hooks: after_load, before_insert, after_insert,
before_update, after_update, before_delete, after_delete.
Single-table inheritance¶
Subclass an entity to create an STI hierarchy. NextORM stores all subclasses in a single table and uses a discriminator column to distinguish them:
class Animal(Entity):
_discriminator_col_ = "kind"
name: Req[str]
class Dog(Animal):
_discriminator_val_ = "dog"
breed: Req[str]
class Cat(Animal):
_discriminator_val_ = "cat"
indoor: Req[bool]
When you SELECT from the parent class all subclass instances are returned
with their full type.
Enum fields¶
Python enum.Enum values are stored as their underlying value (str
or int). No extra configuration is needed — the ORM serialises and
deserialises automatically:
import enum
class Status(enum.StrEnum):
DRAFT = "draft"
PUBLISHED = "published"
class Post(Entity):
title: Req[str]
status: Req[Status]
—
Working with entity instances¶
Creating and saving¶
Construct an entity inside a db_session() block.
NextORM tracks the new object automatically and inserts it on commit — no
explicit save call required:
from nextorm import db_session
with db_session:
user = User(name="alice", age=30)
# ← INSERT fires here; user.id is now assigned by the DB
If you need the auto-generated PK before the session ends, flush manually:
from nextorm import flush, db_session
with db_session:
user = User(name="alice", age=30)
flush() # INSERT fires immediately
print(user.id) # available here
save() is available for fine-grained, explicit
control (e.g. outside a session or when you need the PK back immediately without
a full flush). Use insert() to force an
unconditional INSERT — useful when you set the PK yourself:
with db_session:
item = Product(name="Widget")
item.id = 999 # explicit PK to preserve on import
db.insert(item) # always INSERT, never UPDATE
Updating¶
Assign new values directly or use set() for
bulk updates:
with db_session:
user = db.select(User).filter(User.id == 1).get()
user.name = "bob" # dirty-tracked, flushed on commit
with db_session:
user.set(name="carol", age=25) # equivalent to two setattr calls
Deleting¶
Either call the database method or the shortcut on the instance:
with db_session:
db.delete_instance(user) # explicit: pass db + entity
with db_session:
user.delete() # shortcut: entity must have been loaded by a db
Note
entity.delete() requires that the entity was fetched or saved through a
database session so the _db_ context attribute is set. If you hold a
bare constructed object, use db.delete_instance(entity) directly.
Looking up by primary key¶
Use the subscript shortcut or the query API interchangeably:
# Single-column PK
user = User[1] # raises KeyError when not found
# Composite PK — list values in declaration order
line = OrderLine[order_id, product_id]
# Equivalent via QuerySet
user = db.select(User).filter(User.id == 1).get()
Class-level lookups¶
get() and exists()
match against one or more field values without explicitly passing a database:
user = User.get(email="alice@example.com") # None if not found
if User.exists(email="alice@example.com"):
...
Warning
These methods use _find_db_for_entity() to locate
a bound database automatically. When more than one database is bound to the
entity, pass the query through db.select(Entity) instead to be explicit
about which database to use.
Inspecting the primary key¶
pk = user.get_pk() # returns the PK value (or tuple for composite)
Serialising to a dictionary¶
user.to_dict()
# → {"id": 1, "name": "alice", "age": 30}
user.to_dict(exclude=["id"])
# → {"name": "alice", "age": 30}
# Include prefetched collections as nested lists
post.to_dict(with_collections=True)
# → {"id": 1, "title": "Hello", "comments": [{"id": 5, "text": "Great!"}]}
# Include loaded Single relations as nested dicts
comment.to_dict(related_objects=True)
# → {"id": 1, "text": "Nice", "author": {"id": 2, "name": "bob"}}
Options:
Parameter |
Description |
|---|---|
|
Include only these field names. |
|
Exclude these field names (applied after |
|
Include |
|
Include lazy fields that have not yet been loaded (triggers a SELECT). |
|
Include loaded |
Raw SQL class methods¶
# Sync
users = User.select_by_sql(db, "SELECT * FROM user WHERE age > ?", [18])
user = User.get_by_sql(db, "SELECT * FROM user WHERE email = ?", ["a@b.com"])
# Async
users = await User.aselect_by_sql(db, "SELECT * FROM user WHERE age > %s", [18])
user = await User.aget_by_sql(db, "SELECT * FROM user WHERE email = %s", ["a@b.com"])
These are thin wrappers around db.select(Entity).raw(sql, params) and
db.select(Entity).raw_one(sql, params).
Positional argument shorthands¶
For the most common type-specific options, scalar markers accept a positional argument so you can skip the keyword name:
Full keyword form |
Shorthand |
Positional arg(s) |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Applies equally to Opt[T], PK[T], and Req[T] where the type
supports it (e.g. Opt[str](128) also works).
Field and Relation Marker Options¶
Options for scalar fields (PK, Req, Opt):¶
Column naming and SQL mapping
column: str | NoneOverride the database column name for this field (default: attribute name).
sql_type: str | NoneOverride inferred SQL type string (e.g. “JSONB”).
sql_default: str | NoneRaw SQL expression for DDL DEFAULT (e.g. “CURRENT_TIMESTAMP”).
Primary key and uniqueness
primary_key: boolMark as primary key (implied by PK[]).
unique: boolAdd a unique constraint on this column.
index: boolCreate a non-unique index on this column.
auto: boolAuto-incrementing integer (PK[int] only).
uuid_auto: str | None“v7”, “v4”, or “ulid” — Python-side UUID/ULID auto-generation.
Nullability and defaults
nullable: boolAllow NULL values (implied by Opt[]).
default: AnyPython-side default value or factory (callable or value).
Type-specific options
max_len: int | NoneFor str: Maximum string length.
precision: int | NoneFor Decimal: total significant digits.
scale: int | NoneFor Decimal: digits after decimal point.
unsigned: boolFor int: UNSIGNED modifier (MariaDB only).
size: int | NoneFor int: column bit width — 8, 16, 32, or 64.
dimensions: int | NoneFor Vec: number of vector dimensions (e.g. 384, 1536).
Validation and assignment
py_check: Callable[[object], bool] | NoneCallable validator; receives value; raises/returns falsy on failure.
autostrip: boolStrip leading/trailing whitespace on string assignment.
min: AnyInclusive lower bound for numeric/comparable fields.
max: AnyInclusive upper bound for numeric/comparable fields.
Other
lazy: boolDeferred loading: field excluded from main SELECT; loaded on first access.
volatile: boolExcluded from UPDATE; value is set by a DB trigger.
Options for relations (Single, Set):¶
Nullability and constraints
nullable: boolAllow NULL values (Single only).
primary_key: boolMark as primary key (Single only).
cascade_delete: bool | NoneOverride ON DELETE action (True=CASCADE, False=RESTRICT, None=auto).
Foreign key and join table mapping
column: str | NoneOverride the database column name for the FK (Single only).
columns: list[str] | NoneComposite FK column names (Single only).
fk_name: str | NoneOverride the foreign key constraint name (Single only).
table: str | NoneOverride the join table name for M2M (Set only).
reverse_column: str | NoneOverride the join table column name for the reverse side (Set only).
reverse_columns: list[str] | NoneComposite join table column names for the reverse side (Set only).
Relation ownership
owner: bool | NoneO2O only: explicit owning-side override (rarely needed; advanced usage).
Reverse relation
reverse: str | NoneName of the reverse relation on the target entity.
Examples:
class Product(Entity):
id: PK[int]
name: Req[str](column="product_name", max_len=100, unique=True)
# or using the positional shorthand:
short: Req[str](128) # max_len=128
class Order(Entity):
product: Single[Product] = Single(column="prod_id", fk_name="fk_order__prod_id", nullable=False)
class Tag(Entity):
products: Set[Product] = Set(reverse_column="tag_ref_id", table="product_tag_link")
class CatalogProduct(Entity):
tags: Set[Tag] = Set(reverse_column="product_ref_id")