├── tests ├── __init__.py ├── conftest.py ├── data.sql ├── test_db.py └── test_con_rollback.py ├── .python-version ├── CHANGELOG.rst ├── .gitignore ├── Makefile ├── LICENSE ├── examples └── __init__.py ├── .github └── workflows │ └── python-package.yml ├── pyproject.toml ├── fastapi_asyncpg ├── sql.py └── __init__.py └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | pytest_plugins = [ 2 | "pytest_docker_fixtures", 3 | ] 4 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | - 1.0.1 2 | Fix testing pool incorrectly disconnected 3 | 4 | - 1.0.0 5 | Initial release 6 | -------------------------------------------------------------------------------- /tests/data.sql: -------------------------------------------------------------------------------- 1 | 2 | 3 | CREATE TABLE test ( 4 | id serial primary key, 5 | item varchar, 6 | val varchar 7 | ); 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/__pycache__ 2 | env/ 3 | venv/ 4 | .coverage 5 | .env 6 | .idea 7 | .installed.cfg 8 | .pytest_cache/ 9 | .mypy_cache/ 10 | .tox/ 11 | bin/ 12 | coverage.xml 13 | develop-eggs/ 14 | lib/ 15 | lib64 16 | parts/ 17 | pyvenv.cfg 18 | *.egg-info 19 | *.profraw 20 | *.py? 21 | *.swp 22 | .venv -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: lint mypy test 2 | 3 | lint: 4 | uv run --python 3.10 --extra dev ruff check . --fix 5 | uv run --python 3.10 --extra dev ruff format . 6 | mypy . 7 | 8 | mypy: 9 | uv run --python 3.10 --extra dev mypy fastapi_asyncpg 10 | 11 | test: 12 | uv run --python 3.9 --extra test pytest tests 13 | uv run --python 3.10 --extra test pytest tests 14 | uv run --python 3.11 --extra test pytest tests 15 | uv run --python 3.12 --extra test pytest tests 16 | uv run --python 3.13 --extra test pytest tests 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Jordi collell 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. -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from fastapi import Depends 3 | from fastapi_asyncpg import configure_asyncpg 4 | 5 | import pydantic as pd 6 | 7 | app = FastAPI() 8 | 9 | 10 | db = configure_asyncpg(app, "postgresql://postgres:postgres@localhost/db") 11 | 12 | 13 | class Demo(pd.BaseModel): 14 | key: str 15 | value: str 16 | 17 | 18 | class DemoObj(Demo): 19 | demo_id: int 20 | 21 | 22 | @db.on_init 23 | async def initialize_db(db): 24 | await db.execute( 25 | """ 26 | CREATE TABLE IF NOT EXISTS demo ( 27 | demo_id serial primary key, 28 | key varchar not null, 29 | value varchar not null, 30 | UNIQUE(key) 31 | ); 32 | """ 33 | ) 34 | 35 | 36 | @app.post("/", response_model=DemoObj) 37 | async def add_resource(data: Demo, db=Depends(db.connection)): 38 | """ 39 | Add a resource to db: 40 | curl -X POST -d '{"key": "test", "value": "asdf"}' \ 41 | http://localhost:8000/ 42 | """ 43 | result = await db.fetchrow( 44 | """ 45 | INSERT into demo values (default, $1, $2) returning * 46 | """, 47 | data.key, 48 | data.value, 49 | ) 50 | return dict(result) 51 | 52 | 53 | @app.get("/{key:str}", response_model=DemoObj) 54 | async def get_resouce(key: str, db=Depends(db.connection)): 55 | result = await db.fetchrow( 56 | """ 57 | SELECT * from demo where key=$1 58 | """, 59 | key, 60 | ) 61 | return dict(result) 62 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python package 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | pre-checks: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | python-version: ['3.13'] 18 | 19 | steps: 20 | - name: Checkout the repository 21 | uses: actions/checkout@v5 22 | 23 | - name: Setup Python 24 | uses: astral-sh/ruff-action@v3 25 | 26 | - name: Install package 27 | run: | 28 | pip install mypy 29 | - name: Run pre-checks 30 | run: | 31 | mypy fastapi_asyncpg/ 32 | ruff check . 33 | ruff format --check . 34 | 35 | # Job to run tests 36 | tests: 37 | runs-on: ubuntu-latest 38 | strategy: 39 | matrix: 40 | python-version: ['3.9','3.10', '3.11', '3.12', '3.13'] 41 | # Set environment variables 42 | steps: 43 | - name: Checkout the repository 44 | uses: actions/checkout@v5 45 | 46 | - name: Setup Python 47 | uses: actions/setup-python@v6 48 | with: 49 | python-version: ${{ matrix.python-version }} 50 | 51 | - name: Install the package 52 | run: | 53 | pip install -e .[test] 54 | 55 | - name: Run tests 56 | run: | 57 | pytest -vs tests/ 58 | 59 | - name: Upload coverage to Codecov 60 | uses: codecov/codecov-action@v5 61 | with: 62 | files: ./coverage.xml 63 | 64 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=45", "setuptools-scm[toml]>=6.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "fastapi_asyncpg" 7 | version = "1.0.1" 8 | description = "FastAPI integration for asyncpg" 9 | readme = "README.md" 10 | requires-python = ">=3.9,<3.14" 11 | license = {text = "MIT"} 12 | authors = [ 13 | {name = "Jordi collell", email = "jordic@gmail.com"} 14 | ] 15 | classifiers = [ 16 | "License :: OSI Approved :: MIT License", 17 | "Programming Language :: Python", 18 | "Programming Language :: Python :: 3", 19 | "Programming Language :: Python :: 3.9", 20 | ] 21 | dependencies = [ 22 | "fastapi", 23 | "asyncpg", 24 | ] 25 | 26 | [project.optional-dependencies] 27 | dev = [ 28 | "ruff", 29 | "mypy" 30 | ] 31 | docs = [ 32 | "sphinx", 33 | "recommonmark", 34 | ] 35 | test = [ 36 | "pytest-docker-fixtures[pg]", 37 | "pytest", 38 | "async_asgi_testclient", 39 | "pytest-asyncio", 40 | ] 41 | publish = [ 42 | "twine", 43 | ] 44 | 45 | [project.urls] 46 | Homepage = "https://github.com/jordic/fastapi_asyncpg" 47 | 48 | [tool.setuptools] 49 | packages = {find = {exclude = ["tests"]}} 50 | 51 | [tool.setuptools.package-data] 52 | fastapi = ["py.typed"] 53 | 54 | [tool.ruff] 55 | line-length = 120 56 | exclude = [".git", "__pycache__", "docs/source/conf.py", "old", "build", "dist"] 57 | 58 | [tool.ruff.lint] 59 | ignore = ["E302", "W391", "E701", "F901", "E252", "E203"] 60 | 61 | [tool.ruff.format] 62 | line-ending = "auto" 63 | 64 | [tool.mypy] 65 | namespace_packages = true 66 | ignore_missing_imports = true 67 | follow_imports = "skip" 68 | 69 | [tool.pytest.ini_options] 70 | asyncio_mode = "auto" 71 | asyncio_default_fixture_loop_scope = "function" 72 | -------------------------------------------------------------------------------- /fastapi_asyncpg/sql.py: -------------------------------------------------------------------------------- 1 | """ 2 | helper function to scope sql to postgresql schema 3 | """ 4 | 5 | 6 | async def get(conn, table, condition="1 = 1", args=None, fields="*"): 7 | args = args or [] 8 | sql = f"select {fields} from {table} where {condition}" 9 | return await conn.fetchrow(sql, *args) 10 | 11 | 12 | async def select(conn, table, condition="1 = 1", args=None, fields="*"): 13 | args = args or [] 14 | sql = f"select {fields} from {table} where {condition}" 15 | return await conn.fetch(sql, *args) 16 | 17 | 18 | async def count(conn, table, where="1=1", args=None): 19 | args = args or [] 20 | return await conn.fetchval(f"select count(*) from {table} WHERE {where}", *args) 21 | 22 | 23 | async def insert(conn, table, values): 24 | qs = "insert into {table} ({columns}) values ({values}) returning *".format( 25 | table=table, 26 | values=",".join([f"${p + 1}" for p in range(len(values.values()))]), 27 | columns=",".join(list(values.keys())), 28 | ) 29 | return await conn.fetchrow(qs, *list(values.values())) 30 | 31 | 32 | async def update(conn, table, conditions: dict, values: dict): 33 | qs = "update {table} set {columns} where {cond} returning *" 34 | counter = 1 35 | params = [] 36 | cond = [] 37 | vals = [] 38 | for column, value in conditions.items(): 39 | cond.append(f"{column}=${counter}") 40 | params.append(value) 41 | counter += 1 42 | for column, value in values.items(): 43 | vals.append(f"{column}=${counter}") 44 | params.append(value) 45 | counter += 1 46 | sql = qs.format(table=table, columns=" ,".join(vals), cond=" AND ".join(cond)) 47 | return await conn.fetchrow(sql, *params) 48 | 49 | 50 | async def delete(db, table, condition, args=None): 51 | args = args or [] 52 | await db.execute(f"DELETE FROM {table} WHERE {condition}", *args) 53 | 54 | 55 | def query_to_json(query, name): 56 | """This query is useful to fetch a complex join 57 | with some aggregations as a single blob, and later, 58 | just hydrate it without having to iterate over the resultset 59 | 60 | .. Example: 61 | SELECT 62 | u.id::varchar, 63 | to_jsonb(array_agg(scopes)) as scopes, 64 | FROM auth.auth_user u 65 | LEFT join LATERAL ( 66 | SELECT id, scope 67 | FROM auth.auth_user_scope 68 | WHERE user_id=u.id 69 | ) scopes on true 70 | WHERE user_id = ANY($1) 71 | GROUP BY u.user_id; 72 | 73 | This query will fetch a list of users, and aggregate it's 74 | scopes as an array of dicts 75 | """ 76 | 77 | return f""" 78 | select 79 | array_to_json(array_agg(row_to_json(t))) as {name} 80 | from ( 81 | {query} 82 | ) as t 83 | """ 84 | 85 | 86 | async def load_sqlfile(db, file): 87 | fs = file.open("r") 88 | data = fs.read() 89 | fs.close() 90 | await db.execute(data) 91 | -------------------------------------------------------------------------------- /tests/test_db.py: -------------------------------------------------------------------------------- 1 | from async_asgi_testclient import TestClient 2 | from fastapi import Depends 3 | from fastapi import FastAPI 4 | from fastapi.exceptions import HTTPException 5 | from fastapi_asyncpg import configure_asyncpg 6 | from fastapi_asyncpg import sql 7 | from pytest_docker_fixtures import images 8 | from typing import Optional 9 | 10 | import pydantic as pd 11 | import pytest 12 | import asyncio 13 | 14 | images.configure("postgresql", "postgres", "11.1", env={"POSTGRES_DB": "test_db"}) 15 | 16 | pytestmark = pytest.mark.asyncio 17 | 18 | 19 | class KeyVal(pd.BaseModel): 20 | key: str 21 | value: str 22 | 23 | 24 | SCHEMA = """ 25 | DROP TABLE IF EXISTS keyval; 26 | CREATE TABLE keyval ( 27 | key varchar, 28 | value varchar, 29 | UNIQUE(key) 30 | ); 31 | """ 32 | 33 | 34 | @pytest.fixture(scope="function") 35 | async def asgiapp(pg): 36 | host, port = pg 37 | url = f"postgresql://postgres@{host}:{port}/test_db" 38 | app = FastAPI() 39 | bdd = configure_asyncpg(app, url, min_size=1, max_size=2) 40 | 41 | @bdd.on_init 42 | async def on_init(conn): 43 | await conn.execute(SCHEMA) 44 | 45 | @app.post("/", response_model=KeyVal) 46 | async def add_resource(data: KeyVal, db=Depends(bdd.connection)): 47 | result = await db.fetchrow( 48 | """ 49 | INSERT into keyval values ($1, $2) returning * 50 | """, 51 | data.key, 52 | data.value, 53 | ) 54 | return dict(result) 55 | 56 | @app.get("/transaction") 57 | async def with_transaction(q: Optional[int] = 0, db=Depends(bdd.transaction)): 58 | for i in range(10): 59 | await db.execute("INSERT INTO keyval values ($1, $2)", f"t{i}", f"t{i}") 60 | if q == 1: 61 | raise HTTPException(412) 62 | return dict(result="ok") 63 | 64 | @app.get("/{key:str}", response_model=KeyVal) 65 | async def get_resouce(key: str, db=Depends(bdd.connection)): 66 | result = await db.fetchrow( 67 | """ 68 | SELECT * from keyval where key=$1 69 | """, 70 | key, 71 | ) 72 | if result: 73 | return dict(result) 74 | 75 | yield app, bdd 76 | 77 | 78 | async def test_dependency(asgiapp): 79 | app, db = asgiapp 80 | async with TestClient(app) as client: 81 | res = await client.post("/", json={"key": "test", "value": "val1"}) 82 | assert res.status_code == 200 83 | res = await client.get("/test") 84 | assert res.status_code == 200 85 | assert res.json()["key"] == "test" 86 | assert res.json()["value"] == "val1" 87 | 88 | 89 | async def test_transaction(asgiapp): 90 | app, _ = asgiapp 91 | async with TestClient(app) as client: 92 | res = await client.get("/transaction") 93 | assert res.status_code == 200 94 | async with app.state.pool.acquire() as db: 95 | await sql.count(db, "keyval") == 10 96 | 97 | 98 | async def test_transaction_fails(asgiapp): 99 | app, _ = asgiapp 100 | async with TestClient(app) as client: 101 | res = await client.get("/transaction?q=1") 102 | assert res.status_code == 412 103 | async with app.state.pool.acquire() as db: 104 | await sql.count(db, "keyval") == 0 105 | 106 | 107 | async def test_pool_releases_connections(asgiapp): 108 | app, db = asgiapp 109 | async with TestClient(app) as client: 110 | res = await client.post("/", json={"key": "test", "value": "val1"}) 111 | assert res.status_code == 200 112 | tasks = [] 113 | for i in range(20): 114 | tasks.append(client.get("/test")) 115 | 116 | await asyncio.gather(*tasks) 117 | async with app.state.pool.acquire() as db: 118 | result = await db.fetchval("SELECT sum(numbackends) FROM pg_stat_database;") 119 | assert result == 2 120 | -------------------------------------------------------------------------------- /tests/test_con_rollback.py: -------------------------------------------------------------------------------- 1 | from async_asgi_testclient import TestClient 2 | from fastapi import Depends 3 | from fastapi import FastAPI 4 | from fastapi_asyncpg import configure_asyncpg 5 | from fastapi_asyncpg import create_pool_test 6 | from fastapi_asyncpg import sql 7 | from pathlib import Path 8 | from pytest_docker_fixtures import images 9 | 10 | import json 11 | import pydantic as pd 12 | import pytest 13 | import typing 14 | 15 | pytestmark = pytest.mark.asyncio 16 | 17 | dir = Path(__file__).parent 18 | 19 | images.configure("postgresql", "postgres", "11.1", env={"POSTGRES_DB": "test_db"}) 20 | 21 | 22 | @pytest.fixture 23 | async def pool(pg): 24 | host, port = pg 25 | url = f"postgresql://postgres@{host}:{port}/test_db" 26 | 27 | async def initialize(conn): 28 | await sql.load_sqlfile(conn, dir / "data.sql") 29 | 30 | pool = await create_pool_test(url, initialize=initialize) 31 | yield pool 32 | if pool._conn.is_closed(): 33 | return 34 | await pool.release() 35 | 36 | 37 | async def test_testing_pool_works(pool): 38 | async with pool.acquire() as db: 39 | await sql.insert(db, "test", {"item": "test", "val": "value"}) 40 | assert await sql.count(db, "test") == 1 41 | 42 | 43 | async def test_the_db_is_empty_again(pool): 44 | async with pool.acquire() as db: 45 | assert await sql.count(db, "test") == 0 46 | 47 | 48 | async def test_sql(pool): 49 | """sql.py contains poor man sql helpers to work with sql and asyncpg""" 50 | async with pool.acquire() as db: 51 | res = await sql.insert(db, "test", {"item": "test", "val": "value"}) 52 | result = await sql.get(db, "test", "id=$1", args=[res["id"]]) 53 | assert dict(res) == dict(result) 54 | elements = await sql.select(db, "test") 55 | assert len(elements) == 1 56 | assert dict(elements[0]) == dict(result) 57 | elements = await sql.select(db, "test", condition="id=$1", args=[1]) 58 | assert dict(elements[0]) == dict(result) 59 | updated = await sql.update(db, "test", {"id": 1}, {"val": "value2"}) 60 | assert dict(updated) != dict(result) 61 | assert updated["val"] == "value2" 62 | 63 | res = await db.fetchrow(sql.query_to_json("SELECT * from test", "row")) 64 | data = json.loads(res["row"]) 65 | assert data[0] == dict(updated) 66 | await sql.delete(db, "test", "id=$1", args=[1]) 67 | assert await sql.count(db, "test") == 0 68 | 69 | 70 | async def test_app_with_fixture(pool): 71 | """ 72 | Things are more interesting when you want to test some 73 | data, and you want to setup the db state 74 | """ 75 | async with pool.acquire() as db: 76 | # we setup the db at a desired state 77 | await sql.insert(db, "test", {"item": "test", "val": "value"}) 78 | 79 | app = FastAPI() 80 | bdd = configure_asyncpg(app, "", pool=pool) 81 | 82 | @app.get("/") 83 | async def get(conn=Depends(bdd.connection)): 84 | res = await conn.fetch("SELECT * from test") 85 | return [dict(r) for r in res] 86 | 87 | async with TestClient(app) as client: 88 | res = await client.get("/") 89 | assert res.status_code == 200 90 | data = res.json() 91 | assert data[0]["item"] == "test" 92 | assert data[0]["val"] == "value" 93 | 94 | 95 | # we can go a bit crazy with pydantic 96 | # and simulate an orm wiht it 97 | # this could be a mixin, that you add to your schemas 98 | # also you need some __property__ with your primary key 99 | # and check it 100 | class Schema(pd.BaseModel): 101 | __tablename__ = "test" 102 | 103 | item: str 104 | val: str 105 | id: typing.Optional[int] = None 106 | 107 | async def save(self, db): 108 | if self.id is None: 109 | result = await sql.insert(db, self.__tablename__, self.dict(exclude_unset=True)) 110 | self.id = result["id"] 111 | else: 112 | result = await sql.update_by( 113 | db, 114 | self.__tablename__, 115 | {"id": self.id}, 116 | self.dict(exclude=["id"]), 117 | ) 118 | for key, val in dict(result): 119 | setattr(self, key, val) 120 | 121 | 122 | async def test_experimental(pool): 123 | item = Schema(item="asdf", val="xxxx") 124 | async with pool.acquire() as db: 125 | await item.save(db) 126 | assert await sql.count(db, "test") == 1 127 | -------------------------------------------------------------------------------- /fastapi_asyncpg/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from fastapi import FastAPI 4 | 5 | import asyncpg 6 | import typing 7 | 8 | 9 | async def noop(db: asyncpg.Connection): 10 | return 11 | 12 | 13 | class configure_asyncpg: 14 | def __init__( 15 | self, 16 | app: FastAPI, 17 | dsn: str, 18 | *, 19 | init_db: typing.Optional[typing.Callable] = None, # callable for running sql on init 20 | pool=None, # usable on testing 21 | **options, 22 | ): 23 | """This is the entry point to configure an asyncpg pool with fastapi. 24 | 25 | Arguments 26 | app: The fastapp application that we use to store the pool 27 | and bind to it's initialitzation events 28 | dsn: A postgresql desn like postgresql://user:password@postgresql:5432/db 29 | init_db: Optional callable that receives a db connection, 30 | for doing an initialitzation of it 31 | pool: This is used for testing to skip the pool initialitzation 32 | an just use the SingleConnectionTestingPool 33 | **options: connection options to directly pass to asyncpg driver 34 | see: https://magicstack.github.io/asyncpg/current/api/index.html#connection-pools 35 | """ 36 | self.app = app 37 | self.dsn = dsn 38 | self.init_db = init_db 39 | self.con_opts = options 40 | self._pool = pool 41 | self.app.router.add_event_handler("startup", self.on_connect) 42 | self.app.router.add_event_handler("shutdown", self.on_disconnect) 43 | 44 | async def on_connect(self): 45 | """handler called during initialitzation of asgi app, that connects to 46 | the db""" 47 | # if the pool is comming from outside (tests), don't connect it 48 | if self._pool: 49 | self.app.state.pool = self._pool 50 | return 51 | pool = await asyncpg.create_pool(dsn=self.dsn, **self.con_opts) 52 | async with pool.acquire() as db: 53 | await self.init_db(db) 54 | self.app.state.pool = pool 55 | 56 | async def on_disconnect(self): 57 | # if the pool is comming from outside, don't desconnect it 58 | # someone else will do (usualy a pytest fixture) 59 | if self._pool: 60 | return 61 | await self.app.state.pool.close() 62 | 63 | def on_init(self, func): 64 | self.init_db = func 65 | return func 66 | 67 | @property 68 | def pool(self): 69 | return self.app.state.pool 70 | 71 | async def connection(self): 72 | """ 73 | A ready to use connection Dependency just usable 74 | on your path functions that gets a connection from the pool 75 | Example: 76 | db = configure_asyncpg(app, "dsn://") 77 | @app.get("/") 78 | async def get_content(db = Depens(db.connection)): 79 | await db.fetch("SELECT * from pg_schemas") 80 | """ 81 | async with self.pool.acquire() as db: 82 | yield db 83 | 84 | async def transaction(self): 85 | """ 86 | A ready to use transaction Dependecy just usable on a path function 87 | Example: 88 | db = configure_asyncpg(app, "dsn://") 89 | @app.get("/") 90 | async def get_content(db = Depens(db.transaction)): 91 | await db.execute("insert into keys values (1, 2)") 92 | await db.execute("insert into keys values (1, 2)") 93 | All view function executed, are wrapped inside a postgresql transaction 94 | """ 95 | async with self.pool.acquire() as db: 96 | txn = db.transaction() 97 | await txn.start() 98 | try: 99 | yield db 100 | except: # noqa 101 | await txn.rollback() 102 | raise 103 | else: 104 | await txn.commit() 105 | 106 | atomic = transaction 107 | 108 | 109 | class SingleConnectionTestingPool: 110 | """A fake pool that simulates pooling, but runs on 111 | a single transaction that it's rolled back after 112 | each test. 113 | With some large schemas this seems to be faster than 114 | the other approach 115 | """ 116 | 117 | def __init__( 118 | self, 119 | conn: asyncpg.Connection, 120 | initialize: typing.Optional[typing.Callable] = None, 121 | add_logger_postgres: bool = False, 122 | ): 123 | self._conn = conn 124 | self.tx = None 125 | self.started = False 126 | self.add_logger_postgres = add_logger_postgres 127 | self.initialize = initialize 128 | 129 | def acquire(self, *, timeout=None): 130 | return ConAcquireContext(self._conn, self) 131 | 132 | async def start(self): 133 | if self.started: 134 | return 135 | 136 | def log_postgresql(con, message): 137 | print(message) 138 | 139 | if self.add_logger_postgres: 140 | self._conn.add_log_listener(log_postgresql) 141 | self.tx = self._conn.transaction() 142 | await self.tx.start() 143 | await self.initialize(self._conn) 144 | self.started = True 145 | 146 | async def release(self): 147 | if self.tx: 148 | await self.tx.rollback() 149 | 150 | def __getattr__(self, key): 151 | return getattr(self._conn, key) 152 | 153 | 154 | async def create_pool_test( 155 | dsn: str, 156 | *, 157 | initialize: typing.Optional[typing.Callable] = None, 158 | add_logger_postgres: bool = False, 159 | ): 160 | """This part is only used for testing, 161 | we create a fake "pool" that just starts a connecion, 162 | that does a transaction inside it""" 163 | conn = await asyncpg.connect(dsn=dsn) 164 | pool = SingleConnectionTestingPool(conn, initialize=initialize, add_logger_postgres=add_logger_postgres) 165 | return pool 166 | 167 | 168 | class ConAcquireContext: 169 | def __init__(self, conn, manager): 170 | self._conn = conn 171 | self.manager = manager 172 | 173 | async def __aenter__(self): 174 | if not self.manager.tx: 175 | await self.manager.start() 176 | self.tr = self._conn.transaction() 177 | await self.tr.start() 178 | return self._conn 179 | 180 | async def __aexit__(self, exc_type, exc, tb): 181 | if exc_type: 182 | await self.tr.rollback() 183 | else: 184 | await self.tr.commit() 185 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FastAPI AsyncPG 2 | 3 | FastAPI integration for AsyncPG 4 | 5 | ## Narrative 6 | 7 | First of all, so sorry for my poor english. I will be so happy, 8 | if someone pushes a PR correcting all my english mistakes. Anyway 9 | I will try to do my best. 10 | 11 | Looking at fastapi ecosystem seems like everyone is trying to integrate 12 | fastapi with orms, but from my experience working with raw 13 | sql I'm so productive. 14 | 15 | If you think a bit around, your real model layer, is the schema on your 16 | db (you can add abastractions on top of it), but what ends 17 | is your data, and these are tables, columns and rows. 18 | 19 | Also, sql, it's one of the best things I learned 20 | because it's something that always is there. 21 | 22 | On another side, postgresql it's robust and rock solid, 23 | thousands of projects depend on it, and use it as their storage layer. 24 | AsyncPG it's a crazy fast postgresql driver 25 | written from scratch. 26 | 27 | FastAPI seems like a clean, and developer productive approach to web 28 | frameworks. It's crazy how well it integrates with OpenAPI, 29 | and how easy makes things to a developer to move on. 30 | 31 | ## Integration 32 | 33 | fastapi_asyncpg trys to integrate fastapi and asyncpg in an idiomatic way. 34 | fastapi_asyncpg when configured exposes two injectable providers to 35 | fastapi path functions, can use: 36 | 37 | - `db.connection` : it's just a raw connection picked from the pool, 38 | that it's auto released when pathfunction ends, this is mostly 39 | merit of the DI system around fastapi. 40 | 41 | - `db.transaction`: the same, but wraps the pathfuncion on a transaction 42 | this is more or less the same than the `atomic` decorator from Django. 43 | also `db.atomic` it's aliased 44 | 45 | ```python 46 | from fastapi import FastAPI 47 | from fastapi import Depends 48 | from fastapi_asyncpg import configure_asyncpg 49 | 50 | app = FastAPI() 51 | # we need to pass the fastapi app to make use of lifespan asgi events 52 | db = configure_asyncpg(app, "postgresql://postgres:postgres@localhost/db") 53 | 54 | @db.on_init 55 | async def initialization(conn): 56 | # you can run your db initialization code here 57 | await conn.execute("SELECT 1") 58 | 59 | 60 | @app.get("/") 61 | async def get_content(db=Depends(db.connection)): 62 | rows = await db.fetch("SELECT wathever FROM tablexxx") 63 | return [dict(r) for r in rows] 64 | 65 | @app.post("/") 66 | async def mutate_something_compled(db=Depends(db.atomic)) 67 | await db.execute() 68 | await db.execute() 69 | # if something fails, everyting is rolleback, you know all or nothing 70 | ``` 71 | 72 | And there's also an `initialization` callable on the main factory function. 73 | That can be used like in flask to initialize whatever you need on the db. 74 | The `initialization` is called right after asyncpg stablishes a connection, 75 | and before the app fully boots. (Some projects use this as a poor migration 76 | runner, not the best practice if you are deploying multiple 77 | instances of the app). 78 | 79 | ## Testing 80 | 81 | For testing we use [pytest-docker-fixtures](https://pypi.org/project/pytest-docker-fixtures/), it requires docker on the host machine or on whatever CI you use 82 | (seems like works as expected with github actions) 83 | 84 | It works, creating a container for the session and exposing it as pytest fixture. 85 | It's a good practice to run tests with a real database, and 86 | pytest-docker-fixtures make it's so easy. As a bonus, all fixtures run on a CI. 87 | We use Jenkins witht docker and docker, but also seems like travis and github actions 88 | also work. 89 | 90 | The fixture needs to be added to the pytest plugins `conftest.py` file. 91 | 92 | on conftest.py 93 | 94 | ```python 95 | pytest_plugins = [ 96 | "pytest_docker_fixtures", 97 | ] 98 | ``` 99 | 100 | With this in place, we can just yield a pg fixture 101 | 102 | ```python 103 | from pytest_docker_fixtures import images 104 | 105 | # image params can be configured from here 106 | images.configure( 107 | "postgresql", "postgres", "11.1", env={"POSTGRES_DB": "test_db"} 108 | ) 109 | 110 | # and then on our test we have a pg container running 111 | # ready to recreate our db 112 | async def test_pg(pg): 113 | host, port = pg 114 | dsn = f"postgresql://postgres@{host}:{port}/test_db" 115 | await asyncpg.Connect(dsn=dsn) 116 | # let's go 117 | 118 | ``` 119 | 120 | With this in place, we can just create our own pytest.fixture that 121 | _patches_ the app dsn to make it work with our custom created 122 | container. 123 | 124 | ````python 125 | 126 | from .app import app, db 127 | from async_asgi_testclient import TestClient 128 | 129 | import pytest 130 | 131 | pytestmark = pytest.mark.asyncio 132 | 133 | @pytest.fixture 134 | async def asgi_app(pg) 135 | host, port = pg 136 | dsn = f"postgresql://postgres@{host}:{port}/test_db" 137 | # here we patch the dsn for the db 138 | # con_opts: are also accessible 139 | db.dsn = dsn 140 | yield app, db 141 | 142 | async def test_something(asgi_app): 143 | app, db = asgi_app 144 | async with db.pool.acquire() as db: 145 | # setup your test state 146 | 147 | # this context manager handlers lifespan events 148 | async with TestClient(app) as client: 149 | res = await client.request("/") 150 | ``` 151 | 152 | Anyway if the application will grow, to multiples subpackages, 153 | and apps, we trend to build the main app as a factory, that 154 | creates it, something like: 155 | 156 | ```python 157 | from fastapi_asyncpg import configure_asyncpg 158 | from apppackage import settings 159 | 160 | import venusian 161 | 162 | def make_asgi_app(settings): 163 | app = FastAPI() 164 | db = configure_asyncpg(settings.DSN) 165 | 166 | scanner = venusian.Scanner(app=app) 167 | venusian.scan(theapp) 168 | return app 169 | ```` 170 | 171 | Then on the fixture, we just need, to factorze and app from our function 172 | 173 | ```python 174 | 175 | from .factory import make_asgi_app 176 | from async_asgi_testclient import TestClient 177 | 178 | import pytest 179 | 180 | pytestmark = pytest.mark.asyncio 181 | 182 | @pytest.fixture 183 | async def asgi_app(pg) 184 | host, port = pg 185 | dsn = f"postgresql://postgres@{host}:{port}/test_db" 186 | app = make_asgi_app({"dsn": dsn}) 187 | # ther's a pointer on the pool into app.state 188 | yield app 189 | 190 | async def test_something(asgi_app): 191 | app = asgi_app 192 | pool = app.state.pool 193 | async with db.pool.acquire() as db: 194 | # setup your test state 195 | 196 | # this context manager handlers lifespan events 197 | async with TestClient(app) as client: 198 | res = await client.request("/") 199 | 200 | ``` 201 | 202 | There's also another approach exposed and used on [tests](tests/test_db.py), 203 | that exposes a single connection to the test and rolls back changes on end. 204 | We use this approach on a large project (500 tables per schema and 205 | multiples schemas), and seems like it speeds up a bit test creation. 206 | This approach is what [Databases](https://www.encode.io/databases/) it's using. 207 | Feel free to follow the tests to see if it feets better. 208 | 209 | ## Extras 210 | 211 | There are some utility functions I daily use with asyncpg that helps me 212 | speed up some sql operations like, they are all on sql.py, and mostly are 213 | self documented. They are in use on tests. 214 | 215 | ### Authors 216 | 217 | `fastapi_asyncpg` was written by `Jordi collell `\_. 218 | --------------------------------------------------------------------------------