Skip to content

Commit b75ad6e

Browse files
cleanup and docs
1 parent ce6b335 commit b75ad6e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

71 files changed

+419
-45
lines changed

examples/eager_loading.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
from __future__ import annotations
2+
3+
from datetime import datetime
4+
5+
import sqlalchemy
6+
import sqlalchemy.orm
7+
import sqlalchemy.sql.selectable
8+
from sqlalchemy.orm import Mapped, mapped_column
9+
10+
sa = sqlalchemy
11+
12+
engine = sa.create_engine("sqlite://", echo=False)
13+
Base = sa.orm.declarative_base()
14+
Session = sa.orm.sessionmaker(bind=engine, expire_on_commit=False)
15+
16+
17+
class User(Base):
18+
__tablename__ = "user"
19+
20+
id: Mapped[int] = mapped_column(sa.Identity(), primary_key=True)
21+
name: Mapped[str] = mapped_column(sa.String(255), nullable=True)
22+
time_created: Mapped[datetime] = mapped_column(
23+
default=sa.func.now(),
24+
server_default=sa.FetchedValue(),
25+
)
26+
time_updated: Mapped[datetime] = mapped_column(
27+
default=sa.func.now(),
28+
onupdate=sa.func.now(),
29+
server_default=sa.FetchedValue(),
30+
server_onupdate=sa.FetchedValue(),
31+
)
32+
33+
posts = sa.orm.relationship("Post", back_populates="user")
34+
35+
36+
class Post(Base):
37+
__tablename__ = "post"
38+
39+
id: Mapped[int] = mapped_column(sa.Identity(), primary_key=True)
40+
user_id: Mapped[int] = mapped_column(sa.ForeignKey("user.id"), nullable=True)
41+
42+
user = sa.orm.relationship("User", back_populates="posts", uselist=False)
43+
44+
45+
Base.metadata.create_all(bind=engine)
46+
47+
with Session() as session:
48+
with session.begin():
49+
user = User(name="joe", posts=[Post(), Post()])
50+
session.add(user)
51+
session.flush()
52+
session.refresh(user)
53+
54+
55+
with Session() as session:
56+
statement = sa.select(User).where(User.id == user.id)
57+
eager_statement = statement.options(sa.orm.joinedload(User.posts))
58+
59+
print(user.id, user.time_created, user.time_updated)
60+
61+
with Session() as session:
62+
with session.begin():
63+
user = session.get(User, user.id)
64+
new_user = session.merge(User(id=user.id, name="new"))
65+
session.flush()
66+
session.refresh(new_user)
67+
68+
# time_updated not fetched, needs to be refreshed
69+
# print(new_user.name, new_user.time_created)
70+
71+
print(user.id, user.name, user.time_created, user.time_updated)
72+
73+
with Session() as session:
74+
user = session.get(User, new_user.id)
75+
76+
print(user.id, user.name, user.time_created, user.time_updated)
77+
78+
>>> print(user.id, user.time_created, user.time_updated)
79+
'1 2023-03-21 18:02:56 2023-03-21 18:02:56'

src/quart_sqlalchemy/repository/abstract.py renamed to examples/repository/base.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,13 @@
99
import sqlalchemy.exc
1010
import sqlalchemy.orm
1111
import sqlalchemy.sql
12+
from builder import StatementBuilder
1213

13-
from ..types import ColumnExpr
14-
from ..types import EntityIdT
15-
from ..types import EntityT
16-
from ..types import ORMOption
17-
from ..types import Selectable
18-
from .statement import StatementBuilder
14+
from quart_sqlalchemy.types import ColumnExpr
15+
from quart_sqlalchemy.types import EntityIdT
16+
from quart_sqlalchemy.types import EntityT
17+
from quart_sqlalchemy.types import ORMOption
18+
from quart_sqlalchemy.types import Selectable
1919

2020

2121
sa = sqlalchemy

src/quart_sqlalchemy/repository/statement.py renamed to examples/repository/builder.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99
import sqlalchemy.sql
1010
from sqlalchemy.orm.interfaces import ORMOption
1111

