├── .github └── workflows │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── .readthedocs.yaml ├── CHANGELOG.rst ├── LICENSE ├── README.rst ├── docs ├── Makefile ├── conf.py ├── discussion │ ├── bind_parameters.rst │ ├── databases.rst │ ├── index.rst │ └── per_connection.rst ├── how_to_guides │ ├── backends.rst │ ├── bind_parameters.rst │ ├── configuration.rst │ ├── development_data.rst │ ├── index.rst │ ├── migrations.rst │ ├── multiple_dbs.rst │ ├── schema_visualisation.rst │ ├── testing.rst │ └── type_conversion.rst ├── index.rst ├── make.bat ├── reference │ ├── api.rst │ └── index.rst └── tutorials │ ├── index.rst │ ├── installation.rst │ └── quickstart.rst ├── pyproject.toml ├── setup.cfg ├── src └── quart_db │ ├── __init__.py │ ├── _migration.py │ ├── backends │ ├── __init__.py │ ├── aiosqlite.py │ ├── asyncpg.py │ └── psycopg.py │ ├── extension.py │ ├── interfaces.py │ └── py.typed ├── tests ├── __init__.py ├── conftest.py ├── migrations │ └── 0.py ├── test_basic.py ├── test_connection.py ├── test_converters.py └── utils.py └── tox.ini /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: [ main ] 5 | pull_request: 6 | branches: [ main ] 7 | workflow_dispatch: 8 | 9 | jobs: 10 | tox: 11 | name: ${{ matrix.name }} 12 | runs-on: ubuntu-latest 13 | 14 | container: python:${{ matrix.python }} 15 | 16 | services: 17 | postgres: 18 | image: postgres 19 | env: 20 | POSTGRES_DB: quart_db 21 | POSTGRES_USER: quart_db 22 | POSTGRES_PASSWORD: quart_db 23 | POSTGRES_HOST_AUTH_METHOD: "trust" 24 | options: >- 25 | --health-cmd pg_isready 26 | --health-interval 10s 27 | --health-timeout 5s 28 | --health-retries 5 29 | 30 | strategy: 31 | fail-fast: false 32 | matrix: 33 | include: 34 | - {name: '3.13', python: '3.13', tox: py313} 35 | - {name: '3.12', python: '3.12', tox: py312} 36 | - {name: '3.11', python: '3.11', tox: py311} 37 | - {name: '3.10', python: '3.10', tox: py310} 38 | - {name: '3.9', python: '3.9', tox: py39} 39 | - {name: 'format', python: '3.13', tox: format} 40 | - {name: 'mypy', python: '3.13', tox: mypy} 41 | - {name: 'pep8', python: '3.13', tox: pep8} 42 | - {name: 'package', python: '3.13', tox: package} 43 | - {name: 'docs', python: '3.13', tox: docs} 44 | 45 | env: 46 | DATABASE_URL: "postgresql://quart_db:quart_db@postgres:5432/quart_db" 47 | 48 | steps: 49 | - uses: pgjones/actions/tox@v1 50 | with: 51 | environment: ${{ matrix.tox }} 52 | 53 | zizmor: 54 | name: Zizmor 55 | runs-on: ubuntu-latest 56 | 57 | steps: 58 | - uses: pgjones/actions/zizmor@v1 59 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: pgjones/actions/build@v1 11 | 12 | pypi-publish: 13 | needs: ['build'] 14 | environment: 'publish' 15 | 16 | name: upload release to PyPI 17 | runs-on: ubuntu-latest 18 | permissions: 19 | # IMPORTANT: this permission is mandatory for trusted publishing 20 | id-token: write 21 | steps: 22 | - uses: actions/download-artifact@v4 23 | 24 | - name: Publish package distributions to PyPI 25 | uses: pypa/gh-action-pypi-publish@release/v1 26 | with: 27 | packages-dir: artifact/ 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | venv/ 3 | __pycache__/ 4 | Quart_DB.egg-info/ 5 | .cache/ 6 | .tox/ 7 | TODO 8 | .mypy_cache/ 9 | .hypothesis/ 10 | docs/_build/ 11 | docs/reference/source/ 12 | .coverage 13 | .pytest_cache/ 14 | dist/ 15 | pdm.lock 16 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-24.04 5 | tools: 6 | python: "3.13" 7 | 8 | python: 9 | install: 10 | - method: pip 11 | path: . 12 | extra_requirements: 13 | - docs 14 | 15 | sphinx: 16 | configuration: docs/conf.py 17 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | 0.9.0 2024-12-15 2 | ---------------- 3 | 4 | * Add support for a psycopg backend. 5 | * Separate the backend and test connection options. 6 | * Support Python 3.13, drop Python 3.8. 7 | 8 | 0.8.3 2024-08-29 9 | ---------------- 10 | 11 | * Bugfix correctly support list values (not named). 12 | 13 | 0.8.2 2024-03-06 14 | ---------------- 15 | 16 | * Bugfix ensure the value of data_loaded is kept. 17 | 18 | 0.8.1 2024-02-26 19 | ---------------- 20 | 21 | * Bugfix naming typo. 22 | * Bugfix background migrations should not run in a transaction. 23 | 24 | 0.8.0 2024-02-25 25 | ---------------- 26 | 27 | * Make the valid_migration function optional, if not present the 28 | migration is assumed to be valid. 29 | * Support background migrations. Note the first deployment should be 30 | monitored as this requires a change to Quart-DB's state table. 31 | * Bugfix ensure None is returned if there is no result for 32 | ``fetch_val`` and ``fetch_one``. 33 | 34 | 0.7.1 2023-10-30 35 | ---------------- 36 | 37 | * Ensure auto request connectons are released. 38 | 39 | 0.7.0 2023-10-07 40 | ---------------- 41 | 42 | * Add a migrations timeout defaulting to 60s. 43 | * Support Quart 0.19 onwards. 44 | * Support 3.12 drop 3.7. 45 | 46 | 0.6.2 2023-09-01 47 | ---------------- 48 | 49 | * Bugfix ensure connections are released. 50 | 51 | 0.6.1 2023-08-23 52 | ---------------- 53 | 54 | * Bugfix add missing f-string designator. 55 | 56 | 0.6.0 2023-08-10 57 | ---------------- 58 | 59 | * Bugfix ensure aiosqlite works, and specifically type conversion for 60 | JSON. 61 | * Improve the typing utilising LiteralStrings. 62 | * Allow the state table name to be configured. 63 | 64 | 0.5.0 2023-05-07 65 | ---------------- 66 | 67 | * Allow postgres as a valid URL scheme alongside postgresql. 68 | * Allow additional options to be passed to the backend db driver. 69 | * Add a db-migrate cli command. 70 | * Bugfix protocol typing for iterate method. 71 | * Bugfix avoid double-close if the pool has already been disconnected. 72 | 73 | 0.4.1 2022-10-09 74 | ---------------- 75 | 76 | * Bugfix add missing aiosqlite dependency 77 | 78 | 0.4.0 2022-10-08 79 | ---------------- 80 | 81 | * Support SQLite databases. 82 | * Bugfix ensure that exceptions are not propagated, unless 83 | specifically set. This prevents connection exhaustion on errors. 84 | 85 | 0.3.0 2022-08-23 86 | ---------------- 87 | 88 | * Require the connection lock earlier in iterate. 89 | * Acquire the connection lock on Transaction. 90 | * Add a db-schema command to output the entity relation diagram. 91 | 92 | 0.2.0 2022-04-12 93 | ---------------- 94 | 95 | * Add a lock on connection operations to ensure only one concurrent 96 | operation per connection (use multiple connections). 97 | * Add a default per request connection on g, so that g.connection can 98 | be used in request contexts. 99 | * Switch to github rather than gitlab. 100 | 101 | 0.1.0 2022-03-07 102 | ---------------- 103 | 104 | * Basic initial release. 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright P G Jones 2022. 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Quart-DB 2 | ======== 3 | 4 | |Build Status| |docs| |pypi| |python| |license| 5 | 6 | Quart-DB is a Quart extension that provides managed connection(s) to 7 | postgresql or sqlite database(s). 8 | 9 | Quickstart 10 | ---------- 11 | 12 | Quart-DB is used by associating it with an app and a DB (via a URL) 13 | and then utilising the ``g.connection`` connection, 14 | 15 | .. code-block:: python 16 | 17 | from quart import g, Quart, websocket 18 | from quart_db import QuartDB 19 | 20 | app = Quart(__name__) 21 | db = QuartDB(app, url="postgresql://user:pass@localhost:5432/db_name") 22 | 23 | @app.get("/") 24 | async def get_count(id: int): 25 | result = await g.connection.fetch_val( 26 | "SELECT COUNT(*) FROM tbl WHERE id = :id", 27 | {"id": id}, 28 | ) 29 | return {"count": result} 30 | 31 | @app.post("/") 32 | async def set_with_transaction(): 33 | async with g.connection.transaction(): 34 | await db.execute("UPDATE tbl SET done = :done", {"done": True}) 35 | ... 36 | return {} 37 | 38 | @app.get("/explicit") 39 | async def explicit_usage(): 40 | async with db.connection() as connection: 41 | ... 42 | 43 | Contributing 44 | ------------ 45 | 46 | Quart-DB is developed on `GitHub 47 | `_. If you come across an issue, 48 | or have a feature request please open an `issue 49 | `_. If you want to 50 | contribute a fix or the feature-implementation please do (typo fixes 51 | welcome), by proposing a `merge request 52 | `_. 53 | 54 | Testing 55 | ~~~~~~~ 56 | 57 | The best way to test Quart-DB is with `Tox 58 | `_, 59 | 60 | .. code-block:: console 61 | 62 | $ pip install tox 63 | $ tox 64 | 65 | this will check the code style and run the tests. 66 | 67 | Help 68 | ---- 69 | 70 | The Quart-DB `documentation 71 | `_ is the best places to 72 | start, after that try searching `stack overflow 73 | `_ or ask for help 74 | `on gitter `_. If you still 75 | can't find an answer please `open an issue 76 | `_. 77 | 78 | 79 | .. |Build Status| image:: https://github.com/pgjones/quart-db/actions/workflows/ci.yml/badge.svg 80 | :target: https://github.com/pgjones/quart-db/commits/main 81 | 82 | .. |docs| image:: https://readthedocs.org/projects/quart-db/badge/?version=latest&style=flat 83 | :target: https://quart-db.readthedocs.io/en/latest/ 84 | 85 | .. |pypi| image:: https://img.shields.io/pypi/v/quart-db.svg 86 | :target: https://pypi.python.org/pypi/Quart-DB/ 87 | 88 | .. |python| image:: https://img.shields.io/pypi/pyversions/quart-db.svg 89 | :target: https://pypi.python.org/pypi/Quart-DB/ 90 | 91 | .. |license| image:: https://img.shields.io/badge/license-MIT-blue.svg 92 | :target: https://github.com/pgjones/quart-db/blob/main/LICENSE 93 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | import os 21 | import sys 22 | from importlib.metadata import version as imp_version 23 | sys.path.insert(0, os.path.abspath('../')) 24 | 25 | 26 | project = 'Quart-DB' 27 | copyright = '2022, Philip Jones' 28 | author = 'Philip Jones' 29 | version = imp_version("quart-db") 30 | release = version 31 | 32 | # -- General configuration --------------------------------------------------- 33 | 34 | # Add any Sphinx extension module names here, as strings. They can be 35 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 36 | # ones. 37 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.napoleon'] 38 | 39 | source_suffix = '.rst' 40 | 41 | # List of patterns, relative to source directory, that match files and 42 | # directories to ignore when looking for source files. 43 | # This pattern also affects html_static_path and html_extra_path. 44 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 45 | 46 | 47 | # -- Options for HTML output ------------------------------------------------- 48 | 49 | # The theme to use for HTML and HTML Help pages. See the documentation for 50 | # a list of builtin themes. 51 | # 52 | html_theme = "pydata_sphinx_theme" 53 | 54 | html_theme_options = { 55 | "external_links": [ 56 | {"name": "Source code", "url": "https://github.com/pgjones/quart-db"}, 57 | {"name": "Issues", "url": "https://github.com/pgjones/quart-db/issues"}, 58 | ], 59 | "icon_links": [ 60 | { 61 | "name": "Github", 62 | "url": "https://github.com/pgjones/quart-db", 63 | "icon": "fab fa-github", 64 | }, 65 | ], 66 | } 67 | -------------------------------------------------------------------------------- /docs/discussion/bind_parameters.rst: -------------------------------------------------------------------------------- 1 | Keyword only bind parameters 2 | ============================ 3 | 4 | Bind parameters are only supported by keywords as they lead to fewer 5 | bugs when compared to positional parameters. They are also easier to 6 | support in a consistent fashion across the backends. 7 | -------------------------------------------------------------------------------- /docs/discussion/databases.rst: -------------------------------------------------------------------------------- 1 | Why not use databases? 2 | ====================== 3 | 4 | Quart-DB has a similar design to `Encode's databases 5 | `_ which is a fantastic 6 | library. However, it doesn't support migrations and prefers a singular 7 | implicit connection. This latter design decision causes major issues 8 | in practice (in my view/experience) as seen in this `issue 9 | `_, hence why Quart-DB 10 | exists. 11 | -------------------------------------------------------------------------------- /docs/discussion/index.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | Discussions 3 | =========== 4 | 5 | .. toctree:: 6 | :maxdepth: 1 7 | 8 | bind_parameters.rst 9 | databases.rst 10 | per_connection.rst 11 | -------------------------------------------------------------------------------- /docs/discussion/per_connection.rst: -------------------------------------------------------------------------------- 1 | Why a connection per request? 2 | ============================= 3 | 4 | Quart-DB automatically provides a connection per request from the pool 5 | even if the connection is never used. This means that the request 6 | request could potentially block until a connection in the pool is 7 | available and hence limits the concurrency to the pool size. 8 | 9 | This decision is made on basis that most uses of QuartDB will gain 10 | from the conveniance of using ``g.connection`` as the usage is for a 11 | single database with most/all routes using a connection. 12 | 13 | This can be disabled as desired by setting the 14 | ``auto_request_connection`` constructor argument to False or setting 15 | the ``QUART_DB_AUTO_REQUEST_CONNECTION`` configuration value to False. 16 | -------------------------------------------------------------------------------- /docs/how_to_guides/backends.rst: -------------------------------------------------------------------------------- 1 | Database backends 2 | ================= 3 | 4 | Quart-DB supports 3 backends, PostgreSQL+asyncpg, PostgreSQL+psycopg, 5 | and SQLite+aiosqlite. The backend used will be chosen based on the 6 | scheme provided in the ``QUART_DB_DATABASE_URL``. To choose 7 | PostgreSQL+asyncpg 8 | 9 | ================== ========== ========= 10 | Scheme Database Engine 11 | ------------------ ---------- --------- 12 | postgresql+asyncpg PostgreSQL asyncpg 13 | postgresql+psycopg PostgreSQL psycopg 14 | sqlite SQLite aiosqlite 15 | ================== ========== ========= 16 | 17 | Note that ``postgresql`` as the scheme will default to 18 | PostgreSQL+asyncpg. 19 | -------------------------------------------------------------------------------- /docs/how_to_guides/bind_parameters.rst: -------------------------------------------------------------------------------- 1 | Bind parameters 2 | =============== 3 | 4 | In order to paramterise a query you should use binding parameters and 5 | avoid string formatting. This is particularly important with user 6 | input as string formatting may leave you vulnerable to SQL injection 7 | attacks. 8 | 9 | Quart-DB only supports keyword bind parameters with the latter 10 | utilising `buildpg `_ for 11 | postgresql databases. The Quart-DB connection instance methods accept 12 | a query (str) and a collection of values as arguments. 13 | 14 | A ``UndefinedParameterError`` will be raised if a parameter is 15 | specified in the query without a parameter being provided. 16 | 17 | Keyword binds 18 | ------------- 19 | 20 | Keyword binds are so called as the values are taken by key-name, and 21 | hence should be supplied via a dictionary. 22 | 23 | .. code-block:: python 24 | 25 | await connection.execute( 26 | "SELECT * FROM tbl WHERE a = :a AND b = :b", 27 | {"a": 1, "b": 2}, 28 | ) 29 | -------------------------------------------------------------------------------- /docs/how_to_guides/configuration.rst: -------------------------------------------------------------------------------- 1 | Configuring Quart-DB 2 | ==================== 3 | 4 | The following configuration options are used by Quart-DB. They should 5 | be set as part of the standard `Quart configuration 6 | `_. 7 | 8 | ================================ ============ ================ 9 | Configuration key type default 10 | -------------------------------- ------------ ---------------- 11 | QUART_DB_DATABASE_URL str 12 | QUART_DB_MIGRATIONS_FOLDER str migrations 13 | QUART_DB_DATA_PATH str 14 | QUART_DB_AUTO_REQUEST_CONNECTION bool 15 | QUART_DB_MIGRATION_TIMEOUT float | None 60 16 | QUART_DB_STATE_TABLE_NAME str schema_migration 17 | ================================ ============ ================ 18 | 19 | ``QUART_DB_DATABASE_URL`` allows this database url to be specified and 20 | is ``None`` by default (set via constructor argument). 21 | 22 | ``QUART_DB_MIGRATIONS_FOLDER`` refers to the location of the 23 | migrations folder relative to the app's root path. You can set 24 | this to `None` in order to disable the migrations system. 25 | 26 | ``QUART_DB_DATA_PATH`` refers to the location of the data module 27 | relative to the app's root path. 28 | 29 | ``QUART_DB_AUTO_REQUEST_CONNECTION`` can be used to disable (when 30 | False) the automatic ``g.connection`` connection per request. 31 | 32 | ``QUART_DB_STATE_TABLE_NAME`` can be used to change the table Quart-DB 33 | uses to store the database migration state. 34 | 35 | ``QUART_DB_MIGRATION_TIMEOUT`` sets the maximum time the migrations 36 | may take. Note that most ASGI servers will also timeout the startup 37 | phase as well. This can be disabled by setting the value to ``None``. 38 | 39 | 40 | SQLite configuration 41 | -------------------- 42 | 43 | To use a relative path ``QUART_DB_DATABASE_URL`` should start with 44 | ``sqlite:///``, whereas for an absolute path it should start with 45 | ``sqlite:////``. In memory usage should be avoided as changes will not 46 | be persisted. 47 | -------------------------------------------------------------------------------- /docs/how_to_guides/development_data.rst: -------------------------------------------------------------------------------- 1 | Development & testing data 2 | ========================== 3 | 4 | It is often desired to load an initial set of data into the database 5 | often for developing against or testing against. You can do this by 6 | specifying the ``data_path`` either by the constructor argument or via 7 | the ``QUART_DB_DATA_PATH`` configuration variable. The path should be 8 | relative to the app's root and contain a function with the following 9 | signature, 10 | 11 | .. code-block:: python 12 | 13 | async def execute(connection: quart_db.Connection) -> None: 14 | ... 15 | 16 | The data will only be into the database loaded once, after any 17 | migrations have completed. 18 | -------------------------------------------------------------------------------- /docs/how_to_guides/index.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | How to guides 3 | ============= 4 | 5 | .. toctree:: 6 | :maxdepth: 1 7 | 8 | backends.rst 9 | bind_parameters.rst 10 | configuration.rst 11 | development_data.rst 12 | migrations.rst 13 | multiple_dbs.rst 14 | schema_visualisation.rst 15 | testing.rst 16 | type_conversion.rst 17 | -------------------------------------------------------------------------------- /docs/how_to_guides/migrations.rst: -------------------------------------------------------------------------------- 1 | .. _migrations: 2 | 3 | Migrations 4 | ========== 5 | 6 | A migration is a change to the database schema (structure) or 7 | data. Migrations are either run before the app starts serving 8 | (foreground) or whilst the app is serving (background). Typically 9 | schema changes are done in foreground and data background migrations, 10 | but this need not be the case. 11 | 12 | Quart-DB considers migrations to be linear and forward only, as 13 | such migrations are numbered from 0, to 1, onwards. Each migration is 14 | a python file containing a ``migrate`` function with the following 15 | signature, 16 | 17 | .. code-block:: python 18 | 19 | async def migrate(connection: quart_db.Connection) -> None: 20 | ... 21 | 22 | ``migrate`` will run before the app starts serving (in the foreground) 23 | and should run whatever queries are required for the migration. 24 | 25 | .. warning:: 26 | 27 | If you are using postgres Quart-DB will ensure only a single 28 | invocation of ``migrate`` will occur regardless of the number of 29 | app instances. This is not possible though with SQLite. 30 | 31 | A migration may also include a ``background_migrate`` function for a 32 | migration that runs whilst the app is serving (in the background) with 33 | the following signature, 34 | 35 | .. code-block:: python 36 | 37 | async def background_migrate(connection: quart_db.Connection) -> None: 38 | ... 39 | 40 | ``background_migrate`` will run whilst the app is serving. Note all 41 | the foreground migrations will complete before the background 42 | migrations start. 43 | 44 | .. warning:: 45 | 46 | If you are running multiple instances of your app there will be 47 | multiple instances of ``background_migrate`` running concurrently. 48 | 49 | The file can also contain an optional ``valid_migration`` function 50 | with the following signature, 51 | 52 | .. code-block:: python 53 | 54 | async def valid_migration(connection: quart_db.Connection) -> bool: 55 | ... 56 | 57 | ``valid_migration`` should check that the migration was successful and 58 | the data in the database is as expected. It can be omitted if this is 59 | something you'd prefer to skip. 60 | 61 | Transactions 62 | ------------ 63 | 64 | Foreground migrations run in a transaction and hence the migration 65 | code must execute without error and the ``valid_migration`` function 66 | (if present) return True, otherwise the transaction is rolled back. 67 | 68 | Background migrations do not run in a transaction, but should be 69 | idempotent to allow Quart-DB to retry if the migration if it is 70 | cancelled by the app shutdown. 71 | 72 | Type conversion 73 | --------------- 74 | 75 | Custom type conversion is not possible in the migration scripts as the 76 | conversion code must be registered before the corresponding type is 77 | created in the migtration. 78 | 79 | Invocation 80 | ---------- 81 | 82 | The migrations are automatically invoked during the app 83 | startup. Alternatively it can be invoked via this command:: 84 | 85 | quart db-migrate 86 | 87 | Note that background migrations are forced to run in the foreground 88 | thereby blocking this command until they finish. 89 | -------------------------------------------------------------------------------- /docs/how_to_guides/multiple_dbs.rst: -------------------------------------------------------------------------------- 1 | Multiple databases 2 | ================== 3 | 4 | Multiple QuartDB instances can be used to connect to multiple 5 | databases, with an instance per database. Connections should then be 6 | managed explicitly with the ``auto_request_connection`` disabled, as 7 | so, 8 | 9 | .. code-block:: python 10 | 11 | read_db = QuartDB(app, url=READ_DB_URL, auto_request_connection=False) 12 | write_db = QuartDB(app, url=WRITE_DB_URL, auto_request_connection=False) 13 | 14 | @app.get("/") 15 | async def read(): 16 | async with read_db.connection() as connection: 17 | await connection.execute("SELECT ...") 18 | 19 | @app.post("/") 20 | async def write(): 21 | async with write_db.connection() as connection: 22 | await connection.execute("INSERT INTO ...") 23 | -------------------------------------------------------------------------------- /docs/how_to_guides/schema_visualisation.rst: -------------------------------------------------------------------------------- 1 | Schema visualisation 2 | ==================== 3 | 4 | The database schema, or entity relations, can be visualised if 5 | Quart-DB is installed with the ``erdiagram`` extra. When installed the 6 | command:: 7 | 8 | quart db-schema 9 | 10 | can be used to output the schema to ``quart_db_schema.png`` or a 11 | custom file via:: 12 | 13 | quart db-schema outputfile.ext 14 | 15 | This command uses `eralchemy2 16 | `_ to draw the diagrams and 17 | the various formats supported by it are supported. The following 18 | output file extensions are supported, 19 | 20 | - '.er': writes to a file the markup to generate an ER style diagram. 21 | - '.dot': returns the graph in the dot syntax. 22 | - '.md': writes to a file the markup to generate an Mermaid-JS style diagram 23 | 24 | [This detail is from the eralchemy2 code]. 25 | -------------------------------------------------------------------------------- /docs/how_to_guides/testing.rst: -------------------------------------------------------------------------------- 1 | Testing 2 | ======= 3 | 4 | Quart-DB uses the Quart startup functionality to connect to the 5 | database and create the connection pool. Therefore the test_app will 6 | need to be used to ensure that the connection is setup for testing as 7 | well, see `the docs 8 | `_. To 9 | do so I recommend the following fixture be used with pytest, 10 | 11 | .. code-block:: python 12 | 13 | @pytest.fixture(name="app", scope="function") 14 | async def _app(): 15 | app = create_app() # Initialize app 16 | async with app.test_app() as test_app: 17 | yield test_app 18 | 19 | If you would like only a connection for usage in tests I recommend the 20 | following fixture note the ``DATABASE_URL`` is being supplied as a 21 | environment variable, 22 | 23 | .. code-block:: python 24 | 25 | @pytest.fixture(name="connection") 26 | async def _connection(): 27 | db = QuartDB(Quart(__name__), url=os.environ["DATABASE_URL"]) 28 | connection = Connection(asyncpg_connection) 29 | async with db.connection() as connection: 30 | yield connection 31 | -------------------------------------------------------------------------------- /docs/how_to_guides/type_conversion.rst: -------------------------------------------------------------------------------- 1 | Convert between Python and Postgres types 2 | ========================================= 3 | 4 | By default Quart-DB uses the default converters whilst ensuring that 5 | JSON is converted to and from ``dict``s using the stdlib 6 | ``json.loads`` and ``json.dumps`` functions. Custom type converters 7 | are supported, but depend on the DB backend used. 8 | 9 | Postgres - asyncpg 10 | ------------------ 11 | 12 | A custom type converter (also called codecs) can be specified the 13 | ``set_converter`` method can be used. For example for enums: 14 | 15 | .. code-block:: python 16 | 17 | from enum import Enum 18 | 19 | class Options(Enum): 20 | A = "A" 21 | B = "B" 22 | 23 | db.set_converter("options_t", lambda type_: type_.value, Options) 24 | 25 | The keyword argument ``schema`` can be used to specify the schema to 26 | which the typename belongs. 27 | 28 | Postgres - psycopg 29 | ------------------ 30 | 31 | A custom type converter (also called adapter) can be specified the 32 | ``set_converter`` method can be used. For example for enums: 33 | 34 | .. code-block:: python 35 | 36 | from enum import Enum 37 | 38 | class Options(Enum): 39 | A = "A" 40 | B = "B" 41 | 42 | db.set_converter("options_t", lambda type_: type_.value, Options) 43 | 44 | The keyword argument ``schema`` has no affect. 45 | 46 | SQLite - aiosqlite 47 | ------------------ 48 | 49 | To define a custom type converter (also called codecs) can be 50 | specified the ``set_converter`` method can be used. For example for 51 | enums: 52 | 53 | .. code-block:: python 54 | 55 | from enum import Enum 56 | 57 | class Options(Enum): 58 | A = "A" 59 | B = "B" 60 | 61 | db.set_converter( 62 | "options_t", lambda type_: type_.value, Options, pytype=Options 63 | ) 64 | 65 | Note the ``pytype`` argument is required and the keyword argument 66 | ``schema`` has no affect. 67 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | .. title:: Quart-DB documentation 4 | 5 | Quart-DB 6 | ======== 7 | 8 | Quart-DB is a `Quart `_ extension 9 | that provides managed connection(s) to postgresql or sqlite 10 | database(s). Using Quart-DB you can, 11 | 12 | * connect to Postgresql or SQLite databases, 13 | * on top of everything Quart can do. 14 | 15 | Quart-DB when connecting to PostgresQL databases uses `asyncpg 16 | `_ with `buildpg 17 | `_ to allow for named bind 18 | parameters. 19 | 20 | Quart-DB uses `aiosqlite `_ to 21 | connect to SQLite databases. 22 | 23 | If you are, 24 | 25 | * new to Quart-DB then try the :ref:`quickstart`, 26 | * new to Quart then try the `Quart documentation 27 | `_, 28 | 29 | Quart-DB is developed on `GitHub 30 | `_. If you come across an issue, 31 | or have a feature request please open an `issue 32 | `_.If you want to 33 | contribute a fix or the feature-implementation please do (typo fixes 34 | welcome), by proposing a `merge request 35 | `_. If you want to 36 | ask for help try `on gitter `_. 37 | 38 | Tutorials 39 | --------- 40 | 41 | .. toctree:: 42 | :maxdepth: 2 43 | 44 | tutorials/index.rst 45 | 46 | How to guides 47 | ------------- 48 | 49 | .. toctree:: 50 | :maxdepth: 2 51 | 52 | how_to_guides/index.rst 53 | 54 | Discussion 55 | ---------- 56 | 57 | .. toctree:: 58 | :maxdepth: 2 59 | 60 | discussion/index.rst 61 | 62 | References 63 | ---------- 64 | 65 | .. toctree:: 66 | :maxdepth: 2 67 | 68 | reference/index.rst 69 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/reference/api.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============= 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Contents: 7 | 8 | source/modules.rst 9 | -------------------------------------------------------------------------------- /docs/reference/index.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Reference 3 | ========= 4 | 5 | .. toctree:: 6 | :maxdepth: 1 7 | 8 | api.rst 9 | -------------------------------------------------------------------------------- /docs/tutorials/index.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Tutorials 3 | ========= 4 | 5 | .. toctree:: 6 | :maxdepth: 1 7 | 8 | installation.rst 9 | quickstart.rst 10 | -------------------------------------------------------------------------------- /docs/tutorials/installation.rst: -------------------------------------------------------------------------------- 1 | .. _installation: 2 | 3 | Installation 4 | ============ 5 | 6 | Quart-DB is only compatible with Python 3.9 or higher and can be 7 | installed using pip or your favorite python package manager. You will 8 | need to decide which database you wish to connect to as you install, 9 | for example for postgresql: 10 | 11 | .. code-block:: sh 12 | 13 | pip install quart-db[postgresql] 14 | 15 | Whereas for sqlite: 16 | 17 | .. code-block:: sh 18 | 19 | pip install quart-db[sqlite] 20 | 21 | Installing quart-db will install Quart if it is not present in your 22 | environment. 23 | -------------------------------------------------------------------------------- /docs/tutorials/quickstart.rst: -------------------------------------------------------------------------------- 1 | .. _quickstart: 2 | 3 | Quickstart 4 | ========== 5 | 6 | A simple CRUD API can be written as given below, noting that the 7 | database url should be customised to match your setup (the given URL 8 | creates an in memory sqlite database). 9 | 10 | .. code-block:: python 11 | :caption: schema.py 12 | 13 | from quart import g, Quart, request 14 | from quart_db import QuartDB 15 | 16 | app = Quart(__name__) 17 | db = QuartDB(app, url="sqlite:memory:") 18 | 19 | @app.get("/") 20 | async def get_all(): 21 | results = await g.connection.fetch_all("SELECT col1, col2 FROM tbl") 22 | return [{"a": row["col1"], "b": row["col2"]} for row in results] 23 | 24 | @app.post("/") 25 | async def create(): 26 | data = await request.get_json() 27 | await g.connection.execute( 28 | "INSERT INTO tbl (col1, col2) VALUES (:col1, :col2)", 29 | {"col1": data["a"], "col2": data["b"]}, 30 | ) 31 | return {} 32 | 33 | @app.get("/") 34 | async def get(id): 35 | result = await g.connection.fetch_one( 36 | "SELECT col1, col2 FROM tbl WHERE id = :id", 37 | {"id": id}, 38 | ) 39 | return {"a": result["col1"], "b": result["col2"]} 40 | 41 | @app.delete("/") 42 | async def delete(id): 43 | await g.connection.execute("DELETE FROM tbl WHERE id = :id", {"id": id}) 44 | return {} 45 | 46 | @app.put("/") 47 | async def update(id): 48 | data = await request.get_json() 49 | await g.connection.execute( 50 | "UPDATE tbl SET col1 = :col1, col2 = :col2 WHERE id = :id", 51 | {"id": id, "col1": data["a"], "col2": data["b"]}, 52 | ) 53 | return {} 54 | 55 | Initial migrations 56 | ------------------ 57 | 58 | In the above example it is assumed there is a table named ``tbl`` with 59 | columns ``id``, ``col1`` and ``col2``. If the database does not have a 60 | structure (schema) or you wish to change it, a migration can be 61 | used. 62 | 63 | Quart-DB looks for migrations in a ``migrations`` folder that should 64 | be placed alongside the application (as with ``templates``). A example 65 | would be placing the following in ``migrations/0.py`` 66 | 67 | .. code-block:: python 68 | :caption: migrations/0.py 69 | 70 | async def migrate(connection): 71 | await connection.execute( 72 | "CREATE TABLE tbl (id INT NOT NULL PRIMARY KEY, col1 TEXT, col2 TEXT)" 73 | ) 74 | 75 | async def valid_migration(connection): 76 | return True 77 | 78 | This migration will run once when the application starts. See 79 | :ref:`migrations` for more. 80 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "quart-db" 3 | version = "0.9.0" 4 | description = "Quart-DB is a Quart extension that provides managed connection(s) to database(s)." 5 | authors = [ 6 | {name = "pgjones", email = "philip.graham.jones@googlemail.com"}, 7 | ] 8 | classifiers = [ 9 | "Development Status :: 3 - Alpha", 10 | "Environment :: Web Environment", 11 | "Intended Audience :: Developers", 12 | "License :: OSI Approved :: MIT License", 13 | "Operating System :: OS Independent", 14 | "Programming Language :: Python", 15 | "Programming Language :: Python :: 3", 16 | "Programming Language :: Python :: 3.9", 17 | "Programming Language :: Python :: 3.10", 18 | "Programming Language :: Python :: 3.11", 19 | "Programming Language :: Python :: 3.12", 20 | "Programming Language :: Python :: 3.13", 21 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 22 | "Topic :: Software Development :: Libraries :: Python Modules", 23 | ] 24 | include = ["src/quart_db/py.typed"] 25 | license = {text = "MIT"} 26 | readme = "README.rst" 27 | repository = "https://github.com/pgjones/quart-db/" 28 | dependencies = [ 29 | "asyncpg >= 0.25.0", 30 | "buildpg >= 0.4", 31 | "quart >= 0.16.3", 32 | "typing_extensions; python_version < '3.11'", 33 | ] 34 | requires-python = ">=3.9" 35 | 36 | [project.optional-dependencies] 37 | docs = ["pydata_sphinx_theme"] 38 | erdiagram = ["eralchemy"] 39 | psycopg = ["psycopg >= 3.2"] 40 | sqlite = ["aiosqlite"] 41 | 42 | 43 | [tool.black] 44 | line-length = 100 45 | target-version = ["py39"] 46 | 47 | [tool.isort] 48 | combine_as_imports = true 49 | force_grid_wrap = 0 50 | include_trailing_comma = true 51 | known_first_party = "quart_db, tests" 52 | line_length = 100 53 | multi_line_output = 3 54 | no_lines_before = "LOCALFOLDER" 55 | order_by_type = false 56 | reverse_relative = true 57 | 58 | [tool.mypy] 59 | allow_redefinition = true 60 | disallow_any_generics = false 61 | disallow_subclassing_any = true 62 | disallow_untyped_calls = false 63 | disallow_untyped_defs = true 64 | implicit_reexport = true 65 | no_implicit_optional = true 66 | show_error_codes = true 67 | strict = true 68 | strict_equality = true 69 | strict_optional = false 70 | warn_redundant_casts = true 71 | warn_return_any = false 72 | warn_unused_configs = true 73 | warn_unused_ignores = true 74 | 75 | [[tool.mypy.overrides]] 76 | module =["aiosqlite.*", "asyncpg.*", "buildpg.*"] 77 | ignore_missing_imports = true 78 | 79 | [tool.pytest.ini_options] 80 | addopts = "--no-cov-on-fail --showlocals --strict-markers" 81 | asyncio_default_fixture_loop_scope = "function" 82 | asyncio_mode = "auto" 83 | testpaths = ["tests"] 84 | 85 | [build-system] 86 | requires = ["pdm-backend"] 87 | build-backend = "pdm.backend" 88 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203, E252, W503, W504 3 | max_line_length = 100 4 | -------------------------------------------------------------------------------- /src/quart_db/__init__.py: -------------------------------------------------------------------------------- 1 | from .extension import QuartDB 2 | from .interfaces import ( 3 | ConnectionABC as Connection, 4 | TransactionABC as Transaction, 5 | UndefinedParameterError, 6 | ) 7 | 8 | __all__ = ("Connection", "QuartDB", "Transaction", "UndefinedParameterError") 9 | -------------------------------------------------------------------------------- /src/quart_db/_migration.py: -------------------------------------------------------------------------------- 1 | import importlib.util 2 | from contextlib import AbstractAsyncContextManager, asynccontextmanager 3 | from pathlib import Path 4 | from types import ModuleType 5 | from typing import AsyncGenerator, Callable, Literal 6 | 7 | from .interfaces import BackendABC, ConnectionABC 8 | 9 | 10 | class MigrationFailedError(Exception): 11 | pass 12 | 13 | 14 | @asynccontextmanager 15 | async def null_context() -> AsyncGenerator[None, None]: 16 | yield None 17 | 18 | 19 | async def execute_foreground_migrations( 20 | backend: BackendABC, 21 | migrations_path: Path, 22 | state_table_name: str, 23 | ) -> None: 24 | connection = await backend._acquire_migration_connection() 25 | try: 26 | async for module in _migration_generator( 27 | connection, "foreground", migrations_path, state_table_name, connection.transaction 28 | ): 29 | await module.migrate(connection) 30 | valid = not hasattr(module, "valid_migration") or await module.valid_migration( 31 | connection 32 | ) 33 | if not valid: 34 | raise MigrationFailedError(f"Migration {module.__name__} is not valid") 35 | finally: 36 | await backend._release_migration_connection(connection) 37 | 38 | 39 | async def execute_background_migrations( 40 | backend: BackendABC, 41 | migrations_path: Path, 42 | state_table_name: str, 43 | ) -> None: 44 | connection = await backend._acquire_migration_connection() 45 | try: 46 | async for module in _migration_generator( 47 | connection, "background", migrations_path, state_table_name, null_context 48 | ): 49 | migrate = getattr(module, "background_migrate", None) 50 | if migrate is not None: 51 | await migrate(connection) 52 | finally: 53 | await backend._release_migration_connection(connection) 54 | 55 | 56 | async def execute_data_loader( 57 | backend: BackendABC, 58 | data_path: Path, 59 | state_table_name: str, 60 | ) -> None: 61 | connection = await backend._acquire_migration_connection() 62 | try: 63 | for_update = "FOR UPDATE" if connection.supports_for_update else "" 64 | 65 | async with connection.transaction(): 66 | data_loaded = await connection.fetch_val( 67 | f"SELECT data_loaded FROM {state_table_name} {for_update}" 68 | ) 69 | if not data_loaded: 70 | module = _load_module("quart_db_data", data_path) 71 | try: 72 | await module.execute(connection) 73 | except Exception: 74 | raise MigrationFailedError("Error loading data") 75 | else: 76 | await connection.execute(f"UPDATE {state_table_name} SET data_loaded = TRUE") 77 | finally: 78 | await backend._release_migration_connection(connection) 79 | 80 | 81 | def _load_module(name: str, path: Path) -> ModuleType: 82 | spec = importlib.util.spec_from_file_location(name, path) 83 | module = importlib.util.module_from_spec(spec) 84 | spec.loader.exec_module(module) 85 | return module 86 | 87 | 88 | async def _migration_generator( 89 | connection: ConnectionABC, 90 | type_name: Literal["foreground", "background"], 91 | migrations_path: Path, 92 | state_table_name: str, 93 | context: Callable[..., AbstractAsyncContextManager], 94 | ) -> AsyncGenerator[ModuleType, None]: 95 | for_update = "FOR UPDATE" if connection.supports_for_update else "" 96 | 97 | while True: 98 | async with context(): 99 | migration = await connection.fetch_val( 100 | f"SELECT {type_name} FROM {state_table_name} {for_update}" 101 | ) 102 | migration += 1 103 | migration_path = migrations_path / f"{migration}.py" 104 | try: 105 | module = _load_module(f"quart_db_{migration}", migration_path) 106 | except FileNotFoundError: 107 | if migration > 0 and not (migrations_path / f"{migration - 1}.py").exists(): 108 | raise MigrationFailedError("Database is ahead of local migrations") from None 109 | else: 110 | return 111 | 112 | yield module 113 | 114 | await connection.execute( 115 | f"UPDATE {state_table_name} SET {type_name} = :migration", 116 | values={"migration": migration}, 117 | ) 118 | 119 | 120 | async def ensure_state_table(backend: BackendABC, state_table_name: str) -> None: 121 | connection = await backend._acquire_migration_connection() 122 | 123 | # This is required to migrate previous state version tables 124 | try: 125 | result = await connection.fetch_one(f"SELECT version, data_loaded FROM {state_table_name}") 126 | except Exception: # Either table or column "version" does not exist 127 | version = -1 128 | data_loaded = False 129 | else: # "version" does exist => old table structure 130 | version = result["version"] 131 | data_loaded = result["data_loaded"] 132 | await connection.execute(f"DROP TABLE {state_table_name}") 133 | 134 | try: 135 | await connection.execute( 136 | f"""CREATE TABLE IF NOT EXISTS {state_table_name} ( 137 | onerow_id BOOL PRIMARY KEY DEFAULT TRUE, 138 | background INTEGER NOT NULL, 139 | data_loaded BOOL NOT NULL, 140 | foreground INTEGER NOT NULL, 141 | 142 | CONSTRAINT onerow_uni CHECK (onerow_id) 143 | )""", 144 | ) 145 | await connection.execute( 146 | f"""INSERT INTO {state_table_name} (background, data_loaded, foreground) 147 | VALUES (:version, :data_loaded, :version) 148 | ON CONFLICT DO NOTHING""", 149 | {"version": version, "data_loaded": data_loaded}, 150 | ) 151 | finally: 152 | await backend._release_migration_connection(connection) 153 | -------------------------------------------------------------------------------- /src/quart_db/backends/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pgjones/quart-db/c55b6ef1c6564300ccfe5b22b33124967aee4ec8/src/quart_db/backends/__init__.py -------------------------------------------------------------------------------- /src/quart_db/backends/aiosqlite.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | from sqlite3 import PARSE_DECLTYPES, ProgrammingError 4 | from types import TracebackType 5 | from typing import Any, AsyncGenerator, Dict, List, Optional, Set 6 | from urllib.parse import urlsplit 7 | from uuid import uuid4 8 | 9 | import aiosqlite 10 | 11 | from ..interfaces import ( 12 | BackendABC, 13 | ConnectionABC, 14 | RecordType, 15 | TransactionABC, 16 | TypeConverters, 17 | UndefinedParameterError, 18 | ValueType, 19 | ) 20 | 21 | try: 22 | from typing import LiteralString 23 | except ImportError: 24 | from typing_extensions import LiteralString 25 | 26 | DEFAULT_TYPE_CONVERTERS = { 27 | "": { 28 | "json": (json.dumps, json.loads, dict), 29 | }, 30 | } 31 | 32 | 33 | class Transaction(TransactionABC): 34 | def __init__(self, connection: "Connection", *, force_rollback: bool = False) -> None: 35 | self._connection = connection 36 | self._force_rollback = force_rollback 37 | self._savepoints: List[str] = [] 38 | 39 | async def __aenter__(self) -> "Transaction": 40 | await self.start() 41 | return self 42 | 43 | async def __aexit__(self, exc_type: type, exc_value: BaseException, tb: TracebackType) -> None: 44 | if self._force_rollback or exc_type is not None: 45 | await self.rollback() 46 | else: 47 | await self.commit() 48 | 49 | async def start(self) -> None: 50 | if self._connection._connection.in_transaction: 51 | savepoint_name = f"QUART_DB_SAVEPOINT_{uuid4().hex}" 52 | await self._connection._connection.execute(f"SAVEPOINT {savepoint_name}") 53 | self._savepoints.append(savepoint_name) 54 | else: 55 | async with self._connection._lock: 56 | await self._connection._connection.execute("BEGIN") 57 | 58 | async def commit(self) -> None: 59 | if len(self._savepoints): 60 | savepoint_name = self._savepoints.pop() 61 | await self._connection._connection.execute(f"RELEASE SAVEPOINT {savepoint_name}") 62 | else: 63 | async with self._connection._lock: 64 | await self._connection._connection.execute("COMMIT") 65 | 66 | async def rollback(self) -> None: 67 | if len(self._savepoints): 68 | savepoint_name = self._savepoints.pop() 69 | await self._connection._connection.execute(f"ROLLBACK TO SAVEPOINT {savepoint_name}") 70 | else: 71 | async with self._connection._lock: 72 | await self._connection._connection.execute("ROLLBACK") 73 | 74 | 75 | class Connection(ConnectionABC): 76 | supports_for_update = False 77 | 78 | def __init__(self, connection: aiosqlite.Connection) -> None: 79 | self._connection = connection 80 | self._lock = asyncio.Lock() 81 | 82 | async def execute(self, query: LiteralString, values: Optional[ValueType] = None) -> None: 83 | try: 84 | async with self._lock: 85 | await self._connection.execute(query, values) 86 | except ProgrammingError as error: 87 | raise UndefinedParameterError(str(error)) 88 | 89 | async def execute_many(self, query: LiteralString, values: List[ValueType]) -> None: 90 | if not values: 91 | return 92 | 93 | try: 94 | async with self._lock: 95 | await self._connection.executemany(query, values) 96 | except ProgrammingError as error: 97 | raise UndefinedParameterError(str(error)) 98 | 99 | async def fetch_all( 100 | self, 101 | query: LiteralString, 102 | values: Optional[ValueType] = None, 103 | ) -> List[RecordType]: 104 | try: 105 | async with self._lock: 106 | async with self._connection.execute(query, values) as cursor: 107 | rows = await cursor.fetchall() 108 | except ProgrammingError as error: 109 | raise UndefinedParameterError(str(error)) 110 | else: 111 | return [{key: row[key] for key in row.keys()} for row in rows] 112 | 113 | async def fetch_one( 114 | self, 115 | query: LiteralString, 116 | values: Optional[ValueType] = None, 117 | ) -> Optional[RecordType]: 118 | try: 119 | async with self._lock: 120 | async with self._connection.execute(query, values) as cursor: 121 | row = await cursor.fetchone() 122 | except ProgrammingError as error: 123 | raise UndefinedParameterError(str(error)) 124 | else: 125 | if row is not None: 126 | return {key: row[key] for key in row.keys()} 127 | return None 128 | 129 | async def fetch_val( 130 | self, 131 | query: LiteralString, 132 | values: Optional[ValueType] = None, 133 | ) -> Optional[Any]: 134 | try: 135 | async with self._lock: 136 | async with self._connection.execute(query, values) as cursor: 137 | result = await cursor.fetchone() 138 | except ProgrammingError as error: 139 | raise UndefinedParameterError(str(error)) 140 | else: 141 | if result is not None: 142 | return result[0] 143 | return None 144 | 145 | async def iterate( 146 | self, 147 | query: LiteralString, 148 | values: Optional[ValueType] = None, 149 | ) -> AsyncGenerator[RecordType, None]: 150 | try: 151 | async with self._lock: 152 | async with self._connection.execute(query, values) as cursor: 153 | async for row in cursor: 154 | yield {key: row[key] for key in row.keys()} 155 | except ProgrammingError as error: 156 | raise UndefinedParameterError(str(error)) 157 | 158 | def transaction(self, *, force_rollback: bool = False) -> "Transaction": 159 | return Transaction(self, force_rollback=force_rollback) 160 | 161 | 162 | class Backend(BackendABC): 163 | def __init__(self, url: str, options: Dict[str, Any], type_converters: TypeConverters) -> None: 164 | _, _, path, *_ = urlsplit(url) 165 | self._path = path[1:] 166 | self._options = options 167 | self._connections: Set[aiosqlite.Connection] = set() 168 | for _, converters in {**DEFAULT_TYPE_CONVERTERS, **type_converters}.items(): 169 | for typename, (encoder, decoder, pytype) in converters.items(): # type: ignore 170 | aiosqlite.register_adapter(pytype, encoder) 171 | aiosqlite.register_converter(typename, decoder) 172 | 173 | async def connect(self) -> None: 174 | pass 175 | 176 | async def disconnect(self, timeout: Optional[int] = None) -> None: 177 | tasks = [asyncio.ensure_future(connection.close()) for connection in self._connections] 178 | await asyncio.wait_for(asyncio.gather(*tasks), timeout) 179 | 180 | async def acquire(self) -> Connection: 181 | connection = aiosqlite.connect( 182 | database=self._path, 183 | isolation_level=None, 184 | detect_types=PARSE_DECLTYPES, 185 | **self._options, 186 | ) 187 | await connection.__aenter__() 188 | connection.row_factory = aiosqlite.Row 189 | self._connections.add(connection) 190 | return Connection(connection) 191 | 192 | async def release(self, connection: Connection) -> None: # type: ignore[override] 193 | await connection._connection.__aexit__(None, None, None) 194 | self._connections.remove(connection._connection) 195 | 196 | async def _acquire_migration_connection(self) -> Connection: 197 | connection = aiosqlite.connect( 198 | database=self._path, 199 | isolation_level=None, 200 | ) 201 | await connection.__aenter__() 202 | connection.row_factory = aiosqlite.Row 203 | return Connection(connection) 204 | 205 | async def _release_migration_connection(self, connection: Connection) -> None: # type: ignore[override] # noqa: E501 206 | await connection._connection.__aexit__(None, None, None) 207 | 208 | 209 | class TestingBackend(BackendABC): 210 | def __init__(self, url: str, options: Dict[str, Any], type_converters: TypeConverters) -> None: 211 | _, _, path, *_ = urlsplit(url) 212 | self._path = path[1:] 213 | self._options = options 214 | for _, converters in {**DEFAULT_TYPE_CONVERTERS, **type_converters}.items(): 215 | for typename, (encoder, decoder, pytype) in converters.items(): # type: ignore 216 | aiosqlite.register_adapter(pytype, encoder) 217 | aiosqlite.register_converter(typename, decoder) 218 | 219 | async def connect(self) -> None: 220 | connection = aiosqlite.connect( 221 | database=self._path, 222 | isolation_level=None, 223 | detect_types=PARSE_DECLTYPES, 224 | **self._options, 225 | ) 226 | await connection.__aenter__() 227 | self._connection = Connection(connection) 228 | 229 | async def disconnect(self, timeout: Optional[int] = None) -> None: 230 | await asyncio.wait_for(self._connection._connection.close(), timeout) 231 | 232 | async def acquire(self) -> Connection: 233 | return self._connection 234 | 235 | async def release(self, connection: Connection) -> None: # type: ignore[override] 236 | pass 237 | 238 | async def _acquire_migration_connection(self) -> Connection: 239 | connection = aiosqlite.connect( 240 | database=self._path, 241 | isolation_level=None, 242 | ) 243 | await connection.__aenter__() 244 | connection.row_factory = aiosqlite.Row 245 | return Connection(connection) 246 | 247 | async def _release_migration_connection(self, connection: Connection) -> None: # type: ignore[override] # noqa: E501 248 | await connection._connection.__aexit__(None, None, None) 249 | -------------------------------------------------------------------------------- /src/quart_db/backends/asyncpg.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | from types import TracebackType 4 | from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple 5 | 6 | import asyncpg 7 | from buildpg import BuildError, render 8 | 9 | from ..interfaces import ( 10 | BackendABC, 11 | ConnectionABC, 12 | RecordType, 13 | TransactionABC, 14 | TypeConverters, 15 | UndefinedParameterError, 16 | ValueType, 17 | ) 18 | 19 | try: 20 | from typing import LiteralString 21 | except ImportError: 22 | from typing_extensions import LiteralString 23 | 24 | DEFAULT_TYPE_CONVERTERS = { 25 | "pg_catalog": { 26 | "json": (json.dumps, json.loads, None), 27 | "jsonb": (json.dumps, json.loads, None), 28 | } 29 | } 30 | 31 | 32 | class Transaction(TransactionABC): 33 | def __init__(self, connection: "Connection", *, force_rollback: bool = False) -> None: 34 | self._connection = connection 35 | self._transaction: Optional[asyncpg.Transaction] = None 36 | self._force_rollback = force_rollback 37 | 38 | async def __aenter__(self) -> "Transaction": 39 | await self.start() 40 | return self 41 | 42 | async def __aexit__(self, exc_type: type, exc_value: BaseException, tb: TracebackType) -> None: 43 | if self._force_rollback or exc_type is not None: 44 | await self.rollback() 45 | else: 46 | await self.commit() 47 | 48 | async def start(self) -> None: 49 | async with self._connection._lock: 50 | self._transaction = self._connection._connection.transaction() 51 | await self._transaction.start() 52 | 53 | async def commit(self) -> None: 54 | async with self._connection._lock: 55 | await self._transaction.commit() 56 | self._transaction = None 57 | 58 | async def rollback(self) -> None: 59 | async with self._connection._lock: 60 | await self._transaction.rollback() 61 | self._transaction = None 62 | 63 | 64 | class Connection(ConnectionABC): 65 | supports_for_update = True 66 | 67 | def __init__(self, connection: asyncpg.Connection) -> None: 68 | self._connection = connection 69 | self._lock = asyncio.Lock() 70 | 71 | async def execute(self, query: LiteralString, values: Optional[ValueType] = None) -> None: 72 | compiled_query, args = self._compile(query, values) 73 | try: 74 | async with self._lock: 75 | return await self._connection.execute(compiled_query, *args) 76 | except asyncpg.exceptions.UndefinedParameterError as error: 77 | raise UndefinedParameterError(str(error)) 78 | 79 | async def execute_many(self, query: LiteralString, values: List[ValueType]) -> None: 80 | if not values: 81 | return 82 | 83 | compiled_queries = [self._compile(query, value) for value in values] 84 | compiled_query = compiled_queries[0][0] 85 | args = [query[1] for query in compiled_queries] 86 | try: 87 | async with self._lock: 88 | return await self._connection.executemany(compiled_query, args) 89 | except asyncpg.exceptions.UndefinedParameterError as error: 90 | raise UndefinedParameterError(str(error)) 91 | 92 | async def fetch_all( 93 | self, 94 | query: LiteralString, 95 | values: Optional[ValueType] = None, 96 | ) -> List[RecordType]: 97 | compiled_query, args = self._compile(query, values) 98 | try: 99 | async with self._lock: 100 | return await self._connection.fetch(compiled_query, *args) 101 | except asyncpg.exceptions.UndefinedParameterError as error: 102 | raise UndefinedParameterError(str(error)) 103 | 104 | async def fetch_one( 105 | self, 106 | query: LiteralString, 107 | values: Optional[ValueType] = None, 108 | ) -> RecordType: 109 | compiled_query, args = self._compile(query, values) 110 | try: 111 | async with self._lock: 112 | return await self._connection.fetchrow(compiled_query, *args) 113 | except asyncpg.exceptions.UndefinedParameterError as error: 114 | raise UndefinedParameterError(str(error)) 115 | 116 | async def fetch_val( 117 | self, 118 | query: LiteralString, 119 | values: Optional[ValueType] = None, 120 | ) -> Any: 121 | compiled_query, args = self._compile(query, values) 122 | try: 123 | async with self._lock: 124 | return await self._connection.fetchval(compiled_query, *args) 125 | except asyncpg.exceptions.UndefinedParameterError as error: 126 | raise UndefinedParameterError(str(error)) 127 | 128 | async def iterate( 129 | self, 130 | query: LiteralString, 131 | values: Optional[ValueType] = None, 132 | ) -> AsyncGenerator[RecordType, None]: 133 | compiled_query, args = self._compile(query, values) 134 | async with self._lock: 135 | async with self._connection.transaction(): 136 | try: 137 | async for record in self._connection.cursor(compiled_query, *args): 138 | yield record 139 | except asyncpg.exceptions.UndefinedParameterError as error: 140 | raise UndefinedParameterError(str(error)) 141 | 142 | def transaction(self, *, force_rollback: bool = False) -> "Transaction": 143 | return Transaction(self, force_rollback=force_rollback) 144 | 145 | def _compile( 146 | self, query: LiteralString, values: Optional[ValueType] = None 147 | ) -> Tuple[str, List[Any]]: 148 | if isinstance(values, dict): 149 | try: 150 | return render(query, **(values or {})) 151 | except BuildError as error: 152 | raise UndefinedParameterError(str(error)) 153 | elif values is not None: 154 | return query, values 155 | else: 156 | return query, [] 157 | 158 | 159 | class Backend(BackendABC): 160 | def __init__(self, url: str, options: Dict[str, Any], type_converters: TypeConverters) -> None: 161 | self._pool: Optional[asyncpg.Pool] = None 162 | self._url = url 163 | self._options = options 164 | self._type_converters = {**DEFAULT_TYPE_CONVERTERS, **type_converters} 165 | 166 | async def connect(self) -> None: 167 | if self._pool is None: 168 | self._pool = await asyncpg.create_pool(dsn=self._url, init=self._init, **self._options) 169 | 170 | async def disconnect(self, timeout: Optional[int] = None) -> None: 171 | if self._pool is not None: 172 | await asyncio.wait_for(self._pool.close(), timeout) 173 | self._pool = None 174 | 175 | async def acquire(self) -> Connection: 176 | connection = await self._pool.acquire() 177 | return Connection(connection) 178 | 179 | async def release(self, connection: Connection) -> None: # type: ignore[override] 180 | await self._pool.release(connection._connection) 181 | 182 | async def _acquire_migration_connection(self) -> Connection: 183 | asyncpg_connection = await asyncpg.connect(dsn=self._url) 184 | await _init_connection(asyncpg_connection, DEFAULT_TYPE_CONVERTERS) # type: ignore 185 | return Connection(asyncpg_connection) 186 | 187 | async def _release_migration_connection(self, connection: Connection) -> None: # type: ignore[override] # noqa: E501 188 | await connection._connection.close() 189 | 190 | async def _init(self, connection: asyncpg.Connection) -> None: 191 | await _init_connection(connection, self._type_converters) # type: ignore 192 | 193 | 194 | class TestingBackend(BackendABC): 195 | def __init__(self, url: str, options: Dict[str, Any], type_converters: TypeConverters) -> None: 196 | self._url = url 197 | self._options = options 198 | self._type_converters = {**DEFAULT_TYPE_CONVERTERS, **type_converters} 199 | 200 | async def connect(self) -> None: 201 | self._connection = Connection(await asyncpg.connect(dsn=self._url, **self._options)) 202 | await _init_connection(self._connection._connection, self._type_converters) # type: ignore 203 | 204 | async def disconnect(self, timeout: Optional[int] = None) -> None: 205 | await asyncio.wait_for(self._connection._connection.close(), timeout) 206 | 207 | async def acquire(self) -> Connection: 208 | return self._connection 209 | 210 | async def release(self, connection: Connection) -> None: # type: ignore[override] 211 | pass 212 | 213 | async def _acquire_migration_connection(self) -> Connection: 214 | asyncpg_connection = await asyncpg.connect(dsn=self._url) 215 | await _init_connection(asyncpg_connection, DEFAULT_TYPE_CONVERTERS) # type: ignore 216 | return Connection(asyncpg_connection) 217 | 218 | async def _release_migration_connection(self, connection: Connection) -> None: # type: ignore[override] # noqa: E501 219 | await connection._connection.close() 220 | 221 | 222 | async def _init_connection(connection: asyncpg.Connection, type_converters: TypeConverters) -> None: 223 | for schema, converters in type_converters.items(): 224 | for typename, (encoder, decoder, _) in converters.items(): 225 | await connection.set_type_codec( 226 | typename, 227 | encoder=encoder, 228 | decoder=decoder, 229 | schema=schema, 230 | ) 231 | -------------------------------------------------------------------------------- /src/quart_db/backends/psycopg.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | from types import TracebackType 4 | from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple 5 | 6 | import asyncpg 7 | import psycopg 8 | from buildpg import BuildError, render 9 | from psycopg.adapt import Dumper, Loader 10 | from psycopg.rows import dict_row 11 | from psycopg.types import TypeInfo 12 | from psycopg_pool import AsyncConnectionPool 13 | 14 | from ..interfaces import ( 15 | BackendABC, 16 | ConnectionABC, 17 | RecordType, 18 | TransactionABC, 19 | TypeConverters, 20 | UndefinedParameterError, 21 | ValueType, 22 | ) 23 | 24 | try: 25 | from typing import LiteralString 26 | except ImportError: 27 | from typing_extensions import LiteralString 28 | 29 | DEFAULT_TYPE_CONVERTERS = { 30 | "pg_catalog": { 31 | "json": (json.dumps, json.loads, dict), 32 | "jsonb": (json.dumps, json.loads, dict), 33 | } 34 | } 35 | 36 | 37 | class Transaction(TransactionABC): 38 | def __init__(self, connection: "Connection", *, force_rollback: bool = False) -> None: 39 | self._connection = connection 40 | self._transaction: Optional[psycopg.AsyncTransaction] = None 41 | self._force_rollback = force_rollback 42 | 43 | async def __aenter__(self) -> "Transaction": 44 | await self.start() 45 | return self 46 | 47 | async def __aexit__(self, exc_type: type, exc_value: BaseException, tb: TracebackType) -> None: 48 | if self._force_rollback or exc_type is not None: 49 | await self.rollback() 50 | else: 51 | await self.commit() 52 | 53 | async def start(self) -> None: 54 | self._transaction = psycopg.AsyncTransaction(self._connection._connection) 55 | await self._connection._connection.wait(self._transaction._enter_gen()) 56 | 57 | async def commit(self) -> None: 58 | await self._connection._connection.wait(self._transaction._exit_gen(None, None, None)) 59 | self._transaction = None 60 | 61 | async def rollback(self) -> None: 62 | self._transaction.force_rollback = True 63 | await self._connection._connection.wait(self._transaction._exit_gen(None, None, None)) 64 | self._transaction = None 65 | 66 | 67 | class Connection(ConnectionABC): 68 | supports_for_update = True 69 | 70 | def __init__(self, connection: psycopg.AsyncConnection) -> None: 71 | self._connection = connection 72 | 73 | async def execute(self, query: LiteralString, values: Optional[ValueType] = None) -> None: 74 | compiled_query, args = self._compile(query, values) 75 | try: 76 | async with self._connection.cursor() as cursor: 77 | await cursor.execute(compiled_query, args) 78 | except psycopg.ProgrammingError as error: 79 | raise UndefinedParameterError(str(error)) 80 | 81 | async def execute_many(self, query: LiteralString, values: List[ValueType]) -> None: 82 | if not values: 83 | return 84 | 85 | compiled_queries = [self._compile(query, value) for value in values] 86 | compiled_query = compiled_queries[0][0] 87 | args = [query[1] for query in compiled_queries] 88 | try: 89 | async with self._connection.cursor() as cursor: 90 | return await cursor.executemany(compiled_query, args) 91 | except psycopg.ProgrammingError as error: 92 | raise UndefinedParameterError(str(error)) 93 | 94 | async def fetch_all( 95 | self, 96 | query: LiteralString, 97 | values: Optional[ValueType] = None, 98 | ) -> List[RecordType]: 99 | compiled_query, args = self._compile(query, values) 100 | try: 101 | async with self._connection.cursor() as cursor: 102 | await cursor.execute(compiled_query, args) 103 | return await cursor.fetchall() # type: ignore 104 | except psycopg.ProgrammingError as error: 105 | raise UndefinedParameterError(str(error)) 106 | 107 | async def fetch_one( 108 | self, 109 | query: LiteralString, 110 | values: Optional[ValueType] = None, 111 | ) -> Optional[RecordType]: 112 | compiled_query, args = self._compile(query, values) 113 | try: 114 | async with self._connection.cursor() as cursor: 115 | await cursor.execute(compiled_query, args) 116 | return await cursor.fetchone() # type: ignore 117 | except psycopg.ProgrammingError as error: 118 | raise UndefinedParameterError(str(error)) 119 | 120 | async def fetch_val( 121 | self, 122 | query: LiteralString, 123 | values: Optional[ValueType] = None, 124 | ) -> Optional[Any]: 125 | compiled_query, args = self._compile(query, values) 126 | try: 127 | async with self._connection.cursor() as cursor: 128 | await cursor.execute(compiled_query, args) 129 | result = await cursor.fetchone() 130 | if result is not None: 131 | return next(iter(result.values())) # type: ignore 132 | else: 133 | return None 134 | except psycopg.ProgrammingError as error: 135 | raise UndefinedParameterError(str(error)) 136 | 137 | async def iterate( 138 | self, 139 | query: LiteralString, 140 | values: Optional[ValueType] = None, 141 | ) -> AsyncGenerator[RecordType, None]: 142 | compiled_query, args = self._compile(query, values) 143 | async with self._connection.cursor() as cursor: 144 | try: 145 | async for record in cursor.stream(compiled_query, *args): 146 | yield record # type: ignore 147 | except psycopg.ProgrammingError as error: 148 | raise UndefinedParameterError(str(error)) 149 | 150 | def transaction(self, *, force_rollback: bool = False) -> "Transaction": 151 | return Transaction(self, force_rollback=force_rollback) 152 | 153 | def _compile( 154 | self, query: LiteralString, values: Optional[ValueType] = None 155 | ) -> Tuple[str, List[Any]]: 156 | if isinstance(values, list): 157 | return query, values 158 | else: 159 | try: 160 | return render(query, **(values or {})) 161 | except BuildError as error: 162 | raise UndefinedParameterError(str(error)) 163 | 164 | 165 | class Backend(BackendABC): 166 | def __init__(self, url: str, options: Dict[str, Any], type_converters: TypeConverters) -> None: 167 | self._pool: Optional[asyncpg.Pool] = None 168 | self._url = url 169 | self._options = options 170 | self._type_converters = {**DEFAULT_TYPE_CONVERTERS, **type_converters} 171 | 172 | async def connect(self) -> None: 173 | if self._pool is None: 174 | self._pool = AsyncConnectionPool( 175 | self._url, 176 | kwargs={ 177 | "autocommit": True, 178 | "cursor_factory": psycopg.AsyncRawCursor, 179 | "row_factory": dict_row, 180 | }, 181 | **self._options, 182 | ) 183 | await self._pool.open() 184 | 185 | async def disconnect(self, timeout: Optional[int] = None) -> None: 186 | if self._pool is not None: 187 | await asyncio.wait_for(self._pool.close(), timeout) 188 | self._pool = None 189 | 190 | async def acquire(self) -> Connection: 191 | connection = await self._pool.getconn() 192 | await _init_connection(connection, self._type_converters) # type: ignore 193 | return Connection(connection) 194 | 195 | async def release(self, connection: Connection) -> None: # type: ignore[override] 196 | await self._pool.putconn(connection._connection) 197 | 198 | async def _acquire_migration_connection(self) -> Connection: 199 | psycopg_connection = await psycopg.AsyncConnection.connect( 200 | self._url, autocommit=True, cursor_factory=psycopg.AsyncRawCursor, row_factory=dict_row 201 | ) 202 | await _init_connection(psycopg_connection, DEFAULT_TYPE_CONVERTERS) # type: ignore 203 | return Connection(psycopg_connection) 204 | 205 | async def _release_migration_connection(self, connection: Connection) -> None: # type: ignore[override] # noqa: E501 206 | await connection._connection.close() 207 | 208 | async def _init(self, connection: asyncpg.Connection) -> None: 209 | await _init_connection(connection, self._type_converters) # type: ignore 210 | 211 | 212 | class TestingBackend(BackendABC): 213 | def __init__(self, url: str, options: Dict[str, Any], type_converters: TypeConverters) -> None: 214 | self._url = url 215 | self._options = options 216 | self._type_converters = {**DEFAULT_TYPE_CONVERTERS, **type_converters} 217 | 218 | async def connect(self) -> None: 219 | self._connection = Connection( 220 | await psycopg.AsyncConnection.connect( 221 | self._url, 222 | autocommit=True, 223 | cursor_factory=psycopg.AsyncRawCursor, 224 | row_factory=dict_row, # type: ignore 225 | ) 226 | ) 227 | await _init_connection(self._connection._connection, self._type_converters) # type: ignore 228 | 229 | async def disconnect(self, timeout: Optional[int] = None) -> None: 230 | await asyncio.wait_for(self._connection._connection.close(), timeout) 231 | 232 | async def acquire(self) -> Connection: 233 | return self._connection 234 | 235 | async def release(self, connection: Connection) -> None: # type: ignore[override] 236 | pass 237 | 238 | async def _acquire_migration_connection(self) -> Connection: 239 | psycopg_connection = await psycopg.AsyncConnection.connect( 240 | self._url, autocommit=True, cursor_factory=psycopg.AsyncRawCursor, row_factory=dict_row 241 | ) 242 | await _init_connection(psycopg_connection, DEFAULT_TYPE_CONVERTERS) # type: ignore 243 | return Connection(psycopg_connection) 244 | 245 | async def _release_migration_connection(self, connection: Connection) -> None: # type: ignore[override] # noqa: E501 246 | await connection._connection.close() 247 | 248 | 249 | async def _init_connection(connection: psycopg.Connection, type_converters: TypeConverters) -> None: 250 | for schema, converters in type_converters.items(): 251 | for typename, (encoder, decoder, type_) in converters.items(): 252 | psycopg_type = await TypeInfo.fetch(connection, typename) # type: ignore 253 | psycopg_type.register(connection) 254 | 255 | class CustomLoader(Loader): 256 | def load(self, data: bytes, decoder=decoder) -> Any: # type: ignore 257 | return decoder(data.decode()) 258 | 259 | class CustomDumper(Dumper): 260 | oid = psycopg_type.oid 261 | 262 | def dump(self, elem: Any, encoder=encoder) -> bytes: # type: ignore 263 | return encoder(elem).encode() 264 | 265 | connection.adapters.register_loader(psycopg_type.oid, CustomLoader) 266 | connection.adapters.register_dumper(type_, CustomDumper) 267 | -------------------------------------------------------------------------------- /src/quart_db/extension.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from collections import defaultdict 3 | from contextlib import asynccontextmanager 4 | from pathlib import Path 5 | from typing import Any, AsyncIterator, Callable, Dict, Optional, Type 6 | from urllib.parse import urlsplit, urlunsplit 7 | 8 | import click 9 | from quart import g, Quart 10 | from quart.cli import pass_script_info, ScriptInfo 11 | 12 | from ._migration import ( 13 | ensure_state_table, 14 | execute_background_migrations, 15 | execute_data_loader, 16 | execute_foreground_migrations, 17 | ) 18 | from .interfaces import BackendABC, ConnectionABC, TypeConverters 19 | 20 | 21 | class MigrationTimeoutError(Exception): 22 | pass 23 | 24 | 25 | class QuartDB: 26 | """A QuartDB database instance from which connections can be acquired. 27 | 28 | This can be used to initialise Quart-Schema documentation a given 29 | app, either directly, 30 | 31 | .. code-block:: python 32 | 33 | app = Quart(__name__) 34 | quart_db = QuartDB(app) 35 | 36 | or via the factory pattern, 37 | 38 | .. code-block:: python 39 | 40 | quart_db = QuartDB() 41 | 42 | def create_app(): 43 | app = Quart(__name__) 44 | quart_db.init_app(app) 45 | return app 46 | 47 | It can then be used to establish connections to the database, 48 | 49 | .. code-block:: python 50 | 51 | async with quart_db.connection() as connection: 52 | await connection.execute("SELECT 1") 53 | 54 | Arguments: 55 | app: The app to associate this instance with, can be None if 56 | using the factory pattern. 57 | url: The URL to use to connect to the database, can be None 58 | and QUART_DB_DATABASE_URL used instead. 59 | migrations_folder: Location of migrations relative to the 60 | app's root path, defaults to "migrations". 61 | data_path: Location of any initial data relative to the apps' 62 | root path. Can be None. 63 | auto_request_connection: If True (the default) a connection 64 | is acquired and placed on g for each request. 65 | backend_options: Options to pass directly to the backend 66 | engines. Will depend on the backend used. 67 | test_connection_options: Options to pass directly to the 68 | connection used for testing. Will depend on the 69 | backend used. 70 | state_table_name: The name of the table used to store the 71 | migration status. 72 | """ 73 | 74 | def __init__( 75 | self, 76 | app: Optional[Quart] = None, 77 | *, 78 | url: Optional[str] = None, 79 | migrations_folder: Optional[str] = "migrations", 80 | data_path: Optional[str] = None, 81 | auto_request_connection: bool = True, 82 | backend_options: Optional[Dict[str, Any]] = None, 83 | test_connection_options: Optional[Dict[str, Any]] = None, 84 | migration_timeout: Optional[float] = None, 85 | state_table_name: Optional[str] = None, 86 | ) -> None: 87 | self._close_timeout = 5 # Seconds 88 | self._url = url 89 | self._backend_options = backend_options 90 | if self._backend_options is None: 91 | self._backend_options = {} 92 | self._test_connection_options = test_connection_options 93 | if self._test_connection_options is None: 94 | self._test_connection_options = {} 95 | self._backend: Optional[BackendABC] = None 96 | self._type_converters: TypeConverters = defaultdict(dict) 97 | self._migrations_folder = migrations_folder 98 | self._migration_timeout = migration_timeout 99 | self._data_path = data_path 100 | self._auto_request_connection = auto_request_connection 101 | self._state_table_name = state_table_name 102 | if app is not None: 103 | self.init_app(app) 104 | 105 | def init_app(self, app: Quart) -> None: 106 | app.extensions.setdefault("QUART_DB", []).append(self) 107 | 108 | if self._url is None: 109 | self._url = app.config["QUART_DB_DATABASE_URL"] 110 | if self._migrations_folder is None: 111 | self._migrations_folder = app.config.get("QUART_DB_MIGRATIONS_FOLDER") 112 | if self._data_path is None: 113 | self._data_path = app.config.get("QUART_DB_DATA_PATH") 114 | if self._migration_timeout is None: 115 | self._migration_timeout = app.config.get("QUART_DB_MIGRATION_TIMEOUT", 60) 116 | if self._state_table_name is None: 117 | self._state_table_name = app.config.get("QUART_DB_STATE_TABLE_NAME", "schema_migration") 118 | self._root_path = Path(app.root_path) 119 | self._testing = app.testing and app.config.get("QUART_DB_TESTING", None) 120 | 121 | if app.config["PROPAGATE_EXCEPTIONS"] is None: 122 | # Ensure exceptions aren't propagated so as to ensure 123 | # connections are released. 124 | app.config["PROPAGATE_EXCEPTIONS"] = False 125 | 126 | app.before_serving(self.before_serving) 127 | app.after_serving(self.after_serving) 128 | 129 | if app.config.get("QUART_DB_AUTO_REQUEST_CONNECTION", self._auto_request_connection): 130 | app.before_request(self.before_request) 131 | app.teardown_request(self.teardown_request) 132 | 133 | app.cli.add_command(_migrate_command) 134 | app.cli.add_command(_schema_command) 135 | 136 | self._app = app 137 | 138 | async def before_serving(self) -> None: 139 | self._backend = self._create_backend() 140 | 141 | if self._migrations_folder is not None or self._data_path is not None: 142 | try: 143 | await asyncio.wait_for(self.migrate(), timeout=self._migration_timeout) 144 | except asyncio.TimeoutError: 145 | raise MigrationTimeoutError() 146 | await self._backend.connect() 147 | 148 | async def after_serving(self) -> None: 149 | await self._backend.disconnect(self._close_timeout) 150 | 151 | async def before_request(self) -> None: 152 | g.connection = await self.acquire() 153 | 154 | async def teardown_request(self, _exception: Optional[BaseException]) -> None: 155 | if getattr(g, "connection", None) is not None: 156 | await self.release(g.connection) 157 | g.connection = None 158 | 159 | async def migrate(self, force_foreground: bool = False) -> None: 160 | await ensure_state_table(self._backend, self._state_table_name) 161 | 162 | if self._migrations_folder is not None: 163 | migrations_folder = self._root_path / self._migrations_folder 164 | await execute_foreground_migrations( 165 | self._backend, migrations_folder, self._state_table_name 166 | ) 167 | if force_foreground: 168 | await execute_background_migrations( 169 | self._backend, 170 | migrations_folder, 171 | self._state_table_name, 172 | ) 173 | else: 174 | self._app.add_background_task( 175 | execute_background_migrations, 176 | self._backend, 177 | migrations_folder, 178 | self._state_table_name, 179 | ) 180 | 181 | if self._data_path is not None: 182 | data_path = self._root_path / self._data_path 183 | await execute_data_loader(self._backend, data_path, self._state_table_name) 184 | 185 | @asynccontextmanager 186 | async def connection(self) -> AsyncIterator[ConnectionABC]: 187 | """Acquire a connection to the database. 188 | 189 | This should be used in an async with block as so, 190 | 191 | .. code-block:: python 192 | 193 | async with quart_db.connection() as connection: 194 | await connection.execute("SELECT 1") 195 | 196 | """ 197 | conn = await self.acquire() 198 | try: 199 | yield conn 200 | finally: 201 | await self.release(conn) 202 | 203 | async def acquire(self) -> ConnectionABC: 204 | """Acquire a connection to the database. 205 | 206 | Don't forget to release it after usage, 207 | 208 | .. code-block::: python 209 | 210 | connection = await quart_db.acquire() 211 | await connection.execute("SELECT 1") 212 | await quart_db.release(connection) 213 | """ 214 | return await self._backend.acquire() 215 | 216 | async def release(self, connection: ConnectionABC) -> None: 217 | """Release a connection to the database. 218 | 219 | This should be used with :meth:`acquire`, 220 | 221 | .. code-block::: python 222 | 223 | connection = await quart_db.acquire() 224 | await connection.execute("SELECT 1") 225 | await quart_db.release(connection) 226 | """ 227 | await self._backend.release(connection) 228 | 229 | def set_converter( 230 | self, 231 | typename: str, 232 | encoder: Callable, 233 | decoder: Callable, 234 | *, 235 | pytype: Optional[Type] = None, 236 | schema: str = "public", 237 | ) -> None: 238 | """Set the type converter 239 | 240 | This allows postgres and python types to be converted between 241 | one another. 242 | 243 | Arguments: 244 | typename: The postgres name for the type. 245 | encoder: A callable that takes the Python type and encodes it 246 | into data postgres understands. 247 | decoder: A callable that takes the postgres data and decodes 248 | it into a Python type. 249 | pytype: Optional Python type, required for SQLite. 250 | schema: Optional Postgres schema, defaults to "public". 251 | """ 252 | self._type_converters[schema][typename] = (encoder, decoder, pytype) 253 | 254 | def _create_backend(self) -> BackendABC: 255 | scheme, *parts = urlsplit(self._url) 256 | if scheme.startswith(("postgresql", "postgres")): 257 | if scheme.endswith("psycopg"): 258 | from .backends.psycopg import Backend, TestingBackend 259 | else: 260 | from .backends.asyncpg import Backend, TestingBackend # type: ignore 261 | scheme = "postgresql" 262 | url = urlunsplit((scheme, *parts)) 263 | elif scheme == "sqlite": 264 | from .backends.aiosqlite import Backend, TestingBackend # type: ignore 265 | 266 | url = self._url 267 | else: 268 | raise ValueError(f"{scheme} is not a supported backend") 269 | 270 | if self._testing: 271 | return TestingBackend(url, self._test_connection_options, self._type_converters) 272 | else: 273 | return Backend(url, self._backend_options, self._type_converters) 274 | 275 | 276 | @click.command("db-schema") 277 | @click.option( 278 | "--output", 279 | "-o", 280 | default="quart_db_schema.png", 281 | type=click.Path(), 282 | help="Output the schema diagram to a file given by a path.", 283 | ) 284 | @pass_script_info 285 | def _schema_command(info: ScriptInfo, output: Optional[str]) -> None: 286 | app = info.load_app() 287 | 288 | try: 289 | from eralchemy2 import render_er # type: ignore 290 | except ImportError: 291 | click.echo("Quart-DB needs to be installed with the erdiagram extra") 292 | else: 293 | render_er(app.config["QUART_DB_DATABASE_URL"], output, exclude_tables=["schema_migration"]) 294 | 295 | 296 | @click.command("db-migrate") 297 | @pass_script_info 298 | def _migrate_command(info: ScriptInfo) -> None: 299 | app = info.load_app() 300 | 301 | async def _inner() -> None: 302 | for extension in app.extensions["QUART_DB"]: 303 | extension._backend = extension._create_backend() 304 | await extension.migrate(force_foreground=True) 305 | 306 | asyncio.run(_inner()) 307 | click.echo("Quart-DB migration complete") 308 | -------------------------------------------------------------------------------- /src/quart_db/interfaces.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from types import TracebackType 3 | from typing import Any, AsyncGenerator, Callable, Dict, List, Mapping, Optional, Tuple, Type, Union 4 | 5 | try: 6 | from typing import LiteralString 7 | except ImportError: 8 | from typing_extensions import LiteralString 9 | 10 | 11 | ValueType = Union[Dict[str, Any], List[Any]] 12 | RecordType = Mapping[str, Any] 13 | TypeConverters = Dict[str, Dict[str, Tuple[Callable, Callable, Optional[Type]]]] 14 | 15 | 16 | class UndefinedParameterError(Exception): 17 | pass 18 | 19 | 20 | class TransactionABC(ABC): 21 | @abstractmethod 22 | async def __aenter__(self) -> "TransactionABC": 23 | pass 24 | 25 | @abstractmethod 26 | async def __aexit__(self, exc_type: type, exc_value: BaseException, tb: TracebackType) -> None: 27 | pass 28 | 29 | @abstractmethod 30 | async def start(self) -> None: 31 | pass 32 | 33 | @abstractmethod 34 | async def commit(self) -> None: 35 | pass 36 | 37 | @abstractmethod 38 | async def rollback(self) -> None: 39 | pass 40 | 41 | 42 | class ConnectionABC(ABC): 43 | @property 44 | @abstractmethod 45 | def supports_for_update(self) -> bool: 46 | pass 47 | 48 | @abstractmethod 49 | async def execute(self, query: LiteralString, values: Optional[ValueType] = None) -> None: 50 | """Execute a query, with bind values if needed 51 | 52 | The query accepts named arguments i.e. `:name`in the query 53 | with values set being a dictionary. 54 | 55 | """ 56 | pass 57 | 58 | @abstractmethod 59 | async def execute_many(self, query: LiteralString, values: List[ValueType]) -> None: 60 | """Execute a query for each set of values 61 | 62 | The query accepts a list of named arguments i.e. `:name`in the 63 | query with values set being a list of dictionaries. 64 | 65 | """ 66 | pass 67 | 68 | @abstractmethod 69 | async def fetch_all( 70 | self, query: LiteralString, values: Optional[ValueType] = None 71 | ) -> List[RecordType]: 72 | """Execute a query, returning all the result rows 73 | 74 | The query accepts named arguments i.e. `:name`in the query 75 | with values set being a dictionary. 76 | 77 | """ 78 | pass 79 | 80 | @abstractmethod 81 | async def fetch_one( 82 | self, query: LiteralString, values: Optional[ValueType] = None 83 | ) -> Optional[RecordType]: 84 | """Execute a query, returning only the first result rows 85 | 86 | The query accepts named arguments i.e. `:name`in the query 87 | with values set being a dictionary. 88 | 89 | """ 90 | pass 91 | 92 | @abstractmethod 93 | async def fetch_val( 94 | self, query: LiteralString, values: Optional[ValueType] = None 95 | ) -> Optional[Any]: 96 | """Execute a query, returning only a value 97 | 98 | The query accepts named arguments i.e. `:name`in the query 99 | with values set being a dictionary. 100 | 101 | """ 102 | pass 103 | 104 | @abstractmethod 105 | def iterate( 106 | self, 107 | query: LiteralString, 108 | values: Optional[ValueType] = None, 109 | ) -> AsyncGenerator[RecordType, None]: 110 | """Execute a query, and iterate over the result rows 111 | 112 | The query accepts named arguments i.e. `:name`in the query 113 | with values set being a dictionary. 114 | 115 | """ 116 | pass 117 | 118 | @abstractmethod 119 | def transaction(self, *, force_rollback: bool = False) -> "TransactionABC": 120 | """Open a transaction 121 | 122 | .. code-block:: python 123 | 124 | async with connection.transaction(): 125 | await connection.execute("SELECT 1") 126 | 127 | Arguments: 128 | force_rollback: Force the transaction to rollback on completion. 129 | """ 130 | pass 131 | 132 | 133 | class BackendABC(ABC): 134 | @abstractmethod 135 | def __init__( 136 | self, url: str, options: Optional[Dict[str, Any]], type_converters: TypeConverters 137 | ) -> None: 138 | pass 139 | 140 | @abstractmethod 141 | async def connect(self) -> None: 142 | """Connect to the database. 143 | 144 | This will establish a connection pool if the backend supports 145 | it. 146 | 147 | """ 148 | pass 149 | 150 | @abstractmethod 151 | async def disconnect(self, timeout: Optional[int] = None) -> None: 152 | """Disconnect from the database. 153 | 154 | This will wait up to timeout for any active queries to 155 | complete. 156 | 157 | """ 158 | pass 159 | 160 | @abstractmethod 161 | async def acquire(self) -> ConnectionABC: 162 | """Acquire a connection to the database. 163 | 164 | Don't forget to release it after usage, 165 | 166 | .. code-block::: python 167 | 168 | connection = await backend.acquire() 169 | await connection.execute("SELECT 1") 170 | await quart_db.release(connection) 171 | 172 | """ 173 | pass 174 | 175 | @abstractmethod 176 | async def release(self, connection: ConnectionABC) -> None: 177 | """Release a connection to the database. 178 | 179 | This should be used with :meth:`acquire`, 180 | 181 | .. code-block::: python 182 | 183 | connection = await backend.acquire() await 184 | connection.execute("SELECT 1") await 185 | quart_db.release(connection) 186 | 187 | """ 188 | pass 189 | 190 | @abstractmethod 191 | async def _acquire_migration_connection(self) -> ConnectionABC: 192 | pass 193 | 194 | @abstractmethod 195 | async def _release_migration_connection(self, connection: ConnectionABC) -> None: 196 | pass 197 | -------------------------------------------------------------------------------- /src/quart_db/py.typed: -------------------------------------------------------------------------------- 1 | Marker 2 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pgjones/quart-db/c55b6ef1c6564300ccfe5b22b33124967aee4ec8/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | from typing import AsyncGenerator 4 | from urllib.parse import urlsplit, urlunsplit 5 | 6 | import pytest 7 | from quart import Quart 8 | 9 | from quart_db import Connection, QuartDB 10 | from .utils import Options 11 | 12 | 13 | @pytest.fixture(name="url", params=["aiosqlite", "asyncpg", "psycopg"]) 14 | def _url(request: pytest.FixtureRequest, tmp_path: Path) -> str: 15 | if request.param in ("asyncpg", "psycopg"): 16 | scheme, *parts = urlsplit(os.environ["DATABASE_URL"]) 17 | scheme = f"postgresql+{request.param}" 18 | return urlunsplit((scheme, *parts)) 19 | else: 20 | db_path = tmp_path / "temp.sql" 21 | return f"sqlite:////{db_path}" 22 | 23 | 24 | @pytest.fixture(name="connection") 25 | async def _connection(url: str) -> AsyncGenerator[Connection, None]: 26 | app = Quart(__name__) 27 | db = QuartDB(app, url=url) 28 | 29 | db.set_converter("options_t", lambda type_: type_.value, Options, pytype=Options) 30 | await app.startup() 31 | async with db.connection() as connection: 32 | async with connection.transaction(force_rollback=True): 33 | yield connection 34 | await app.shutdown() 35 | -------------------------------------------------------------------------------- /tests/migrations/0.py: -------------------------------------------------------------------------------- 1 | from quart_db import Connection 2 | from quart_db.backends.asyncpg import Connection as AsyncPGConnection 3 | 4 | 5 | async def migrate(connection: Connection) -> None: 6 | await connection.execute("DROP TABLE IF EXISTS tbl") 7 | if not isinstance(connection, AsyncPGConnection): 8 | await connection.execute( 9 | """CREATE TABLE tbl ( 10 | id INTEGER PRIMARY KEY, 11 | data JSON, 12 | value INTEGER 13 | )""" 14 | ) 15 | else: 16 | await connection.execute("DROP TYPE IF EXISTS options_t") 17 | await connection.execute("CREATE TYPE options_t AS ENUM ('A', 'B')") 18 | await connection.execute( 19 | """CREATE TABLE tbl ( 20 | id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, 21 | data JSON, 22 | value INT, 23 | options OPTIONS_T 24 | )""" 25 | ) 26 | -------------------------------------------------------------------------------- /tests/test_basic.py: -------------------------------------------------------------------------------- 1 | from asyncio import CancelledError 2 | from typing import NoReturn, Type 3 | 4 | import pytest 5 | from quart import g, Quart, ResponseReturnValue 6 | 7 | from quart_db import QuartDB 8 | 9 | 10 | async def test_extension(url: str) -> None: 11 | app = Quart(__name__) 12 | QuartDB(app, auto_request_connection=True, url=url) 13 | 14 | @app.get("/") 15 | async def index() -> ResponseReturnValue: 16 | return await g.connection.fetch_val("SELECT 'test'") 17 | 18 | async with app.test_app(): 19 | test_client = app.test_client() 20 | response = await test_client.get("/") 21 | data = await response.get_data(as_text=True) 22 | assert data == "test" 23 | 24 | 25 | @pytest.mark.parametrize( 26 | "exception", 27 | [CancelledError, ValueError], 28 | ) 29 | async def test_g_connection_release(url: str, exception: Type[Exception]) -> None: 30 | if not url.startswith("sqlite"): 31 | pytest.skip("aiosqlite - simpler backend to test") 32 | 33 | app = Quart(__name__) 34 | db = QuartDB(app, auto_request_connection=True, url=url) 35 | 36 | @app.get("/") 37 | async def index() -> NoReturn: 38 | raise exception() 39 | 40 | async with app.test_app(): 41 | test_client = app.test_client() 42 | await test_client.get("/") 43 | assert db._backend._connections == set() # type: ignore 44 | -------------------------------------------------------------------------------- /tests/test_connection.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from quart_db import Connection, UndefinedParameterError 4 | from quart_db.backends.asyncpg import Connection as AsyncpgConnection 5 | from quart_db.backends.psycopg import Connection as PsycopgConnection 6 | 7 | 8 | async def test_execute(connection: Connection) -> None: 9 | await connection.execute("SELECT 1") 10 | 11 | 12 | async def test_execute_many(connection: Connection) -> None: 13 | await connection.execute_many( 14 | "INSERT INTO tbl (value) VALUES (:value)", 15 | [{"value": 2}, {"value": 3}], 16 | ) 17 | results = await connection.fetch_all("SELECT value FROM tbl") 18 | assert [2, 3] == [result["value"] for result in results] 19 | 20 | 21 | async def test_fetch_one(connection: Connection) -> None: 22 | await connection.execute( 23 | "INSERT INTO tbl (value) VALUES (:value)", 24 | {"value": 2}, 25 | ) 26 | result = await connection.fetch_one("SELECT * FROM tbl") 27 | assert result["value"] == 2 28 | 29 | 30 | async def test_fetch_one_list_params(connection: Connection) -> None: 31 | if isinstance(connection, (AsyncpgConnection, PsycopgConnection)): 32 | param = "$1" 33 | else: 34 | param = "?" 35 | await connection.execute(f"INSERT INTO tbl (data) VALUES ({param})", [{"A": 2}]) 36 | result = await connection.fetch_one("SELECT * FROM tbl") 37 | assert result["data"] == {"A": 2} 38 | 39 | 40 | async def test_fetch_one_no_result(connection: Connection) -> None: 41 | result = await connection.fetch_one("SELECT * FROM tbl WHERE id = -1") 42 | assert result is None 43 | 44 | 45 | async def test_fetch_val(connection: Connection) -> None: 46 | value = await connection.fetch_val("SELECT 2") 47 | assert value == 2 48 | 49 | 50 | async def test_fetch_val_no_result(connection: Connection) -> None: 51 | value = await connection.fetch_val("SELECT id FROM tbl WHERE id = -1") 52 | assert value is None 53 | 54 | 55 | async def test_iterate(connection: Connection) -> None: 56 | await connection.execute_many( 57 | "INSERT INTO tbl (value) VALUES (:value)", 58 | [{"value": 2}, {"value": 3}], 59 | ) 60 | assert [2, 3] == [ 61 | result["value"] async for result in connection.iterate("SELECT value FROM tbl") 62 | ] 63 | 64 | 65 | async def test_transaction(connection: Connection) -> None: 66 | async with connection.transaction(): 67 | await connection.execute("SELECT 1") 68 | 69 | 70 | async def test_transaction_rollback(connection: Connection) -> None: 71 | try: 72 | async with connection.transaction(): 73 | await connection.execute("INSERT INTO tbl (value) VALUES (:value)", {"value": 2}) 74 | raise Exception() 75 | except Exception: 76 | pass 77 | rows = await connection.fetch_val("SELECT COUNT(*) FROM tbl") 78 | assert rows == 0 79 | 80 | 81 | async def test_missing_bind(connection: Connection) -> None: 82 | with pytest.raises(UndefinedParameterError) as exc: 83 | await connection.execute("SELECT * FROM tbl WHERE id = :id", {"a": 2}) 84 | assert "id" in str(exc.value) 85 | -------------------------------------------------------------------------------- /tests/test_converters.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | 3 | import pytest 4 | 5 | from quart_db import Connection 6 | from quart_db.backends.asyncpg import Connection as AsyncPGConnection 7 | from .utils import Options 8 | 9 | 10 | @pytest.mark.skipif(sqlite3.sqlite_version < "3.35.0", reason="Requires SQLite 3.35 for RETURNING") 11 | async def test_json_conversion(connection: Connection) -> None: 12 | id_ = await connection.fetch_val("INSERT INTO tbl (data) VALUES ('{\"a\": 2}') RETURNING id") 13 | data = await connection.fetch_val("SELECT data FROM tbl WHERE id = :id", {"id": id_}) 14 | assert data == {"a": 2} 15 | 16 | id_ = await connection.fetch_val( 17 | "INSERT INTO tbl (data) VALUES (:data) RETURNING id", 18 | {"data": {"a": 1}}, 19 | ) 20 | data = await connection.fetch_val("SELECT data FROM tbl WHERE id = :id", {"id": id_}) 21 | assert data == {"a": 1} 22 | 23 | 24 | async def test_enum_conversion(connection: Connection) -> None: 25 | if not isinstance(connection, AsyncPGConnection): 26 | pytest.skip() 27 | 28 | id_ = await connection.fetch_val( 29 | "INSERT INTO tbl (options) VALUES (:options) RETURNING id", 30 | {"options": Options.B}, 31 | ) 32 | options = await connection.fetch_val("SELECT options FROM tbl WHERE id = :id", {"id": id_}) 33 | assert options == options.B 34 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class Options(Enum): 5 | A = "A" 6 | B = "B" 7 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = docs,format,mypy,py39,py310,py311,py312,py313,pep8,package 3 | isolated_build = True 4 | 5 | [testenv] 6 | deps = 7 | aiosqlite 8 | asyncpg 9 | buildpg 10 | psycopg[pool] 11 | pytest 12 | pytest-asyncio 13 | pytest-cov 14 | pytest-sugar 15 | commands = pytest --cov=quart_db {posargs} 16 | passenv = DATABASE_URL 17 | 18 | [testenv:docs] 19 | basepython = python3.13 20 | deps = 21 | aiosqlite 22 | asyncpg 23 | buildpg 24 | pydata-sphinx-theme 25 | psycopg[pool] 26 | sphinx 27 | commands = 28 | sphinx-apidoc -e -f -o docs/reference/source/ src/quart_db/ 29 | sphinx-build -b html -d {envtmpdir}/doctrees docs/ docs/_build/html/ 30 | 31 | [testenv:format] 32 | basepython = python3.13 33 | deps = 34 | black 35 | isort 36 | commands = 37 | black --check --diff src/quart_db/ tests/ 38 | isort --check --diff src/quart_db/ tests 39 | 40 | [testenv:pep8] 41 | basepython = python3.13 42 | deps = 43 | flake8 44 | pep8-naming 45 | flake8-print 46 | commands = flake8 src/quart_db/ tests/ 47 | 48 | [testenv:mypy] 49 | basepython = python3.13 50 | deps = 51 | mypy 52 | psycopg[pool] 53 | pytest 54 | commands = 55 | mypy src/quart_db/ tests/ 56 | 57 | [testenv:package] 58 | basepython = python3.13 59 | deps = 60 | pdm 61 | twine 62 | commands = 63 | pdm build 64 | twine check dist/* 65 | --------------------------------------------------------------------------------