Part 4: Relationships¶
A task with no project isn't very useful for a multi-project tracker.
Add the connection to app/Models/task.py:
from sqlalchemy import Boolean, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from zeython import Model, max_length, required
from app.Models.project import Project
class Task(Model):
__tablename__ = "tasks"
__rules__ = {
"title": [required(), max_length(255)],
}
title: Mapped[str] = mapped_column(String(255))
done: Mapped[bool] = mapped_column(Boolean, default=False)
project_id: Mapped[int] = mapped_column(ForeignKey("projects.id"))
project: Mapped[Project] = relationship(back_populates="tasks")
And the other side, in app/Models/project.py:
from sqlalchemy.orm import Mapped, mapped_column, relationship
# ... existing imports ...
class Project(Model):
# ... existing __tablename__/__rules__/name ...
tasks: Mapped[list["Task"]] = relationship(back_populates="project")
(The string "Task" in the type hint — not the class itself — sidesteps
a circular import: task.py imports Project, so project.py can't
also import Task at module scope. SQLAlchemy resolves the string by
class name once both models are loaded.)
Migrate¶
If you followed Part 3's walkthrough exactly,
the task you created and then deleted there is still in the tasks table
— soft-delete marks it is_deleted, it doesn't remove the row — and it
has no project_id. Open the migration file Alembic just generated under
migrations/versions/; the new column comes out nullable=False with no
value for that existing row to take, which SQLite refuses outright. Give
it a default so the existing row backfills to project 1 (the one you
created first in Part 3):
(Skip this if your tasks table happens to be empty — a fresh scaffold
where you skipped straight to Part 4 won't hit it. Either way, apply it:)
The one rule that matters here¶
Never touch a relationship attribute you didn't explicitly load.
Zeython's ORM is fully async, and an unloaded relationship has no
synchronous fallback the way sync SQLAlchemy's lazy loading does —
touching one raises MissingGreenlet, not a helpful lazy fetch. Load
relationships you're about to use with include=(...):
task = await Task.find(1, include=("project",))
task.project.name # safe -- already loaded
task.to_dict(include=("project",)) # {"id": 1, "title": "...", "project": {"id": 1, "name": "..."}, ...}
Update TaskController to always eager-load the project, and to accept
project_id when creating a task:
async def index(self, request):
tasks = await Task.all(include=("project",))
return JSONResponse([task.to_dict(include=("project",)) for task in tasks])
async def show(self, request):
task = await Task.find(int(request.path_params["id"]), include=("project",))
if task is None:
raise NotFoundException("Task not found")
return JSONResponse(task.to_dict(include=("project",)))
async def store(self, request):
data = await request.json()
task = await Task.create(title=data.get("title"), project_id=data.get("project_id"))
task = await Task.find(task.id, include=("project",))
return JSONResponse(task.to_dict(include=("project",)), status_code=201)
(store re-fetches with include= after creating, rather than trying
to load the relationship onto the just-saved instance — simpler than
juggling two different code paths for "freshly created" vs. "loaded from
the database," and one extra indexed lookup is cheap.)
Try it:
curl -sS -X POST http://127.0.0.1:8000/tasks \
-H 'Content-Type: application/json' \
-d '{"title": "Design the new homepage", "project_id": 1}'
{"title":"Design the new homepage","done":false,"project_id":1,"id":2,"created_at":"...","updated_at":"...","is_deleted":false,"deleted_at":null,"project":{"name":"Website Redesign","id":1,"created_at":"...","updated_at":"...","is_deleted":false,"deleted_at":null}}
One request, both objects, no N+1 query for every task in the list. See
Relationships for the deeper dive — many-to-many,
loading a chain of nested relationships, and the dev-only warning that
catches a forgotten include= before it ships.
Continue to Part 5 — Authentication & Authorization.