Skip to content

the shape every Python database has - #18

Merged
tamnd merged 1 commit into
mainfrom
dbapi
Aug 18, 2026
Merged

tamnd merged 1 commit into
mainfrom
dbapi

Conversation

@tamnd

@tamnd tamnd commented Aug 18, 2026

Copy link
Copy Markdown
Owner

zudb.dbapi is PEP 249 over the same connection. It is a layer and not a second client: a statement goes to the same execute underneath, the rows are the same rows, and nothing here reimplements anything. It is worth having even where the native client is nicer, because the code that expects a driver is not always code anyone can change.

Parameters are ?, which PEP 249 calls qmark, rewritten into the engine's own $name before the statement runs. The named style cannot work here: :name is how a pattern names its label, so (p:person) and WHERE p.uid = :uid cannot be told apart without parsing the statement, while ? is a character GQL has no meaning for anywhere. The scanner that finds them follows the lexer's own rules, so a ? inside a string, a raw @'...' string, a backtick-quoted name or either kind of comment is text somebody wrote and stays that way. The name a marker is rewritten to is checked against the statement first, because a value bound over the top of somebody's own text is a wrong answer with no error in it. A dict hands the statement over untouched, so $name still works for anyone writing zu statements rather than generating them.

Transactions are implicit, which PEP 249 requires and the native client does not do: one opens before the first statement after each commit or rollback, and closing a connection rolls back what was not committed. That is the one place the two clients disagree about what a program meant, so connect(autocommit=True) turns it off and gives the native behaviour back. A read-only connection opens a read-only transaction, or its first statement would be refused before it ran.

The exception classes are both hierarchies at once rather than two sitting side by side. zudb.Error is the Error PEP 249 asks for, and each class the engine raises arrives as a class that is both: a syntax error is a zudb.SyntaxError and a dbapi.ProgrammingError and the same object, carrying the code, the condition, the position, the excerpt and the documentation link the engine wrote. So a driver-shaped library and code written against this client can catch the same failure in the same program. The original traceback is kept and the chain is suppressed, since an exception that is an instance of the class it came from does not need printing twice.

description gives the Python type of what each column holds, read from the first rows, because a result declares no types of its own and a guess would be worse than a None. The rows read to work that out are handed to the caller first, so typing a result takes none of it. rowcount is rows produced and is -1 after a write, since the engine does not count what a write touched. callproc and nextset are left out entirely rather than defined and refused, which is what PEP 249 asks for. conn.zu and cur.result are the way back out to the rest of the client.

51 new tests. zudb.dbapi is a submodule to ask for by name, with a test that says importing zudb does not pull it in. Local gate is green: ruff, cargo fmt, clippy and the full suite at 580 passed.

PEP 249 is what a Python program expects a database to look like, and
the code that expects it is not always code anyone can change: a
dashboard, a test harness, a helper somebody wrote against sqlite3 five
years ago. zudb.dbapi is that shape over the same connection, and it is
a layer rather than a second client. A statement goes to the same
execute underneath, the rows are the same rows, and nothing here
reimplements anything.

Parameters are ?, which PEP 249 calls qmark, rewritten into the
engine's own $name before the statement runs. The named style cannot
work here: :name is how a pattern names its label, so (p:person) and
WHERE p.uid = :uid cannot be told apart without parsing the statement.
A question mark is a character GQL has no meaning for anywhere, which
makes it unambiguous, and the scanner that finds them follows the
lexer's own rules so that a ? inside a string, a raw string, a quoted
name or a comment is text somebody wrote and stays that way. The name a
marker is rewritten to is checked against the statement first, since a
value bound over the top of somebody's own text is a wrong answer with
no error in it. A dict hands the statement over untouched, so $name
still works for anyone writing zu statements rather than generating
them.

Transactions are implicit, which PEP 249 requires and the native client
does not do: one opens before the first statement after each commit or
rollback, and closing a connection rolls back what was not committed.
That is the one place the two clients disagree about what a program
meant, so connect(autocommit=True) turns it off and gives the native
behaviour back.

The exception classes are both hierarchies at once rather than two
sitting side by side. zudb.Error is the Error PEP 249 asks for, and
each class the engine raises arrives as a class that is both: a syntax
error is a zudb.SyntaxError and a dbapi.ProgrammingError and the same
object, carrying the code, the condition, the position, the excerpt and
the documentation link the engine wrote. A driver-shaped library and
code written against this client can catch the same failure in the same
program.

description gives the Python type of what each column holds, read from
the first rows, because a result declares no types of its own and a
guess would be worse than a None. The rows read to work that out are
handed back to the caller first, so typing a result takes none of it.
rowcount is rows produced and is -1 after a write, since the engine
does not count what a write touched and a number nobody can stand
behind is worse than the absence of one. callproc and nextset are left
out entirely rather than defined and refused, which is what PEP 249
asks for.

51 tests, and zudb.dbapi is a submodule to ask for by name so that
importing zudb still costs what it costs.
@tamnd
tamnd merged commit 2f04e75 into main Aug 18, 2026
5 of 8 checks passed
@tamnd
tamnd deleted the dbapi branch August 18, 2026 20:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant