Exercise: Refactoring to CRUD
Bloated route handlers mix HTTP concerns with database logic.
In this exercise, you'll implement a new crud.py holding the database operations and review how to call those methods from main.py. The result: main.py handles HTTP routing and parameter injection only; crud.py handles all the database interactions.
Before You Start
Set up the starting state for this exercise:
git fetch
git checkout -b my-ch06 --no-track origin/ch06
uv sync
Files that changed in this chapter:
src/release_tracker/crud.py(new, you'll fill in TODOs increate_projectandupdate_project)src/release_tracker/main.py(modified, refactored to callcrudfunctions and useSessionDep)tests/conftest.py(new, sets up an in-memory SQLite database for tests)tests/test_api.py(new, test suite for CRUD operations)
Postgres still running?
If you want to test against the dev server while you refactor, confirm Postgres is up first:
docker compose ps
The db service should show as healthy. If it isn't, start it with docker compose up -d.
Guidelines
-
slugifylives incrud.py. -
CRUD functions own
session.add(),session.commit(), andsession.refresh().Routes don't touch transaction boundaries on the success path.
-
Routes own HTTP concerns: status codes and
HTTPException. -
Don't change API behavior.
- Same status codes, same error messages, same response bodies.
Implement the CRUD Functions
Open src/release_tracker/crud.py. The slugify helper is provided. The five CRUD functions (list_projects, get_project, create_project, update_project, delete_project) all have TODO comments waiting for you to fill in.
from sqlmodel import Session, select
from .models import Project, ProjectCreate, ProjectUpdate
def slugify(value: str) -> str:
cleaned = "".join(c for c in value.lower() if c.isalnum() or c == " ")
return "-".join(cleaned.split()) or "project"
def list_projects(session: Session) -> list[Project]:
# TODO: Build a select statement ordered by Project.name and return the list of all projects.
pass
def get_project(session: Session, project_id: int) -> Project | None:
# TODO: Return the Project for `project_id`, or None if it doesn't exist.
pass
def create_project(session: Session, payload: ProjectCreate) -> Project:
# TODO: Build a Project from the payload, derive its slug, add/commit/refresh, return it.
pass
def update_project(
session: Session, project: Project, payload: ProjectUpdate
) -> Project:
# TODO: Apply payload to project (use exclude_unset=True so missing fields stay missing),
# re-slugify if the name was set, then add/commit/refresh.
pass
def delete_project(session: Session, project: Project) -> None:
# TODO: Delete the project and commit.
pass
How main.py Calls into crud.py
src/release_tracker/main.py is already refactored to use the CRUD layer. Each route handler parses the request, delegates to crud, and returns the result. For example, the POST /projects handler:
@app.post(
"/projects", response_model=ProjectRead, status_code=status.HTTP_201_CREATED
)
def create_project(
payload: ProjectCreate,
session: SessionDep,
):
return crud.create_project(session, payload)
The other handlers do the same thing for their HTTP verbs: pull the parameters, call into crud, return what crud returns. Open main.py to read all five.
Testing Your Refactor
Two test files ship in your branch:
tests/conftest.pysets up an in-memory SQLite database and overridesget_sessionso the routes use the test database instead of Postgres. The SQLite-specific arguments (check_same_thread=FalseandStaticPool) are required for in-memory databases shared acrossTestClient's thread pool. Running tests against Postgres is out of scope for this exercise.tests/test_api.pycontains the tests that exercise the endpoints: create, get, list, update, delete.
Running the Tests
Once you've finished implementing the two TODOs in crud.py, run the tests to verify your implementation preserves the API's behavior:
uv run pytest
Issues running pytest?
pytest should already be in your dev dependencies.
Confirm with uv tree --dev.
If for some reason it's missing, add it: uv add --dev pytest.
Success
If all five tests pass, your refactor preserves the API's behavior end-to-end: create, read, update, and delete all work as before.
Compare with the Solution
git diff solutions/ch06 -- src/ tests/