Skip to content

Commit e2e7e62

Browse files
authored
Feature/sqlite repo: setup for GeoLocationServiceSqlRepoDBTest (#68)
Unstable, active and WIP: This is where things start taking shape for setting test coverage `GeoLocationServiceSqlRepoDBTest` to implement issue: #26 ; - major rewrite of test services for integration test for Sqlite Repo DB. refactored code to use container to setup database using start_test_database and asyncSetUp. WIP: test_fetch_facilities - added sqlite models to support sqlite database. database.py enhances app level infra setup using DI using rodi's Container that sets up database and session
1 parent 769c0e1 commit e2e7e62

File tree

10 files changed

+243
-40
lines changed

10 files changed

+243
-40
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ pip-install:
1111
pip install --prefer-binary --use-pep517 --check-build-dependencies .[dev]
1212

1313
test:
14-
APP_ENV=test APP_DB_ENGINE_URL="sqlite+aiosqlite://" pytest -s xcov19/tests/ -m "not integration"
14+
APP_ENV=test APP_DB_ENGINE_URL="sqlite+aiosqlite://" pytest -s xcov19/tests/ -m "not slow and not integration and not api"
1515

1616
test-integration:
1717
APP_ENV=test APP_DB_ENGINE_URL="sqlite+aiosqlite://" pytest -s xcov19/tests/ -m "integration"

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ asyncio_mode = "auto"
4747
markers = [
4848
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
4949
"integration: marks tests as integration tests",
50+
"api: mark api tests",
5051
"unit: marks tests as unit tests",
5152
# Add more markers as needed
5253
]

xcov19/app/database.py

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
from collections.abc import AsyncGenerator
2+
from contextlib import asynccontextmanager
23
import sys
34
from rodi import Container
4-
from sqlmodel import SQLModel
5-
5+
from xcov19.infra.models import SQLModel
6+
from sqlmodel import text
67
from xcov19.app.settings import Settings
8+
from sqlmodel.ext.asyncio.session import AsyncSession as AsyncSessionWrapper
79
from sqlalchemy.ext.asyncio import (
810
create_async_engine,
911
AsyncEngine,
10-
AsyncSession,
1112
async_sessionmaker,
1213
)
1314

@@ -38,36 +39,33 @@ class SessionFactory:
3839
def __init__(self, engine: AsyncEngine):
3940
self._engine = engine
4041

41-
def __call__(self) -> async_sessionmaker[AsyncSession]:
42+
def __call__(self) -> async_sessionmaker[AsyncSessionWrapper]:
4243
return async_sessionmaker(
43-
self._engine, class_=AsyncSession, expire_on_commit=False
44+
self._engine, class_=AsyncSessionWrapper, expire_on_commit=False
4445
)
4546

4647

4748
async def setup_database(engine: AsyncEngine) -> None:
4849
"""Sets up tables for database."""
4950
async with engine.begin() as conn:
51+
# see: https://sqlmodel.tiangolo.com/tutorial/relationship-attributes/cascade-delete-relationships/#enable-foreign-key-support-in-sqlite
52+
await conn.execute(text("PRAGMA foreign_keys=ON"))
5053
await conn.run_sync(SQLModel.metadata.create_all)
54+
await conn.commit()
55+
db_logger.info("===== Database tables setup. =====")
5156

5257

53-
async def create_async_session(
54-
AsyncSessionFactory: async_sessionmaker[AsyncSession],
55-
) -> AsyncGenerator[AsyncSession, None]:
56-
"""Create an asynchronous database session."""
57-
async with AsyncSessionFactory() as session:
58-
try:
59-
yield session
60-
finally:
61-
await session.close()
62-
63-
64-
async def start_db_session(container: Container):
58+
@asynccontextmanager
59+
async def start_db_session(
60+
container: Container,
61+
) -> AsyncGenerator[AsyncSessionWrapper, None]:
6562
"""Starts a new database session given SessionFactory."""
6663
# add LocalAsyncSession
67-
local_async_session = create_async_session(
68-
container.resolve(async_sessionmaker[AsyncSession])
64+
async_session_factory: async_sessionmaker[AsyncSessionWrapper] = container.resolve(
65+
async_sessionmaker[AsyncSessionWrapper]
6966
)
70-
container.add_instance(local_async_session, AsyncSession)
67+
async with async_session_factory() as local_async_session:
68+
yield local_async_session
7169

7270

7371
def configure_database_session(container: Container, settings: Settings) -> Container:
@@ -82,8 +80,9 @@ def configure_database_session(container: Container, settings: Settings) -> Cont
8280
container.add_instance(engine, AsyncEngine)
8381

8482
# add sessionmaker
83+
session_factory = SessionFactory(engine)
8584
container.add_singleton_by_factory(
86-
SessionFactory(engine), async_sessionmaker[AsyncSession]
85+
session_factory, async_sessionmaker[AsyncSessionWrapper]
8786
)
8887

8988
db_logger.info("====== Database session configured. ======")

xcov19/app/main.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
from xcov19.app.database import (
99
configure_database_session,
1010
setup_database,
11-
start_db_session,
1211
)
1312
from xcov19.app.auth import configure_authentication
1413
from xcov19.app.controllers import controller_router
@@ -46,6 +45,5 @@ async def on_start():
4645
container: ContainerProtocol = app.services
4746
if not isinstance(container, Container):
4847
raise ValueError("Container is not a valid container")
49-
await start_db_session(container)
5048
engine = container.resolve(AsyncEngine)
5149
await setup_database(engine)

xcov19/infra/__init__.py

Whitespace-only changes.

xcov19/infra/models.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""
2+
Database Models and Delete Behavior Design Principles
3+
4+
1. Query-Patient-Location Relationship:
5+
- Every Query must have both a Patient and a Location associated with it.
6+
- A Patient can have multiple Queries.
7+
- A Location can be associated with multiple Queries.
8+
9+
2. Delete Restrictions:
10+
- Patient and Location records cannot be deleted if there are any Queries referencing them.
11+
- This is enforced by the "RESTRICT" ondelete option in the Query model's foreign keys.
12+
13+
3. Orphan Deletion:
14+
- A Patient or Location should be deleted only when there are no more Queries referencing it.
15+
- This is handled by custom event listeners that check for remaining Queries after a Query deletion.
16+
17+
4. Cascading Behavior:
18+
- There is no automatic cascading delete from Patient or Location to Query.
19+
- Queries must be explicitly deleted before their associated Patient or Location can be removed.
20+
21+
5. Transaction Handling:
22+
- Delete operations and subsequent orphan checks should occur within the same transaction.
23+
- Event listeners use the existing database connection to ensure consistency with the main transaction.
24+
25+
6. Error Handling:
26+
- Errors during the orphan deletion process should not silently fail.
27+
- Exceptions in event listeners are logged and re-raised to ensure proper transaction rollback.
28+
29+
7. Data Integrity:
30+
- Database-level constraints (foreign keys, unique constraints) are used in conjunction with SQLAlchemy model definitions to ensure data integrity.
31+
32+
These principles aim to maintain referential integrity while allowing for the cleanup of orphaned Patient and Location records when appropriate.
33+
"""
34+
35+
from __future__ import annotations
36+
37+
from typing import List
38+
from sqlmodel import SQLModel, Field, Relationship
39+
from sqlalchemy import Column, Text, Float, Index
40+
from sqlalchemy.orm import relationship, Mapped
41+
import uuid
42+
from sqlalchemy.dialects.sqlite import TEXT
43+
44+
45+
class Patient(SQLModel, table=True):
46+
patient_id: str = Field(
47+
sa_column=Column(
48+
TEXT, unique=True, primary_key=True, default=str(uuid.uuid4())
49+
),
50+
allow_mutation=False,
51+
)
52+
queries: Mapped[List["Query"]] = Relationship(
53+
# back_populates="patient",
54+
passive_deletes="all",
55+
cascade_delete=True,
56+
sa_relationship=relationship(back_populates="patient"),
57+
)
58+
59+
60+
class Query(SQLModel, table=True):
61+
"""Every Query must have both a Patient and a Location."""
62+
63+
query_id: str = Field(
64+
sa_column=Column(
65+
TEXT, unique=True, primary_key=True, default=str(uuid.uuid4())
66+
),
67+
allow_mutation=False,
68+
)
69+
query: str = Field(allow_mutation=False, sa_column=Column(Text))
70+
# Restrict deleting Patient record when there is atleast 1 query referencing it
71+
patient_id: str = Field(foreign_key="patient.patient_id", ondelete="RESTRICT")
72+
# Restrict deleting Location record when there is atleast 1 query referencing it
73+
location_id: str = Field(foreign_key="location.location_id", ondelete="RESTRICT")
74+
location: Location = Relationship(back_populates="queries")
75+
patient: Patient = Relationship(back_populates="queries")
76+
77+
78+
class Location(SQLModel, table=True):
79+
__table_args__ = (
80+
Index("ix_location_composite_lat_lng", "latitude", "longitude", unique=True),
81+
)
82+
location_id: str = Field(
83+
sa_column=Column(
84+
TEXT, unique=True, primary_key=True, default=str(uuid.uuid4())
85+
),
86+
allow_mutation=False,
87+
)
88+
latitude: float = Field(sa_column=Column(Float))
89+
longitude: float = Field(sa_column=Column(Float))
90+
queries: Mapped[List["Query"]] = Relationship(
91+
# back_populates="location",
92+
cascade_delete=True,
93+
passive_deletes=True,
94+
sa_relationship=relationship(back_populates="location"),
95+
)
96+
97+
98+
# TODO: Define Provider SQL model fields
99+
# class Provider(SQLModel, table=True):
100+
# # TODO: Compare with Github issue, domain model and noccodb
101+
# ...
102+
103+
104+
# TODO: Add Model events for database ops during testing
105+
# @event.listens_for(Query, "after_delete")
106+
# def delete_dangling_location(mapper: Mapper, connection: Engine, target: Query):
107+
# """Deletes orphan Location when no related queries exist."""
108+
# local_session = sessionmaker(connection)
109+
# with local_session() as session:
110+
# stmt = (
111+
# select(func.count())
112+
# .select_from(Query)
113+
# .where(Query.location_id == target.location_id)
114+
# )
115+
# if (
116+
# num_queries := session.execute(stmt).scalar_one_or_none()
117+
# ) and num_queries <= 1:
118+
# location: Location = session.get(Location, target.location_id)
119+
# session.delete(location)
120+
# session.flush()
121+
122+
123+
# @event.listens_for(Query, "after_delete")
124+
# def delete_dangling_patient(mapper: Mapper, connection: Engine, target: Query):
125+
# """Deletes orphan Patient records when no related queries exist."""
126+
# local_session = sessionmaker(connection)
127+
# with local_session() as session:
128+
# stmt = (
129+
# select(func.count())
130+
# .select_from(Query)
131+
# .where(Query.patient_id == target.patient_id)
132+
# )
133+
# if (
134+
# num_queries := session.execute(stmt).scalar_one_or_none()
135+
# ) and num_queries <= 1:
136+
# patient: Patient = session.get(Patient, target.patient_id)
137+
# session.delete(patient)
138+
# session.flush()

xcov19/tests/data/__init__.py

Whitespace-only changes.

xcov19/tests/data/seed_db.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Dummy data to seed to database models.
2+
Mapped to SQLModel.
3+
4+
dummy GeoLocation:
5+
lat=0
6+
lng=0
7+
8+
cust_id=test_cust_id
9+
query_id=test_query_id
10+
"""
11+
12+
from sqlalchemy import ScalarResult
13+
from sqlmodel import select
14+
from xcov19.infra.models import Patient, Query, Location
15+
from sqlmodel.ext.asyncio.session import AsyncSession as AsyncSessionWrapper
16+
17+
18+
async def seed_data(session: AsyncSessionWrapper):
19+
"""
20+
Now you can do:
21+
res = await self._session.exec(select(Query))
22+
query = res.first()
23+
print("query", query)
24+
res = await self._session.exec(select(Patient).where(Patient.queries.any(Query.query_id == query.query_id)))
25+
print("patient", res.first())
26+
res = await self._session.exec(select(Location).where(Location.queries.any(Query.query_id == query.query_id)))
27+
print("location", res.first())
28+
"""
29+
query = Query(
30+
query="""
31+
Runny nose and high fever suddenly lasting for few hours.
32+
Started yesterday.
33+
"""
34+
) # type: ignore
35+
36+
patient = Patient(queries=[query]) # type: ignore
37+
38+
patient_location = Location(latitude=0, longitude=0, queries=[query]) # type: ignore
39+
session.add_all([patient_location, patient])
40+
await session.commit()
41+
query_result: ScalarResult = await session.exec(select(Query))
42+
if not query_result.first():
43+
raise RuntimeError("Database seeding failed")

xcov19/tests/start_server.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,27 @@
11
from collections.abc import AsyncGenerator
2-
from xcov19.app.main import app
32
from blacksheep import Application
3+
from contextlib import asynccontextmanager
4+
from rodi import Container, ContainerProtocol
5+
from xcov19.app.database import configure_database_session, setup_database
6+
from xcov19.app.settings import load_settings
7+
from sqlalchemy.ext.asyncio import AsyncEngine
48

59

6-
async def start_server() -> AsyncGenerator[Application, None]:
10+
@asynccontextmanager
11+
async def start_server(app: Application) -> AsyncGenerator[Application, None]:
712
"""Start a test server for automated testing."""
813
try:
914
await app.start()
1015
yield app
1116
finally:
1217
if app.started:
1318
await app.stop()
19+
20+
21+
async def start_test_database(container: ContainerProtocol) -> None:
22+
"""Database setup for integration tests."""
23+
if not isinstance(container, Container):
24+
raise RuntimeError("container not of type Container.")
25+
configure_database_session(container, load_settings())
26+
engine = container.resolve(AsyncEngine)
27+
await setup_database(engine)

xcov19/tests/test_services.py

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
from collections.abc import Callable
2+
from contextlib import AsyncExitStack
23
from typing import List
34
import pytest
45
import unittest
56

6-
from rodi import ContainerProtocol
7-
from xcov19.tests.start_server import start_server
7+
from rodi import Container, ContainerProtocol
8+
from xcov19.app.database import start_db_session
9+
from xcov19.tests.data.seed_db import seed_data
10+
from xcov19.tests.start_server import start_test_database
811
from xcov19.domain.models.provider import (
912
Contact,
1013
FacilityEstablishment,
@@ -24,7 +27,8 @@
2427

2528
import random
2629

27-
from sqlalchemy.ext.asyncio import AsyncSession
30+
from sqlmodel.ext.asyncio.session import AsyncSession as AsyncSessionWrapper
31+
2832

2933
RANDOM_SEED = random.seed(1)
3034

@@ -185,7 +189,7 @@ async def test_fetch_facilities_no_results(self):
185189
self.assertIsNone(result)
186190

187191

188-
@pytest.mark.skip(reason="WIP")
192+
# @pytest.mark.skip(reason="WIP")
189193
@pytest.mark.integration
190194
@pytest.mark.usefixtures("dummy_reverse_geo_lookup_svc", "dummy_geolocation_query_json")
191195
class GeoLocationServiceSqlRepoDBTest(unittest.IsolatedAsyncioTestCase):
@@ -198,23 +202,29 @@ class GeoLocationServiceSqlRepoDBTest(unittest.IsolatedAsyncioTestCase):
198202
"""
199203

200204
async def asyncSetUp(self) -> None:
201-
app = await anext(start_server())
202-
self._container: ContainerProtocol = app.services
203-
self._seed_db(self._container.resolve(AsyncSession))
205+
self._stack = AsyncExitStack()
206+
container: ContainerProtocol = Container()
207+
await start_test_database(container)
208+
self._session = await self._stack.enter_async_context(
209+
start_db_session(container)
210+
)
211+
if not isinstance(self._session, AsyncSessionWrapper):
212+
raise RuntimeError(f"{self._session} is not a AsyncSessionWrapper value.")
213+
await seed_data(self._session)
204214
await super().asyncSetUp()
205215

206-
def _seed_db(self, session: AsyncSession) -> None:
207-
# TODO: add data to sqlite tables based on dummy_geolocation_query_json
208-
# and add providers data.
209-
...
216+
async def asyncTearDown(self) -> None:
217+
print("async closing test server db session closing.")
218+
await self._session.commit()
219+
await self._stack.aclose()
220+
print("async test server closing.")
221+
await super().asyncTearDown()
210222

211223
def _patient_query_lookup_svc_using_repo(
212224
self, address: Address, query: LocationQueryJSON
213225
) -> Callable[[Address, LocationQueryJSON], List[FacilitiesResult]]: ...
214226

215-
async def test_fetch_facilities(
216-
self, dummy_reverse_geo_lookup_svc, dummy_geolocation_query_json
217-
):
227+
async def test_fetch_facilities(self):
218228
# TODO Implement test_fetch_facilities like this:
219229
# providers = await GeolocationQueryService.fetch_facilities(
220230
# dummy_reverse_geo_lookup_svc,

0 commit comments

Comments
 (0)