diff --git a/README.md b/README.md index 8a38d54..31cb530 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,26 @@ Cancelling the task that awaits a statement interrupts the statement. The engine `transaction()` and `appender()` are opened with `async with`, `in_transaction()` and `registered()` are methods here because both answers live behind the lock, and everything else is the sync call with an `await` in front of it. +## Through the DB-API + +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. + +```python +import zudb.dbapi + +with zudb.dbapi.connect("social.zu1") as conn: + cur = conn.cursor() + cur.execute("INSERT (p:person {uid: 1, name: 'ada', score: 41.0})") + cur.execute("MATCH (p:person) WHERE p.score > ? RETURN p.name AS name", (40,)) + print(cur.fetchall()) +``` + +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 a 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. A question mark inside a string, a quoted name or a comment is text somebody wrote and is left alone, and passing a dict instead of a sequence 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. `connect(..., autocommit=True)` turns that off and gives back the native behaviour, where every statement stands alone. + +The exception classes are both hierarchies at once. `zudb.Error` is the `Error` PEP 249 asks for, and a syntax error is a `zudb.SyntaxError` and a `dbapi.ProgrammingError` and the same object, carrying the same code, position and documentation link, so a driver-shaped library and code written against this client can catch the same failure in the same program. `cur.description` names the columns and gives the Python type of what is in them, read from the first rows, since a result declares no types of its own. `conn.zu` is the connection underneath and `cur.result` is the result the last statement gave back, so appenders, registered frames, `interrupt()` and `to_arrow()` are all still there. It is a layer, not a second client. + ## When a program is wrong Every condition arrives as an exception class carrying its GQLSTATUS code, its position and a link to what the standard says about it, and the class is the class a Python caller would have written: a mistake the program made is a `zudb.ProgrammingError`, a value Python has and zu does not is a `TypeError`, a value of the right type and the wrong shape is a `ValueError`, and a file that is not a database is a `zudb.ConnectionError`, the same as a file that is not there. Both of those are a path that does not lead to a database, and telling a caller who mistyped one to file a bug would be the wrong answer twice. @@ -203,7 +223,7 @@ The stub is checked against the module it describes in CI: griffe reads the stub ## What works today -The list above is what this client is for. What it does so far is the core of it: `connect`, `execute` and `sql` with named parameters, results that iterate and fetch, values as Python objects both ways including dates, times, datetimes and durations, `Node`, `Rel` and `Path` as classes, `load` for building a graph with edges in it, an appender for growing one, transactions as a context manager that commits at the end of a block and rolls back when it raises, every condition as an exception class carrying its code, its position and its documentation link, results as Arrow columns and as pandas and polars frames, `register` for putting a frame under a name a statement can match on and reading it where it lies, stubs inside the wheel with a gate that keeps them true, the GIL released around every statement, every load and every copy out, `Ctrl-C` and `interrupt()` stopping a statement without touching the connection under it, `zudb.aio` for the same calls awaited on an event loop, and results, nodes, rels and paths that draw themselves in a notebook with `%gql` and `%%gql` to run statements in one. A DB-API 2.0 wrapper is next, and each one lands with the tests that say it works. +The list above is what this client is for. What it does so far is the core of it: `connect`, `execute` and `sql` with named parameters, results that iterate and fetch, values as Python objects both ways including dates, times, datetimes and durations, `Node`, `Rel` and `Path` as classes, `load` for building a graph with edges in it, an appender for growing one, transactions as a context manager that commits at the end of a block and rolls back when it raises, every condition as an exception class carrying its code, its position and its documentation link, results as Arrow columns and as pandas and polars frames, `register` for putting a frame under a name a statement can match on and reading it where it lies, stubs inside the wheel with a gate that keeps them true, the GIL released around every statement, every load and every copy out, `Ctrl-C` and `interrupt()` stopping a statement without touching the connection under it, `zudb.aio` for the same calls awaited on an event loop, results, nodes, rels and paths that draw themselves in a notebook with `%gql` and `%%gql` to run statements in one, and `zudb.dbapi` for code written against PEP 249. Each one landed with the tests that say it works. ## Wheels diff --git a/python/zudb/__init__.py b/python/zudb/__init__.py index bca1237..7f54920 100644 --- a/python/zudb/__init__.py +++ b/python/zudb/__init__.py @@ -11,9 +11,10 @@ nothing to run, and no server to connect to. Statements are ISO/IEC 39075 GQL. -On an event loop the same calls are awaited, from `zudb.aio`. It is a -submodule to ask for by name rather than one imported here, so a script -that never awaits anything pays for none of it. +On an event loop the same calls are awaited, from `zudb.aio`. Code +written against PEP 249 gets what it expects from `zudb.dbapi`. Both +are submodules to ask for by name rather than ones imported here, so a +script that uses neither pays for neither. """ from __future__ import annotations diff --git a/python/zudb/dbapi.py b/python/zudb/dbapi.py new file mode 100644 index 0000000..1a8c235 --- /dev/null +++ b/python/zudb/dbapi.py @@ -0,0 +1,812 @@ +"""zu through the interface every Python database has. + + import zudb.dbapi + + conn = zudb.dbapi.connect("social.zu1") + cur = conn.cursor() + cur.execute("MATCH (p:person) WHERE p.score > ? RETURN p.name AS name", (40,)) + print(cur.fetchall()) + conn.commit() + +PEP 249 is what a Python program expects a database to look like: +`connect`, a connection with `commit` and `rollback`, a cursor with +`execute` and `fetchone`, and a fixed list of exception classes. It is +worth having even where the native client is nicer, because the code +that reads it is not always code anyone can change: a dashboard, a test +harness, a notebook helper someone wrote against sqlite3 five years ago. + +This is a layer and not a second client. A statement goes to the same +`execute` underneath, the rows are the same rows, and the exception a +statement raises is the exception it would have raised, only carrying a +PEP 249 class as well. Nothing here reimplements anything. + +Two things about it are worth reading before writing against it. + +Parameters are `?`, which PEP 249 calls `qmark`, and they are rewritten +into the engine's own `$name` before the statement is run. GQL cannot +use the `named` style: `:name` is how a node pattern names its label, +so `(p:person)` and `WHERE p.uid = :uid` cannot be told apart without +parsing the statement. `?` is a character GQL has no meaning for at +all, which makes it unambiguous. Passing a dict instead of a sequence +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: a transaction opens before the first statement after each +`commit` or `rollback`, and closing a connection rolls back what was +not committed. `connect(..., autocommit=True)` turns that off and gives +back the native behaviour, where every statement stands alone. +""" + +from __future__ import annotations + +import contextlib +import datetime +import os +import time +from collections import deque +from collections.abc import Iterable, Iterator, Mapping, Sequence +from typing import Any, NoReturn + +import zudb +from zudb.types import Value + +__all__ = [ + "apilevel", + "threadsafety", + "paramstyle", + "connect", + "Connection", + "Cursor", + "Warning", + "Error", + "InterfaceError", + "DatabaseError", + "DataError", + "OperationalError", + "IntegrityError", + "InternalError", + "ProgrammingError", + "NotSupportedError", + "ConnectionError", + "TransactionError", + "Interrupted", + "SyntaxError", + "STRING", + "BINARY", + "NUMBER", + "DATETIME", + "ROWID", + "Date", + "Time", + "Timestamp", + "DateFromTicks", + "TimeFromTicks", + "TimestampFromTicks", + "Binary", +] + +#: The version of the interface this module implements. There has never +#: been another one. +apilevel = "2.0" + +#: 2 means threads may share the module and the connections made from +#: it, but not the cursors. A connection is one lock and statements on +#: it queue behind it, which is what makes sharing safe; a cursor holds +#: a position in a result and two threads taking rows from one would +#: each get half of them. +threadsafety = 2 + +#: `?`, positional. See the note at the top for why not `named`. +paramstyle = "qmark" + +#: Rows read ahead of the caller to work out what type each column +#: holds, since a statement's result does not declare one. A hundred is +#: enough to type a column that starts with a null and few enough that +#: a result of ten million rows is not walked to answer a question +#: about its shape. +AHEAD = 100 + + +class Warning(Exception): # noqa: A001 - PEP 249 names it, builtin or not + """PEP 249 requires the name. Nothing raises it. + + A condition that does not stop a statement arrives on the result as + a notice rather than through Python's warning machinery, so + `cur.result.notices` is where they are and there is nothing here to + catch. + """ + + +#: PEP 249's root class, which is zu's own root class. +#: +#: The two hierarchies meet rather than sitting side by side: everything +#: the engine raises is already a `zudb.Error`, so making that the +#: `Error` PEP 249 asks for means one `except` catches the same set +#: whichever spelling the caller reached for. +Error = zudb.Error + + +class InterfaceError(zudb.ProgrammingError, Error): + """A mistake in the use of this layer rather than in the database. + + A cursor used after it was closed, a connection used after it was + closed. Nothing reached the engine, which is why it is also a + `zudb.ProgrammingError`: that is the class the native client raises + for the same mistakes. + """ + + +class DatabaseError(Error): + """Everything the database itself reports.""" + + +class DataError(DatabaseError, zudb.DataError): + """Class 22: a value was wrong. Division by zero, a bad cast, a number that did not fit.""" + + +class OperationalError(DatabaseError): + """Something outside the statement went wrong. + + The database could not be opened, the transaction could not go on, + the statement was interrupted. The three classes below are the ones + that actually arrive, each of them an `OperationalError` and the + native class it would have been. + """ + + +class IntegrityError(DatabaseError): + """PEP 249 requires the name. Nothing raises it yet. + + It is what a constraint violation would be, and zu has no + constraints to violate: no primary key, no foreign key, no check. + When it has them, they land here. + """ + + +class InternalError(DatabaseError, zudb.InternalError): + """A failure the engine could not describe as a condition. + + A corrupt file, an assumption that did not hold. Worth reporting at + https://github.com/tamnd/zu/issues. + """ + + +class ProgrammingError(DatabaseError, zudb.ProgrammingError): + """The caller made a mistake the engine or the client caught. + + A parameter of a type zu has no place for, a fetch before an + execute, a statement given a different number of parameters than it + has placeholders. + """ + + +class NotSupportedError(DatabaseError): + """PEP 249 requires the name. Nothing raises it. + + The optional methods this layer does not implement, `callproc` and + `nextset`, are left out entirely rather than defined and refused, + which is what PEP 249 asks for: a caller can ask whether a method + is there and cannot ask whether it works. + """ + + +class ConnectionError(OperationalError, zudb.ConnectionError): # noqa: A001 - zu's name for class 08 + """Class 08: the database could not be reached or could not be read.""" + + +class TransactionError(OperationalError, zudb.TransactionError): + """Classes 25, 2D and 40: the transaction is what went wrong. + + Check `retryable` before running it again. A rollback with nothing + done can be retried; a statement whose completion is unknown cannot. + """ + + +class Interrupted(OperationalError, zudb.Interrupted): + """The statement was asked to stop and did. + + An `OperationalError` because that is where a caller written + against PEP 249 looks for a statement that did not finish for a + reason outside itself. + """ + + +class SyntaxError(ProgrammingError, zudb.SyntaxError): # noqa: A001 - the standard's name for class 42 + """Class 42: the statement could not be parsed, or named something that is not there.""" + + +#: Which PEP 249 class each native one arrives as. A class missing from +#: here is raised untouched, which is still a `dbapi.Error`, because +#: losing the class the engine chose would be worse than not having a +#: PEP 249 name for it. +_CLASSES: dict[type[zudb.Error], type[zudb.Error]] = { + zudb.ConnectionError: ConnectionError, + zudb.DataError: DataError, + zudb.TransactionError: TransactionError, + zudb.SyntaxError: SyntaxError, + zudb.ProgrammingError: ProgrammingError, + zudb.InternalError: InternalError, + zudb.Interrupted: Interrupted, +} + + +def _reraise(failure: zudb.Error) -> NoReturn: + """Raises the same failure again as its PEP 249 class. + + Every field goes across, so nothing is lost by passing through + here: the code, the position, the excerpt and the documentation + link are the ones the engine wrote. The original traceback is kept + and the chain is not, because an exception that is an instance of + the class it came from does not need to be printed twice. + """ + kind = _CLASSES.get(type(failure)) + if kind is None: + raise failure + raise kind( + str(failure), + code=failure.code, + condition=failure.condition, + severity=failure.severity, + line=failure.line, + column=failure.column, + offset=failure.offset, + excerpt=failure.excerpt, + doc_url=failure.doc_url, + retryable=failure.retryable, + ).with_traceback(failure.__traceback__) from None + + +@contextlib.contextmanager +def _translating() -> Iterator[None]: + """Around every call into the native client.""" + try: + yield + except zudb.Error as failure: + _reraise(failure) + + +class _Type: + """One of PEP 249's type objects, which is a set of Python types. + + A result does not declare what its columns hold, so the type code + in `Cursor.description` is the Python type of the values in it, + read from the first rows. These objects compare equal to the codes + they cover, so `cur.description[0][1] == dbapi.STRING` answers what + it looks like it answers and `is str` works too. + """ + + __slots__ = ("_kinds", "_name") + + def __init__(self, name: str, kinds: tuple[type, ...]) -> None: + self._name = name + self._kinds = frozenset(kinds) + + def __eq__(self, other: object) -> Any: + if isinstance(other, _Type): + return other._name == self._name + if isinstance(other, type): + return other in self._kinds + return NotImplemented + + def __hash__(self) -> int: + return hash(self._name) + + def __repr__(self) -> str: + return f"" + + +STRING = _Type("STRING", (str,)) +BINARY = _Type("BINARY", (bytes, bytearray, memoryview)) +NUMBER = _Type("NUMBER", (int, float)) +DATETIME = _Type("DATETIME", (datetime.date, datetime.time, datetime.datetime)) +#: The values that identify a row, which in a graph are the ones that +#: carry a table and an offset in it. +ROWID = _Type("ROWID", (zudb.Node, zudb.Rel)) + +# PEP 249's constructors. Python's own types are the ones zu takes and +# gives back, so these are the types themselves rather than wrappers +# around them, and a program that builds a date either way builds the +# same date. +Date = datetime.date +Time = datetime.time +Timestamp = datetime.datetime +Binary = bytes + + +def DateFromTicks(ticks: float) -> datetime.date: # noqa: N802 - PEP 249 names it + """The date at a Unix timestamp, in local time, as PEP 249 defines it.""" + return Date(*time.localtime(ticks)[:3]) + + +def TimeFromTicks(ticks: float) -> datetime.time: # noqa: N802 - PEP 249 names it + """The time of day at a Unix timestamp, in local time.""" + return Time(*time.localtime(ticks)[3:6]) + + +def TimestampFromTicks(ticks: float) -> datetime.datetime: # noqa: N802 - PEP 249 names it + """The moment at a Unix timestamp, in local time.""" + return Timestamp(*time.localtime(ticks)[:6]) + + +def _closing(statement: str, opened: int, *, escapes: bool, doubled: bool) -> int: + """Where the thing quoted at `opened` ends, one past its closer. + + Three kinds of quoting and three rules, which are the lexer's: + `'a\\'b'` escapes with a backslash, `@'a''b'` has no escapes and + doubles the quote instead, and a backtick-quoted name ends at the + next backtick and has neither. An unterminated one runs to the end + of the text, because the statement is about to be handed to the + engine and the engine says where the string started. + """ + quote = statement[opened] + at = opened + 1 + while at < len(statement): + letter = statement[at] + if escapes and letter == "\\": + at += 2 + continue + if letter == quote: + if doubled and statement[at + 1 : at + 2] == quote: + at += 2 + continue + return at + 1 + at += 1 + return len(statement) + + +def _placeholders(statement: str) -> list[int]: + """Where the `?` markers are, and only those. + + A `?` inside a string, a quoted name or a comment is text somebody + wrote, not a parameter, so this walks the statement the way the + lexer does rather than counting characters. It is the whole of the + parsing this layer does: everything else about the statement is the + engine's business. + """ + found: list[int] = [] + at = 0 + while at < len(statement): + letter = statement[at] + if letter == "?": + found.append(at) + at += 1 + elif letter in "'\"": + at = _closing(statement, at, escapes=True, doubled=False) + elif letter == "`": + at = _closing(statement, at, escapes=False, doubled=False) + elif letter == "@" and statement[at + 1 : at + 2] in ("'", '"'): + at = _closing(statement, at + 1, escapes=False, doubled=True) + elif statement[at : at + 2] == "//": + end = statement.find("\n", at) + at = len(statement) if end < 0 else end + 1 + elif statement[at : at + 2] == "/*": + end = statement.find("*/", at + 2) + at = len(statement) if end < 0 else end + 2 + else: + at += 1 + return found + + +def _bound( + statement: str, parameters: Sequence[Value] | Mapping[str, Value] | None +) -> tuple[str, dict[str, Value] | None]: + """The statement the engine will run, and the parameters for it. + + A mapping goes through untouched, which is how a caller writing zu + statements keeps `$name`. A sequence is bound to the `?` markers, + each one becoming a name the engine can find. + """ + if parameters is None: + return statement, None + if isinstance(parameters, Mapping): + return statement, dict(parameters) + if isinstance(parameters, (str, bytes)): + raise ProgrammingError( + f"parameters must be a sequence or a mapping, not {type(parameters).__name__}" + ) + values = list(parameters) + marks = _placeholders(statement) + if len(marks) != len(values): + raise ProgrammingError( + f"the statement has {len(marks)} placeholders and {len(values)} parameters were given" + ) + if not marks: + return statement, None + # A name the statement cannot already be using. Almost always `_1` + # on the first look, and a caller who really has written `$_1` gets + # `$__1` instead of a collision nobody would ever find. + prefix = "_" + while f"${prefix}" in statement: + prefix = "_" + prefix + out: list[str] = [] + last = 0 + for place, at in enumerate(marks, start=1): + out.append(statement[last:at]) + out.append(f"${prefix}{place}") + last = at + 1 + out.append(statement[last:]) + return "".join(out), {f"{prefix}{place}": value for place, value in enumerate(values, start=1)} + + +class Cursor: + """A statement, its rows, and where the caller has read up to. + + Cursors are cheap and are not shared between threads. Making one + per statement is the usual shape and costs nothing here: the + connection underneath is what holds the engine, and a cursor is a + position in a result that already exists. + """ + + #: PEP 249's optional extension: the exception classes reachable + #: from the cursor, for code holding one and nothing else. + Warning = Warning + Error = Error + InterfaceError = InterfaceError + DatabaseError = DatabaseError + DataError = DataError + OperationalError = OperationalError + IntegrityError = IntegrityError + InternalError = InternalError + ProgrammingError = ProgrammingError + NotSupportedError = NotSupportedError + + def __init__(self, connection: Connection) -> None: + self._connection = connection + self._closed = False + self._result: zudb.Result | None = None + self._ahead: deque[tuple[Value, ...]] = deque() + self._description: tuple[tuple[Any, ...], ...] | None = None + self._rowcount = -1 + #: Rows `fetchmany` takes when it is not told how many. One, + #: which PEP 249 asks for and nobody should leave alone: rows + #: are already in memory here, so a bigger number costs + #: nothing and saves calls. + self.arraysize = 1 + + @property + def connection(self) -> Connection: + """The connection this cursor was made from.""" + return self._connection + + @property + def closed(self) -> bool: + """Whether this cursor has been closed.""" + return self._closed or self._connection.closed + + @property + def description(self) -> tuple[tuple[Any, ...], ...] | None: + """One seven-item tuple per column, or `None` after a statement + that returned no columns. + + `(name, type_code, display_size, internal_size, precision, + scale, null_ok)`, of which zu answers the first two. The type + code is the Python type of the values in that column, read from + the first rows of the result, and `None` for a column that is + null in all of them. A result carries no declared types to + report instead: what a column holds is what the statement put + in it. + """ + return self._description + + @property + def rowcount(self) -> int: + """Rows the last statement produced, or -1 when there is no answer. + + Rows produced, never rows affected: a statement that writes + gives back no columns and the engine does not count what it + touched, so a write leaves this at -1 rather than at a number + somebody would believe. + """ + return self._rowcount + + @property + def result(self) -> zudb.Result | None: + """The native result the last statement gave back. + + The way out of this layer and into the rest of the client: + `cur.result.to_arrow()` and `cur.result.notices` are there + without opening a second connection. Reading rows from it moves + this cursor's own position, since they are the same rows. + """ + return self._result + + def execute( + self, operation: str, parameters: Sequence[Value] | Mapping[str, Value] | None = None + ) -> Cursor: + """Runs one statement and keeps its rows to be fetched.""" + self._usable() + statement, values = _bound(operation, parameters) + self._connection._begin() + with _translating(): + result = self._connection._conn.execute(statement, values) + self._result = result + self._ahead.clear() + if not result.columns: + self._description = None + self._rowcount = -1 + else: + self._description = self._typed(result) + self._rowcount = len(result) + return self + + def executemany( + self, operation: str, seq_of_parameters: Iterable[Sequence[Value] | Mapping[str, Value]] + ) -> Cursor: + """Runs one statement once for each set of parameters. + + For writing rows, which is what PEP 249 has it for. Rows any of + them produce are not kept, so `description` and `rowcount` are + empty afterwards rather than describing whichever one happened + to be last. + """ + self._usable() + self._connection._begin() + for parameters in seq_of_parameters: + statement, values = _bound(operation, parameters) + with _translating(): + self._connection._conn.execute(statement, values) + self._result = None + self._ahead.clear() + self._description = None + self._rowcount = -1 + return self + + def fetchone(self) -> tuple[Value, ...] | None: + """The next row, or `None` when there are no more.""" + result = self._rows() + if self._ahead: + return self._ahead.popleft() + with _translating(): + return result.fetchone() + + def fetchmany(self, size: int | None = None) -> list[tuple[Value, ...]]: + """The next `size` rows, or as many as are left.""" + self._rows() + wanted = self.arraysize if size is None else size + got: list[tuple[Value, ...]] = [] + while len(got) < wanted: + row = self.fetchone() + if row is None: + break + got.append(row) + return got + + def fetchall(self) -> list[tuple[Value, ...]]: + """Every row that has not been fetched yet.""" + result = self._rows() + taken = list(self._ahead) + self._ahead.clear() + with _translating(): + while (row := result.fetchone()) is not None: + taken.append(row) + return taken + + def setinputsizes(self, sizes: Iterable[object]) -> None: + """Nothing. PEP 249 requires the method, not an effect. + + It is there for drivers that have to reserve a buffer per + parameter before the statement runs. Parameters here are Python + objects handed straight across. + """ + + def setoutputsizes(self, size: int, column: int | None = None) -> None: + """Nothing, for the same reason as `setinputsizes`.""" + + def close(self) -> None: + """Closes the cursor and lets go of its rows. + + The connection is left alone. Closing twice is allowed, since + the second call has nothing to do and refusing it would only + make callers write a flag. + """ + self._closed = True + self._result = None + self._ahead.clear() + + def _usable(self) -> None: + """That there is something to run a statement on.""" + if self._closed: + raise InterfaceError("this cursor is closed") + if self._connection.closed: + raise InterfaceError("the connection this cursor was made from is closed") + + def _rows(self) -> zudb.Result: + """The result to fetch from, if the last statement left one.""" + self._usable() + if self._result is None or self._description is None: + raise ProgrammingError( + "there are no rows to fetch: the last statement on this cursor returned no columns" + ) + return self._result + + def _typed(self, result: zudb.Result) -> tuple[tuple[Any, ...], ...]: + """`description` for a result, and the rows read to work it out. + + Reading stops as soon as every column has been seen holding + something, so the usual cost is one row. The rows read are kept + and handed to the caller first, so typing a result takes none + of it away. + """ + names = result.columns + kinds: list[type | None] = [None] * len(names) + missing = len(names) + with _translating(): + while missing and len(self._ahead) < AHEAD: + row = result.fetchone() + if row is None: + break + self._ahead.append(row) + for at, value in enumerate(row): + if kinds[at] is None and value is not None: + kinds[at] = type(value) + missing -= 1 + return tuple( + (name, kind, None, None, None, None, None) + for name, kind in zip(names, kinds, strict=True) + ) + + def __iter__(self) -> Iterator[tuple[Value, ...]]: + """Rows one at a time, which PEP 249 lists as an extension.""" + while (row := self.fetchone()) is not None: + yield row + + def __enter__(self) -> Cursor: + return self + + def __exit__(self, *_exception: object) -> bool: + self.close() + return False + + def __repr__(self) -> str: + if self.closed: + return "" + if self._description is None: + return "" + return f"" + + +class Connection: + """One connection, with the transaction PEP 249 says is running. + + The native connection is underneath and reachable as `zu`, so + nothing this layer does not have is out of reach: appenders, + registered frames and `interrupt()` are all still there, on the + same connection and inside the same transaction. + """ + + #: The exception classes, reachable from the connection. PEP 249 + #: lists this as an extension and it is the only way for code that + #: was handed a connection to catch what it raises without knowing + #: which module made it. + Warning = Warning + Error = Error + InterfaceError = InterfaceError + DatabaseError = DatabaseError + DataError = DataError + OperationalError = OperationalError + IntegrityError = IntegrityError + InternalError = InternalError + ProgrammingError = ProgrammingError + NotSupportedError = NotSupportedError + + def __init__(self, connection: zudb.Connection, *, autocommit: bool = False) -> None: + self._conn = connection + self._autocommit = autocommit + self._work: zudb.Transaction | None = None + + @property + def zu(self) -> zudb.Connection: + """The native connection this one wraps.""" + return self._conn + + @property + def autocommit(self) -> bool: + """Whether each statement stands alone rather than joining a transaction.""" + return self._autocommit + + @property + def closed(self) -> bool: + """Whether this connection is still open.""" + return self._conn.closed + + def cursor(self) -> Cursor: + """A new cursor on this connection.""" + if self.closed: + raise InterfaceError("this connection is closed") + return Cursor(self) + + def commit(self) -> None: + """Keeps what the transaction wrote and ends it. + + A no-op when nothing has been written since the last one, which + is what lets a caller commit in a loop without asking whether + there is anything to commit. + """ + if self.closed: + raise InterfaceError("this connection is closed") + work, self._work = self._work, None + if work is not None: + with _translating(): + work.commit() + + def rollback(self) -> None: + """Throws away what the transaction wrote and ends it.""" + if self.closed: + raise InterfaceError("this connection is closed") + work, self._work = self._work, None + if work is not None: + with _translating(): + work.rollback() + + def close(self) -> None: + """Rolls back what was not committed and closes the connection. + + PEP 249 is explicit that an uncommitted transaction is lost + here rather than kept, which is the one place the two clients + differ on what a program meant: a statement written outside a + transaction on the native client is committed when it finishes. + """ + if self.closed: + return + try: + self.rollback() + finally: + self._conn.close() + + def _begin(self) -> None: + """Starts the transaction the next statement will run in. + + Lazily, because a connection nobody has run anything on is not + holding a transaction open, and read-only when the connection + is, since that is the only kind it could start. + """ + if self._autocommit or self._work is not None: + return + with _translating(): + self._work = self._conn.transaction(read_only=self._conn.read_only) + + def __enter__(self) -> Connection: + return self + + def __exit__(self, kind: object, *_rest: object) -> bool: + """Commits a block that finished and rolls back one that raised. + + The connection stays open, which is what sqlite3 and psycopg + both do: the block is the unit of work, not the connection. A + block that closed the connection itself is left alone, since + closing already decided what happens to the transaction and + raising here would bury whatever the block was doing. + """ + if self.closed: + return False + if kind is None: + self.commit() + else: + self.rollback() + return False + + def __repr__(self) -> str: + if self.closed: + return "" + return f"" + + +def connect( + path: str | os.PathLike[str], + *, + read_only: bool = False, + memory_limit: int | None = None, + threads: int | None = None, + autocommit: bool = False, +) -> Connection: + """Opens the database at `path` and connects to it. + + The same arguments `zudb.connect` takes, and one more: with + `autocommit` every statement stands alone the way it does on the + native client, instead of joining a transaction that runs until + `commit` or `rollback`. + """ + with _translating(): + conn = zudb.connect(path, read_only=read_only, memory_limit=memory_limit, threads=threads) + return Connection(conn, autocommit=autocommit) diff --git a/tests/test_dbapi.py b/tests/test_dbapi.py new file mode 100644 index 0000000..12326fe --- /dev/null +++ b/tests/test_dbapi.py @@ -0,0 +1,520 @@ +"""PEP 249, and the two places zu is not sqlite3. + +Most of this is the specification read back: the module globals, the +seven-item description, the exception hierarchy, what a fetch before an +execute is told. It is worth writing down because PEP 249 is a contract +somebody else's code holds us to, and code that holds a driver to a +contract is exactly the code nobody runs until it breaks. + +The two parts that are ours are the `?` markers and the implicit +transaction. `?` has to survive strings, quoted names and comments, +because a question mark inside a string is text somebody wrote. The +transaction has to open by itself and close on `commit`, `rollback` and +`close`, which is what a program written against any other driver +already assumes. +""" + +from __future__ import annotations + +import datetime +from pathlib import Path + +import pytest +import zudb +import zudb.dbapi as dbapi + +PEOPLE = [(10, "ada", 36.5), (20, "grace", 45.0), (30, "kay", 22.25)] + + +@pytest.fixture +def conn(tmp_path: Path) -> dbapi.Connection: + """The three people, through the layer that is being tested.""" + connection = dbapi.connect(tmp_path / "social.zu1") + cur = connection.cursor() + first, rest = PEOPLE[0], PEOPLE[1:] + cur.execute(f"INSERT (p:person {{uid: {first[0]}, name: '{first[1]}', score: {first[2]}}})") + for uid, name, score in rest: + cur.execute("INSERT (p:person {uid: ?, name: ?, score: ?})", (uid, name, score)) + connection.commit() + yield connection + connection.close() + + +def test_the_module_says_what_it_is() -> None: + """The three globals every driver has to carry.""" + assert dbapi.apilevel == "2.0" + assert dbapi.threadsafety == 2 + assert dbapi.paramstyle == "qmark" + + +def test_a_statement_runs_and_its_rows_come_back(conn: dbapi.Connection) -> None: + cur = conn.cursor() + cur.execute("MATCH (p:person) RETURN p.uid AS uid, p.name AS name") + assert cur.fetchall() == [(10, "ada"), (20, "grace"), (30, "kay")] + + +def test_rows_come_back_one_and_a_few_at_a_time(conn: dbapi.Connection) -> None: + cur = conn.cursor() + cur.execute("MATCH (p:person) RETURN p.name AS name") + assert cur.fetchone() == ("ada",) + assert cur.fetchmany(2) == [("grace",), ("kay",)] + assert cur.fetchmany(2) == [] + assert cur.fetchone() is None + + +def test_fetchmany_takes_arraysize_when_it_is_not_told(conn: dbapi.Connection) -> None: + cur = conn.cursor() + cur.execute("MATCH (p:person) RETURN p.name AS name") + assert cur.arraysize == 1, "what PEP 249 says the default is" + assert cur.fetchmany() == [("ada",)] + cur.arraysize = 5 + assert cur.fetchmany() == [("grace",), ("kay",)] + + +def test_a_cursor_iterates(conn: dbapi.Connection) -> None: + """An extension PEP 249 names, and the way anyone actually reads rows.""" + cur = conn.cursor() + cur.execute("MATCH (p:person) RETURN p.name AS name") + assert [name for (name,) in cur] == ["ada", "grace", "kay"] + + +def test_description_names_the_columns_and_their_types(conn: dbapi.Connection) -> None: + cur = conn.cursor() + cur.execute("MATCH (p:person) RETURN p.uid AS uid, p.name AS name, p.score AS score") + assert [column[0] for column in cur.description] == ["uid", "name", "score"] + assert [len(column) for column in cur.description] == [7, 7, 7] + assert [column[1] for column in cur.description] == [int, str, float] + assert cur.description[0][1] == dbapi.NUMBER + assert cur.description[1][1] == dbapi.STRING + assert cur.description[2][1] == dbapi.NUMBER + + +def test_a_column_that_is_null_the_whole_way_down_has_no_type(conn: dbapi.Connection) -> None: + """There is nothing to read a type off, and a guess would be a lie.""" + cur = conn.cursor() + cur.execute("MATCH (p:person) RETURN p.name AS name, null AS nothing") + assert [column[1] for column in cur.description] == [str, None] + + +def test_working_out_the_types_takes_none_of_the_rows(conn: dbapi.Connection) -> None: + """The rows read to type the columns are handed back first.""" + cur = conn.cursor() + cur.execute("MATCH (p:person) RETURN p.name AS name") + assert cur.description[0][1] is str + assert cur.fetchall() == [("ada",), ("grace",), ("kay",)] + + +def test_rowcount_is_the_rows_a_statement_produced(conn: dbapi.Connection) -> None: + cur = conn.cursor() + cur.execute("MATCH (p:person) RETURN p.name AS name") + assert cur.rowcount == 3 + cur.execute("MATCH (p:person) WHERE p.uid > 99 RETURN p.name AS name") + assert cur.rowcount == 0 + + +def test_a_statement_that_writes_has_no_description_and_no_count(conn: dbapi.Connection) -> None: + """PEP 249 asks for -1 when there is no answer, and there is none: + a write gives back no columns and the engine does not count what it + touched.""" + cur = conn.cursor() + cur.execute("INSERT (p:person {uid: 40, name: 'hopper', score: 1.0})") + assert cur.description is None + assert cur.rowcount == -1 + + +def test_fetching_after_a_statement_that_wrote_says_why_it_cannot(conn: dbapi.Connection) -> None: + cur = conn.cursor() + cur.execute("INSERT (p:person {uid: 40, name: 'hopper', score: 1.0})") + with pytest.raises(dbapi.ProgrammingError, match="no rows to fetch"): + cur.fetchone() + + +def test_fetching_before_anything_ran_says_the_same(conn: dbapi.Connection) -> None: + with pytest.raises(dbapi.ProgrammingError, match="no rows to fetch"): + conn.cursor().fetchall() + + +def test_a_placeholder_takes_a_value(conn: dbapi.Connection) -> None: + cur = conn.cursor() + cur.execute("MATCH (p:person) WHERE p.uid = ? RETURN p.name AS name", (20,)) + assert cur.fetchall() == [("grace",)] + + +def test_placeholders_are_filled_in_the_order_they_are_written(conn: dbapi.Connection) -> None: + cur = conn.cursor() + cur.execute( + "MATCH (p:person) WHERE p.uid > ? AND p.uid < ? RETURN p.name AS name", + (10, 30), + ) + assert cur.fetchall() == [("grace",)] + + +def test_the_wrong_number_of_parameters_says_both_numbers(conn: dbapi.Connection) -> None: + cur = conn.cursor() + with pytest.raises(dbapi.ProgrammingError, match="2 placeholders and 1 parameters"): + cur.execute("MATCH (p:person) WHERE p.uid > ? AND p.uid < ? RETURN p.uid AS uid", (10,)) + + +def test_a_question_mark_inside_a_string_is_not_a_placeholder(conn: dbapi.Connection) -> None: + """It is a character in somebody's data, and rewriting it would put + a parameter name in the middle of their text.""" + cur = conn.cursor() + cur.execute("MATCH (p:person) WHERE p.name = 'who?' RETURN p.name AS name") + assert cur.fetchall() == [] + cur.execute("RETURN 'why? because.' AS asked") + assert cur.fetchall() == [("why? because.",)] + + +def test_a_question_mark_in_a_comment_is_not_one_either(conn: dbapi.Connection) -> None: + cur = conn.cursor() + cur.execute( + "// which one? this one\n" + "MATCH (p:person) /* or ? this */ WHERE p.uid = ? RETURN p.name AS name", + (30,), + ) + assert cur.fetchall() == [("kay",)] + + +def test_a_question_mark_in_a_quoted_name_is_not_one_either(conn: dbapi.Connection) -> None: + cur = conn.cursor() + cur.execute("MATCH (p:person) WHERE p.uid = ? RETURN p.name AS `who?`", (10,)) + assert cur.description[0][0] == "who?" + + +def test_an_escaped_quote_does_not_end_the_string(conn: dbapi.Connection) -> None: + """The scanner follows the lexer's rules or it loses its place and + every `?` after it is read wrong.""" + cur = conn.cursor() + cur.execute("RETURN 'it\\'s ?' AS text, ? AS given", (1,)) + assert cur.fetchall() == [("it's ?", 1)] + + +def test_a_raw_string_doubles_its_quote_rather_than_escaping(conn: dbapi.Connection) -> None: + cur = conn.cursor() + cur.execute("RETURN @'it''s ?' AS text, ? AS given", (2,)) + assert cur.fetchall() == [("it's ?", 2)] + + +def test_a_dict_hands_the_statement_over_untouched(conn: dbapi.Connection) -> None: + """Which is how a caller who writes zu statements keeps `$name`.""" + cur = conn.cursor() + cur.execute("MATCH (p:person) WHERE p.uid = $uid RETURN p.name AS name", {"uid": 30}) + assert cur.fetchall() == [("kay",)] + + +def test_a_statement_that_already_says_the_obvious_name_gets_another( + conn: dbapi.Connection, +) -> None: + """A collision nobody would ever find, avoided by looking first. + + The name the marker is rewritten to has to be one the statement is + not already using, anywhere, for anything: a value bound over the + top of somebody's own text is a wrong answer with no error in it. + """ + cur = conn.cursor() + cur.execute("RETURN 'costs $_1 a row' AS text, ? AS given", (7,)) + assert cur.fetchall() == [("costs $_1 a row", 7)] + + +def test_a_string_of_parameters_is_refused_rather_than_read_letter_by_letter( + conn: dbapi.Connection, +) -> None: + cur = conn.cursor() + with pytest.raises(dbapi.ProgrammingError, match="not str"): + cur.execute("MATCH (p:person) WHERE p.name = ? RETURN p.uid AS uid", "ada") + + +def test_executemany_writes_a_row_for_each_set_of_parameters(conn: dbapi.Connection) -> None: + cur = conn.cursor() + cur.executemany( + "INSERT (p:person {uid: ?, name: ?, score: ?})", + [(40, "hopper", 1.0), (50, "lovelace", 2.0)], + ) + conn.commit() + cur.execute("MATCH (p:person) WHERE p.uid > 39 RETURN p.name AS name") + assert cur.fetchall() == [("hopper",), ("lovelace",)] + + +def test_executemany_keeps_no_rows(conn: dbapi.Connection) -> None: + """PEP 249 leaves it undefined for statements that return rows, so + what it leaves behind says nothing rather than the last one's.""" + cur = conn.cursor() + cur.executemany("MATCH (p:person) WHERE p.uid = ? RETURN p.name AS name", [(10,), (20,)]) + assert cur.description is None + assert cur.rowcount == -1 + + +def test_a_transaction_is_running_from_the_first_statement(conn: dbapi.Connection) -> None: + """PEP 249 has no `begin`, so the driver is the one that starts it.""" + assert conn.zu.in_transaction is False + conn.cursor().execute("MATCH (p:person) RETURN p.uid AS uid") + assert conn.zu.in_transaction is True + conn.commit() + assert conn.zu.in_transaction is False + + +def test_a_rollback_undoes_what_was_not_committed(conn: dbapi.Connection) -> None: + cur = conn.cursor() + cur.execute("INSERT (p:person {uid: 40, name: 'hopper', score: 1.0})") + conn.rollback() + cur.execute("MATCH (p:person) RETURN count(p) AS people") + assert cur.fetchall() == [(3,)] + + +def test_a_commit_keeps_it(conn: dbapi.Connection) -> None: + cur = conn.cursor() + cur.execute("INSERT (p:person {uid: 40, name: 'hopper', score: 1.0})") + conn.commit() + cur.execute("MATCH (p:person) RETURN count(p) AS people") + assert cur.fetchall() == [(4,)] + + +def test_committing_twice_is_allowed(conn: dbapi.Connection) -> None: + """There is nothing to do the second time, and refusing would only + make callers keep a flag.""" + conn.cursor().execute("MATCH (p:person) RETURN p.uid AS uid") + conn.commit() + conn.commit() + + +def test_closing_rolls_back_what_was_not_committed(tmp_path: Path) -> None: + """The one place the two clients disagree about what a program + meant, and PEP 249 is explicit about it.""" + path = tmp_path / "lost.zu1" + first = dbapi.connect(path) + cur = first.cursor() + cur.execute("INSERT (p:person {uid: 10, name: 'ada'})") + first.commit() + cur.execute("INSERT (p:person {uid: 20, name: 'grace'})") + first.close() + with dbapi.connect(path) as second: + rows = second.cursor().execute("MATCH (p:person) RETURN p.name AS name") + assert rows.fetchall() == [("ada",)] + + +def test_autocommit_gives_back_the_native_behaviour(tmp_path: Path) -> None: + path = tmp_path / "each.zu1" + with dbapi.connect(path, autocommit=True) as conn: + cur = conn.cursor() + cur.execute("INSERT (p:person {uid: 10, name: 'ada'})") + assert conn.zu.in_transaction is False + conn.close() + with dbapi.connect(path, read_only=True) as reader: + rows = reader.cursor().execute("MATCH (p:person) RETURN p.name AS name") + assert rows.fetchall() == [("ada",)] + + +def test_a_block_that_finishes_commits_and_one_that_raises_rolls_back(tmp_path: Path) -> None: + path = tmp_path / "blocks.zu1" + conn = dbapi.connect(path) + with conn: + conn.cursor().execute("INSERT (p:person {uid: 10, name: 'ada'})") + assert conn.closed is False, "the block is the unit of work, not the connection" + with pytest.raises(ZeroDivisionError), conn: + conn.cursor().execute("INSERT (p:person {uid: 20, name: 'grace'})") + raise ZeroDivisionError + rows = conn.cursor().execute("MATCH (p:person) RETURN p.name AS name") + assert rows.fetchall() == [("ada",)] + conn.close() + + +def test_a_block_that_closed_the_connection_itself_leaves_quietly(tmp_path: Path) -> None: + """Closing decided what happened to the transaction already, and + raising on the way out would bury whatever the block was doing.""" + conn = dbapi.connect(tmp_path / "shut-early.zu1") + with conn: + conn.cursor().execute("INSERT (p:person {uid: 10, name: 'ada'})") + conn.close() + assert conn.closed is True + + +def test_a_read_only_connection_reads(tmp_path: Path) -> None: + """The transaction it opens by itself has to be a read-only one, or + the first statement would be refused before it ran.""" + path = tmp_path / "readonly.zu1" + zudb.load(path, nodes="person", columns={"uid": [1, 2, 3]}) + with dbapi.connect(path, read_only=True) as conn: + cur = conn.cursor() + cur.execute("MATCH (p:person) RETURN p.uid AS uid") + assert cur.rowcount == 3 + + +def test_a_cursor_used_after_it_was_closed_says_so(conn: dbapi.Connection) -> None: + cur = conn.cursor() + cur.close() + assert cur.closed is True + with pytest.raises(dbapi.InterfaceError, match="cursor is closed"): + cur.execute("MATCH (p:person) RETURN p.uid AS uid") + + +def test_a_cursor_on_a_closed_connection_says_which_is_closed(tmp_path: Path) -> None: + conn = dbapi.connect(tmp_path / "shut.zu1") + cur = conn.cursor() + conn.close() + assert cur.closed is True + with pytest.raises(dbapi.InterfaceError, match="connection this cursor was made from"): + cur.execute("MATCH (p:person) RETURN p.uid AS uid") + + +def test_a_connection_used_after_it_was_closed_says_so(tmp_path: Path) -> None: + conn = dbapi.connect(tmp_path / "gone.zu1") + conn.close() + assert conn.closed is True + for call in (conn.cursor, conn.commit, conn.rollback): + with pytest.raises(dbapi.InterfaceError, match="connection is closed"): + call() + conn.close() + + +def test_closing_a_cursor_twice_is_allowed(conn: dbapi.Connection) -> None: + cur = conn.cursor() + cur.close() + cur.close() + + +def test_a_cursor_closes_at_the_end_of_its_block(conn: dbapi.Connection) -> None: + with conn.cursor() as cur: + cur.execute("MATCH (p:person) RETURN p.name AS name") + assert cur.fetchone() == ("ada",) + assert cur.closed is True + + +def test_the_hierarchy_is_the_one_pep_249_draws() -> None: + """Code written against another driver catches these by name and + expects the classes underneath them to follow.""" + assert issubclass(dbapi.Error, Exception) + assert issubclass(dbapi.InterfaceError, dbapi.Error) + assert not issubclass(dbapi.InterfaceError, dbapi.DatabaseError) + assert issubclass(dbapi.DatabaseError, dbapi.Error) + for kind in ( + dbapi.DataError, + dbapi.OperationalError, + dbapi.IntegrityError, + dbapi.InternalError, + dbapi.ProgrammingError, + dbapi.NotSupportedError, + ): + assert issubclass(kind, dbapi.DatabaseError), kind + assert issubclass(dbapi.Warning, Exception) + assert not issubclass(dbapi.Warning, dbapi.Error) + + +def test_a_failure_is_both_classes_at_once(conn: dbapi.Connection) -> None: + """The whole point of the exception design: code that catches the + PEP 249 class and code that catches zu's own catch the same object, + so a program can mix a driver-shaped library with this client.""" + cur = conn.cursor() + with pytest.raises(dbapi.ProgrammingError) as raised: + cur.execute("MATCH (p:person RETURN p") + assert isinstance(raised.value, zudb.SyntaxError) + assert isinstance(raised.value, dbapi.DatabaseError) + assert isinstance(raised.value, dbapi.Error) + + +def test_a_translated_failure_keeps_every_field(conn: dbapi.Connection) -> None: + """Passing through here costs nothing: the code, the position and + the link are the ones the engine wrote.""" + with pytest.raises(dbapi.SyntaxError) as raised: + conn.cursor().execute("MATCH (p:person RETURN p") + assert raised.value.code == "42001" + assert raised.value.line == 1 + assert raised.value.column is not None + assert raised.value.excerpt is not None + assert raised.value.doc_url is not None + assert raised.value.caret() is not None + + +def test_a_failure_is_not_printed_twice(conn: dbapi.Connection) -> None: + """Re-raised as its PEP 249 class and not chained to itself, since + the traceback of the original is the traceback this one has.""" + with pytest.raises(dbapi.SyntaxError) as raised: + conn.cursor().execute("MATCH (p:person RETURN p") + assert raised.value.__cause__ is None + assert raised.value.__suppress_context__ is True + + +def test_the_exceptions_hang_off_the_connection_and_the_cursor(conn: dbapi.Connection) -> None: + """PEP 249's extension, for code that was handed a connection and + does not know which module made it.""" + assert conn.Error is dbapi.Error + assert conn.cursor().DatabaseError is dbapi.DatabaseError + assert conn.OperationalError is dbapi.OperationalError + + +def test_the_type_objects_cover_what_a_column_can_hold(tmp_path: Path) -> None: + path = tmp_path / "types.zu1" + with dbapi.connect(path) as conn: + cur = conn.cursor() + cur.execute("INSERT (p:person {uid: 1, name: 'ada', score: 1.5, born: DATE '1815-12-10'})") + conn.commit() + cur.execute( + "MATCH (p:person) RETURN p.uid AS uid, p.name AS name, p.score AS score, p.born AS born" + ) + codes = [column[1] for column in cur.description] + assert codes == [int, str, float, datetime.date] + assert [code == dbapi.NUMBER for code in codes] == [True, False, True, False] + assert [code == dbapi.STRING for code in codes] == [False, True, False, False] + assert [code == dbapi.DATETIME for code in codes] == [False, False, False, True] + # Comparing a type object to a type is what these are for, which is + # the one place `==` on a type is not the mistake ruff takes it for. + assert dbapi.BINARY == bytes # noqa: E721 + assert dbapi.ROWID == zudb.Node # noqa: E721 + + +def test_the_constructors_build_what_a_parameter_takes(conn: dbapi.Connection) -> None: + """PEP 249 asks for them so a program can build a value without + knowing what the driver wants. Here it wants Python's own types, so + they are Python's own types.""" + assert dbapi.Date(1815, 12, 10) == datetime.date(1815, 12, 10) + assert dbapi.Time(13, 30) == datetime.time(13, 30) + assert dbapi.Timestamp(1815, 12, 10, 13, 30) == datetime.datetime(1815, 12, 10, 13, 30) + assert dbapi.Binary(b"raw") == b"raw" + ticks = datetime.datetime(2026, 8, 19, 9, 15, 30).timestamp() + assert dbapi.DateFromTicks(ticks) == datetime.date(2026, 8, 19) + assert dbapi.TimeFromTicks(ticks) == datetime.time(9, 15, 30) + assert dbapi.TimestampFromTicks(ticks) == datetime.datetime(2026, 8, 19, 9, 15, 30) + cur = conn.cursor() + cur.execute("RETURN ? AS born", (dbapi.Date(1815, 12, 10),)) + assert cur.fetchall() == [(datetime.date(1815, 12, 10),)] + + +def test_the_optional_methods_this_layer_has_not_got_are_absent(conn: dbapi.Connection) -> None: + """PEP 249 asks for them to be left out rather than defined and + refused, so that asking whether a method is there is an answer.""" + cur = conn.cursor() + assert not hasattr(cur, "callproc") + assert not hasattr(cur, "nextset") + cur.setinputsizes([1, 2]) + cur.setoutputsizes(1024) + + +def test_the_native_connection_is_reachable_and_is_the_same_one(conn: dbapi.Connection) -> None: + """A layer, not a second client: everything this does not have is + still there, on the connection and inside its transaction.""" + assert isinstance(conn.zu, zudb.Connection) + cur = conn.cursor() + cur.execute("INSERT (p:person {uid: 40, name: 'hopper', score: 1.0})") + assert conn.zu.execute("MATCH (p:person) RETURN count(p) AS people").fetchall() == [(4,)] + conn.rollback() + + +def test_the_result_of_the_last_statement_is_reachable(conn: dbapi.Connection) -> None: + cur = conn.cursor() + cur.execute("MATCH (p:person) RETURN p.name AS name") + assert isinstance(cur.result, zudb.Result) + assert len(cur.result) == 3 + + +def test_what_a_repr_says(conn: dbapi.Connection) -> None: + cur = conn.cursor() + assert repr(cur) == "" + cur.execute("MATCH (p:person) RETURN p.uid AS uid, p.name AS name") + assert repr(cur) == "" + cur.close() + assert repr(cur) == "" + assert "social.zu1" in repr(conn) + + +def test_a_database_that_is_not_there_is_the_class_pep_249_expects(tmp_path: Path) -> None: + with pytest.raises(dbapi.OperationalError): + dbapi.connect(tmp_path / "no" / "such" / "place.zu1") diff --git a/tests/test_import.py b/tests/test_import.py index c6c5c24..cfe1392 100644 --- a/tests/test_import.py +++ b/tests/test_import.py @@ -122,6 +122,16 @@ def test_the_event_loop_module_is_not_imported_by_importing_this_one() -> None: assert done.stdout.strip() == "False False" +def test_the_dbapi_module_is_not_imported_by_importing_this_one() -> None: + """`zudb.dbapi` is a submodule a caller asks for by name too. + + It is there for code written against PEP 249, which is not most + code, and the package that everyone imports should not carry it. + """ + done = run("import sys, zudb; print('zudb.dbapi' in sys.modules)") + assert done.stdout.strip() == "False" + + def test_pyarrow_arrives_when_a_result_is_asked_for_its_columns(tmp_path: Path) -> None: pytest.importorskip("pyarrow") path = tmp_path / "columns.zu1" diff --git a/tests/test_readme.py b/tests/test_readme.py index fb7d295..30c84a3 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -60,7 +60,7 @@ def run(program: str, where: Path) -> subprocess.CompletedProcess[str]: def test_the_readme_prints_programs_and_not_fragments() -> None: """The rule above holds: the page has both kinds and knows which.""" - assert len(programs()) == 3, "the README's whole programs" + assert len(programs()) == 4, "the README's whole programs" assert len(blocks("python")) > len(programs()), "and its fragments" @@ -88,6 +88,15 @@ def test_the_event_loop_snippet_runs_as_printed(tmp_path: Path) -> None: assert done.stdout.split() == ["ada"] +def test_the_dbapi_snippet_runs_as_printed(tmp_path: Path) -> None: + """The fourth block: a cursor, a `?` parameter, a block that commits.""" + snippet = programs()[3] + assert "zudb.dbapi.connect" in snippet and "fetchall" in snippet + done = run(snippet, tmp_path) + assert done.returncode == 0, done.stderr + assert done.stdout.strip() == "[('ada',)]" + + @pytest.mark.parametrize("index", range(len(programs()))) def test_every_whole_program_in_the_readme_runs(index: int, tmp_path: Path) -> None: """Including the ones no other test looks at the output of."""