|
| 1 | +# Bind Contexts |
| 2 | +## Introduction |
| 3 | +The concept of a "Bind Context" has been added to Quart-SQLAlchemy to better support multiple engines without making major breaking API changes to `SQLAlchemy`. |
| 4 | + |
| 5 | +As an example, a SQLAlchemy object has the following properties: |
| 6 | +* `db.engine` -> returns the `Engine` object for the default (None) bind |
| 7 | +* `db.metadata` -> returns the `MetaData` object for the default (None) bind |
| 8 | +* `db.session` -> returns a `scoped_session` object that defaults to (None) bind |
| 9 | + |
| 10 | +SQLAlchemy supports the configuration of multiple binds, but by default only supports multiple binds for the use case of defining models that are specific to only one of those binds, expressed through class `__bind_key__` attribute of the model, which defaults to (None). |
| 11 | + |
| 12 | +In order to support more interesting use cases, such as read-write masters, read-only replicas, and async replicas, a solution was devised where you could temporarily enter a bind context for a given `bind_key` and the object returned would very much resemble the `SQLAlchemy` API in regards to `ctx.engine`, `ctx.metadata`, `ctx.session` but scoped to the provided `bind_key` instead of default (None). |
| 13 | + |
| 14 | +## Usage |
| 15 | +Below is a brief example of configuring SQLAlchemy for multiple binds. The example below utilizes sqlite uri strings to define three seperate binds that all share the same in-memory virtual database, so changes made in the default (None) bind will be available in "read-replica" and "async". To be clear, no files will be created, this is a virtual construct available only within the sqlite dialect. |
| 16 | + |
| 17 | +```python |
| 18 | +config = { |
| 19 | + "SQLALCHEMY_BINDS": { |
| 20 | + None: dict( |
| 21 | + url="sqlite:///file:mem.db?mode=memory&cache=shared&uri=true", |
| 22 | + connect_args=dict(check_same_thread=False), |
| 23 | + ), |
| 24 | + "replica": dict( |
| 25 | + url="sqlite:///file:mem.db?mode=memory&cache=shared&uri=true", |
| 26 | + connect_args=dict(check_same_thread=False), |
| 27 | + ), |
| 28 | + "async": dict( |
| 29 | + url="sqlite+aiosqlite:///file:mem.db?mode=memory&cache=shared&uri=true", |
| 30 | + connect_args=dict(check_same_thread=False), |
| 31 | + ), |
| 32 | + }, |
| 33 | +} |
| 34 | + |
| 35 | +app = Quart(__name__) |
| 36 | +app.config.from_mapping(config) |
| 37 | +db = SQLAlchemy(app) |
| 38 | + |
| 39 | + |
| 40 | +class Todo(db.Model): |
| 41 | + Mapped[int] = sa.orm.mapped_column(primary_key=True) |
| 42 | + |
| 43 | + |
| 44 | +async with app.app_context(): |
| 45 | + db.create_all(None) |
| 46 | +``` |
| 47 | + |
| 48 | +Adding a single Todo to the default bind. |
| 49 | +```python |
| 50 | +async with app.app_context(): |
| 51 | + db.session.add(Todo()) |
| 52 | + db.session.commit() |
| 53 | + assert len(db.session.scalars(select(Todo)).all()) == 1 |
| 54 | +``` |
| 55 | +Using bind contexts we can execute the same operations just as easily on binds other than default simply by passing their key to `db.bind_context()`. |
| 56 | + |
| 57 | +Adding another Todo through 'replica': |
| 58 | +```python |
| 59 | +async with app.app_context(): |
| 60 | + with db.bind_context('replica') as ctx: |
| 61 | + ctx.session.add(Todo()) |
| 62 | + ctx.session.commit() |
| 63 | + assert len(ctx.session.scalars(select(Todo)).all()) == 2 |
| 64 | +``` |
| 65 | + |
| 66 | +Adding another Todo through 'async': |
| 67 | +```python |
| 68 | +async with app.app_context(): |
| 69 | + async with db.bind_context('async') as ctx: # BindContext supports both with and async with depending on whether the underlying engine is Async or not. |
| 70 | + ctx.session.add(Todo()) |
| 71 | + await ctx.session.commit() |
| 72 | + assert len((await ctx.session.scalars(select(Todo))).all()) == 2 |
| 73 | +``` |
| 74 | +***Note the `async` and `await` keywords that have been added to `db.bind_context`, `session.commit`, and `session.scalars`.*** |
| 75 | + |
| 76 | +### Using bind context to temporarily enter a different transaction isolation level |
| 77 | +There are number of use cases where one may want to temporarily enter into a different transaction isolation level. Support for this is built into `bind_context` in the form of the `execution_options` parameter. The result is that `ctx.engine` is a shallow clone of the engine for the requested `bind_key` with the contents of `execution_options` applied. |
| 78 | + |
| 79 | +**Isolation levels:** |
| 80 | +* `AUTOCOMMIT` |
| 81 | +* `READ COMMITTED` |
| 82 | +* `READ UNCOMMITTED` |
| 83 | +* `REPEATABLE READ` |
| 84 | +* `SERIALIZABLE` |
| 85 | + |
| 86 | +**Requesting a `bind_context` with `SERIALIZABLE` `isolation_level`:** |
| 87 | +```python |
| 88 | +async with app.app_context(): |
| 89 | + with db.bind_context( |
| 90 | + None, |
| 91 | + execution_options=dict(isolation_level="SERIALIZABLE"), |
| 92 | + ) as ctx. |
| 93 | + with ctx.connection.begin(): |
| 94 | + ctx.connection.execute("statement") |
| 95 | +``` |
| 96 | + |
| 97 | +## API |
| 98 | +### `SQLAlchemy.bind_context` |
| 99 | +```python |
| 100 | +def bind_context( |
| 101 | + self, |
| 102 | + bind_key: Optional[str] = None, |
| 103 | + execution_options: Optional[dict[str, Any]] = None, |
| 104 | + app: Optional[Quart] = None, |
| 105 | +) -> BindContext: |
| 106 | + ... |
| 107 | +``` |
| 108 | +`SQLAlchemy.bind_context` accepts an optional `app` parameter. When provided, the `BindContext` returned will resolve the correct `engine` using the provided `app` object rather than first attempting to resolve it using the `Quart.current_app` proxy. This option has very few compelling use cases, most of which occur during usage in a shell such as IPython or integration tests. |
| 109 | + |
| 110 | + |
| 111 | +### `BindContext` |
| 112 | +This is the API of the object returned by `SQLAlchemy.bind_context`. As mentioned, it implements the essential database functionality often provided by the `SQLAlchemy`or `db` object for the given `bind_key`. |
| 113 | +```python |
| 114 | +class BindContext: |
| 115 | + bind_key: Optional[str] |
| 116 | + is_async: bool |
| 117 | + execution_options: dict[str, Any] |
| 118 | + engine: Engine | AsyncEngine |
| 119 | + metadata: MetaData |
| 120 | + connection: Connection | AsyncConnection |
| 121 | + session: scoped_session | async_scoped_session |
| 122 | +``` |
| 123 | + |
| 124 | +`BindContext` supports the context manager protocol as well as the async context manager protocol. If you request a synchronous bind, you should enter the context using `with`, if you request an asynchronous bind, you should enter the context using `async with`. |
0 commit comments