Database (Tortoise ORM)¶
In Nori, asynchronous database mapping (ORM) is handled by Tortoise ORM — a fully async ORM for Python (async/await native).
We chose Tortoise because it's async from the ground up — not an async wrapper around synchronous code. In an async framework, the ORM shouldn't be the bottleneck that blocks the event loop.
Connection and Configuration¶
The engines (MySQL, PostgreSQL, SQLite) are defined in the .env file. The framework parses the requested engine in the central configuration file rootsystem/application/settings.py. Models are registered separately — see Registering a Model below.
Defining a Model¶
Models are located in the rootsystem/application/models/ directory. They must inherit from NoriModelMixin and Model. Class names are PascalCase; table names are plural (e.g., class User → table users).
from __future__ import annotations
from tortoise.models import Model
from tortoise import fields
from core.mixins.model import NoriModelMixin
class User(NoriModelMixin, Model):
protected_fields = ['password_hash', 'remember_token'] # Excluded from to_dict()
id = fields.IntField(primary_key=True)
slug = fields.CharField(max_length=50, unique=True)
name = fields.CharField(max_length=100)
email = fields.CharField(max_length=255)
password_hash = fields.CharField(max_length=255)
remember_token = fields.CharField(max_length=255, default='')
level = fields.IntField(default=0)
status = fields.BooleanField(default=True)
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = 'users' # Always plural, explicitly specified
Registering a Model¶
After creating a model, two things must happen in rootsystem/application/models/__init__.py for it to participate in migrations, queries, and Nori's registry-driven features (the shell REPL, test factories, framework hooks):
# rootsystem/application/models/__init__.py
from models.user import User
from core.registry import register_model
register_model('User', User)
- Import the model class — Tortoise ORM only discovers models that are imported. Without the import, Aerich won't pick the model up for migrations.
- Call
register_model('Name', Class)— populatescore.registryso the rest of Nori (shell, test client factories, future framework features) can look the model up by name.
Forgetting the import causes silent migration failures (Aerich generates an empty migration). Forgetting the register_model call causes registry-driven features to report "model not found" at runtime. The make:model CLI command reminds you of both steps after generating the skeleton.
NoriModelMixin (to_dict + protected_fields)¶
Inheriting this mixin alongside Model adds the .to_dict() method and protected_fields security. It strips ORM metadata and resolves everything into JSON-serializable primitives.
user = await User.get(id=1)
user.to_dict()
# → {'id': 1, 'slug': 'alice', 'name': 'Alice', 'email': '...', ...}
# password_hash and remember_token are auto-excluded via protected_fields
user.to_dict(exclude=['email'])
# → protected_fields AND explicit exclude are merged
user.to_dict(include_protected=True)
# → force-include for internal/admin operations
Always use .to_dict() when serializing models to JSON responses — this ensures protected_fields safety. See Security — protected_fields for details.
Queries, Insertion and Basic Modification¶
# Create
new_user = await User.create(name='Ellery', slug='ellery-1')
# Update
user = await User.get(id=1)
user.name = 'Ellery Modified'
await user.save()
# Select and Filter
active_users = await User.filter(status=True).all()
first_match = await User.filter(slug='foo').first()
# Filtered selects and exclusions
above_level_5 = await User.filter(level__gt=5).all()
inactive = await User.exclude(status=True).all()
(Refer to the official Tortoise ORM documentation for advanced behaviors like Q(), F(), prefetching or raw SQL).
Migrations (Aerich)¶
Nori uses Aerich for database migrations. Aerich is Tortoise ORM's native migration tool and is already configured.
First-time setup¶
This initializes the Aerich migration system, generates the initial migrations for both apps (framework + models) against your current DB engine, and applies them. Run it once per project.
Why "against your current engine"? Aerich emits engine-specific SQL (
AUTOINCREMENTfor SQLite,AUTO_INCREMENTfor MySQL,SERIALfor PostgreSQL). A pre-generated migration cannot work across all three engines, so each site owns its own migrations undermigrations/framework/andmigrations/models/.⚠️ Dev should mirror prod: because migrations are committed to your repo and applied by every environment, the engine you generate them against has to match production. Generating against SQLite locally and deploying to MySQL produces error 1064 on the first
migrate:upgrade. Thedocker-compose.ymlshipped with Nori bundles a MySQL service for exactly this reason —docker compose up -d dband point your local.envatDB_HOST=localhost(orDB_HOST=dbfrom inside the app container).
Creating a migration¶
After modifying your models (adding fields, changing types, etc.):
This generates a migration file in migrations/models/.
Running migrations¶
Important: In production (
DEBUG=false), Nori relies exclusively on Aerich migrations to manage the database schema.generate_schemas()is only called in development mode as a convenience. Always runmigrate:upgradebefore deploying or seeding in production.
Rolling back¶
python3 nori.py migrate:downgrade # roll back 1 migration
python3 nori.py migrate:downgrade --steps 3 # roll back 3 migrations
Workflow summary¶
- Edit your model in
models/ - Run
python3 nori.py migrate:make <description> - Review the generated migration file
- Run
python3 nori.py migrate:upgrade - Commit the migration file to version control
Database Seeding¶
Nori includes a seeder system for populating your database with test or default data.
Creating a seeder¶
This creates seeders/user_seeder.py with a boilerplate async def run() function.
Registering and running seeders¶
- Edit your seeder to create records
- Register it in
seeders/database_seeder.pyby adding the module path to theSEEDERSlist - Run all seeders:
Built-in Models¶
Nori ships with three models for audit logging and granular permissions (ACL). They are registered automatically in models/__init__.py.
AuditLog (models/audit_log.py)¶
Tracks who did what and when. Entries are created via the fire-and-forget audit() function from core.audit.
| Field | Type | Description |
|---|---|---|
user_id |
IntField (nullable) | The acting user. Not a FK — preserves logs if users are deleted |
action |
CharField | Action performed: create, update, delete, login, logout, or custom |
model_name |
CharField (nullable) | Model name (e.g. Article) |
record_id |
CharField (nullable) | The affected record ID |
changes |
JSONField (nullable) | Change diff: {"field": {"before": ..., "after": ...}} |
ip_address |
CharField (nullable) | Client IP (supports X-Forwarded-For) |
request_id |
CharField (nullable) | From RequestIdMiddleware for tracing |
created_at |
DatetimeField | Auto-set on creation |
Permission (models/permission.py)¶
A granular permission using dot-notation convention (e.g. articles.edit, users.delete).
| Field | Type | Description |
|---|---|---|
name |
CharField (unique) | Permission identifier, e.g. reports.view |
description |
CharField | Human-readable description |
Role (models/role.py)¶
Groups permissions via a Many-to-Many relationship through the role_permission table.
| Field | Type | Description |
|---|---|---|
name |
CharField (unique) | Role name, e.g. editor |
permissions |
M2M → Permission | Linked via role_permission join table |
Advanced Native Mixins¶
Nori includes pre-built abstracted layers in the form of Python mixins for solving repetitive modern tasks.
These mixins exist because we've seen the same patterns in every project: soft deletes (you always regret a hard delete), tree structures (categories, permissions, org charts), and safe serialization (sensitive fields leaking into API responses). We built them once, correctly.
NoriSoftDeletes (Logical Deletion)¶
Protects transactional entropy by preventing hard DROP or DELETE SQL operations. NoriSoftDeletes already inherits from Tortoise's Model, so you do not need to inherit from both — just replace Model with NoriSoftDeletes. The mixin adds a nullable deleted_at DatetimeField that is automatically set to the current timestamp when .delete() is called.
from core.mixins.soft_deletes import NoriSoftDeletes
from core.mixins.model import NoriModelMixin
class Post(NoriModelMixin, NoriSoftDeletes): # NoriSoftDeletes replaces Model
title = fields.CharField(max_length=200)
Managers:
* Post.objects (default) — automatically excludes soft-deleted records from all queries.
* Post.all_objects — includes everything (active + deleted).
* Post.trashed — only soft-deleted records.
Instance methods:
* await post.delete() — sets deleted_at to NOW(). Queries via objects manager will exclude this record.
* await post.restore() — clears deleted_at, returning the record to the active filter. Idempotent — no-op if the record is already active.
* await post.force_delete() — bypasses the soft-delete override and issues a real DELETE to the DB.
* post.is_trashed — property, returns True if the record is soft-deleted.
Class methods:
* await Post.with_trashed().all() — retrieves all records including deleted.
* await Post.only_trashed().all() — retrieves only soft-deleted records.
Bulk operations (queryset-level):
* await Post.objects.filter(...).delete() — soft delete: issues a single UPDATE ... SET deleted_at = NOW() for every matching row. Returns the number of rows affected.
* await Post.objects.filter(...).force_delete() — hard delete: issues a real SQL DELETE for every matching row. Use this when you actually want to purge.
Important — always go through
Post.objects(orPost.all_objects/Post.trashed) for bulk operations. Tortoise's barePost.filter(...)uses the framework's default Manager and does not apply the soft-delete filter or the soft-deletedelete()override. If you callawait Post.filter(...).delete()directly, rows are physically removed regardless of the mixin. Stick toPost.objects.filter(...)to keep the soft-delete contract intact.
NoriTreeMixin (Advanced Recursive Adjacency CTE)¶
Converts a table into a self-referential recursive ecosystem. Useful for infinite categories, nested permissions, or corporate hierarchies. Explicitly requires a Foreign Key field named "parent".
from core.mixins.tree import NoriTreeMixin
class Category(NoriTreeMixin):
name = fields.CharField(max_length=100)
parent = fields.ForeignKeyField('models.Category', related_name='children_rel', null=True, default=None)
Natively injected functions:
* await node.children() -> Direct children of the node.
* await node.parent_node() -> Parent node (None if root).
* await node.ancestors() -> All ancestors via recursive CTE (single query, from node up to root).
* await node.descendants() -> All descendants via recursive CTE (single query).
* await node.siblings() -> Siblings (same parent, excluding self).
* await node.is_leaf() -> True if the node has no children.
* await node.is_root() -> True if the node has no parent.
* await node.move_to(new_parent_id=5) -> Move node to a new parent (validates against cycles).
* await Category.tree() -> Load the entire tree in one query, structured in memory.
* await Category.tree(root_id=5) -> Load only the subtree under node 5 (children of that node, structured recursively).