Archive completed GraphQL API implementation proposal: - Move proposal to openspec/changes/archive/2025-11-20-add-graphql-member-api/ - Create capability specs in openspec/specs/: - database-layer: SQLAlchemy async with Alembic migrations - graphql-api: Strawberry GraphQL with FastAPI integration - member-crud: Member management with conditional validation - project-setup: Python 3.11+ with uv package manager All 73 tasks completed and validated. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
204 lines
7.7 KiB
Markdown
204 lines
7.7 KiB
Markdown
# database-layer Specification
|
|
|
|
## Purpose
|
|
TBD - created by archiving change add-graphql-member-api. Update Purpose after archive.
|
|
## Requirements
|
|
### Requirement: SQLAlchemy Member model with async support
|
|
|
|
The system MUST define a Member SQLAlchemy model with async session support for persisting member data with name, address, and contact information.
|
|
|
|
**Model fields:**
|
|
- `id`: Integer primary key (auto-increment)
|
|
- `first_name`: String (max 100 chars, required)
|
|
- `last_name`: String (max 100 chars, optional)
|
|
- `street`: String (max 200 chars, optional)
|
|
- `apartment_number`: String (max 20 chars, optional)
|
|
- `zip`: String (max 20 chars, optional)
|
|
- `city`: String (max 100 chars, optional)
|
|
- `country`: String (max 100 chars, optional)
|
|
- `email`: String (max 255 chars, optional)
|
|
- `phone`: String (max 50 chars, optional)
|
|
- `created_at`: DateTime (auto-set on creation)
|
|
- `updated_at`: DateTime (auto-update on modification)
|
|
|
|
#### Scenario: Member model is defined with proper constraints
|
|
|
|
**Given** src/models/member.py exists
|
|
**When** the Member class is inspected
|
|
**Then** it inherits from SQLAlchemy Base
|
|
**And** tablename is "members"
|
|
**And** first_name has nullable=False
|
|
**And** all other data fields (last_name, email, phone, address fields) have nullable=True
|
|
**And** string fields have length constraints via String(N)
|
|
|
|
#### Scenario: Timestamps are automatically managed
|
|
|
|
**Given** a new Member instance is created
|
|
**When** the instance is added to session and committed
|
|
**Then** created_at is set to current UTC timestamp
|
|
**And** updated_at is set to current UTC timestamp
|
|
**When** the instance is later modified and committed
|
|
**Then** updated_at is updated to new UTC timestamp
|
|
**And** created_at remains unchanged
|
|
|
|
#### Scenario: Model supports async operations
|
|
|
|
**Given** Member model is defined
|
|
**When** async database session is used
|
|
**Then** CRUD operations execute without blocking event loop
|
|
**And** SQLAlchemy async patterns are followed (select, add, commit)
|
|
|
|
### Requirement: Database connection and session management
|
|
|
|
The system MUST provide async database connection factory and session management with proper lifecycle handling.
|
|
|
|
**Connection configuration:**
|
|
- Async engine using aiosqlite for SQLite
|
|
- Connection pooling disabled for SQLite (single-writer)
|
|
- Echo mode configurable via DEBUG setting
|
|
|
|
#### Scenario: Async engine is created on application startup
|
|
|
|
**Given** src/database.py defines engine initialization
|
|
**When** FastAPI app starts
|
|
**Then** async engine is created with database_url from config
|
|
**And** engine is configured for SQLite with aiosqlite driver
|
|
**And** SQL echo is enabled if DEBUG=True
|
|
|
|
#### Scenario: Session factory provides isolated sessions
|
|
|
|
**Given** async_session_maker is defined
|
|
**When** resolver requests database session
|
|
**Then** new AsyncSession is created from factory
|
|
**And** session is isolated from other concurrent requests
|
|
**And** session is properly closed after request completes
|
|
|
|
#### Scenario: Session lifecycle is managed via dependency injection
|
|
|
|
**Given** get_db_session() dependency is defined
|
|
**When** FastAPI resolver depends on db_session
|
|
**Then** session is yielded for resolver use
|
|
**And** session is committed if no exceptions occur
|
|
**And** session is rolled back if exceptions occur
|
|
**And** session is closed in finally block
|
|
|
|
### Requirement: Alembic database migration setup
|
|
|
|
The system MUST use Alembic for version-controlled schema migrations with async support for SQLite database.
|
|
|
|
**Alembic configuration:**
|
|
- Migrations stored in `migrations/versions/`
|
|
- Environment configured for async operations
|
|
- Migration template includes docstring and revision metadata
|
|
|
|
#### Scenario: Alembic is initialized with project structure
|
|
|
|
**Given** alembic init migrations was run
|
|
**When** migrations/ directory is inspected
|
|
**Then** migrations/env.py exists with async configuration
|
|
**And** migrations/versions/ directory exists for migration files
|
|
**And** alembic.ini contains database connection template
|
|
|
|
#### Scenario: Initial migration creates members table
|
|
|
|
**Given** Alembic is configured
|
|
**When** developer runs `alembic revision --autogenerate -m "create members table"`
|
|
**Then** new migration file is generated in migrations/versions/
|
|
**And** upgrade() function contains CREATE TABLE for members
|
|
**And** downgrade() function contains DROP TABLE for members
|
|
**And** all Member model columns are included
|
|
|
|
#### Scenario: Migrations are applied to database
|
|
|
|
**Given** migration files exist in migrations/versions/
|
|
**When** developer runs `alembic upgrade head`
|
|
**Then** all pending migrations are executed in order
|
|
**And** members table is created in database
|
|
**And** alembic_version table tracks current revision
|
|
**And** command exits with success code
|
|
|
|
#### Scenario: Migrations are reversible
|
|
|
|
**Given** database is at current migration head
|
|
**When** developer runs `alembic downgrade -1`
|
|
**Then** most recent migration is reversed
|
|
**And** members table is dropped (for initial migration)
|
|
**And** alembic_version is updated to previous revision
|
|
|
|
### Requirement: Database initialization on application startup
|
|
|
|
The system MUST verify database connectivity and schema readiness when FastAPI application starts.
|
|
|
|
#### Scenario: Application startup checks database connection
|
|
|
|
**Given** FastAPI app has startup event handler
|
|
**When** application starts
|
|
**Then** database engine connection is tested
|
|
**And** exception is raised if database is unreachable
|
|
**And** startup log message confirms database ready
|
|
|
|
#### Scenario: Database file is created if missing
|
|
|
|
**Given** SQLite database file does not exist
|
|
**When** application starts
|
|
**Then** database file is created automatically
|
|
**And** schema tables exist after migrations run
|
|
**And** application continues startup normally
|
|
|
|
### Requirement: Test database fixtures with isolation
|
|
|
|
The system MUST provide pytest fixtures for database testing with transaction rollback to ensure test isolation.
|
|
|
|
#### Scenario: Test database session fixture is available
|
|
|
|
**Given** tests/conftest.py defines db_session fixture
|
|
**When** test function requests db_session parameter
|
|
**Then** isolated AsyncSession is provided
|
|
**And** session uses in-memory SQLite database (`:memory:`)
|
|
**And** schema is created before test runs
|
|
**And** all changes are rolled back after test completes
|
|
|
|
#### Scenario: Test database is independent from development database
|
|
|
|
**Given** tests use db_session fixture
|
|
**When** tests create or modify data
|
|
**Then** changes are not visible in development database
|
|
**And** test data does not persist between test runs
|
|
**And** tests can run in parallel without interference
|
|
|
|
### Requirement: Database query helpers for common operations
|
|
|
|
The system MUST provide reusable query patterns for fetching, creating, updating, and deleting members.
|
|
|
|
#### Scenario: Get member by ID query
|
|
|
|
**Given** member exists in database with id=1
|
|
**When** query executes `select(Member).where(Member.id == 1)`
|
|
**Then** Member instance is returned
|
|
**When** query executes with non-existent id
|
|
**Then** None is returned (not exception)
|
|
|
|
#### Scenario: List all members query
|
|
|
|
**Given** multiple members exist in database
|
|
**When** query executes `select(Member).order_by(Member.last_name.nulls_last(), Member.first_name)`
|
|
**Then** all members are returned as list
|
|
**And** members are sorted by last name (nulls last), then first name
|
|
**And** empty list is returned if no members exist
|
|
|
|
#### Scenario: Update member fields
|
|
|
|
**Given** member exists with id=1
|
|
**When** member attributes are modified and session commits
|
|
**Then** database record is updated
|
|
**And** updated_at timestamp is refreshed
|
|
**And** created_at timestamp is unchanged
|
|
|
|
#### Scenario: Delete member from database
|
|
|
|
**Given** member exists with id=1
|
|
**When** session executes delete(member) and commits
|
|
**Then** member is removed from database
|
|
**And** subsequent queries for that id return None
|
|
|