├── .flake8 ├── .github └── workflows │ └── build.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── blacksheepsqlalchemy └── __init__.py ├── dev-requirements.txt ├── pyproject.toml └── tests ├── README.md ├── __init__.py ├── alembic.ini ├── app.py ├── conftest.py ├── domain.py ├── fixtures.py ├── migrations ├── env.py ├── script.py.mako └── versions │ └── ae4207a54d40_structure.py ├── test_blacksheepsqlalchemy.py └── utils.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = __pycache__,built,build,venv 3 | ignore = E203, E266, W503 4 | max-line-length = 88 5 | max-complexity = 18 6 | select = B,C,E,F,W,T4,B9 7 | per-file-ignores = 8 | tests/app.py:E402 9 | tests/migrations/env.py:E402 10 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [published] 7 | push: 8 | branches: 9 | - main 10 | - dev 11 | pull_request: 12 | branches: 13 | - "*" 14 | 15 | env: 16 | PROJECT_NAME: blacksheepsqlalchemy 17 | 18 | jobs: 19 | build: 20 | runs-on: ubuntu-latest 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | python-version: [3.9, "3.10", "3.11", "3.12", "3.13"] 25 | 26 | steps: 27 | - uses: actions/checkout@v1 28 | with: 29 | fetch-depth: 9 30 | submodules: false 31 | 32 | - name: Use Python ${{ matrix.python-version }} 33 | uses: actions/setup-python@v3 34 | with: 35 | python-version: ${{ matrix.python-version }} 36 | 37 | - name: Install dependencies 38 | run: | 39 | pip install -r dev-requirements.txt 40 | 41 | - name: Prepare test database 42 | run: | 43 | cd tests 44 | alembic upgrade head 45 | cp example.db ../ 46 | 47 | - name: Run tests 48 | run: | 49 | flake8 && pytest --doctest-modules --junitxml=junit/pytest-results-${{ matrix.python-version }}.xml --cov=$PROJECT_NAME --cov-report=xml --ignore=tests/migrations tests/ 50 | 51 | - name: Upload pytest test results 52 | uses: actions/upload-artifact@master 53 | with: 54 | name: pytest-results-${{ matrix.python-version }} 55 | path: junit/pytest-results-${{ matrix.python-version }}.xml 56 | if: always() 57 | 58 | - name: Codecov 59 | run: | 60 | bash <(curl -s https://codecov.io/bash) 61 | 62 | - name: Install distribution dependencies 63 | run: pip install build 64 | if: matrix.python-version == 3.12 65 | 66 | - name: Create distribution package 67 | run: python -m build 68 | if: matrix.python-version == 3.12 69 | 70 | - name: Upload distribution package 71 | uses: actions/upload-artifact@v4 72 | with: 73 | name: dist 74 | path: dist 75 | if: matrix.python-version == 3.12 76 | 77 | publish: 78 | runs-on: ubuntu-latest 79 | needs: build 80 | if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' 81 | steps: 82 | - name: Download a distribution artifact 83 | uses: actions/download-artifact@v4 84 | with: 85 | name: dist 86 | path: dist 87 | 88 | - name: Use Python 3.12 89 | uses: actions/setup-python@v1 90 | with: 91 | python-version: '3.12' 92 | 93 | - name: Install dependencies 94 | run: | 95 | pip install twine 96 | 97 | - name: Publish distribution 📦 to Test PyPI 98 | run: | 99 | twine upload -r testpypi dist/* 100 | env: 101 | TWINE_USERNAME: __token__ 102 | TWINE_PASSWORD: ${{ secrets.test_pypi_password }} 103 | 104 | - name: Publish distribution 📦 to PyPI 105 | run: | 106 | twine upload -r pypi dist/* 107 | env: 108 | TWINE_USERNAME: __token__ 109 | TWINE_PASSWORD: ${{ secrets.pypi_password }} 110 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | /tests/example.db 131 | 132 | example.db 133 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.0.4] - 2025-03-27 9 | 10 | - Upgrade versions of dependencies. 11 | - Handle setuptools warning: _SetuptoolsDeprecationWarning: License classifiers are deprecated_. 12 | - Add Python 3.13 to the build matrix, remove Python 3.8. 13 | 14 | ## [0.0.3] - 2023-12-29 :christmas_tree: 15 | 16 | - Upgrades the library to use `pyproject.toml`. 17 | - Updates the GitHub Workflow. 18 | - Updates the LICENSE contact. 19 | 20 | ## [0.0.2] - 2021-11-28 :sheep: 21 | 22 | - Unpins the `SQLAlchemy` version from requirements 23 | 24 | ## [0.0.1] - 2021-05-22 :four_leaf_clover: 25 | 26 | - First working implementation, with integration offering injection of 27 | db connections and ORM sessions provided by SQLAlchemy 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021, current Roberto Prevato 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: release test 2 | 3 | 4 | artifacts: test 5 | python setup.py sdist bdist_wheel 6 | 7 | 8 | clean: 9 | rm -rf dist/ 10 | 11 | 12 | prepforbuild: 13 | pip install --upgrade twine setuptools wheel 14 | 15 | 16 | uploadtest: 17 | twine upload --repository-url https://test.pypi.org/legacy/ dist/* 18 | 19 | 20 | release: clean artifacts 21 | twine upload --repository-url https://upload.pypi.org/legacy/ dist/* 22 | 23 | 24 | test: 25 | flake8 blacksheepsqlalchemy && pytest tests/ 26 | 27 | 28 | testcov: 29 | pytest --cov-report html --cov=blacksheepsqlalchemy tests/ 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build](https://github.com/Neoteroi/BlackSheep-SQLAlchemy/actions/workflows/build.yml/badge.svg)](https://github.com/Neoteroi/BlackSheep-SQLAlchemy/actions/workflows/build.yml) 2 | [![pypi](https://img.shields.io/pypi/v/BlackSheep-SQLAlchemy.svg?color=blue)](https://pypi.org/project/BlackSheep-SQLAlchemy/) 3 | [![versions](https://img.shields.io/pypi/pyversions/blacksheep-sqlalchemy.svg)](https://github.com/Neoteroi/BlackSheep-SQLAlchemy/) 4 | [![license](https://img.shields.io/github/license/Neoteroi/blacksheep-sqlalchemy.svg)](https://github.com/Neoteroi/BlackSheep-SQLAlchemy/blob/main/LICENSE) 5 | 6 | # BlackSheep-SQLAlchemy 7 | Extension for [BlackSheep](https://github.com/Neoteroi/BlackSheep) that 8 | simplifies the use of SQLAlchemy in the web framework. 9 | 10 | ```bash 11 | pip install blacksheep-sqlalchemy 12 | ``` 13 | 14 | **Important:** this library only supports `rodi` dependency injection 15 | container. However, the implementation can be used for reference to configure 16 | other DI containers to work with SQLAlchemy. 17 | 18 | ## How to use 19 | 20 | ```python 21 | from blacksheep.server import Application 22 | from blacksheepsqlalchemy import use_sqlalchemy 23 | 24 | app = Application() 25 | 26 | use_sqlalchemy(app, connection_string="") 27 | 28 | ``` 29 | 30 | After registering SQLAlchemy, services are configured in the application, so 31 | they are automatically resolved in any request handler requiring a SQLAlchemy 32 | db connection or db session; for example: 33 | 34 | ```python 35 | 36 | @get("/api/countries") 37 | async def get_countries(db_connection) -> List[CountryData]: 38 | """ 39 | Fetches the countries using a database connection. 40 | """ 41 | result = [] 42 | async with db_connection: 43 | items = await db_connection.execute(text("SELECT * FROM country")) 44 | for item in items.fetchall(): 45 | result.append(CountryData(item["id"], item["name"])) 46 | return result 47 | 48 | ``` 49 | 50 | Services can be injected at any level of the resolution graph, so `BlackSheep` 51 | and `rodi` support out of the box the scenario of db connections or db sessions 52 | referenced in the business logic but not directly by the front-end layer 53 | (depending on programmers' preference and their notion of best practices when 54 | building web apps). 55 | 56 | Services can be injected in the following ways: 57 | 58 | | By alias | By type annotation | Value | 59 | | ------------- | ------------------ | --------------------------------------------------- | 60 | | db_connection | AsyncConnection | instance of AsyncConnection (scoped to web request) | 61 | | db_session | AsyncSession | instance of AsyncSession (scoped to web request) | 62 | | db_engine | AsyncEngine | instance of AsyncEngine (singleton) | 63 | 64 | --- 65 | 66 | For example, using SQLite: 67 | 68 | * requires driver: `pip install aiosqlite` 69 | * connection string: `sqlite+aiosqlite:///example.db` 70 | 71 | See the `tests` folder for a [working example](https://github.com/Neoteroi/BlackSheep-SQLAlchemy/blob/main/tests/app.py) 72 | using database migrations applied with `Alembic`, and a documented API that offers methods to fetch, create, 73 | delete countries objects. 74 | 75 | --- 76 | 77 | ### Note 78 | BlackSheep is designed to be used in `async` way, therefore this library 79 | requires the use of an asynchronous driver. 80 | 81 | ## References 82 | 83 | * [SQLAlchemy - support for asyncio](https://docs.sqlalchemy.org/en/14/orm/extensions/asyncio.html) 84 | 85 | ## Documentation 86 | Please refer to the [documentation website](https://www.neoteroi.dev/blacksheep/). 87 | -------------------------------------------------------------------------------- /blacksheepsqlalchemy/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from blacksheep.server import Application 4 | from rodi import Container 5 | from sqlalchemy.ext.asyncio import ( 6 | AsyncConnection, 7 | AsyncEngine, 8 | AsyncSession, 9 | create_async_engine, 10 | ) 11 | 12 | 13 | def __configure_services( 14 | app: Application, 15 | engine: AsyncEngine, 16 | db_engine_alias: str, 17 | db_connection_alias: str, 18 | db_session_alias: str, 19 | ) -> None: 20 | # Note: pytest-cov generates false negatives for the following three functions 21 | # defined locally; they work and this is verified by tests 22 | 23 | async def dispose_engine(_): 24 | nonlocal engine 25 | await engine.dispose() 26 | 27 | app.on_stop += dispose_engine 28 | 29 | def connection_factory() -> AsyncConnection: 30 | return engine.connect() 31 | 32 | def session_factory() -> AsyncSession: 33 | return AsyncSession(engine, expire_on_commit=False) 34 | 35 | assert isinstance( 36 | app.services, Container 37 | ), "blacksheep-sqlalchemy only supports rodi for dependency injection" 38 | app.services.add_instance(engine) 39 | app.services.add_alias(db_engine_alias, AsyncEngine) 40 | 41 | app.services.add_scoped_by_factory(connection_factory) 42 | app.services.add_alias(db_connection_alias, AsyncConnection) 43 | 44 | app.services.add_scoped_by_factory(session_factory) 45 | app.services.add_alias(db_session_alias, AsyncSession) 46 | 47 | 48 | def use_sqlalchemy( 49 | app: Application, 50 | *, 51 | connection_string: Optional[str] = None, 52 | echo: bool = False, 53 | engine: Optional[AsyncEngine] = None, 54 | db_engine_alias: str = "db_engine", 55 | db_connection_alias: str = "db_connection", 56 | db_session_alias: str = "db_session", 57 | ) -> None: 58 | """ 59 | Configures the given application to use SQLAlchemy and provide services that can be 60 | injected in request handlers. 61 | """ 62 | if engine is None: 63 | if not connection_string: 64 | raise TypeError( 65 | "Either pass a connection_string or an instance of " 66 | "sqlalchemy.ext.asyncio.AsyncEngine" 67 | ) 68 | engine = create_async_engine(connection_string, echo=echo) 69 | 70 | assert engine is not None 71 | __configure_services( 72 | app, engine, db_engine_alias, db_connection_alias, db_session_alias 73 | ) 74 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | blacksheep 2 | SQLAlchemy 3 | pytest 4 | pytest-asyncio 5 | pytest-cov 6 | flake8 7 | black 8 | isort 9 | uvicorn 10 | aiosqlite 11 | alembic 12 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "blacksheep-sqlalchemy" 7 | version = "0.0.4" 8 | authors = [{ name = "Roberto Prevato", email = "roberto.prevato@gmail.com" }] 9 | description = "Extension for BlackSheep to use SQLAlchemy." 10 | license = { file = "LICENSE" } 11 | readme = "README.md" 12 | requires-python = ">=3.7" 13 | classifiers = [ 14 | "Development Status :: 5 - Production/Stable", 15 | "Programming Language :: Python :: 3", 16 | "Programming Language :: Python :: 3.8", 17 | "Programming Language :: Python :: 3.9", 18 | "Programming Language :: Python :: 3.10", 19 | "Programming Language :: Python :: 3.11", 20 | "Programming Language :: Python :: 3.12", 21 | "Programming Language :: Python :: 3.13", 22 | "Operating System :: OS Independent", 23 | ] 24 | keywords = ["blacksheep", "sqlalchemy", "orm", "database"] 25 | 26 | dependencies = ["blacksheep", "SQLAlchemy~=2.0.24"] 27 | 28 | [tool.hatch.build.targets.wheel] 29 | packages = ["blacksheepsqlalchemy"] 30 | 31 | [tool.hatch.build.targets.sdist] 32 | exclude = ["tests"] 33 | 34 | [tool.hatch.build] 35 | only-packages = true 36 | 37 | [project.urls] 38 | "Homepage" = "https://github.com/Neoteroi/BlackSheep-SQLAlchemy" 39 | "Bug Tracker" = "https://github.com/Neoteroi/BlackSheep-SQLAlchemy/issues" 40 | 41 | [tool.pytest.ini_options] 42 | asyncio_mode = "auto" 43 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | ## How to run tests 2 | 3 | This folder includes an example model, including: 4 | 5 | * Topic 6 | * Country 7 | * Topic-Country for a many-to-many relationship 8 | 9 | Dev dependencies are required to run the tests. Running the following command 10 | with `alembic`: 11 | 12 | ```bash 13 | alembic upgrade head 14 | ``` 15 | 16 | Will cause the creation of an `example.db` SQLite database (as per `alembic.ini` 17 | configuration, containing the structure defined in the `structure` migration). 18 | 19 | The structure migration has been generated automatically from the model defined 20 | in `env.py`, using the following command: 21 | 22 | ```bash 23 | alembic revision -m "structure" --autogenerate 24 | ``` 25 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Neoteroi/BlackSheep-SQLAlchemy/0e3a070d462d00c803f7ebe14b555f39e7e0d8e6/tests/__init__.py -------------------------------------------------------------------------------- /tests/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = migrations 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # sys.path path, will be prepended to sys.path if present. 11 | # defaults to the current working directory. 12 | prepend_sys_path = . 13 | 14 | # timezone to use when rendering the date 15 | # within the migration file as well as the filename. 16 | # string value is passed to dateutil.tz.gettz() 17 | # leave blank for localtime 18 | # timezone = 19 | 20 | # max length of characters to apply to the 21 | # "slug" field 22 | # truncate_slug_length = 40 23 | 24 | # set to 'true' to run the environment during 25 | # the 'revision' command, regardless of autogenerate 26 | # revision_environment = false 27 | 28 | # set to 'true' to allow .pyc and .pyo files without 29 | # a source .py file to be detected as revisions in the 30 | # versions/ directory 31 | # sourceless = false 32 | 33 | # version location specification; this defaults 34 | # to migrations/versions. When using multiple version 35 | # directories, initial revisions must be specified with --version-path 36 | # version_locations = %(here)s/bar %(here)s/bat migrations/versions 37 | 38 | # the output encoding used when revision files 39 | # are written from script.py.mako 40 | # output_encoding = utf-8 41 | 42 | sqlalchemy.url = sqlite:///example.db 43 | 44 | 45 | [post_write_hooks] 46 | # post_write_hooks defines scripts or Python functions that are run 47 | # on newly generated revision scripts. See the documentation for further 48 | # detail and examples 49 | 50 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 51 | hooks = black 52 | black.type = console_scripts 53 | black.entrypoint = black 54 | black.options = -l 79 REVISION_SCRIPT_FILENAME 55 | 56 | # Logging configuration 57 | [loggers] 58 | keys = root,sqlalchemy,alembic 59 | 60 | [handlers] 61 | keys = console 62 | 63 | [formatters] 64 | keys = generic 65 | 66 | [logger_root] 67 | level = WARN 68 | handlers = console 69 | qualname = 70 | 71 | [logger_sqlalchemy] 72 | level = WARN 73 | handlers = 74 | qualname = sqlalchemy.engine 75 | 76 | [logger_alembic] 77 | level = INFO 78 | handlers = 79 | qualname = alembic 80 | 81 | [handler_console] 82 | class = StreamHandler 83 | args = (sys.stderr,) 84 | level = NOTSET 85 | formatter = generic 86 | 87 | [formatter_generic] 88 | format = %(levelname)-5.5s [%(name)s] %(message)s 89 | datefmt = %H:%M:%S 90 | -------------------------------------------------------------------------------- /tests/app.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List 3 | 4 | import uvicorn 5 | from blacksheep.messages import Response 6 | from blacksheep.server import Application 7 | from blacksheep.server.openapi.v3 import OpenAPIHandler 8 | from openapidocs.v3 import Info 9 | from sqlalchemy import delete as sql_delete 10 | from sqlalchemy import text 11 | from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession 12 | from sqlalchemy.sql.expression import select 13 | 14 | from blacksheepsqlalchemy import use_sqlalchemy 15 | from tests.domain import Country 16 | 17 | app = Application(show_error_details=True) 18 | 19 | docs = OpenAPIHandler(info=Info(title="Example API", version="0.0.1")) 20 | docs.bind_app(app) 21 | 22 | use_sqlalchemy(app, connection_string="sqlite+aiosqlite:///example.db", echo=True) 23 | 24 | get = app.router.get 25 | post = app.router.post 26 | delete = app.router.delete 27 | 28 | 29 | @dataclass 30 | class CreateCountryInput: 31 | id: str 32 | name: str 33 | 34 | 35 | @dataclass 36 | class CountryData: 37 | id: str 38 | name: str 39 | 40 | 41 | @docs(tags=["db-connection"]) 42 | @post("/api/connection/countries") 43 | async def create_country_1( 44 | db_connection: AsyncConnection, data: CreateCountryInput 45 | ) -> Response: 46 | """ 47 | Inserts a country using a database connection. 48 | """ 49 | async with db_connection: 50 | await db_connection.execute( 51 | text("INSERT INTO country (id, name) VALUES (:id, :name)"), 52 | [{"id": data.id, "name": data.name}], 53 | ) 54 | await db_connection.commit() 55 | return Response(201) 56 | 57 | 58 | @docs(tags=["db-connection"]) 59 | @delete("/api/connection/countries/{country_id}") 60 | async def delete_country_1(db_connection: AsyncConnection, country_id: str) -> Response: 61 | """ 62 | Deletes a country by id using a database connection. 63 | """ 64 | async with db_connection: 65 | await db_connection.execute( 66 | text("DELETE FROM country WHERE id = :id"), 67 | [{"id": country_id}], 68 | ) 69 | await db_connection.commit() 70 | return Response(204) 71 | 72 | 73 | @docs(tags=["db-connection"]) 74 | @get("/api/connection/countries") 75 | async def get_countries_1(db_connection: AsyncConnection) -> List[CountryData]: 76 | """ 77 | Fetches the countries using a database connection. 78 | """ 79 | result = [] 80 | async with db_connection: 81 | items = await db_connection.execute(text("SELECT id, name FROM country")) 82 | for item in items.fetchall(): 83 | result.append(CountryData(item[0], item[1])) 84 | return result 85 | 86 | 87 | @docs(tags=["ORM"]) 88 | @get("/api/orm/countries") 89 | async def get_countries_2(db_session: AsyncSession) -> List[CountryData]: 90 | """ 91 | Fetches the countries using the ORM pattern. 92 | """ 93 | result = [] 94 | async with db_session: 95 | items = await db_session.execute(select(Country).order_by(Country.name)) 96 | for item in items.fetchall(): 97 | for country in item: 98 | result.append(CountryData(country.id, country.name)) 99 | return result 100 | 101 | 102 | @docs(tags=["ORM"]) 103 | @post("/api/orm/countries") 104 | async def create_country_2( 105 | db_session: AsyncSession, data: CreateCountryInput 106 | ) -> Response: 107 | """ 108 | Inserts a country using the ORM pattern. 109 | """ 110 | async with db_session: 111 | db_session.add(Country(id=data.id, name=data.name)) 112 | await db_session.commit() 113 | return Response(201) 114 | 115 | 116 | @docs(tags=["ORM"]) 117 | @delete("/api/orm/countries/{country_id}") 118 | async def delete_country_2(db_session: AsyncSession, country_id: str) -> Response: 119 | """ 120 | Deletes a country using the ORM pattern. 121 | """ 122 | async with db_session: 123 | await db_session.execute(sql_delete(Country).where(Country.id == country_id)) 124 | await db_session.commit() 125 | return Response(204) 126 | 127 | 128 | if __name__ == "__main__": 129 | uvicorn.run(app, host="127.0.0.1", port=44566, log_level="debug") 130 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | os.environ["APP_DEFAULT_ROUTER"] = "0" 4 | -------------------------------------------------------------------------------- /tests/domain.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module defines an example model that can be used with SQLAlchemy to initialize 3 | a database. 4 | """ 5 | from datetime import datetime 6 | 7 | from sqlalchemy import Column, DateTime, ForeignKey, String 8 | from sqlalchemy.orm import registry, relationship 9 | from sqlalchemy.sql.schema import Index, Table 10 | from sqlalchemy.sql.sqltypes import Integer 11 | 12 | mapper_registry = registry() 13 | metadata = mapper_registry.metadata 14 | 15 | Base = mapper_registry.generate_base() 16 | 17 | 18 | # region mixins 19 | 20 | 21 | class ETagMixin: 22 | created_at = Column(DateTime, default=datetime.utcnow, nullable=False) 23 | updated_at = Column(DateTime, default=datetime.utcnow, nullable=False) 24 | etag = Column(String(50), nullable=False) 25 | 26 | 27 | ETagMixin.created_at._creation_order = 9000 28 | ETagMixin.updated_at._creation_order = 9001 29 | ETagMixin.etag._creation_order = 9002 30 | 31 | 32 | # endregion 33 | 34 | 35 | country_topic_table = Table( 36 | "topic_country", 37 | Base.metadata, 38 | Column("country_id", String(2), ForeignKey("country.id"), primary_key=True), 39 | Column("topic_id", Integer, ForeignKey("topic.id"), primary_key=True), 40 | ) 41 | 42 | 43 | class Country(Base): 44 | __tablename__ = "country" 45 | id = Column(String(2), primary_key=True) 46 | name = Column(String(100)) 47 | 48 | topics = relationship( 49 | "Topic", secondary=country_topic_table, back_populates="countries" 50 | ) 51 | 52 | def __repr__(self): 53 | return f"Country(id={self.id!r}, name={self.name!r})" 54 | 55 | 56 | class Topic(ETagMixin, Base): 57 | __tablename__ = "topic" 58 | id = Column(Integer, primary_key=True, autoincrement=True) 59 | name = Column(String(100)) 60 | description = Column(String) 61 | 62 | countries = relationship( 63 | "Country", secondary=country_topic_table, back_populates="topics" 64 | ) 65 | 66 | def __repr__(self): 67 | return f"Topic(id={self.id!r}, name={self.name!r})" 68 | 69 | 70 | Index("ix_topic_country_topic_id", "topic_country.topic_id") 71 | Index("ix_topic_country_country_id", "topic_country.country_id") 72 | -------------------------------------------------------------------------------- /tests/fixtures.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from multiprocessing import Process 3 | from time import sleep 4 | 5 | import pytest 6 | import uvicorn 7 | from blacksheep.client import ClientSession 8 | from blacksheep.client.pool import ConnectionPools 9 | 10 | from tests.utils import get_sleep_time 11 | 12 | from .app import app 13 | 14 | 15 | @pytest.fixture() 16 | async def client_session(server_host, server_port): 17 | # It is important to pass the instance of ConnectionPools, 18 | # to ensure that the connections are reused and closed 19 | event_loop = asyncio.get_running_loop() 20 | session = ClientSession( 21 | loop=event_loop, 22 | base_url=f"http://{server_host}:{server_port}", 23 | pools=ConnectionPools(event_loop), 24 | ) 25 | yield session 26 | await session.close() 27 | 28 | 29 | @pytest.fixture(scope="module") 30 | def server_host(): 31 | return "127.0.0.1" 32 | 33 | 34 | @pytest.fixture(scope="module") 35 | def server_port(): 36 | return 44555 37 | 38 | 39 | @pytest.fixture(scope="module") 40 | def connection_string(): 41 | return "sqlite:///example.db" 42 | 43 | 44 | def start_server(): 45 | uvicorn.run(app, host="127.0.0.1", port=44555, log_level="debug") 46 | 47 | 48 | @pytest.fixture(scope="module", autouse=True) 49 | def server(server_host, server_port): 50 | server_process = Process(target=start_server) 51 | server_process.start() 52 | sleep(get_sleep_time()) 53 | 54 | yield 1 55 | 56 | sleep(1.2) 57 | server_process.terminate() 58 | -------------------------------------------------------------------------------- /tests/migrations/env.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from logging.config import fileConfig 3 | 4 | from alembic import context 5 | from sqlalchemy import engine_from_config, pool 6 | 7 | # hack to keep example code inside the tests folder without polluting the root folder 8 | # of the repository 9 | sys.path.append("../") 10 | 11 | from domain import metadata 12 | 13 | # this is the Alembic Config object, which provides 14 | # access to the values within the .ini file in use. 15 | config = context.config 16 | 17 | # Interpret the config file for Python logging. 18 | # This line sets up loggers basically. 19 | fileConfig(config.config_file_name) 20 | 21 | # add your model's MetaData object here 22 | # for 'autogenerate' support 23 | # from myapp import mymodel 24 | # target_metadata = mymodel.Base.metadata 25 | target_metadata = metadata 26 | 27 | # other values from the config, defined by the needs of env.py, 28 | # can be acquired: 29 | # my_important_option = config.get_main_option("my_important_option") 30 | # ... etc. 31 | 32 | 33 | def run_migrations_offline(): 34 | """Run migrations in 'offline' mode. 35 | 36 | This configures the context with just a URL 37 | and not an Engine, though an Engine is acceptable 38 | here as well. By skipping the Engine creation 39 | we don't even need a DBAPI to be available. 40 | 41 | Calls to context.execute() here emit the given string to the 42 | script output. 43 | 44 | """ 45 | url = config.get_main_option("sqlalchemy.url") 46 | context.configure( 47 | url=url, 48 | target_metadata=target_metadata, 49 | literal_binds=True, 50 | dialect_opts={"paramstyle": "named"}, 51 | ) 52 | 53 | with context.begin_transaction(): 54 | context.run_migrations() 55 | 56 | 57 | def run_migrations_online(): 58 | """Run migrations in 'online' mode. 59 | 60 | In this scenario we need to create an Engine 61 | and associate a connection with the context. 62 | 63 | """ 64 | connectable = engine_from_config( 65 | config.get_section(config.config_ini_section), 66 | prefix="sqlalchemy.", 67 | poolclass=pool.NullPool, 68 | ) 69 | 70 | with connectable.connect() as connection: 71 | context.configure(connection=connection, target_metadata=target_metadata) 72 | 73 | with context.begin_transaction(): 74 | context.run_migrations() 75 | 76 | 77 | if context.is_offline_mode(): 78 | run_migrations_offline() 79 | else: 80 | run_migrations_online() 81 | -------------------------------------------------------------------------------- /tests/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /tests/migrations/versions/ae4207a54d40_structure.py: -------------------------------------------------------------------------------- 1 | """structure 2 | 3 | Revision ID: ae4207a54d40 4 | Revises: 5 | Create Date: 2021-05-22 09:54:27.486818 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "ae4207a54d40" 13 | down_revision = None 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.create_table( 21 | "country", 22 | sa.Column("id", sa.String(length=2), nullable=False), 23 | sa.Column("name", sa.String(length=100), nullable=True), 24 | sa.PrimaryKeyConstraint("id"), 25 | ) 26 | op.create_table( 27 | "topic", 28 | sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), 29 | sa.Column("name", sa.String(length=100), nullable=True), 30 | sa.Column("description", sa.String(), nullable=True), 31 | sa.Column("created_at", sa.DateTime(), nullable=False), 32 | sa.Column("updated_at", sa.DateTime(), nullable=False), 33 | sa.Column("etag", sa.String(length=50), nullable=False), 34 | sa.PrimaryKeyConstraint("id"), 35 | ) 36 | op.create_table( 37 | "topic_country", 38 | sa.Column("country_id", sa.String(length=2), nullable=False), 39 | sa.Column("topic_id", sa.Integer(), nullable=False), 40 | sa.ForeignKeyConstraint( 41 | ["country_id"], 42 | ["country.id"], 43 | ), 44 | sa.ForeignKeyConstraint( 45 | ["topic_id"], 46 | ["topic.id"], 47 | ), 48 | sa.PrimaryKeyConstraint("country_id", "topic_id"), 49 | ) 50 | # ### end Alembic commands ### 51 | 52 | 53 | def downgrade(): 54 | # ### commands auto generated by Alembic - please adjust! ### 55 | op.drop_table("topic_country") 56 | op.drop_table("topic") 57 | op.drop_table("country") 58 | # ### end Alembic commands ### 59 | -------------------------------------------------------------------------------- /tests/test_blacksheepsqlalchemy.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | import pytest 4 | from blacksheep.client import ClientSession 5 | from blacksheep.contents import JSONContent 6 | from blacksheep.server import Application 7 | 8 | from blacksheepsqlalchemy import use_sqlalchemy 9 | 10 | from .fixtures import * # NoQA 11 | 12 | 13 | async def insert_fetch_delete_scenario( 14 | client_session: ClientSession, 15 | option: Literal["connection", "orm"], 16 | country_code: str, 17 | country_name: str, 18 | ): 19 | await client_session.delete(f"/api/{option}/countries/{country_code}") 20 | 21 | response = await client_session.post( 22 | "/api/connection/countries", 23 | JSONContent({"id": country_code, "name": country_name}), 24 | ) 25 | 26 | assert response.status == 201 27 | 28 | response = await client_session.get(f"/api/{option}/countries") 29 | 30 | assert response.status == 200 31 | 32 | countries = await response.json() 33 | 34 | country = next((item for item in countries if item["id"] == country_code), None) 35 | assert country is not None 36 | assert country["name"] == country_name 37 | 38 | response = await client_session.delete(f"/api/{option}/countries/{country_code}") 39 | 40 | assert response.status == 204 41 | 42 | response = await client_session.get(f"/api/{option}/countries") 43 | 44 | assert response.status == 200 45 | 46 | countries = await response.json() 47 | 48 | country = next((item for item in countries if item["id"] == country_code), None) 49 | assert country is None 50 | 51 | 52 | @pytest.mark.asyncio 53 | async def test_using_connection(client_session: ClientSession): 54 | await insert_fetch_delete_scenario(client_session, "connection", "jp", "Japan") 55 | 56 | 57 | @pytest.mark.asyncio 58 | async def test_using_orm(client_session: ClientSession): 59 | await insert_fetch_delete_scenario(client_session, "orm", "kr", "South Korea") 60 | 61 | 62 | def test_throws_for_missing_engine_and_connection_string(): 63 | app = Application() 64 | with pytest.raises(TypeError): 65 | use_sqlalchemy(app, connection_string="") 66 | 67 | with pytest.raises(TypeError): 68 | use_sqlalchemy(app, connection_string=None) 69 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def get_sleep_time(): 5 | # when starting a server process, 6 | # a longer sleep time is necessary on Windows 7 | if os.name == "nt": 8 | return 1.5 9 | return 0.5 10 | --------------------------------------------------------------------------------