Skip to content

Commit 5982b56

Browse files
authored
MySQL backend
2 parents 74f360e + dcd7a42 commit 5982b56

File tree

11 files changed

+202
-73
lines changed

11 files changed

+202
-73
lines changed

.github/workflows/test.yaml

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616

1717
strategy:
1818
matrix:
19-
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
19+
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
2020

2121
services:
2222
postgres:
@@ -29,6 +29,14 @@ jobs:
2929
- 5432:5432
3030
options: --health-cmd pg_isready --health-interval 5s --health-timeout 2s --health-retries 5
3131

32+
mysql:
33+
image: mariadb:latest
34+
env:
35+
MARIADB_ROOT_PASSWORD: based
36+
MARIADB_DB: based
37+
ports:
38+
- 3306:3306
39+
3240
steps:
3341
- uses: "actions/checkout@v4"
3442
- uses: "actions/setup-python@v5"
@@ -41,7 +49,8 @@ jobs:
4149
- name: "Run tests"
4250
env:
4351
BASED_TEST_DB_URLS: |
44-
postgresql://based:based@localhost:5432/based
52+
postgresql://based:based@localhost:5432/based,
53+
mysql://root:based@127.0.0.1:3306/based
4554
run: "make test"
4655

4756
coverage:
@@ -59,6 +68,14 @@ jobs:
5968
- 5432:5432
6069
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
6170

71+
mysql:
72+
image: mariadb:latest
73+
env:
74+
MARIADB_ROOT_PASSWORD: based
75+
MARIADB_DB: based
76+
ports:
77+
- 3306:3306
78+
6279
steps:
6380
- uses: "actions/checkout@v4"
6481
- uses: "actions/setup-python@v5"
@@ -69,7 +86,8 @@ jobs:
6986
- name: "Run tests"
7087
env:
7188
BASED_TEST_DB_URLS: |
72-
postgresql://based:based@localhost:5432/based
89+
postgresql://based:based@localhost:5432/based,
90+
mysql://root:based@127.0.0.1:3306/based
7391
run: "make test"
7492
- name: Coverage report
7593
uses: irongut/CodeCoverageSummary@v1.3.0

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
.venv
44
*.egg-info
55

6-
.mypy_cache
76
.ruff_cache
87

98
.coverage

LICENSE.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
Copyright (c) 2024 ansipunk
3+
Copyright (c) 2024 ansipunk <kysput@gmail.com>
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy
66
of this software and associated documentation files (the "Software"), to deal

Makefile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: help bootstrap lint test clean
1+
.PHONY: help bootstrap lint test build clean
22
DEFAULT: help
33

44
VENV = .venv
@@ -9,16 +9,16 @@ help:
99
@echo " bootstrap - setup development environment"
1010
@echo " lint - run static code analysis"
1111
@echo " test - run project tests"
12+
@echo " build - build packages"
1213
@echo " clean - clean environment and remove development artifacts"
1314

1415
bootstrap:
1516
python3 -m venv $(VENV)
1617
$(PYTHON) -m pip install --upgrade pip==24.2 setuptools==75.2.0 wheel==0.44.0 build==1.2.2.post1
17-
$(PYTHON) -m pip install -e .[postgres,sqlite,dev]
18+
$(PYTHON) -m pip install -e .[postgres,sqlite,mysql,dev]
1819

1920
lint: $(VENV)
2021
$(PYTHON) -m ruff check based tests
21-
$(PYTHON) -m mypy --strict based
2222

2323
test: $(VENV)
2424
$(PYTHON) -m pytest

README.md

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,17 @@
33
A based asynchronous database connection manager.
44

55
Based is designed to be used with SQLAlchemy Core requests. Currently, the only
6-
supported databases are SQLite and PostgreSQL. It's fairly simple to add a new
7-
backend, should you need one. Work in progress - any contributions - issues or
8-
pull requests - are very welcome. API might change, as library is still at its
9-
early experiment stage.
6+
supported databases are SQLite, PostgreSQL and MySQL. It's fairly simple to add
7+
a new backend, should you need one. Work in progress - any contributions -
8+
issues or pull requests - are very welcome. API might change, as library is
9+
still at its early experiment stage.
1010

