Skip to content

Commit c7dbc03

Browse files
add runnable chapter 6 registration service and exercises
Provide the missing companion code with database migrations, isolated tests, and workspace/CI integration so readers can run the chapter end to end. Generated with [Snowflake CoCo](https://docs.snowflake.com/en/user-guide/cortex-code/cortex-code) Co-authored-by: Snowflake CoCo <noreply@snowflake.com>
1 parent a2fd1a9 commit c7dbc03

23 files changed

Lines changed: 1251 additions & 0 deletions

‎.github/workflows/ci.yaml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ jobs:
4444
path: Chapter04/quote-service
4545
- name: api-gateway
4646
path: Chapter05/api-gateway
47+
- name: registration-service
48+
path: Chapter06/registration-service
4749
- name: sitters-catalog
4850
path: Chapter07/sitters-catalog
4951
- name: authenticator-service
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
*.db
2+
*.db-*
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Chapter 6: Registration Service
2+
3+
This is the final version of the chapter's SQLAlchemy service, including the three exercises. It uses Python 3.13 and uv. Run these commands from this directory:
4+
5+
```bash
6+
uv sync
7+
uv run alembic upgrade head
8+
uv run fastapi dev main.py
9+
```
10+
11+
Open http://127.0.0.1:8000/docs to register, list, read, update, and delete users. This is a teaching example with no authentication or authorization; do not expose it publicly. Access controls are covered later in the book.
12+
13+
## Database setup
14+
15+
SQLite is the default and needs no server. The final application does not create tables at startup: run the committed migrations before starting it against an empty database. Do not generate a second initial migration. If following the earlier `create_all` example, use a fresh database for the migration walkthrough rather than applying the initial migration to an existing `users` table.
16+
17+
For PostgreSQL, provision a database and supply a `postgresql+asyncpg` connection URL through `DATABASE_URL` using your deployment's secret management. Never commit real credentials. Run the same migrations and `uv run python check_db.py` before starting the service. The check script also supports SQLite.
18+
19+
The migrations create the seven-column users table, add the optional phone number, and add JSON preferences. Preferences use JSONB on PostgreSQL and JSON on SQLite. Python-side defaults are applied by SQLAlchemy, not by direct database inserts.
20+
21+
## Tests
22+
23+
```bash
24+
uv run pytest -v
25+
uv run ruff check .
26+
```
27+
28+
Tests use temporary SQLite databases and do not connect to the configured application database. They cover the chapter's eight API cases, stored password hashing, validation, pagination, concurrent-duplicate handling, and migration upgrades/downgrades that preserve existing users. PostgreSQL integration must be checked separately against a running server.
29+
30+
Compared with the printed snippets, the runnable service also returns 409 if a duplicate insert wins a race and rejects explicit null updates to non-nullable columns. Omitted update fields remain unchanged.
31+
32+
## Exercises
33+
34+
- **6.1:** `crud.get_users` and `GET /users` provide pagination ordered by user ID, with validated bounds.
35+
- **6.2:** `User.phone_number`, `UserBase.phone_number`, and migration `0002` add an optional phone number without deleting users.
36+
- **6.3:** `exercises/slot_repository.py` implements Chapter 3's `AvailabilitySlotRepository`. It imports the existing domain rather than duplicating it; pytest adds the Chapter 3 service to its Python path. The tests also run the unchanged Chapter 3 use cases against this adapter.
37+
38+
The slot repository is synchronous because Chapter 3's repository interface is synchronous. Its separate metadata is not part of the registration database migrations. Construct it with a SQLAlchemy `sessionmaker` and inject it in place of the in-memory repository. The example does not implement concurrent reservation conflict detection.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[alembic]
2+
script_location = %(here)s/alembic
3+
prepend_sys_path = %(here)s
4+
path_separator = os
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import asyncio
2+
3+
from sqlalchemy import pool
4+
from sqlalchemy.ext.asyncio import async_engine_from_config
5+
6+
from alembic import context
7+
from database import DATABASE_URL
8+
from models import Base
9+
10+
config = context.config
11+
config.set_main_option(
12+
"sqlalchemy.url", DATABASE_URL.replace("%", "%%")
13+
)
14+
target_metadata = Base.metadata
15+
16+
17+
def run_migrations_offline() -> None:
18+
context.configure(
19+
url=DATABASE_URL,
20+
target_metadata=target_metadata,
21+
literal_binds=True,
22+
dialect_opts={"paramstyle": "named"},
23+
)
24+
with context.begin_transaction():
25+
context.run_migrations()
26+
27+
28+
def do_run_migrations(connection) -> None:
29+
context.configure(
30+
connection=connection, target_metadata=target_metadata
31+
)
32+
with context.begin_transaction():
33+
context.run_migrations()
34+
35+
36+
async def run_async_migrations() -> None:
37+
connectable = async_engine_from_config(
38+
config.get_section(config.config_ini_section, {}),
39+
prefix="sqlalchemy.",
40+
poolclass=pool.NullPool,
41+
)
42+
try:
43+
async with connectable.connect() as connection:
44+
await connection.run_sync(do_run_migrations)
45+
finally:
46+
await connectable.dispose()
47+
48+
49+
if context.is_offline_mode():
50+
run_migrations_offline()
51+
else:
52+
asyncio.run(run_async_migrations())
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""${message}
2+
3+
Revision ID: ${up_revision}
4+
Revises: ${down_revision | comma,n}
5+
"""
6+
from alembic import op
7+
import sqlalchemy as sa
8+
${imports if imports else ""}
9+
10+
revision = ${repr(up_revision)}
11+
down_revision = ${repr(down_revision)}
12+
branch_labels = ${repr(branch_labels)}
13+
depends_on = ${repr(depends_on)}
14+
15+
16+
def upgrade() -> None:
17+
${upgrades if upgrades else "pass"}
18+
19+
20+
def downgrade() -> None:
21+
${downgrades if downgrades else "pass"}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Create the seven-column users table."""
2+
3+
import sqlalchemy as sa
4+
5+
from alembic import op
6+
7+
revision = "0001"
8+
down_revision = None
9+
branch_labels = None
10+
depends_on = None
11+
12+
13+
def upgrade() -> None:
14+
op.create_table(
15+
"users",
16+
sa.Column("id", sa.Integer(), primary_key=True),
17+
sa.Column("email", sa.String(255), nullable=False),
18+
sa.Column("full_name", sa.String(255), nullable=False),
19+
sa.Column(
20+
"hashed_password", sa.String(255), nullable=False
21+
),
22+
sa.Column("role", sa.String(50), nullable=False),
23+
sa.Column("is_active", sa.Boolean(), nullable=False),
24+
sa.Column(
25+
"created_at",
26+
sa.DateTime(timezone=True),
27+
nullable=False,
28+
),
29+
)
30+
op.create_index(
31+
"ix_users_email", "users", ["email"], unique=True
32+
)
33+
34+
35+
def downgrade() -> None:
36+
op.drop_index("ix_users_email", table_name="users")
37+
op.drop_table("users")
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""Exercise 6.2: add an optional phone number."""
2+
3+
import sqlalchemy as sa
4+
5+
from alembic import op
6+
7+
revision = "0002"
8+
down_revision = "0001"
9+
branch_labels = None
10+
depends_on = None
11+
12+
13+
def upgrade() -> None:
14+
op.add_column(
15+
"users",
16+
sa.Column("phone_number", sa.String(50), nullable=True),
17+
)
18+
19+
20+
def downgrade() -> None:
21+
with op.batch_alter_table("users") as batch_op:
22+
batch_op.drop_column("phone_number")
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Add portable JSON preferences."""
2+
3+
import sqlalchemy as sa
4+
from sqlalchemy.dialects.postgresql import JSONB
5+
6+
from alembic import op
7+
8+
revision = "0003"
9+
down_revision = "0002"
10+
branch_labels = None
11+
depends_on = None
12+
13+
14+
def upgrade() -> None:
15+
op.add_column(
16+
"users",
17+
sa.Column(
18+
"preferences",
19+
sa.JSON().with_variant(JSONB, "postgresql"),
20+
nullable=True,
21+
),
22+
)
23+
24+
25+
def downgrade() -> None:
26+
with op.batch_alter_table("users") as batch_op:
27+
batch_op.drop_column("preferences")
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import asyncio
2+
3+
from sqlalchemy import text
4+
from sqlalchemy.ext.asyncio import create_async_engine
5+
6+
from database import DATABASE_URL
7+
8+
9+
async def main() -> None:
10+
engine = create_async_engine(DATABASE_URL)
11+
query = (
12+
"SELECT sqlite_version()"
13+
if engine.dialect.name == "sqlite"
14+
else "SELECT version()"
15+
)
16+
try:
17+
async with engine.connect() as conn:
18+
version = await conn.scalar(text(query))
19+
print(f"Connected to: {version}")
20+
finally:
21+
await engine.dispose()
22+
23+
24+
if __name__ == "__main__":
25+
asyncio.run(main())

0 commit comments

Comments
 (0)