Skip to content

Commit 26afc5f

Browse files
authored
Merge pull request #29 from febus982/protocols_documentation
Add documentation on protocols and alembic
2 parents fdc7750 + 3bdc064 commit 26afc5f

File tree

6 files changed

+401
-0
lines changed

6 files changed

+401
-0
lines changed

docs/manager/alembic.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
## Using Alembic with SQLAlchemy bind manager
2+
3+
[Alembic](https://alembic.sqlalchemy.org/en/latest/)
4+
is a database migration tool widely used with SQLAlchemy.
5+
6+
While the installation and configuration of Alembic is not
7+
in the scope of this package, `SQLAlchemyBindManager` class
8+
provides the method `get_bind_mappers_metadata()` for an easier
9+
integration with Alembic when using multiple binds. It will
10+
return each bind metadata organised in a dictionary, using
11+
the bind names as keys.
12+
13+
Alembic provides templates for synchronous engines and for
14+
asynchronous engines, but there is no template supporting
15+
both at the same time.
16+
17+
You can find an example Alembic configuration that works
18+
with synchronous and asynchronous engines at the same time,
19+
using the `SQLAlchemyBindManager` helper method, based on
20+
the following directory structure:
21+
22+
```
23+
├── alembic
24+
│ ├── env.py
25+
│ ├── script.py.mako
26+
│ └── versions
27+
└── alembic.ini
28+
```
29+
30+
## alembic.ini
31+
32+
/// details | alembic.ini
33+
```
34+
--8<-- "docs/manager/alembic/alembic.ini"
35+
```
36+
///
37+
38+
## env.py
39+
40+
/// details | env.py
41+
``` py
42+
--8<-- "docs/manager/alembic/env.py"
43+
```
44+
///
45+
46+
## script.py.mako
47+
48+
/// details | script.py.mako
49+
``` py
50+
--8<-- "docs/manager/alembic/script.py.mako"
51+
```
52+
///

docs/manager/alembic/alembic.ini

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# a multi-database configuration.
2+
3+
[alembic]
4+
# path to migration scripts
5+
script_location = alembic
6+
7+
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
8+
# Uncomment the line below if you want the files to be prepended with date and time
9+
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
10+
# for all available tokens
11+
file_template = %%(year)d-%%(month).2d-%%(day).2d-%%(hour).2d%%(minute).2d%%(second).2d-%%(rev)s_%%(slug)s
12+
13+
# sys.path path, will be prepended to sys.path if present.
14+
# defaults to the current working directory.
15+
prepend_sys_path = .
16+
17+
# timezone to use when rendering the date within the migration file
18+
# as well as the filename.
19+
# If specified, requires the python-dateutil library that can be
20+
# installed by adding `alembic[tz]` to the pip requirements
21+
# string value is passed to dateutil.tz.gettz()
22+
# leave blank for localtime
23+
# timezone =
24+
25+
# max length of characters to apply to the
26+
# "slug" field
27+
# truncate_slug_length = 40
28+
29+
# set to 'true' to run the environment during
30+
# the 'revision' command, regardless of autogenerate
31+
# revision_environment = false
32+
33+
# set to 'true' to allow .pyc and .pyo files without
34+
# a source .py file to be detected as revisions in the
35+
# versions/ directory
36+
# sourceless = false
37+
38+
# version location specification; This defaults
39+
# to alembic/versions. When using multiple version
40+
# directories, initial revisions must be specified with --version-path.
41+
# The path separator used here should be the separator specified by "version_path_separator" below.
42+
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
43+
44+
# version path separator; As mentioned above, this is the character used to split
45+
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
46+
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
47+
# Valid values for version_path_separator are:
48+
#
49+
# version_path_separator = :
50+
# version_path_separator = ;
51+
# version_path_separator = space
52+
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
53+
54+
# the output encoding used when revision files
55+
# are written from script.py.mako
56+
# output_encoding = utf-8
57+
58+
# We inject db names and config using env.py in alembic directory
59+
#databases = engine1, engine2
60+
61+
#[engine1]
62+
#sqlalchemy.url = driver://user:pass@localhost/dbname
63+
64+
#[engine2]
65+
#sqlalchemy.url = driver://user:pass@localhost/dbname2
66+
67+
[post_write_hooks]
68+
# post_write_hooks defines scripts or Python functions that are run
69+
# on newly generated revision scripts. See the documentation for further
70+
# detail and examples
71+
72+
# format using "black" - use the console_scripts runner, against the "black" entrypoint
73+
# hooks = black
74+
# black.type = console_scripts
75+
# black.entrypoint = black
76+
# black.options = -l 79 REVISION_SCRIPT_FILENAME

docs/manager/alembic/env.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import logging
2+
import os
3+
from asyncio import get_event_loop
4+
5+
from alembic import context
6+
from sqlalchemy import Column, Integer, String
7+
from sqlalchemy.ext.asyncio import AsyncEngine
8+
9+
from sqlalchemy_bind_manager import SQLAlchemyAsyncConfig, SQLAlchemyBindManager
10+
11+
################################################################
12+
## Note: The bind_config, sa_manager and models are normally ##
13+
## implemented in an application. This is only an example! ##
14+
################################################################
15+
bind_config = {
16+
"default": SQLAlchemyAsyncConfig(
17+
engine_url=f"sqlite+aiosqlite:///{os.path.dirname(os.path.abspath(__file__))}/sqlite.db",
18+
engine_options=dict(
19+
connect_args={
20+
"check_same_thread": False,
21+
},
22+
echo=False,
23+
future=True,
24+
),
25+
),
26+
}
27+
28+
sa_manager = SQLAlchemyBindManager(config=bind_config)
29+
30+
class BookModel(sa_manager.get_bind().model_declarative_base):
31+
id = Column(Integer)
32+
title = Column(String)
33+
################################################################
34+
## Note: The bind_config, sa_manager and models are normally ##
35+
## implemented in an application. This is only an example! ##
36+
################################################################
37+
38+
39+
USE_TWOPHASE = False
40+
41+
# this is the Alembic Config object, which provides
42+
# access to the values within the .ini file in use.
43+
config = context.config
44+
45+
logger = logging.getLogger("alembic.env")
46+
target_metadata = sa_manager.get_bind_mappers_metadata()
47+
db_names = target_metadata.keys()
48+
config.set_main_option("databases", ",".join(db_names))
49+
50+
51+
def run_migrations_offline() -> None:
52+
"""Run migrations in 'offline' mode.
53+
54+
This configures the context with just a URL
55+
and not an Engine, though an Engine is acceptable
56+
here as well. By skipping the Engine creation
57+
we don't even need a DBAPI to be available.
58+
59+
Calls to context.execute() here emit the given string to the
60+
script output.
61+
62+
"""
63+
# for the --sql use case, run migrations for each URL into
64+
# individual files.
65+
66+
engines = {}
67+
for name in db_names:
68+
engines[name] = {}
69+
engines[name]["url"] = sa_manager.get_bind(name).engine.url
70+
71+
for name, rec in engines.items():
72+
logger.info(f"Migrating database {name}")
73+
file_ = f"{name}.sql"
74+
logger.info(f"Writing output to {file_}")
75+
with open(file_, "w") as buffer:
76+
context.configure(
77+
url=rec["url"],
78+
output_buffer=buffer,
79+
target_metadata=target_metadata.get(name),
80+
literal_binds=True,
81+
dialect_opts={"paramstyle": "named"},
82+
)
83+
with context.begin_transaction():
84+
context.run_migrations(engine_name=name)
85+
86+
87+
def do_run_migration(conn, name):
88+
context.configure(
89+
connection=conn,
90+
upgrade_token=f"{name}_upgrades",
91+
downgrade_token=f"{name}_downgrades",
92+
target_metadata=target_metadata.get(name),
93+
)
94+
context.run_migrations(engine_name=name)
95+
96+
97+
async def run_migrations_online() -> None:
98+
"""Run migrations in 'online' mode.
99+
100+
In this scenario we need to create an Engine
101+
and associate a connection with the context.
102+
"""
103+
104+
# for the direct-to-DB use case, start a transaction on all
105+
# engines, then run all migrations, then commit all transactions.
106+
107+
engines = {}
108+
for name in db_names:
109+
engines[name] = {}
110+
engines[name]["engine"] = sa_manager.get_bind(name).engine
111+
112+
for name, rec in engines.items():
113+
engine = rec["engine"]
114+
if isinstance(engine, AsyncEngine):
115+
rec["connection"] = conn = await engine.connect()
116+
117+
if USE_TWOPHASE:
118+
rec["transaction"] = await conn.begin_twophase()
119+
else:
120+
rec["transaction"] = await conn.begin()
121+
else:
122+
rec["connection"] = conn = engine.connect()
123+
124+
if USE_TWOPHASE:
125+
rec["transaction"] = conn.begin_twophase()
126+
else:
127+
rec["transaction"] = conn.begin()
128+
129+
try:
130+
for name, rec in engines.items():
131+
logger.info(f"Migrating database {name}")
132+
if isinstance(rec["engine"], AsyncEngine):
133+
134+
def migration_callable(*args, **kwargs):
135+
return do_run_migration(*args, name=name, **kwargs)
136+
137+
await rec["connection"].run_sync(migration_callable)
138+
else:
139+
do_run_migration(name, rec)
140+
141+
if USE_TWOPHASE:
142+
for rec in engines.values():
143+
if isinstance(rec["engine"], AsyncEngine):
144+
await rec["transaction"].prepare()
145+
else:
146+
rec["transaction"].prepare()
147+
148+
for rec in engines.values():
149+
if isinstance(rec["engine"], AsyncEngine):
150+
await rec["transaction"].commit()
151+
else:
152+
rec["transaction"].commit()
153+
except:
154+
for rec in engines.values():
155+
if isinstance(rec["engine"], AsyncEngine):
156+
await rec["transaction"].rollback()
157+
else:
158+
rec["transaction"].rollback()
159+
raise
160+
finally:
161+
for rec in engines.values():
162+
if isinstance(rec["engine"], AsyncEngine):
163+
await rec["connection"].close()
164+
else:
165+
rec["connection"].close()
166+
167+
168+
if context.is_offline_mode():
169+
run_migrations_offline()
170+
else:
171+
loop = get_event_loop()
172+
if loop.is_running():
173+
loop.create_task(run_migrations_online())
174+
else:
175+
loop.run_until_complete(run_migrations_online())
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<%!
2+
import re
3+
4+
%>"""${message}
5+
6+
Revision ID: ${up_revision}
7+
Revises: ${down_revision | comma,n}
8+
Create Date: ${create_date}
9+
10+
"""
11+
from alembic import op
12+
import sqlalchemy as sa
13+
${imports if imports else ""}
14+
15+
# revision identifiers, used by Alembic.
16+
revision = ${repr(up_revision)}
17+
down_revision = ${repr(down_revision)}
18+
branch_labels = ${repr(branch_labels)}
19+
depends_on = ${repr(depends_on)}
20+
21+
22+
def upgrade(engine_name: str) -> None:
23+
globals()[f"upgrade_{engine_name}"]()
24+
25+
26+
def downgrade(engine_name: str) -> None:
27+
globals()[f"downgrade_{engine_name}"]()
28+
29+
<%
30+
db_names = config.get_main_option("databases")
31+
%>
32+
33+
## generate an "upgrade_<xyz>() / downgrade_<xyz>()" function
34+
## for each database name in the ini file.
35+
36+
% for db_name in re.split(r',\s*', db_names):
37+
38+
def upgrade_${db_name}() -> None:
39+
${context.get(f"{db_name}_upgrades", "pass")}
40+
41+
42+
def downgrade_${db_name}() -> None:
43+
${context.get(f"{db_name}_downgrades", "pass")}
44+
45+
% endfor

docs/repository/repository.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,29 @@ The classes provide some common use methods:
2929
* `paginated_find`: Search for a list of models, with pagination support
3030
* `cursor_paginated_find`: Search for a list of models, with cursor based pagination support
3131

32+
/// details | Typing and Protocols
33+
type: tip
34+
35+
The repository classes are fully typed (as the rest of this package), but
36+
protocols classes are provided as addition to allow more decoupled type checking
37+
and inversion of control patterns such as
38+
[dependency injection](https://en.wikipedia.org/wiki/Dependency_injection).
39+
40+
```python
41+
from sqlalchemy_bind_manager.protocols import SQLAlchemyRepositoryInterface, SQLAlchemyAsyncRepositoryInterface
42+
43+
def some_function(repository: SQLAlchemyRepositoryInterface[MyModel]):
44+
model = repository.get(123)
45+
...
46+
47+
async def some_async_function(repository: SQLAlchemyAsyncRepositoryInterface[MyModel]):
48+
model = await repository.get(123)
49+
...
50+
```
51+
52+
Both repository and related protocols are Generic, accepting the model class as argument.
53+
///
54+
3255
### Maximum query limit
3356

3457
Repositories have a maximum limit for paginated queries defaulting to 50 to

0 commit comments

Comments
 (0)