1111
This library is inspired by [databases](https://github.com/encode/databases).
1212

1313
## Usage
1414

1515
```bash
16-
pip install based[sqlite] # or based[postgres]
16+
pip install based[sqlite] # or based[postgres] or based[mysql]
1717
```
1818

1919
```python
@@ -99,13 +99,22 @@ need to implement `Backend` class and add its initialization to the `Database`
9999
class. You only need to implement methods that raise `NotImplementedError` in
100100
the base class, adding private helpers as needed.
101101

102+
### Testing
103+
104+
Pass database URLs for those you want to run the tests against. Comma separated
105+
list.
106+
107+
```bash
108+
BASED_TEST_DB_URLS='postgresql://postgres:postgres@localhost:5432/postgres,mysql://root:mariadb@127.0.0.1:3306/mariadb' make test`
109+
```
110+
102111
## TODO
103112

104113
- [x] CI/CD
105114
- [x] Building and uploading packages to PyPi
106115
- [x] Testing with multiple Python versions
107116
- [ ] Database URL parsing and building
108-
- [ ] MySQL backend
117+
- [x] MySQL backend
109118
- [x] Add comments and docstrings
110119
- [x] Add lock for PostgreSQL in `force_rollback` mode and SQLite in both modes
111120
- [x] Refactor tests

based/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
__version__ = "0.4.2"
1+
__version__ = "0.5.0"
22

33
from based.backends import Session
44
from based.database import Database

based/backends/__init__.py

Lines changed: 44 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -122,10 +122,12 @@ def __init__( # noqa: D107
122122
async def _execute(
123123
self,
124124
query: typing.Union[ClauseElement, str],
125-
params: typing.Optional[typing.Union[
126-
typing.Dict[str, typing.Any],
127-
typing.List[typing.Any],
128-
]] = None,
125+
params: typing.Optional[
126+
typing.Union[
127+
typing.Dict[str, typing.Any],
128+
typing.List[typing.Any],
129+
]
130+
] = None,
129131
) -> typing.Any: # noqa: ANN401
130132
"""Execute the provided query and return a corresponding Cursor object.
131133
@@ -145,34 +147,17 @@ async def _execute(
145147
"""
146148
return await self._conn.execute(query, params)
147149

148-
async def _execute_within_transaction(
149-
self,
150-
query: typing.Union[ClauseElement, str],
151-
params: typing.Optional[typing.Union[
152-
typing.Dict[str, typing.Any],
153-
typing.List[typing.Any],
154-
]] = None,
155-
) -> typing.Any: # noqa: ANN401
156-
await self.create_transaction()
157-
158-
try:
159-
cursor = await self._conn.execute(query, params)
160-
except Exception:
161-
await self.cancel_transaction()
162-
raise
163-
else:
164-
await self.commit_transaction()
165-
166-
return cursor
167-
168150
def _compile_query(
169-
self, query: ClauseElement,
151+
self,
152+
query: ClauseElement,
170153
) -> typing.Tuple[
171154
str,
172-
typing.Optional[typing.Union[
173-
typing.Dict[str, typing.Any],
174-
typing.List[typing.Any],
175-
]],
155+
typing.Optional[
156+
typing.Union[
157+
typing.Dict[str, typing.Any],
158+
typing.List[typing.Any],
159+
]
160+
],
176161
]:
177162
compiled_query = query.compile(
178163
dialect=self._dialect,
@@ -182,7 +167,9 @@ def _compile_query(
182167
return str(compiled_query), compiled_query.params
183168

184169
def _cast_row(
185-
self, cursor: typing.Any, row: typing.Any, # noqa: ANN401
170+
self,
171+
cursor: typing.Any, # noqa: ANN401
172+
row: typing.Any, # noqa: ANN401
186173
) -> typing.Dict[str, typing.Any]:
187174
"""Cast a driver specific Row object to a more general mapping."""
188175
fields = [column[0] for column in cursor.description]
@@ -191,10 +178,12 @@ def _cast_row(
191178
async def execute(
192179
self,
193180
query: typing.Union[ClauseElement, str],
194-
params: typing.Optional[typing.Union[
195-
typing.Dict[str, typing.Any],
196-
typing.List[typing.Any],
197-
]] = None,
181+
params: typing.Optional[
182+
typing.Union[
183+
typing.Dict[str, typing.Any],
184+
typing.List[typing.Any],
185+
]
186+
] = None,
198187
) -> None:
199188
"""Execute the provided query.
200189
@@ -207,15 +196,17 @@ async def execute(
207196
"""
208197
if isinstance(query, ClauseElement):
209198
query, params = self._compile_query(query)
210-
await self._execute_within_transaction(query, params)
199+
await self._execute(query, params)
211200

212201
async def fetch_one(
213202
self,
214203
query: typing.Union[ClauseElement, str],
215-
params: typing.Optional[typing.Union[
216-
typing.Dict[str, typing.Any],
217-
typing.List[typing.Any],
218-
]] = None,
204+
params: typing.Optional[
205+
typing.Union[
206+
typing.Dict[str, typing.Any],
207+
typing.List[typing.Any],
208+
]
209+
] = None,
219210
) -> typing.Optional[typing.Dict[str, typing.Any]]:
220211
"""Execute the provided query.
221212
@@ -234,19 +225,23 @@ async def fetch_one(
234225
if isinstance(query, ClauseElement):
235226
query, params = self._compile_query(query)
236227

237-
cursor = await self._execute_within_transaction(query, params)
228+
cursor = await self._execute(query, params)
238229
row = await cursor.fetchone()
239230
if not row:
240231
return None
241-
return self._cast_row(cursor, row)
232+
row = self._cast_row(cursor, row)
233+
await cursor.close()
234+
return row
242235

243236
async def fetch_all(
244237
self,
245238
query: typing.Union[ClauseElement, str],
246-
params: typing.Optional[typing.Union[
247-
typing.Dict[str, typing.Any],
248-
typing.List[typing.Any],
249-
]] = None,
239+
params: typing.Optional[
240+
typing.Union[
241+
typing.Dict[str, typing.Any],
242+
typing.List[typing.Any],
243+
]
244+
] = None,
250245
) -> typing.List[typing.Dict[str, typing.Any]]:
251246
"""Execute the provided query.
252247
@@ -264,9 +259,11 @@ async def fetch_all(
264259
if isinstance(query, ClauseElement):
265260
query, params = self._compile_query(query)
266261

267-
cursor = await self._execute_within_transaction(query, params)
262+
cursor = await self._execute(query, params)
268263
rows = await cursor.fetchall()
269-
return [self._cast_row(cursor, row) for row in rows]
264+
rows = [self._cast_row(cursor, row) for row in rows]
265+
await cursor.close()
266+
return rows
270267

271268
async def create_transaction(self) -> None:
272269
"""Create a transaction and add it to the transaction stack."""

based/backends/mysql.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import typing
2+
from contextlib import asynccontextmanager
3+
4+
import asyncmy
5+
from sqlalchemy import URL, make_url
6+
from sqlalchemy.dialects.mysql.asyncmy import dialect
7+
from sqlalchemy.engine.interfaces import Dialect
8+
from sqlalchemy.sql import ClauseElement
9+
10+
from based.backends import Backend, Session
11+
12+
13+
class MySQL(Backend):
14+
"""A MySQL backend for based.Database using asyncmy."""
15+
16+
_url: URL
17+
_pool: asyncmy.Pool
18+
_force_rollback: bool
19+
_force_rollback_connection: asyncmy.Connection
20+
_dialect: Dialect
21+
22+
def __init__(self, url: str, *, force_rollback: bool = False) -> None: # noqa: D107
23+
self._url = make_url(url)
24+
self._force_rollback = force_rollback
25+
self._dialect = dialect() # type: ignore
26+
27+
async def _connect(self) -> None:
28+
self._pool = await asyncmy.create_pool(
29+
user=self._url.username,
30+
password=self._url.password,
31+
host=self._url.host,
32+
port=self._url.port,
33+
database=self._url.database,
34+
)
35+
36+
if self._force_rollback:
37+
self._force_rollback_connection = await self._pool.acquire()
38+
39+
async def _disconnect(self) -> None:
40+
if self._force_rollback:
41+
await self._force_rollback_connection.rollback()
42+
self._pool.release(self._force_rollback_connection)
43+
44+
self._pool.close()
45+
await self._pool.wait_closed()
46+
47+
@asynccontextmanager
48+
async def _session(self) -> typing.AsyncGenerator["Session", None]:
49+
if self._force_rollback:
50+
connection = self._force_rollback_connection
51+
else:
52+
connection = await self._pool.acquire()
53+
54+
session = _MySQLSession(connection, self._dialect)
55+
56+
if self._force_rollback:
57+
await session.create_transaction()
58+
59+
try:
60+
yield session
61+
except Exception:
62+
await session.cancel_transaction()
63+
raise
64+
else:
65+
await session.commit_transaction()
66+
else:
67+
try:
68+
yield session
69+
except Exception:
70+
await connection.rollback()
71+
raise
72+
else:
73+
await connection.commit()
74+
finally:
75+
self._pool.release(connection)
76+
77+
78+
class _MySQLSession(Session):
79+
async def _execute(
80+
self,
81+
query: typing.Union[ClauseElement, str],
82+
params: typing.Optional[
83+
typing.Union[
84+
typing.Dict[str, typing.Any],
85+
typing.List[typing.Any],
86+
]
87+
] = None,
88+
) -> asyncmy.cursors.Cursor:
89+
cursor = self._conn.cursor()
90+
await cursor.execute(query, params)
91+
return cursor

0 commit comments

Comments
 (0)