12-
from ..types import ColumnExpr
13-
from ..types import DMLTable
14-
from ..types import EntityT
15-
from ..types import Selectable
12+
from quart_sqlalchemy.types import ColumnExpr
13+
from quart_sqlalchemy.types import DMLTable
14+
from quart_sqlalchemy.types import EntityT
15+
from quart_sqlalchemy.types import Selectable
1616

1717

1818
sa = sqlalchemy

src/quart_sqlalchemy/repository/meta.py renamed to examples/repository/meta.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
import typing as t
44

5-
from ..model import SoftDeleteMixin
6-
from ..types import EntityT
7-
from ..util import lazy_property
5+
from quart_sqlalchemy.model import SoftDeleteMixin
6+
from quart_sqlalchemy.types import EntityT
7+
from quart_sqlalchemy.util import lazy_property
88

99

1010
class TableMetadataMixin(t.Generic[EntityT]):

src/quart_sqlalchemy/repository/sqla.py renamed to examples/repository/sqla.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,17 @@
77
import sqlalchemy.exc
88
import sqlalchemy.orm
99
import sqlalchemy.sql
10-
11-
from ..types import ColumnExpr
12-
from ..types import EntityIdT
13-
from ..types import EntityT
14-
from ..types import ORMOption
15-
from ..types import Selectable
16-
from ..types import SessionT
17-
from .abstract import AbstractBulkRepository
18-
from .abstract import AbstractRepository
19-
from .meta import TableMetadataMixin
20-
from .statement import StatementBuilder
10+
from builder import StatementBuilder
11+
from meta import TableMetadataMixin
12+
13+
from base import AbstractBulkRepository
14+
from base import AbstractRepository
15+
from quart_sqlalchemy.types import ColumnExpr
16+
from quart_sqlalchemy.types import EntityIdT
17+
from quart_sqlalchemy.types import EntityT
18+
from quart_sqlalchemy.types import ORMOption
19+
from quart_sqlalchemy.types import Selectable
20+
from quart_sqlalchemy.types import SessionT
2121

2222

2323
sa = sqlalchemy

