Skip to content

Commit

Permalink
ensure that sqlite databases enforce foreign key constraints (#432)
Browse files Browse the repository at this point in the history
  • Loading branch information
defreng authored Nov 23, 2023
1 parent d7f35c2 commit 3ff2ec2
Show file tree
Hide file tree
Showing 5 changed files with 40 additions and 14 deletions.
17 changes: 17 additions & 0 deletions src/foxops/database/engine.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from sqlalchemy import Pool
from sqlalchemy.event import listen
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine


def create_engine(connection_string: str) -> AsyncEngine:
# enforce foreign key constraints on SQLite:
# https://docs.sqlalchemy.org/en/20/dialects/sqlite.html#foreign-key-support
def set_sqlite_pragma(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()

if connection_string.startswith("sqlite+"):
listen(Pool, "connect", set_sqlite_pragma)

return create_async_engine(connection_string, future=True, echo=False, pool_pre_ping=True)
13 changes: 13 additions & 0 deletions src/foxops/database/repositories/change.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from foxops.database.schema import change, incarnations
from foxops.errors import FoxopsError, IncarnationNotFoundError
from foxops.logger import get_logger


class ChangeConflictError(FoxopsError):
Expand Down Expand Up @@ -90,6 +91,8 @@ class ChangeRepository:
def __init__(self, engine: AsyncEngine) -> None:
self.engine = engine

self.log = get_logger(component=self.__class__.__name__)

async def create_change(
self,
incarnation_id: int,
Expand Down Expand Up @@ -154,6 +157,14 @@ async def create_incarnation_with_first_change(
requested_data: str,
template_data_full: str,
) -> ChangeInDB:
logger = self.log.bind(function="create_incarnation_with_first_change")
logger.debug(
"starting transaction",
incarnation_repository=incarnation_repository,
target_directory=target_directory,
template_repository=template_repository,
)

async with self.engine.begin() as conn:
query_insert_incarnation = (
insert(incarnations)
Expand All @@ -167,6 +178,8 @@ async def create_incarnation_with_first_change(
result = await conn.execute(query_insert_incarnation)
incarnation_id = result.one()[0]

logger.debug("inserted incarnation", incarnation_id=incarnation_id)

query_insert_change = (
insert(change)
.values(
Expand Down
5 changes: 3 additions & 2 deletions src/foxops/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
from fastapi import Depends, HTTPException, Request, status
from fastapi.openapi.models import APIKey, APIKeyIn
from fastapi.security.base import SecurityBase
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from sqlalchemy.ext.asyncio import AsyncEngine

from foxops.database.engine import create_engine
from foxops.database.repositories.change import ChangeRepository
from foxops.database.repositories.incarnation.repository import IncarnationRepository
from foxops.hosters import Hoster
Expand Down Expand Up @@ -41,7 +42,7 @@ def get_database_engine(request: Request, settings: DatabaseSettings = Depends(g
if hasattr(request.app.state, "database"):
return request.app.state.database

async_engine = create_async_engine(settings.url.get_secret_value(), future=True, echo=False, pool_pre_ping=True)
async_engine = create_engine(settings.url.get_secret_value())

request.app.state.database = async_engine
return async_engine
Expand Down
15 changes: 4 additions & 11 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@
import pytest
from fastapi import FastAPI
from httpx import AsyncClient
from sqlalchemy import Engine, event
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from sqlalchemy.ext.asyncio import AsyncEngine

from foxops.__main__ import create_app
from foxops.database.engine import create_engine
from foxops.database.repositories.change import ChangeRepository
from foxops.database.repositories.incarnation.repository import IncarnationRepository
from foxops.database.schema import meta
Expand Down Expand Up @@ -60,15 +60,8 @@ def use_testing_gitconfig():

@pytest.fixture(name="test_async_engine")
async def test_async_engine() -> AsyncGenerator[AsyncEngine, None]:
# enforce foreign key constraints on SQLite:
# https://docs.sqlalchemy.org/en/20/dialects/sqlite.html#foreign-key-support
@event.listens_for(Engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()

async_engine = create_async_engine("sqlite+aiosqlite://", future=True, echo=False, pool_pre_ping=True)
async_engine = create_engine("sqlite+aiosqlite://")

async with async_engine.begin() as conn:
await conn.run_sync(meta.create_all)

Expand Down
4 changes: 3 additions & 1 deletion tests/database/test_incarnation_repository.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from pytest import fixture, raises

from foxops.database.repositories.change import ChangeRepository
from foxops.database.repositories.incarnation.errors import (
IncarnationAlreadyExistsError,
IncarnationNotFoundError,
Expand Down Expand Up @@ -93,12 +94,13 @@ async def test_list_returns_existing_incarnations(incarnation_repository: Incarn


async def test_delete_by_id_returns_none_when_deleting_existing_incarnation(
incarnation_repository: IncarnationRepository, incarnation_id: int
incarnation_repository: IncarnationRepository, change_repository: ChangeRepository, incarnation_id: int
):
# WHEN
await incarnation_repository.delete_by_id(incarnation_id)

# THEN
assert len(await change_repository.list_changes(incarnation_id)) == 0
with raises(IncarnationNotFoundError):
await incarnation_repository.get_by_id(incarnation_id)

Expand Down

0 comments on commit 3ff2ec2

Please sign in to comment.