diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6bd13e69..a341de8b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,30 +1,64 @@ name: Test on: - push: - branches: ["**"] - pull_request: - branches: ["**"] + push: + branches: [ "**" ] + pull_request: + branches: [ "**" ] jobs: - pytest: - name: Pytest - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Install uv - uses: astral-sh/setup-uv@v5 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Install dependencies - working-directory: example/config-app - run: uv sync - - - name: Run tests - working-directory: example/config-app - run: uv run pytest tests/ -v + tests: + name: Pytest + runs-on: ubuntu-latest + + env: + DB_HOST: 127.0.0.1 + DB_PORT: 3306 + DB_DATABASE: database_app_test + DB_USERNAME: app + DB_PASSWORD: secret + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Start MySQL + run: docker compose -f docker-compose.test.yml up -d --wait + + # ── fastapi_startkit package ────────────────────────────────────────── + - name: Install dependencies (fastapi_startkit) + working-directory: fastapi_startkit + run: uv sync --group dev + + - name: Run tests (fastapi_startkit) + working-directory: fastapi_startkit + run: uv run pytest tests/ -v + + # ── example/config-app ──────────────────────────────────────────────── + - name: Install dependencies (config-app) + working-directory: example/config-app + run: uv sync + + - name: Run tests (config-app) + working-directory: example/config-app + run: uv run pytest tests/ -v + + # ── example/database-app ────────────────────────────────────────────── + - name: Install dependencies (database-app) + working-directory: example/database-app + run: uv sync + + - name: Run tests (database-app) + working-directory: example/database-app + run: uv run pytest tests/ -v + + - name: Stop MySQL + if: always() + run: docker compose -f docker-compose.test.yml down diff --git a/bin/test.sh b/bin/test.sh new file mode 100755 index 00000000..31298042 --- /dev/null +++ b/bin/test.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -e + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# ── Start MySQL via Docker Compose ──────────────────────────────────────────── +echo "Starting test services..." +docker compose -f "$ROOT/docker-compose.yml" up -d --wait + +# Ensure MySQL is torn down on exit (even if tests fail) +trap 'echo "Stopping test services..."; docker compose -f "$ROOT/docker-compose.test.yml" down' EXIT + +# ── Common DB env vars (match docker-compose.test.yml) ─────────────────────── +export DB_HOST=127.0.0.1 +export DB_PORT=3306 +export DB_DATABASE=database_app_test +export DB_USERNAME=app +export DB_PASSWORD=secret + +echo "" +echo "============================================================" +echo " Running: fastapi_startkit package tests" +echo "============================================================" +(cd "$ROOT/fastapi_startkit" && uv run pytest) + +echo "" +echo "============================================================" +echo " Running: example/config-app tests" +echo "============================================================" +(cd "$ROOT/example/config-app" && uv run pytest) + +echo "" +echo "============================================================" +echo " Running: example/database-app tests" +echo "============================================================" +(cd "$ROOT/example/database-app" && uv run pytest) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..79f7a92b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + mysql: + image: mysql:8.0 + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: database_app_test + MYSQL_USER: app + MYSQL_PASSWORD: secret + ports: + - "3306:3306" + healthcheck: + test: [ "CMD", "mysqladmin", "ping", "--silent" ] + interval: 5s + timeout: 5s + retries: 10 diff --git a/example/database-app/.env.example b/example/database-app/.env.example index 9f340449..df825595 100644 --- a/example/database-app/.env.example +++ b/example/database-app/.env.example @@ -1,3 +1,5 @@ +APP_NAME="Fastapi starter Kit Database example app" + DB_HOST=localhost DB_DATABASE=postgres DB_USER=postgres diff --git a/example/database-app/.env.testing b/example/database-app/.env.testing index ae53c9e2..0f0977bf 100644 --- a/example/database-app/.env.testing +++ b/example/database-app/.env.testing @@ -1,4 +1,4 @@ -APP_NAME="Masonite Testing" +APP_NAME="Fastapi starter Kit Database example app" APP_ENV=testing DB_HOST=127.0.0.1 @@ -7,4 +7,4 @@ DB_USERNAME=app DB_PASSWORD=secret DB_PORT=3306 -LOG_CHANNEL=syslog +LOG_CHANNEL=terminal diff --git a/example/database-app/app/students/controllers/auth.py b/example/database-app/app/students/controllers/auth.py new file mode 100644 index 00000000..13b04a89 --- /dev/null +++ b/example/database-app/app/students/controllers/auth.py @@ -0,0 +1,2 @@ +def login(): + pass diff --git a/example/database-app/app/students/controllers/auth_controller.py b/example/database-app/app/students/controllers/registration.py similarity index 96% rename from example/database-app/app/students/controllers/auth_controller.py rename to example/database-app/app/students/controllers/registration.py index ef1e5984..b4b085d7 100644 --- a/example/database-app/app/students/controllers/auth_controller.py +++ b/example/database-app/app/students/controllers/registration.py @@ -25,7 +25,3 @@ async def register(request: StudentRegistrationRequest): await profile.save() return {"message": "Student registered successfully", "user_id": user.id} - - -def login(): - pass diff --git a/example/database-app/docker-compose.yml b/example/database-app/docker-compose.yml deleted file mode 100644 index c98619db..00000000 --- a/example/database-app/docker-compose.yml +++ /dev/null @@ -1,16 +0,0 @@ -services: - mysql: - image: mysql:8.0 - environment: - MYSQL_ROOT_PASSWORD: rootsecret - MYSQL_DATABASE: database_app - MYSQL_USER: app - MYSQL_PASSWORD: secret - ports: - - "3306:3306" - volumes: - - mysql_data:/var/lib/mysql - - ./docker/mysql/init.sql:/docker-entrypoint-initdb.d/init.sql - -volumes: - mysql_data: diff --git a/example/database-app/docker/mysql/init.sql b/example/database-app/docker/mysql/init.sql deleted file mode 100644 index 330f7f29..00000000 --- a/example/database-app/docker/mysql/init.sql +++ /dev/null @@ -1,17 +0,0 @@ --- Create the application database -CREATE DATABASE IF NOT EXISTS `database_app` - CHARACTER SET utf8mb4 - COLLATE utf8mb4_unicode_ci; - --- Create the application user and grant privileges -CREATE USER IF NOT EXISTS 'app'@'%' IDENTIFIED BY 'secret'; -GRANT ALL PRIVILEGES ON `database_app`.* TO 'app'@'%'; - --- Create a separate test database -CREATE DATABASE IF NOT EXISTS `database_app_test` - CHARACTER SET utf8mb4 - COLLATE utf8mb4_unicode_ci; - -GRANT ALL PRIVILEGES ON `database_app_test`.* TO 'app'@'%'; - -FLUSH PRIVILEGES; \ No newline at end of file diff --git a/example/database-app/pyproject.toml b/example/database-app/pyproject.toml index 9b394f4d..44e0e4ad 100644 --- a/example/database-app/pyproject.toml +++ b/example/database-app/pyproject.toml @@ -6,6 +6,7 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ "aiomysql>=0.3.2", + "aiosqlite>=0.22.1", "cryptography>=47.0.0", "fastapi-startkit[database,fastapi,postgres]", ] diff --git a/example/database-app/pytest.ini b/example/database-app/pytest.ini index 2fc33af0..68b2e350 100644 --- a/example/database-app/pytest.ini +++ b/example/database-app/pytest.ini @@ -1,5 +1,4 @@ [pytest] asyncio_mode = auto -asyncio_default_fixture_loop_scope = function -asyncio_default_test_loop_scope = function pythonpath = . + diff --git a/example/database-app/routes/api.py b/example/database-app/routes/api.py index 240d1fa5..9485c096 100644 --- a/example/database-app/routes/api.py +++ b/example/database-app/routes/api.py @@ -1,9 +1,7 @@ from fastapi_startkit.fastapi import Router -from app.students.controllers import auth_controller as student_auth from app.http.controllers.auth_controller import AuthController public = Router() -public.post("/register/student", student_auth.register) public.post("/register/teacher", AuthController.register_teacher) diff --git a/example/database-app/routes/student.py b/example/database-app/routes/student.py index 4280bccf..c1ac3b20 100644 --- a/example/database-app/routes/student.py +++ b/example/database-app/routes/student.py @@ -1,8 +1,9 @@ from fastapi_startkit.fastapi import Router -from app.students.controllers import auth_controller +from app.students.controllers.registration import register +from app.students.controllers.auth import login router = Router() -router.post("/students/register", auth_controller.register) -router.get("/students/login", auth_controller.login) +router.post("/students/register", register) +router.get("/students/login", login) diff --git a/example/database-app/tests/__init__.py b/example/database-app/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/example/database-app/tests/features/__init__.py b/example/database-app/tests/features/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/example/database-app/tests/features/students/__init__.py b/example/database-app/tests/features/students/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/example/database-app/tests/features/students/test_register.py b/example/database-app/tests/features/students/test_register.py index 93c810be..08c680c3 100644 --- a/example/database-app/tests/features/students/test_register.py +++ b/example/database-app/tests/features/students/test_register.py @@ -1,8 +1,11 @@ +from fastapi_startkit.fastapi.testing import HttpTestCase +from fastapi_startkit.masoniteorm.testing import RefreshDatabase + from app.models.user import User -from tests.test_case import TestCase, RefreshDatabase +from tests.test_case import TestCase -class TestRegister(TestCase, RefreshDatabase): +class TestRegister(TestCase, HttpTestCase, RefreshDatabase): async def test_user_can_register(self): response = await self.post("/students/register", json={ "name": "John Doe", @@ -40,7 +43,7 @@ async def test_user_cannot_register_with_invalid_data(self): }) assert response.status_code == 422 - # name too short + # name too shorts response = await self.post("/students/register", json={ "name": "J", "email": "john@example.com", diff --git a/example/database-app/tests/features/teachers/__init__.py b/example/database-app/tests/features/teachers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/example/database-app/tests/features/teachers/test_register.py b/example/database-app/tests/features/teachers/test_register.py new file mode 100644 index 00000000..ab4da6eb --- /dev/null +++ b/example/database-app/tests/features/teachers/test_register.py @@ -0,0 +1,20 @@ +from fastapi_startkit.masoniteorm.testing import RefreshDatabase, DatabaseTransaction + +from app.models.user import User +from tests.test_case import TestCase + + +class TestRegister(RefreshDatabase, TestCase): + async def test_register(self): + user = User(name="Teacher", email="teacher@example.com", password="password123", role="teacher") + await user.save() + + found = await User.where("email", "teacher@example.com").first() + assert found is not None + assert found.role == "teacher" + + +class TestTableIsClean(DatabaseTransaction, TestCase): + async def test_table_is_clean(self): + users = await User.all() + assert len(users) == 0 \ No newline at end of file diff --git a/example/database-app/tests/test_case.py b/example/database-app/tests/test_case.py index 0ef8601a..72f59997 100644 --- a/example/database-app/tests/test_case.py +++ b/example/database-app/tests/test_case.py @@ -1,74 +1,13 @@ -import gc -import pytest -from httpx import AsyncClient, ASGITransport -from bootstrap.application import app +from abc import ABC +from typing import TYPE_CHECKING -class TestCase: - @pytest.fixture(autouse=True) - async def setup_client(self): - async with AsyncClient( - transport=ASGITransport(app=app.fastapi), base_url="http://test" - ) as client: - self.client = client - yield +from fastapi_startkit.testing import TestCase as BaseTestCase - async def get(self, url, **kwargs): - return await self.client.get(url, **kwargs) +if TYPE_CHECKING: + from fastapi_startkit.application import Application - async def post(self, url, **kwargs): - return await self.client.post(url, **kwargs) - async def put(self, url, **kwargs): - return await self.client.put(url, **kwargs) - - async def delete(self, url, **kwargs): - return await self.client.delete(url, **kwargs) - - -class RefreshDatabase: - migrated = False - - @staticmethod - async def migrate_database(): - from fastapi_startkit.masoniteorm.migrations import Migration - - if not RefreshDatabase.migrated: - migration = Migration(migration_directory="databases/migrations") - await migration.fresh(ignore_fk=True) - RefreshDatabase.migrated = True - - @pytest.fixture(autouse=True) - async def refresh_database(self): - await RefreshDatabase.migrate_database() - - from fastapi_startkit.masoniteorm.models import Model - db_connection = Model.db_manager.connection(None) - original_engine = db_connection.conn - - async with original_engine.connect() as conn: - transaction = await conn.begin() - - original_commit = conn.sync_connection.commit - conn.sync_connection.commit = lambda: None - - class PatchedEngine: - def connect(self): - return YieldConn() - - class YieldConn: - async def __aenter__(self): - return conn - - async def __aexit__(self, *args): - pass - - db_connection.conn = PatchedEngine() - - yield - - db_connection.conn = original_engine - conn.sync_connection.commit = original_commit - await transaction.rollback() - - await original_engine.dispose() - gc.collect() \ No newline at end of file +class TestCase(BaseTestCase, ABC): + def get_application(self) -> 'Application': + from bootstrap.application import app + return app diff --git a/example/database-app/uv.lock b/example/database-app/uv.lock index a77bb569..e2071d70 100644 --- a/example/database-app/uv.lock +++ b/example/database-app/uv.lock @@ -19,6 +19,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4c/af/aae0153c3e28712adaf462328f6c7a3c196a1c1c27b491de4377dd3e6b52/aiomysql-0.3.2-py3-none-any.whl", hash = "sha256:c82c5ba04137d7afd5c693a258bea8ead2aad77101668044143a991e04632eb2", size = 71834, upload-time = "2025-10-22T00:15:15.905Z" }, ] +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -331,6 +340,7 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "aiomysql" }, + { name = "aiosqlite" }, { name = "cryptography" }, { name = "fastapi-startkit", extra = ["database", "fastapi", "postgres"] }, ] @@ -346,6 +356,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiomysql", specifier = ">=0.3.2" }, + { name = "aiosqlite", specifier = ">=0.22.1" }, { name = "cryptography", specifier = ">=47.0.0" }, { name = "fastapi-startkit", extras = ["database", "fastapi", "postgres"], editable = "../../fastapi_startkit" }, ] diff --git a/fastapi_startkit/src/fastapi_startkit/application.py b/fastapi_startkit/src/fastapi_startkit/application.py index c8c6d76d..3249fbd7 100644 --- a/fastapi_startkit/src/fastapi_startkit/application.py +++ b/fastapi_startkit/src/fastapi_startkit/application.py @@ -7,7 +7,7 @@ from .config import AppConfig from .configuration.providers import ConfigurationProvider from .container import Container -from .environment.environment import LoadEnvironment +from .environment.environment import Environment if TYPE_CHECKING: from fastapi import FastAPI, APIRouter @@ -39,9 +39,7 @@ def __init__( ): super().__init__() - self.base_path: str = ( - str(base_path) if isinstance(base_path, Path) else base_path or os.getcwd() - ) + self.base_path: Path = Path(base_path) if base_path else Path(os.getcwd()) self.env = env self.providers = self.DEFAULT_PROVIDERS + (providers or []) self.published_resources = {} @@ -57,7 +55,7 @@ def __init__( self.configure_exception_handler() # Boot application - self.load_environment() + self.resolve_environment() self.configure_config() self.configure_paths() self.register_providers() @@ -106,7 +104,7 @@ def use_fastapi(self, fastapi: "FastAPI"): return self def use_base_path(self, path: str): - return str(Path(self.base_path) / path) + return self.base_path / path def get(self, path: str, **kwargs) -> Callable: return self.fastapi.get(path, **kwargs) @@ -176,8 +174,9 @@ def fastapi(self) -> "FastAPI": def __call__(self, *args, **kwargs): return self.fastapi - def load_environment(self): - LoadEnvironment(environment=self.env, base_path=self.base_path) + def resolve_environment(self): + self.env = Environment.resolve_environment(base_path=self.base_path, env=self.env) + Environment.load(self.env, base_path=self.base_path) def is_debug(self) -> bool: return ( @@ -186,6 +185,9 @@ def is_debug(self) -> bool: and getattr(self._config_instance, "debug", False) ) + def is_testing(self) -> bool: + return self.env == "testing" + def configure_config(self): if self._config is not None: self._config_instance = self._config() @@ -197,7 +199,7 @@ def config(self) -> TConfig: return self._config_instance def configure_paths(self): - self.bind("config.location", os.path.join(self.base_path, "config")) + self.bind("config.location", self.base_path / "config") def use_config_path(self, path: str = None): self.bind("config.location", path) diff --git a/fastapi_startkit/src/fastapi_startkit/environment/environment.py b/fastapi_startkit/src/fastapi_startkit/environment/environment.py index 894524d7..af993008 100644 --- a/fastapi_startkit/src/fastapi_startkit/environment/environment.py +++ b/fastapi_startkit/src/fastapi_startkit/environment/environment.py @@ -2,29 +2,44 @@ import os import sys -from pathlib import Path - from dotenv import load_dotenv -class LoadEnvironment: - def __init__(self, environment=None, override=True, only=None, base_path=None): - self.base_path = Path(base_path) if base_path else Path(".") - self._detect_env_from_argv() +class Environment: + @staticmethod + def resolve_environment(base_path=None, env: str | None = None): + Environment.resolve_environment_from_argument() - if only: - self.load_file(f".env.{only}", override=override) - return + if "PYTEST_CURRENT_TEST" in os.environ: + return "testing" + + if os.environ.get("APP_ENV"): + return os.environ["APP_ENV"] - resolved = self._resolve_environment(environment) + if env: + return env - # Always load .env as the base, then overlay .env. on top. - self.load_file(".env", override=override) + path = base_path / ".env" + if not path.exists(): + raise ValueError("Unable to determine environment.") + + load_dotenv(path) + + env = os.environ.get("APP_ENV") + if not env: + raise ValueError("APP_ENV not set after loading .env") + + return env - if resolved: - self.load_file(f".env.{resolved}", override=override) + @staticmethod + def load(env: str, override=True, only=None, base_path=None): + path = base_path / f".env.{env}" + if not path.exists(): + return + load_dotenv(path, override=override) - def _detect_env_from_argv(self): + @staticmethod + def resolve_environment_from_argument(): """Parse --env= or --env from sys.argv, set APP_ENV, and remove the tokens so downstream CLI parsers (e.g. cleo) never see them.""" args = sys.argv[1:] @@ -39,24 +54,6 @@ def _detect_env_from_argv(self): sys.argv.pop(i + 1) # then the flag break - def _resolve_environment(self, fallback): - if "PYTEST_CURRENT_TEST" in os.environ: - return "testing" - - if os.environ.get("APP_ENV"): - return os.environ["APP_ENV"] - - if fallback: - return fallback - - return None - - def load_file(self, filename, override=False): - path = self.base_path / filename - if not path.exists(): - return - load_dotenv(path, override=override) - def env(value, default="", cast=True): """Helper to retrieve the value of an environment variable or returns diff --git a/fastapi_startkit/src/fastapi_startkit/fastapi/testing/__init__.py b/fastapi_startkit/src/fastapi_startkit/fastapi/testing/__init__.py new file mode 100644 index 00000000..48fec2e7 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/fastapi/testing/__init__.py @@ -0,0 +1,5 @@ +from fastapi_startkit.fastapi.testing.test_case import HttpTestCase + +__all__= [ + 'HttpTestCase' +] diff --git a/fastapi_startkit/src/fastapi_startkit/fastapi/testing/test_case.py b/fastapi_startkit/src/fastapi_startkit/fastapi/testing/test_case.py new file mode 100644 index 00000000..22478a29 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/fastapi/testing/test_case.py @@ -0,0 +1,31 @@ +from abc import ABC + +from fastapi_startkit.testing import TestCase +from httpx import AsyncClient, ASGITransport + + +class HttpTestCase(TestCase, ABC): + client: AsyncClient + + async def asyncSetUp(self): + await super().asyncSetUp() + self._client_ctx = AsyncClient( + transport=ASGITransport(app=self.get_application().fastapi), base_url="http://test" + ) + self.client = await self._client_ctx.__aenter__() + + async def asyncTearDown(self): + await self._client_ctx.__aexit__(None, None, None) + await super().asyncTearDown() + + async def get(self, url, **kwargs): + return await self.client.get(url, **kwargs) + + async def post(self, url, **kwargs): + return await self.client.post(url, **kwargs) + + async def put(self, url, **kwargs): + return await self.client.put(url, **kwargs) + + async def delete(self, url, **kwargs): + return await self.client.delete(url, **kwargs) \ No newline at end of file diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/connection.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/connection.py index 7badb0a9..6e11180a 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/connection.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/connection.py @@ -1,14 +1,16 @@ - -from sqlalchemy import text -from sqlalchemy.ext.asyncio import AsyncEngine +from typing import List from fastapi_startkit.masoniteorm.models.builder import QueryBuilder +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncConnection, AsyncTransaction class Connection: - def __init__(self, connection: AsyncEngine, config: dict): + def __init__(self, engine: AsyncEngine, config: dict): self.config = config - self.conn: AsyncEngine = connection + self.engine: AsyncEngine = engine + self.connection: AsyncConnection | None = None + self.transactions: List[AsyncTransaction] = [] def query(self) -> "QueryBuilder": return QueryBuilder( @@ -17,35 +19,59 @@ def query(self) -> "QueryBuilder": processor=self.get_post_processor(), ) + async def get_connection(self) -> AsyncConnection: + if self.connection is None: + self.connection = await self.engine.connect() + + assert self.connection is not None + return self.connection + def get_query_grammar(cls): pass def get_post_processor(self): pass - @classmethod - def on(cls, connection: str) -> "Connection": - return cls(connection) - async def begin_transaction(self) -> None: - self.conn = await self.engine.connect() + connection = await self.get_connection() + + if not self.transactions: + transaction = await connection.begin() + else: + transaction = await connection.begin_nested() + + self.transactions.append(transaction) async def commit_transaction(self) -> None: - if self.conn: - await self.conn.commit() - await self.conn.close() - self.conn = None + if not self.transactions: + raise RuntimeError("No active transaction to commit") + + transaction = self.transactions.pop() + await transaction.commit() + + await self._maybe_cleanup() async def rollback(self) -> None: - if self.conn: - await self.conn.rollback() - await self.conn.close() - self.conn = None + if not self.transactions: + raise RuntimeError("No active transaction to rollback") + + transaction = self.transactions.pop() + await transaction.rollback() + + await self._maybe_cleanup() + + async def close(self) -> None: + if self.connection is not None: + await self.connection.close() + self.connection = None + self.transactions = [] async def reconnect(self) -> None: - self.conn = await self.engine.connect() + await self.close() + - def sql_alchemy_bindings(self, query: str, bindings: list | None = None): + @staticmethod + def sql_alchemy_bindings(query: str, bindings: list | None = None): params = {} if bindings: for i, val in enumerate(bindings): @@ -57,38 +83,33 @@ def sql_alchemy_bindings(self, query: str, bindings: list | None = None): async def run(self, query: str, bindings: list | None = None): query, bindings = self.sql_alchemy_bindings(query, bindings) - async with self.conn.connect() as conn: - return await conn.execute(text(query), bindings or {}) + conn = await self.get_connection() - async def statement(self, query: str, bindings: list | None = None) -> bool: + return await conn.execute(text(query), bindings or {}) + + async def execute(self, query: str, bindings: list | None = None): query, bindings = self.sql_alchemy_bindings(query, bindings) - async with self.conn.connect() as conn: - await conn.execute(text(query), bindings or {}) + conn = await self.get_connection() + result = await conn.execute(text(query), bindings or {}) + + if not self.transactions: await conn.commit() - return True + return result async def insert(self, query: str, bindings: list | None = None) -> int | None: - query, params = self.sql_alchemy_bindings(query, bindings) - - async with self.conn.connect() as conn: - result = await conn.execute(text(query), params) - await conn.commit() + result = await self.execute(query, bindings) - return result.lastrowid + return getattr(result, "lastrowid", None) async def update(self, query: str, bindings: list | None = None) -> int: - query, params = self.sql_alchemy_bindings(query, bindings) - - async with self.conn.connect() as conn: - result = await conn.execute(text(query), params) - await conn.commit() + result = await self.execute(query, bindings) return result.rowcount # type: ignore[return-value] async def delete(self, query: str, bindings: list | None = None) -> int: - result = await self.run(query, bindings) + result = await self.execute(query, bindings) return result.rowcount # type: ignore[return-value] async def select(self, query: str, bindings: list | None = None) -> list[dict]: @@ -100,3 +121,20 @@ async def select_one(self, query: str, bindings: list | None = None) -> dict | N result = await self.run(query, bindings) row = result.fetchone() return dict(zip(result.keys(), row)) if row else None + + async def statement(self, query: str, bindings: list | None = None) -> bool: + query, bindings = self.sql_alchemy_bindings(query, bindings) + + conn = await self.get_connection() + await conn.execute(text(query), bindings or {}) + + # Only commit if NOT inside a transaction + if not self.transactions: + await conn.commit() + + return True + + async def _maybe_cleanup(self): + if not self.transactions and self.connection: + await self.connection.close() + self.connection = None diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/factory.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/factory.py index 80d62021..554a94db 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/factory.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/factory.py @@ -1,6 +1,7 @@ from typing import Any from sqlalchemy import StaticPool +from sqlalchemy.pool import NullPool from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine from fastapi_startkit.masoniteorm.connections.connection import Connection @@ -36,7 +37,10 @@ def build_url(cls, config: dict) -> str: def create_engine(cls, cfg: dict) -> AsyncEngine: url = cls.build_url(cfg) kwargs: dict[str, Any] = {"echo": True} - if cfg["driver"] == "sqlite": + from fastapi_startkit.application import app + if app().is_testing(): + kwargs["poolclass"] = NullPool + elif cfg["driver"] == "sqlite": kwargs["connect_args"] = {"check_same_thread": False} kwargs["poolclass"] = StaticPool return create_async_engine(url, **kwargs) diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/manager.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/manager.py index f081a4dc..5add1c27 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/manager.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/manager.py @@ -10,7 +10,7 @@ def __init__(self, factory: "ConnectionFactory", config: dict): self.config = config self.connections = {} - def connection(self, name: str | None): + def connection(self, name: str | None = None): name = self.get_default_connection_name(name) assert name is not None config = self.config.get("connections", {}).get(name) @@ -38,3 +38,8 @@ def get_schema_builder(self): from fastapi_startkit.masoniteorm.schema import Schema return Schema(self) + + def clear(self): + for conn in self.connections.values(): + conn.engine.dispose() + self.connections.clear() diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/postgres_connection.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/postgres_connection.py index 98f094db..c24e43ad 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/postgres_connection.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/postgres_connection.py @@ -26,7 +26,7 @@ async def insert(self, query: str, bindings: list | None = None) -> Any: from sqlalchemy import text - async with self.conn.connect() as conn: + async with self.engine.connect() as conn: result = await conn.execute(text(query), params) await conn.commit() diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/testing/__init__.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/testing/__init__.py new file mode 100644 index 00000000..819d4db8 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/testing/__init__.py @@ -0,0 +1,6 @@ +from fastapi_startkit.masoniteorm.testing.transaction import RefreshDatabase, DatabaseTransaction + +__all__ = [ + 'RefreshDatabase', + 'DatabaseTransaction', +] diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/testing/transaction.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/testing/transaction.py new file mode 100644 index 00000000..54308a68 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/testing/transaction.py @@ -0,0 +1,25 @@ +class DatabaseTransaction: + async def asyncStartTestRun(self): + from fastapi_startkit.masoniteorm.models import Model + + self.connection = Model.db_manager.connection(None) + await self.connection.begin_transaction() + + async def asyncStopTestRun(self): + await self.connection.rollback() + + +class RefreshDatabase(DatabaseTransaction): + migrated = False + + async def asyncStartTestRun(self): + await self.migrate_database() + await super().asyncStartTestRun() + + @staticmethod + async def migrate_database(): + if not RefreshDatabase.migrated: + from fastapi_startkit.masoniteorm.migrations import Migration + + await Migration(migration_directory="databases/migrations").fresh(ignore_fk=True) + RefreshDatabase.migrated = True diff --git a/fastapi_startkit/src/fastapi_startkit/testing/__init__.py b/fastapi_startkit/src/fastapi_startkit/testing/__init__.py new file mode 100644 index 00000000..0336c70c --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/testing/__init__.py @@ -0,0 +1,5 @@ +from .test_case import TestCase + +__all__ = [ + 'TestCase' +] diff --git a/fastapi_startkit/src/fastapi_startkit/testing/test_case.py b/fastapi_startkit/src/fastapi_startkit/testing/test_case.py new file mode 100644 index 00000000..53d868b4 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/testing/test_case.py @@ -0,0 +1,33 @@ +import pytest +from abc import abstractmethod +from typing import TYPE_CHECKING +from unittest import IsolatedAsyncioTestCase + +if TYPE_CHECKING: + from fastapi_startkit import Application + + +class TestCase(IsolatedAsyncioTestCase): + @pytest.fixture(scope='session', autouse=True) + async def app(self): + self.application = self.get_application() + + def setUp(self): + if hasattr(self, 'startTestRun'): + self.startTestRun() + + def tearDown(self): + if hasattr(self, 'stopTestRun'): + self.stopTestRun() + + async def asyncSetUp(self): + if hasattr(self, "asyncStartTestRun"): + await self.asyncStartTestRun() + + async def asyncTearDown(self): + if hasattr(self, "asyncStopTestRun"): + await self.asyncStopTestRun() + + @abstractmethod + def get_application(self) -> 'Application': + ...