Skip to content

Commit df12d71

Browse files
better tests better docs soft delete
1 parent 92c87d5 commit df12d71

27 files changed

Lines changed: 694 additions & 628 deletions

docs/markdown/bind-contexts.md

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
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`.

docs/markdown/routing-session.md

Whitespace-only changes.

docs/markdown/sqlalchemy-20-changes.md

Whitespace-only changes.

pyproject.toml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ build-backend = "setuptools.build_meta"
3131
[tool.pdm.dev-dependencies]
3232
tests = [
3333
"pytest~=7.2.1",
34-
"pytest-asyncio~=0.20.3",
34+
# "pytest-asyncio~=0.20.3",
35+
"pytest-asyncio @ https://github.com/joeblackwaslike/pytest-asyncio/releases/download/v0.20.4.dev42/pytest_asyncio-0.20.4.dev42-py3-none-any.whl",
3536
"pytest-mock~=3.10.0",
3637
"pytest-cov",
3738
"coverage[toml]",
@@ -65,11 +66,13 @@ excludes = [
6566
]
6667

6768
[tool.pytest.ini_options]
69+
addopts = "-rsx --tb=short --loop-scope class"
6870
testpaths = ["tests"]
6971
filterwarnings = ["error"]
7072
asyncio_mode = "auto"
73+
py311_task = true
7174
log_cli = true
72-
addopts = "-s"
75+
7376

7477
[tool.coverage.run]
7578
branch = true

0 commit comments

Comments
 (0)