fix: close() nulls _pool + connect() lock (psql-7/8 twin), % in identifiers, doc caveats

close() now nulls self._pool so a closed instance reports not-connected instead of
masquerading as live (twin of psql-7); connect() is guarded by an internal asyncio.Lock
so concurrent connect() calls serialize instead of racing to create and orphan multiple
live pools (twin of psql-8). _quote_ident now escapes a literal % in identifiers (mysql-7:
PyMySQL's query % args substitution otherwise breaks any Layer-1 call against a %-bearing
table/column name). fetchval/exists tolerate a caller-overridden tuple cursorclass via a
new _first_value helper (mysql-8). Documents the %%-escaping rule for literal % in raw SQL
text (mysql-6) and the upsert() VALUES() deprecation on MySQL 8.0.20+ (mysql-9).

Signed-off-by: disqualifier <dev@disqualifier.me>
This commit is contained in:
2026-07-02 23:32:53 -04:00
parent 50388de22c
commit 2e837da7df
4 changed files with 88 additions and 52 deletions
+14 -5
View File
@@ -11,13 +11,13 @@ wire-compatible and share the driver, so this covers both.
`requirements.txt`:
```
mysql @ git+ssh://git@git.rethinkstudios.io/rethink-public/mysql.git@v0.1.3
mysql @ git+ssh://git@git.rethinkstudios.io/rethink-public/mysql.git@v0.1.4
```
Direct:
```bash
pip install "mysql @ git+ssh://git@git.rethinkstudios.io/rethink-public/mysql.git@v0.1.3"
pip install "mysql @ git+ssh://git@git.rethinkstudios.io/rethink-public/mysql.git@v0.1.4"
```
Pulls `aiomysql` (which pulls `PyMySQL`).
@@ -99,7 +99,12 @@ For anything even Layer 2 doesn't model (`executemany`, server-side cursors), us
- **Values are always parameterized.** Layer 1 builds `%s` internally; Layer 2 takes your
`%s` placeholders + a params sequence. Never f-string/`%`-format a value into SQL (the
`%s` is the DBAPI placeholder, not Python string formatting). Only identifiers
(table/column names) are interpolated, and they're backtick-quoted.
(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 via
`query % args` whenever you pass params, so `"... LIKE '%foo%'"` with params raises
`TypeError`/`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
@@ -115,7 +120,8 @@ Layer 1 is portable — you never see these. A Layer-2 raw-SQL author does:
`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 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
@@ -134,7 +140,10 @@ result (no row, empty table) — never a swallowed failure.
`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.
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