Skip to content
This repository was archived by the owner on Aug 19, 2025. It is now read-only.

Commit 89c9f3f

Browse files
authored
Add asyncmy (mysql) driver (#382)
1 parent 0f80142 commit 89c9f3f

File tree

16 files changed

+485
-29
lines changed

16 files changed

+485
-29
lines changed

.github/workflows/test-suite.yml

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414

1515
strategy:
1616
matrix:
17-
python-version: ["3.6", "3.7", "3.8"]
17+
python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"]
1818

1919
services:
2020
mysql:
@@ -29,7 +29,7 @@ jobs:
2929
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
3030

3131
postgres:
32-
image: postgres:10.8
32+
image: postgres:14
3333
env:
3434
POSTGRES_USER: username
3535
POSTGRES_PASSWORD: password
@@ -45,7 +45,19 @@ jobs:
4545
python-version: "${{ matrix.python-version }}"
4646
- name: "Install dependencies"
4747
run: "scripts/install"
48+
- name: "Run linting checks"
49+
run: "scripts/check"
50+
- name: "Build package & docs"
51+
run: "scripts/build"
4852
- name: "Run tests"
4953
env:
50-
TEST_DATABASE_URLS: "sqlite:///testsuite, sqlite+aiosqlite:///testsuite, mysql://username:password@localhost:3306/testsuite, mysql+aiomysql://username:password@localhost:3306/testsuite, postgresql://username:password@localhost:5432/testsuite, postgresql+aiopg://username:[email protected]:5432/testsuite, postgresql+asyncpg://username:password@localhost:5432/testsuite"
54+
TEST_DATABASE_URLS: |
55+
sqlite:///testsuite,
56+
sqlite+aiosqlite:///testsuite,
57+
mysql://username:password@localhost:3306/testsuite,
58+
mysql+aiomysql://username:password@localhost:3306/testsuite,
59+
mysql+asyncmy://username:password@localhost:3306/testsuite,
60+
postgresql://username:password@localhost:5432/testsuite,
61+
postgresql+aiopg://username:[email protected]:5432/testsuite,
62+
postgresql+asyncpg://username:password@localhost:5432/testsuite
5163
run: "scripts/test"

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,15 @@ $ pip install databases[mysql]
3737
$ pip install databases[sqlite]
3838
```
3939

40-
Driver support is provided using one of [asyncpg][asyncpg], [aiomysql][aiomysql], or [aiosqlite][aiosqlite].
40+
Default driver support is provided using one of [asyncpg][asyncpg], [aiomysql][aiomysql], or [aiosqlite][aiosqlite].
41+
42+
You can also use other database drivers supported by `databases`:
43+
44+
```shel
45+
$ pip install databases[postgresql+aiopg]
46+
$ pip install databases[mysql+asyncmy]
47+
```
48+
4149
Note that if you are using any synchronous SQLAlchemy functions such as `engine.create_all()` or [alembic][alembic] migrations then you still have to install a synchronous DB driver: [psycopg2][psycopg2] for PostgreSQL and [pymysql][pymysql] for MySQL.
4250

4351
---

databases/backends/asyncmy.py

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
import getpass
2+
import logging
3+
import typing
4+
import uuid
5+
6+
import asyncmy
7+
from sqlalchemy.dialects.mysql import pymysql
8+
from sqlalchemy.engine.cursor import CursorResultMetaData
9+
from sqlalchemy.engine.interfaces import Dialect, ExecutionContext
10+
from sqlalchemy.engine.row import Row
11+
from sqlalchemy.sql import ClauseElement
12+
from sqlalchemy.sql.ddl import DDLElement
13+
14+
from databases.core import LOG_EXTRA, DatabaseURL
15+
from databases.interfaces import ConnectionBackend, DatabaseBackend, TransactionBackend
16+
17+
logger = logging.getLogger("databases")
18+
19+
20+
class AsyncMyBackend(DatabaseBackend):
21+
def __init__(
22+
self, database_url: typing.Union[DatabaseURL, str], **options: typing.Any
23+
) -> None:
24+
self._database_url = DatabaseURL(database_url)
25+
self._options = options
26+
self._dialect = pymysql.dialect(paramstyle="pyformat")
27+
self._dialect.supports_native_decimal = True
28+
self._pool = None
29+
30+
def _get_connection_kwargs(self) -> dict:
31+
url_options = self._database_url.options
32+
33+
kwargs = {}
34+
min_size = url_options.get("min_size")
35+
max_size = url_options.get("max_size")
36+
pool_recycle = url_options.get("pool_recycle")
37+
ssl = url_options.get("ssl")
38+
39+
if min_size is not None:
40+
kwargs["minsize"] = int(min_size)
41+
if max_size is not None:
42+
kwargs["maxsize"] = int(max_size)
43+
if pool_recycle is not None:
44+
kwargs["pool_recycle"] = int(pool_recycle)
45+
if ssl is not None:
46+
kwargs["ssl"] = {"true": True, "false": False}[ssl.lower()]
47+
48+
for key, value in self._options.items():
49+
# Coerce 'min_size' and 'max_size' for consistency.
50+
if key == "min_size":
51+
key = "minsize"
52+
elif key == "max_size":
53+
key = "maxsize"
54+
kwargs[key] = value
55+
56+
return kwargs
57+
58+
async def connect(self) -> None:
59+
assert self._pool is None, "DatabaseBackend is already running"
60+
kwargs = self._get_connection_kwargs()
61+
self._pool = await asyncmy.create_pool(
62+
host=self._database_url.hostname,
63+
port=self._database_url.port or 3306,
64+
user=self._database_url.username or getpass.getuser(),
65+
password=self._database_url.password,
66+
db=self._database_url.database,
67+
autocommit=True,
68+
**kwargs,
69+
)
70+
71+
async def disconnect(self) -> None:
72+
assert self._pool is not None, "DatabaseBackend is not running"
73+
self._pool.close()
74+
await self._pool.wait_closed()
75+
self._pool = None
76+
77+
def connection(self) -> "AsyncMyConnection":
78+
return AsyncMyConnection(self, self._dialect)
79+
80+
81+
class CompilationContext:
82+
def __init__(self, context: ExecutionContext):
83+
self.context = context
84+
85+
86+
class AsyncMyConnection(ConnectionBackend):
87+
def __init__(self, database: AsyncMyBackend, dialect: Dialect):
88+
self._database = database
89+
self._dialect = dialect
90+
self._connection = None # type: typing.Optional[asyncmy.Connection]
91+
92+
async def acquire(self) -> None:
93+
assert self._connection is None, "Connection is already acquired"
94+
assert self._database._pool is not None, "DatabaseBackend is not running"
95+
self._connection = await self._database._pool.acquire()
96+
97+
async def release(self) -> None:
98+
assert self._connection is not None, "Connection is not acquired"
99+
assert self._database._pool is not None, "DatabaseBackend is not running"
100+
await self._database._pool.release(self._connection)
101+
self._connection = None
102+
103+
async def fetch_all(self, query: ClauseElement) -> typing.List[typing.Sequence]:
104+
assert self._connection is not None, "Connection is not acquired"
105+
query_str, args, context = self._compile(query)
106+
async with self._connection.cursor() as cursor:
107+
try:
108+
await cursor.execute(query_str, args)
109+
rows = await cursor.fetchall()
110+
metadata = CursorResultMetaData(context, cursor.description)
111+
return [
112+
Row(
113+
metadata,
114+
metadata._processors,
115+
metadata._keymap,
116+
Row._default_key_style,
117+
row,
118+
)
119+
for row in rows
120+
]
121+
finally:
122+
await cursor.close()
123+
124+
async def fetch_one(self, query: ClauseElement) -> typing.Optional[typing.Sequence]:
125+
assert self._connection is not None, "Connection is not acquired"
126+
query_str, args, context = self._compile(query)
127+
async with self._connection.cursor() as cursor:
128+
try:
129+
await cursor.execute(query_str, args)
130+
row = await cursor.fetchone()
131+
if row is None:
132+
return None
133+
metadata = CursorResultMetaData(context, cursor.description)
134+
return Row(
135+
metadata,
136+
metadata._processors,
137+
metadata._keymap,
138+
Row._default_key_style,
139+
row,
140+
)
141+
finally:
142+
await cursor.close()
143+
144+
async def execute(self, query: ClauseElement) -> typing.Any:
145+
assert self._connection is not None, "Connection is not acquired"
146+
query_str, args, context = self._compile(query)
147+
async with self._connection.cursor() as cursor:
148+
try:
149+
await cursor.execute(query_str, args)
150+
if cursor.lastrowid == 0:
151+
return cursor.rowcount
152+
return cursor.lastrowid
153+
finally:
154+
await cursor.close()
155+
156+
async def execute_many(self, queries: typing.List[ClauseElement]) -> None:
157+
assert self._connection is not None, "Connection is not acquired"
158+
async with self._connection.cursor() as cursor:
159+
try:
160+
for single_query in queries:
161+
single_query, args, context = self._compile(single_query)
162+
await cursor.execute(single_query, args)
163+
finally:
164+
await cursor.close()
165+
166+
async def iterate(
167+
self, query: ClauseElement
168+
) -> typing.AsyncGenerator[typing.Any, None]:
169+
assert self._connection is not None, "Connection is not acquired"
170+
query_str, args, context = self._compile(query)
171+
async with self._connection.cursor() as cursor:
172+
try:
173+
await cursor.execute(query_str, args)
174+
metadata = CursorResultMetaData(context, cursor.description)
175+
async for row in cursor:
176+
yield Row(
177+
metadata,
178+
metadata._processors,
179+
metadata._keymap,
180+
Row._default_key_style,
181+
row,
182+
)
183+
finally:
184+
await cursor.close()
185+
186+
def transaction(self) -> TransactionBackend:
187+
return AsyncMyTransaction(self)
188+
189+
def _compile(
190+
self, query: ClauseElement
191+
) -> typing.Tuple[str, dict, CompilationContext]:
192+
compiled = query.compile(
193+
dialect=self._dialect, compile_kwargs={"render_postcompile": True}
194+
)
195+
196+
execution_context = self._dialect.execution_ctx_cls()
197+
execution_context.dialect = self._dialect
198+
199+
if not isinstance(query, DDLElement):
200+
args = compiled.construct_params()
201+
for key, val in args.items():
202+
if key in compiled._bind_processors:
203+
args[key] = compiled._bind_processors[key](val)
204+
205+
execution_context.result_column_struct = (
206+
compiled._result_columns,
207+
compiled._ordered_columns,
208+
compiled._textual_ordered_columns,
209+
compiled._loose_column_name_matching,
210+
)
211+
else:
212+
args = {}
213+
214+
query_message = compiled.string.replace(" \n", " ").replace("\n", " ")
215+
logger.debug("Query: %s Args: %s", query_message, repr(args), extra=LOG_EXTRA)
216+
return compiled.string, args, CompilationContext(execution_context)
217+
218+
@property
219+
def raw_connection(self) -> asyncmy.connection.Connection:
220+
assert self._connection is not None, "Connection is not acquired"
221+
return self._connection
222+
223+
224+
class AsyncMyTransaction(TransactionBackend):
225+
def __init__(self, connection: AsyncMyConnection):
226+
self._connection = connection
227+
self._is_root = False
228+
self._savepoint_name = ""
229+
230+
async def start(
231+
self, is_root: bool, extra_options: typing.Dict[typing.Any, typing.Any]
232+
) -> None:
233+
assert self._connection._connection is not None, "Connection is not acquired"
234+
self._is_root = is_root
235+
if self._is_root:
236+
await self._connection._connection.begin()
237+
else:
238+
id = str(uuid.uuid4()).replace("-", "_")
239+
self._savepoint_name = f"STARLETTE_SAVEPOINT_{id}"
240+
async with self._connection._connection.cursor() as cursor:
241+
try:
242+
await cursor.execute(f"SAVEPOINT {self._savepoint_name}")
243+
finally:
244+
await cursor.close()
245+
246+
async def commit(self) -> None:
247+
assert self._connection._connection is not None, "Connection is not acquired"
248+
if self._is_root:
249+
await self._connection._connection.commit()
250+
else:
251+
async with self._connection._connection.cursor() as cursor:
252+
try:
253+
await cursor.execute(f"RELEASE SAVEPOINT {self._savepoint_name}")
254+
finally:
255+
await cursor.close()
256+
257+
async def rollback(self) -> None:
258+
assert self._connection._connection is not None, "Connection is not acquired"
259+
if self._is_root:
260+
await self._connection._connection.rollback()
261+
else:
262+
async with self._connection._connection.cursor() as cursor:
263+
try:
264+
await cursor.execute(
265+
f"ROLLBACK TO SAVEPOINT {self._savepoint_name}"
266+
)
267+
finally:
268+
await cursor.close()

databases/core.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ class Database:
4646
"postgresql+aiopg": "databases.backends.aiopg:AiopgBackend",
4747
"postgres": "databases.backends.postgres:PostgresBackend",
4848
"mysql": "databases.backends.mysql:MySQLBackend",
49+
"mysql+asyncmy": "databases.backends.asyncmy:AsyncMyBackend",
4950
"sqlite": "databases.backends.sqlite:SQLiteBackend",
5051
}
5152

docs/index.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,15 @@ $ pip install databases[mysql]
3737
$ pip install databases[sqlite]
3838
```
3939

40-
Driver support is provided using one of [asyncpg][asyncpg], [aiomysql][aiomysql], or [aiosqlite][aiosqlite].
40+
Default driver support is provided using one of [asyncpg][asyncpg], [aiomysql][aiomysql], or [aiosqlite][aiosqlite].
41+
42+
You can also use other database drivers supported by `databases`:
43+
44+
```shel
45+
$ pip install databases[postgresql+aiopg]
46+
$ pip install databases[mysql+asyncmy]
47+
```
48+
4149
Note that if you are using any synchronous SQLAlchemy functions such as `engine.create_all()` or [alembic][alembic] migrations then you still have to install a synchronous DB driver: [psycopg2][psycopg2] for PostgreSQL and [pymysql][pymysql] for MySQL.
4250

4351
---

requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
-e .
55

66
# Async database drivers
7-
aiomysql
7+
asyncmy;python_version>"3.6"
8+
aiomysql;python_version<"3.10"
89
aiopg
910
aiosqlite
1011
asyncpg

scripts/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
# Development Scripts
22

33
* `scripts/build` - Build package and documentation.
4+
* `scripts/check` - Check lint and formatting.
45
* `scripts/clean` - Delete any build artifacts.
6+
* `scripts/coverage` - Check test coverage.
57
* `scripts/docs` - Run documentation server locally.
68
* `scripts/install` - Install dependencies in a virtual environment.
79
* `scripts/lint` - Run the code linting.

scripts/check

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
#!/bin/sh -e
2+
3+
export PREFIX=""
4+
if [ -d 'venv' ] ; then
5+
export PREFIX="venv/bin/"
6+
fi
7+
export SOURCE_FILES="databases tests"
8+
9+
set -x
10+
11+
${PREFIX}isort --check --diff --project=databases $SOURCE_FILES
12+
${PREFIX}black --check --diff $SOURCE_FILES
13+
${PREFIX}mypy databases

scripts/coverage

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#!/bin/sh -e
2+
3+
export PREFIX=""
4+
if [ -d 'venv' ] ; then
5+
export PREFIX="venv/bin/"
6+
fi
7+
8+
set -x
9+
10+
${PREFIX}coverage report --show-missing --skip-covered --fail-under=100

0 commit comments

Comments
 (0)