6.7 KiB
mysql
Async MySQL + MariaDB wrapper over aiomysql — a
small, config-free, fail-loud, two-layer API: friendly portable verbs for the common
case, a raw escape hatch for the rest. Third of the datastore trio (redis / psql /
mysql), a sibling of the mongo lib. Class is MysqlDB. MySQL and MariaDB are
wire-compatible and share the driver, so this covers both.
Install
requirements.txt:
mysql @ git+ssh://git@git.rethinkstudios.io/rethink-public/mysql.git@v0.1.5
Direct:
pip install "mysql @ git+ssh://git@git.rethinkstudios.io/rethink-public/mysql.git@v0.1.5"
Pulls aiomysql (which pulls PyMySQL).
Drop the @v0.1.5 suffix from the line above to install the latest unpinned.
The two-layer API
Layer 1 — friendly verbs for simple single-table CRUD. These hide the dialect and are
signature-identical to the psql lib — same method names, arguments, and return
types — so you swap psql↔mysql with zero call-site changes. One value-level difference:
insert() returns the new row id (lastrowid) here vs. the inserted rowcount in psql
(both int); everything else returns the same shape.
Layer 2 — raw escape hatch for the complex ~20% (joins, aggregates, subqueries). You
write the SQL with %s placeholders + a params sequence; the wrapper still gives pooling,
parameterization, fail-loud errors, and dict rows. Raw SQL is mysql-specific.
There is deliberately nothing in between — no query builder, no ORM. A join goes
through raw fetch(), never a chainable .where()/.join().
Usage
from mysql import MysqlDB
# construction is sync; connect() builds the pool + SELECT 1 to fail loud on bad config
db = await MysqlDB(host="localhost", port=3306, db="app",
user="root", password="secret").connect()
# --- layer 1: friendly, portable (identical to psql) ---
await db.create_table("users", {"id": "int auto_increment primary key",
"name": "varchar(255) not null", "age": "int"})
new_id = await db.insert("users", {"name": "ada", "age": 30}) # -> lastrowid
rows = await db.get("users", {"age": 30}) # [{"id": 1, "name": "ada", ...}]
one = await db.get_one("users", {"name": "ada"}) # {...} or None
ok = await db.exists("users", {"name": "ada"}) # True
await db.delete("users", {"name": "ada"}) # -> rowcount
await db.upsert("users", {"id": 1, "name": "ada2"}, conflict=["id"]) # ON DUPLICATE KEY UPDATE
await db.close()
Context-manager form:
async with MysqlDB(db="app", user="root") as db:
await db.insert("events", {"kind": "login"})
Layer 2 — raw SQL for the complex queries
# a join — not modelled by layer 1, so write it directly. %s placeholders, plain-dict rows.
rows = await db.fetch(
"SELECT u.name, count(o.id) AS orders "
"FROM users u JOIN orders o ON o.user_id = u.id "
"WHERE u.active = %s GROUP BY u.name",
(True,),
)
row = await db.fetchone("SELECT * FROM users WHERE id = %s", (7,)) # dict or None
n = await db.fetchval("SELECT count(*) FROM users") # scalar or None
await db.execute("UPDATE users SET active = %s WHERE last_seen < %s", (False, cutoff))
# atomic multi-statement block — commits on clean exit, rolls back + re-raises on error
async with db.transaction() as conn:
async with conn.cursor() as cur:
await cur.execute("INSERT INTO ledger(acct, delta) VALUES(%s, %s)", (a, -amount))
await cur.execute("INSERT INTO ledger(acct, delta) VALUES(%s, %s)", (b, +amount))
For anything even Layer 2 doesn't model (executemany, server-side cursors), use the raw
db.pool — the underlying aiomysql.Pool.
Rows & placeholders
- Rows are plain dicts (
{column: value}) via aiomysql'sDictCursor— identical shape to thepsqllib. - Values are always parameterized. Layer 1 builds
%sinternally; Layer 2 takes your%splaceholders + a params sequence. Never f-string/%-format a value into SQL (the%sis the DBAPI placeholder, not Python string formatting). Only identifiers (table/column names) are interpolated, and they're backtick-quoted — Layer 1 also escapes a literal%inside an identifier for you. - A literal
%in Layer-2 SQL text must be written%%. PyMySQL builds the query viaquery % argswhenever you pass params, so"... LIKE '%foo%'"with params raisesTypeError/ValueError— write"... LIKE '%%foo%%'"instead. Calls with no params are unaffected (no substitution happens), and Layer 1 handles this for you internally.
Dialect vs psql
Layer 1 is portable — you never see these. A Layer-2 raw-SQL author does:
| mysql (this lib) | psql | |
|---|---|---|
| placeholders | %s |
$1, $2 |
| upsert | ON DUPLICATE KEY UPDATE |
ON CONFLICT ... DO UPDATE |
fetchone is the unified Layer-2 name in both libs (psql maps it onto asyncpg's
fetchrow internally; here it's the native DBAPI verb).
upsert()'s returned rowcount follows MySQL's ON DUPLICATE KEY UPDATE convention (1 for
an insert, 2 for an update, 0 for a no-op) — a per-row count that differs from psql's; treat
it as "affected", not "rows matched". It emits the col = VALUES(col) update form, which
MySQL 8.0.20+ deprecates (warning 1287, still functional; MariaDB is unaffected).
Error contract — fail loud
Unlike the mongo lib (which log-and-swallows), this lib re-raises. Every method
catches the driver error (pymysql.err.MySQLError, OSError on connection loss), logs it
via logging.getLogger(__name__), and raises. A None / [] return is only ever a real
result (no row, empty table) — never a swallowed failure.
Surface
- Layer 1:
create_database,create_table(name, {col: "type"}),drop(name, table=),insert(table, {col: val}),get(table, {conds}),get_one(table, {conds}),delete(table, {conds}),exists(table, {conds}),upsert(table, {col: val}, conflict=[...]) - Layer 2:
execute(sql, params=None),fetch,fetchone,fetchval,transaction() - Raw:
poolproperty →aiomysql.Pool
get/delete/exists/get_one take equality conditions only (col = val AND ...);
anything richer goes through Layer 2. The pool runs with autocommit=True; transaction()
opens an explicit transaction for atomic multi-statement blocks. cursorclass is
overridable via a constructor kwarg (default DictCursor); fetchval/exists work under
either a dict or tuple cursorclass, everything else stays dict-shaped only under the
default DictCursor.
Versioning
Releases are tagged vX.Y.Z. The install line above pins a release; drop the @vX.Y.Z
suffix to install the latest unpinned. Pin deliberately for reproducible installs.