examples/testing/conftest.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""Pytest plugin for quart-sqlalchemy.
2+
3+
The lifecycle of the database during testing should look something like this.
4+
5+
1. The QuartSQLAlchemy object is instantiated by the application in a well known location such as the top level package. Usually named `db`. The database should be a fresh, in-memory, sqlite instance.
6+
2. The Quart object is instantiated in a pytest fixture `app` using the application factory pattern, inside this factory, db.init_app(app) is called.
7+
3. The database schema or DDL is executed using something like `db.create_all()`.
8+
4. The necessary database test fixtures are loaded into the database.
9+
5. A test transaction is created with savepoint, this transaction should be scoped at the `function` level.
10+
6. Any calls to Session() should be patched to return a session bound to the test transaction savepoint.
11+
a. Bind.Session: sessionmaker
12+
b. BindContext.Session
13+
c. TestTransaction.Session (already patched)
14+
7. Engine should be patched to return connections bound to the savepoint transaction but this is too complex to be in scope.
15+
8. The test is run.
16+
9. The test transaction goes out of function scope and rolls back the database.
17+
10. The test transaction is recreated for the next test and so on until the pytest session is closed.
18+
"""
19+
20+
import typing as t
21+
22+
import pytest
23+
import sqlalchemy
24+
import sqlalchemy.orm
25+
from quart import Quart
26+
27+
28+
sa = sqlalchemy
29+
30+
from quart_sqlalchemy import AsyncBind
31+
from quart_sqlalchemy import Base
32+
from quart_sqlalchemy import SQLAlchemyConfig
33+
from quart_sqlalchemy.framework import QuartSQLAlchemy
34+
from quart_sqlalchemy.testing import AsyncTestTransaction
35+
from quart_sqlalchemy.testing import TestTransaction
36+
37+
38+
default_app = Quart(__name__)
39+
40+
41+
@pytest.fixture(scope="session")
42+
def app() -> Quart:
43+
"""
44+
This pytest fixture should return the Quart object
45+
"""
46+
return default_app
47+
48+
49+
@pytest.fixture(scope="session")
50+
def db_config() -> SQLAlchemyConfig:
51+
"""
52+
This pytest fixture should return the SQLAlchemyConfig object
53+
"""
54+
return SQLAlchemyConfig(
55+
model_class=Base,
56+
binds={ # type: ignore
57+
"default": {
58+
"engine": {"url": "sqlite:///file:mem.db?mode=memory&cache=shared&uri=true"},
59+
"session": {"expire_on_commit": False},
60+
},
61+
"read-replica": {
62+
"engine": {"url": "sqlite:///file:mem.db?mode=memory&cache=shared&uri=true"},
63+
"session": {"expire_on_commit": False},
64+
"read_only": True,
65+
},
66+
"async": {
67+
"engine": {
68+
"url": "sqlite+aiosqlite:///file:mem.db?mode=memory&cache=shared&uri=true"
69+
},
70+
"session": {"expire_on_commit": False},
71+
},
72+
},
73+
)
74+
75+
76+
@pytest.fixture(scope="session")
77+
@pytest.fixture
78+
def _db(db_config: SQLAlchemyConfig, app: Quart) -> QuartSQLAlchemy:
79+
"""
80+
This pytest fixture should return the QuartSQLAlchemy object
81+
"""
82+
return QuartSQLAlchemy(db_config, app)
83+
84+
85+
@pytest.fixture(scope="session", autouse=True)
86+
@pytest.fixture
87+
def database_test_fixtures(_db: QuartSQLAlchemy) -> t.Generator[None, None, None]:
88+
"""
89+
This pytest fixture should use the injected session to load any necessary testing fixtures.
90+
"""
91+
_db.create_all()
92+
93+
with _db.bind.Session() as s:
94+
with s.begin():
95+
# add test fixtures to this session
96+
pass
97+
98+
yield
99+
100+
_db.drop_all()
101+
102+
103+
@pytest.fixture(autouse=True)
104+
def db_test_transaction(
105+
_db: QuartSQLAlchemy, database_test_fixtures: None
106+
) -> t.Generator[TestTransaction, None, None]:
107+
"""
108+
This pytest fixture should yield a synchronous TestTransaction
109+
"""
110+
with _db.bind.test_transaction(savepoint=True) as test_transaction:
111+
yield test_transaction
112+
113+
114+
@pytest.fixture(autouse=True)
115+
async def async_db_test_transaction(
116+
_db: QuartSQLAlchemy, database_test_fixtures: None
117+
) -> t.AsyncGenerator[TestTransaction, None]:
118+
"""
119+
This pytest fixture should yield an asynchronous TestTransaction
120+
"""
121+
async_bind: AsyncBind = _db.get_bind("async") # type: ignore
122+
async with async_bind.test_transaction(savepoint=True) as async_test_transaction:
123+
yield async_test_transaction
124+
125+
126+
@pytest.fixture(autouse=True)
127+
def patch_sessionmakers(
128+
_db: QuartSQLAlchemy,
129+
db_test_transaction: TestTransaction,
130+
async_db_test_transaction: AsyncTestTransaction,
131+
monkeypatch,
132+
) -> t.Generator[None, None, None]:
133+
for bind in _db.binds.values():
134+
if isinstance(bind, AsyncBind):
135+
savepoint_bound_session = async_db_test_transaction.Session
136+
else:
137+
savepoint_bound_session = db_test_transaction.Session
138+
139+
monkeypatch.setattr(bind, "Session", savepoint_bound_session)
140+
141+
yield
142+
143+
144+
@pytest.fixture(name="db", autouse=True)
145+
def patched_db(_db, patch_sessionmakers):
146+
return _db

src/quart_sqlalchemy/bind.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,12 @@ def register_soft_delete_support_for_session(
201201
options: t.Dict[str, t.Any],
202202
session_factory: t.Union[sa.orm.sessionmaker, sa.ext.asyncio.async_sessionmaker],
203203
) -> None:
204+
"""Register the event handlers that enable soft-delete logic to be applied automatically.
205+
206+
This functionality is opt-in by nature. Opt-in involves adding the SoftDeleteMixin to the
207+
ORM models that should support soft-delete. You can learn more by checking out the
208+
model.mixins module.
209+
"""
204210
if all(
205211
[
206212
isinstance(session_factory, sa.ext.asyncio.async_sessionmaker),
@@ -212,6 +218,13 @@ def register_soft_delete_support_for_session(
212218
setup_soft_delete_for_session(session_factory) # type: ignore
213219

214220

221+
# Beware of Dragons!
222+
#
223+
# The following handlers aren't at all crucial to understanding how this package works, they are
224+
# mostly based on well known sqlalchemy recipes and their impact can be fully understood from
225+
# their docstrings alone.
226+
227+
215228
@signals.after_bind_engine_created.connect
216229
def register_engine_connection_cross_process_safety_handlers(
217230
sender: Bind,
@@ -265,3 +278,57 @@ def checkout(dbapi_connection, connection_record, connection_proxy):
265278

266279
if not sa.event.contains(engine, "checkout", checkout):
267280
sa.event.listen(engine, "checkout", checkout)
281+
282+
283+
@signals.after_bind_engine_created.connect
284+
def register_engine_connection_sqlite_specific_transaction_fix(
285+
sender: Bind,
286+
config: t.Dict[str, t.Any],
287+
prefix: str,
288+
engine: t.Union[sa.Engine, sa.ext.asyncio.AsyncEngine],
289+
) -> None:
290+
"""Register event handlers to fix dbapi broken transaction for sqlite dialects.
291+
292+
The pysqlite DBAPI driver has several long-standing bugs which impact the correctness of its
293+
transactional behavior. In its default mode of operation, SQLite features such as
294+
SERIALIZABLE isolation, transactional DDL, and SAVEPOINT support are non-functional, and in
295+
order to use these features, workarounds must be taken.
296+
297+
The issue is essentially that the driver attempts to second-guess the user’s intent, failing
298+
to start transactions and sometimes ending them prematurely, in an effort to minimize the
299+
SQLite databases’s file locking behavior, even though SQLite itself uses “shared” locks for
300+
read-only activities.
301+
302+
SQLAlchemy chooses to not alter this behavior by default, as it is the long-expected behavior
303+
of the pysqlite driver; if and when the pysqlite driver attempts to repair these issues, that
304+
will be more of a driver towards defaults for SQLAlchemy.
305+
306+
The good news is that with a few events, we can implement transactional support fully, by
307+
disabling pysqlite’s feature entirely and emitting BEGIN ourselves. This is achieved using
308+
two event listeners:
309+
310+
To learn more about this recipe, check out the sqlalchemy docs link below:
311+
https://docs.sqlalchemy.org/en/20/dialects/sqlite.html#pysqlite-serializable
312+
"""
313+
314+
# Use the sync_engine when AsyncEngine
315+
if isinstance(engine, sa.ext.asyncio.AsyncEngine):
316+
engine = engine.sync_engine
317+
318+
if engine.dialect.name != "sqlite":
319+
return
320+
321+
def do_connect(dbapi_connection, connection_record):
322+
# disable pysqlite's emitting of the BEGIN statement entirely.
323+
# also stops it from emitting COMMIT before any DDL.
324+
dbapi_connection.isolation_level = None
325+
326+
if not sa.event.contains(engine, "connect", do_connect):
327+
sa.event.listen(engine, "connect", do_connect)
328+
329+
def do_begin(conn_):
330+
# emit our own BEGIN
331+
conn_.exec_driver_sql("BEGIN")
332+
333+
if not sa.event.contains(engine, "begin", do_begin):
334+
sa.event.listen(engine, "begin", do_begin)

src/quart_sqlalchemy/repository/__init__.py

Lines changed: 0 additions & 6 deletions
This file was deleted.

0 commit comments

Comments
 (0)