API¶
import asyncio
from dataclasses import dataclass
from rowx import query
from rowx.postgres import Pool, connect
type UserRow = tuple[int, str, str | None]
@dataclass(frozen=True, slots=True, kw_only=True)
class User:
id: int
name: str
email: str | None = None
async def main() -> None:
async with await connect("postgresql://app@localhost/app") as conn:
uid = 42
user = await query[UserRow](t"SELECT id, name, email FROM users WHERE id = {uid}").one(conn)
maybe = await query[UserRow](t"SELECT id, name, email FROM users WHERE id = {uid}").optional(conn)
named = await query[User](t"SELECT name, id FROM users WHERE id = {uid}").one(conn)
async for name in query[str](t"SELECT name FROM users ORDER BY name").fetch(conn):
...
result = await query(t"UPDATE users SET name = {'x'} WHERE id = {uid}").execute(conn)
print(result.rows_affected)
async with Pool("postgresql://app@localhost/app", max_size=20) as pool:
await query[UserRow](t"SELECT id, name, email FROM users WHERE id = {uid}").one(pool)
async with pool.transaction() as tx:
await query(t"UPDATE users SET name = {'x'} WHERE id = {uid}").execute(tx)
asyncio.run(main())
The block is an illustration. It is not a runnable snippet.
Public surface¶
The rowx distribution's public imports are rowx, rowx.core, rowx.errors, and rowx.postgres.
from rowx import query and from rowx_core import query are the same object. from rowx.postgres import connect
and from rowx_postgres import connect are the same object. import rowx loads no database backend.
A runtime-only install of rowx-core and rowx-postgres does not include the rowx package, so it imports
rowx_core and rowx_postgres.
rowx and rowx_core export query, query_unchecked, QueryResult, RowStream, the executor protocols
Executor, Connection, Pool and Transaction, and the errors below. The protocols describe what a query can
run on. They are not connections. Open a PostgreSQL connection with rowx.postgres.
rowx.core exists only in the rowx distribution. It re-exports the rowx_core names that rowx and
rowx.errors do not already export. rowx_core exports that same set. A backend that depends only on rowx-core
imports them from rowx_core.
The template surface is Compiled, Compiler, TemplatePlan, Parameter, Identifier, capture_template, and
iter_parts. The row plan is FieldPlan, ShapeSpec, and ValueClass. The executor protocols in that set are
Acquire and StreamHandle.
| Module | Exports |
|---|---|
rowx, also rowx_core |
query, query_unchecked, QueryResult, RowStream, Executor, Connection, Pool, Transaction, and the errors |
rowx.core, also rowx_core |
Acquire, Compiled, Compiler, FieldPlan, Identifier, Parameter, ShapeSpec, StreamHandle, TemplatePlan, ValueClass, capture_template, iter_parts |
rowx.postgres, also rowx_postgres |
connect, ConnectOptions, Connection, Pool, Transaction, RESET_QUERY, Range, Interval, Money, Cube, Point, Line, LineSegment, Box, Path, Polygon, Circle, Migrator, Migration, MigrationStatus, MigrationResult |
rowx.errors |
the same error classes, defined in rowx_core.errors |
rowx.testing |
opt-in pytest plugin (Test plugin) |
Application code uses query from rowx and the driver from rowx.postgres.
query and query_unchecked are siblings. Neither subclasses the other, and user code must not subclass either.
The checker reports indirect-query if it does. query and query_unchecked set no __slots__, so each instance
has a __dict__, which keeps __orig_class__ and therefore T.
Methods:
| Method | Result | Behaviour |
|---|---|---|
one |
T |
The first row, or RowNotFound when there are none. Extra rows are not an error. After the first row the rest are drained and discarded. DML side effects still run. Use LIMIT 1 for a large result. |
optional |
T \| None |
The first row, or None when there are none. Extra rows drain as for one. |
all |
list[T] |
Every row. Use fetch when the size is unbounded. |
fetch |
RowStream[T] |
Streams rows. Does no IO itself. |
execute |
QueryResult |
rows_affected from CommandComplete, or 0. Allowed on query[T]: the row plan is not built and the rows are discarded. |
Every method except execute needs a row type. On query[None] they raise
InterfaceError("query without a row type; use .execute() or add query[T]"). fetch raises that from the call, not
from the first iteration. QueryResult has no command tag. The tag is
#300.
For query[int | None], optional returning None means either no row or a NULL value. Use
query[tuple[int | None]] when the difference matters. The checker does not report it.
The shape check (arity, and for a named row the column-to-field mapping) happens before execution, against the prepared statement. A mismatch is reported even when the result is empty.
Each call compiles SQL with that executor's compiler. Capturing the t-string (rejects, identifiers, nested
templates) happens in the constructor and does not depend on a database. Emitting $n, adjacency spaces and the
parameter cap happens when the method runs. The same compiler emits the SQL and the source-map padding the checker
uses. A query is not permanently bound to the first executor.
one, optional, all and execute are async def. fetch is a plain def. RowStream.__anext__, aclose,
__aenter__ and __aexit__ are async def.
rowx.postgres¶
The rowx-postgres distribution imports the same objects from rowx_postgres.
RESET_QUERY is
RESET SESSION AUTHORIZATION; RESET ALL; SELECT pg_catalog.pg_advisory_unlock_all(); CLOSE ALL; UNLISTEN *; DISCARD TEMP;.
ConnectOptions is a frozen, slotted, keyword-only dataclass. password is omitted from repr. extra values are
shown as '<redacted>'. from_dsn uses the Rust DSN parser. connect and Pool accept str | ConnectOptions.
channel_binding is Literal["disable", "prefer", "require"] | None. An extra key of that name, an invalid value,
or require with sslmode=disable is an InterfaceError. server_settings travel in the startup options as
-c pairs and survive RESET ALL. Every other DSN key the parser keeps goes in extra and is passed through. A
DSN with options=-c search_path=… MUST round-trip. Keys and values are validated
(Option validation). Redaction is in Security.
Connection is an executor. close on a pooled connection releases it. ping is an empty simple query. reset
runs RESET_QUERY. transaction starts a transaction. detach removes a pooled connection from the pool. One
operation at a time; a second raises ConnectionBusyError.
Pool does no IO in the constructor. async with calls open and close. Semantics are in
Pool. Waiting past acquire_timeout raises PoolTimeoutError. A closed pool raises
InterfaceError. Pool operations never raise ConnectionBusyError.
Transaction is an executor. transaction() on it is a savepoint and takes no isolation arguments. Semantics are
in Transactions.
Migrator is constructed synchronously and validates the files immediately, with no database IO. Its methods take a
Connection or a Pool. Semantics are in Migrations.
Streaming¶
fetch(executor) returns a single-use RowStream. It is an async iterator and an async context manager.
- Claiming.
fetchdoes no IO and does not claim the connection. The first__anext__claims it. On a connection or transaction that claim is synchronous, soConnectionBusyError, a shapeRowShapeErrorandEncodeErrorare raised from the first__anext__. On a pool the claim is an acquire. - Busy while open. From the first
__anext__until the stream ends, the connection is busy. A loop body cannot run another query on that connection. batch_sizedefaults to 1024. Below 1 isValueError. It caps how many rows cross into Python per trip. It does not change the server's row limit. The first row of a slow query is not held for a full batch.resultisNoneuntil the stream is exhausted, thenQueryResult. It staysNoneafter an early exit, an error or a cancel.- Early exit drains.
aclose(), leavingasync with, or collecting an unexhausted stream drops the row stream. NoCancelRequestis sent, so DML side effects run to completion. The connection is idle beforeaclose()returns. Afterbreak, a stream still referenced stays open until it is collected, which is whyasync withis the way to release it. Bound a large read withLIMIT, or cancel the task, when stopping early must be fast. - Cancellation of the task awaiting
__anext__cancels the statement (Cancellation). The stream is then over: the next__anext__raisesStopAsyncIteration. A cancel that lands in the loop body is an ordinary early exit, so the stream drains. - A
RowShapeError,DecodeError,RowConstructErrororDatabaseErrorpartway through is raised from__anext__. Rows already yielded stay yielded. The connection returns to idle, or to broken on an IO error. - Schema-change retry applies only before the first row is delivered.
close()on the connection while a stream is open cancels and settles. The next__anext__raisesInterfaceError.- On a pool, the connection is held until the stream ends, then released. A stream created inside
pool.acquire()and iterated after the block raisesInterfaceError. Early exit drains before the release reset.
A stalled consumer keeps the server backend inside the statement, holding its snapshot and locks.
Row-type grammar¶
The runtime row plan (rowx_core) and the checker (src/rowx/check/resolve.py) implement this grammar. Conformance
rows live in packages/rowx-postgres/src/rowx_postgres/__tests__/conformance/row_types.py.
ROW := NONE | tuple[ELEM, ...] | ELEM | NAMED
NONE := None
NAMED := dataclass | NamedTuple | TypedDict | pydantic BaseModel | pydantic dataclass
ELEM := LEAF
| "LEAF | None"
| Optional[LEAF]
| Union[LEAF, None]
| Any
| object
| None
LEAF := int | float | str | bool | bytes
| decimal.Decimal | datetime.date | datetime.time | datetime.datetime
| datetime.timedelta | uuid.UUID
| ipaddress.IPv4Interface | ipaddress.IPv6Interface
| ipaddress.IPv4Network | ipaddress.IPv6Network
| rowx_postgres.Interval | Money | Cube | Point | Line | LineSegment
| Box | Path | Polygon | Circle
| rowx_postgres.Range[RLEAF]
| dict[str, str | None]
| list[ELEM]
RLEAF := int | decimal.Decimal | datetime.date | datetime.datetime
tuple needs at least one element. A scalar plan is one column. None as T is the no-rows plan (query and
query[None] are the same). None as an element matches a void column only.
Wrappers unwrap first: Annotated, ReadOnly, Required, NotRequired, and a type alias, up to 16 hops. Leaf
detection runs before named-type detection, so the rowx_postgres value classes stay leaves even though they are
frozen dataclasses.
Rejected forms are InterfaceError at the first row-returning call and invalid-row-type in the checker: a string
or ForwardRef; an unbound TypeVar; a bare tuple, tuple[()] or tuple[X, ...]; a union with more than one
non-None member other than the two network pairs; Literal; any dict other than dict[str, str | None]; and
the named-row rejections below.
"LEAF | None" is a Python union, one alternative, as are Optional[LEAF] and Union[LEAF, None]. A bare None
element is the checker leaf none: it matches only void, and any other column is type-mismatch. _elem returns
true for None, so that element's nullable_mask is true. An element is also nullable when it is a union with None,
Any, or object. T is read lazily from __orig_class__ at the first row-returning call. The plan is cached per
T.
A new leaf needs a grammar line, a row-plan rule, a checker rule and conformance rows. A new named form needs the same four.
Named row types¶
| Form | Matching | Construction | Required fields |
|---|---|---|---|
@dataclass |
by name | keyword arguments | no default or default_factory |
typing.NamedTuple |
by position; names must match | positional arguments | not in _field_defaults |
TypedDict (typing or typing_extensions) |
by key | the row is the dict | __required_keys__ |
pydantic BaseModel or pydantic dataclass |
by name | the validator, or cls(**row) for a pydantic dataclass |
FieldInfo.is_required() |
Dataclasses may use slots, frozen, kw_only and KW_ONLY. NamedTuple and TypedDict work as classes and as
calls. typing_extensions.TypedDict is detected through sys.modules. TypedDict keywords rowx knows are total,
closed and extra_items. closed and extra_items do not relax unexpected-column.
rowx never imports pydantic. A pydantic class is detected through sys.modules. A pydantic dataclass is recognised
before the stdlib dataclass rule. Pydantic 2.11 or later is required for the features the checker reads. It is a dev
dependency of the test suite, not a dependency of rowx or rowx-postgres.
Fields come from the bases in the library's own order. A redeclared field replaces the base field in place. A
project base that is not a row type contributes no fields. A NamedTuple's fields are only _fields of the class
that directly subclasses NamedTuple. The checker follows the C3 method resolution order over project classes. An
inconsistent MRO or a cycle is unresolved-row-type. A private name __x is keyed by its mangled name.
These are not fields: ClassVar, KW_ONLY, field(init=False), pydantic private attributes, computed_field,
and Field(init=False) on a pydantic dataclass (Field(init=False) on a BaseModel is an ordinary field). A field
typed None matches a void column. A field is optional when it has a default, NotRequired, or total=False.
The column name used for matching is the server name after one trailing ! or ? is stripped. ?column? is
exempt. id!! becomes id!. Matching is case-sensitive. Describe and the mapping both call column_name. The
checker passes raw names through rowx_postgres._core.map_columns.
By name (dataclass, TypedDict, pydantic). Column order does not matter.
- every column needs a field, or
unexpected-column, includingextra="allow"andextra_items; - every field without a default needs a column, or
missing-column; - two columns with the same stripped name, or two accepted names of one pydantic field, are
duplicate-column; - one column that matches two or more fields is
ambiguous-column(pydantic only).
By position (NamedTuple). The column count must lie between the required-field count and the total, or it is
arity-mismatch and no name rules run. A stripped name that differs from the field is column-name-mismatch.
Every violation is reported, not only the first. Column-indexed issues come first, then missing-column by field
order. The checker's text and the runtime RowShapeError text are the same for the mapping messages below, in the
column N "name" style (columns are 1-based). RowShapeError reads {type_name}: {msg}; {msg}.
| Issue | Message |
|---|---|
unexpected-column |
column 3 "age" matches no field of `User` |
missing-column |
field "name" of `User` has no column |
duplicate-column |
columns 1 and 3 are both named "id" |
ambiguous-column |
column 2 "y" matches fields "x" and "y" of `M` |
column-name-mismatch |
column 2 "login" faces field "email" of `UserRow` (position 2) |
A ?column? duplicate reads columns 1 and 3 are both unnamed (`?column?`). Two names of one pydantic field read
columns 1 "id" and 2 "user_id" both map to field "id" of `M`. A NamedTuple arity mismatch reads
the query returns 4 columns but `UserRow` takes between 2 and 3 (takes exactly N when the bounds are equal).
type-mismatch and both nullability-mismatch strings are checker-only. A type mismatch reads
column 2 "age" is `int` but field "age" of `User` is `str`. Nullability reads
column 3 "email" may be NULL but field "email" of `User` is `str` or
column 3 "email" is NOT NULL but field "email" of `User` is `str \| None`.
The NOT NULL form is checker-only. The runtime NULL check does not raise for a NOT NULL column annotated as a union
with None. A NULL in a non-nullable field uses the runtime text
NULL in column N "..." for non-nullable field ... under Runtime validation.
Pydantic. A BaseModel is built with __pydantic_validator__.validate_python(row_dict). A pydantic dataclass is
called as cls(**row_dict), because validate_python(dict) raises dataclass_exact_type under strict=True. The
dict is keyed by column name and includes pass-through columns. A failure is a ValidationError wrapped in
RowConstructError. An incomplete model is rebuilt while the plan is built; still incomplete is InterfaceError.
The plan caches the validator, so a later model_rebuild(force=True) is not seen.
A field's names are its str validation_alias, else its str alias, else its name. validate_by_name or
populate_by_name adds the name. validate_by_alias=False keeps only the name. Config precedence follows pydantic:
an explicit validate_by_name wins, else populate_by_name, else validate_by_alias=False. The legacy
class Config: allow_population_by_field_name is ignored. Literal class keywords on a model are config. A pydantic
dataclass's config does not merge through bases.
Unknown names are a deliberate exception to "uncheckable is an error". AliasChoices, AliasPath or a
non-literal alias makes that field's names unknown. A before or wrap model_validator, an alias_generator, or a
model_config the checker cannot read makes every field unknown, in the checker. If any field is unknown, the four
by-name rules are skipped for the whole model. The note names not checked is attached only to a diagnostic the
model emits. Type and nullability checks still run for columns that map to a field with known names. At runtime
every column goes into the dict, and the NULL check covers only known names. A pass-through column uses the default
decoder.
The checker stays exact. A numeric column into an int field is type-mismatch. There is no pydantic type table
(#297). EmailStr, AnyUrl and SecretStr are invalid-row-type on
both sides. Annotate the column's Python type and validate with Annotated[str, AfterValidator(...)].
Annotations. At runtime, get_type_hints(cls, include_extras=True). An unresolved name is InterfaceError. In
the checker, annotations come from the class body. A string annotation in a class, and every annotation under
from __future__ import annotations, is parsed. A string as T itself is invalid-row-type. A name that exists
only under TYPE_CHECKING is invalid-row-type in a field and as a row-level alias, with help
import X at runtime.
Generics. Generic classes and generic aliases are supported once subscripted. Substitution walks
__type_params__ and never __value__[args]. The argument count is checked. Unbound parameters, TypeVarTuple,
ParamSpec, a wrong count, or more than 16 hops are invalid-row-type. TypeVar bounds are not enforced by the
checker; the leaf check is. A pydantic dataclass Box[int] is built by calling the origin class, so pydantic
validates the unparametrized T (Known limits).
Rejected on both sides: Protocol, collections.namedtuple, attrs, msgspec, RootModel, pydantic v1, a stdlib
dataclass with init=False, an InitVar (including on a pydantic dataclass, whose decorator accepts init=False),
a named row with no fields, a nullable named row (User | None; use .optional()), a named type nested below the
row, and an unsupported generic parameter.
Checker-only rejections (invalid-row-type in the checker, accepted at runtime, failure surfaces as
RowConstructError): a class decorator other than the dataclass decorators and typing.final; metaclass= or
another unknown class keyword; a hand-written __init__ on a stdlib dataclass; a constraint alias such as
PositiveInt or constr(); Json[...]; mixed dataclass and pydantic forms; an undecorated subclass of a pydantic
dataclass; an external stdlib base such as abc.ABC or Exception; a field declared in a conditional block; a
plain alias over a TypeVarTuple.
Runtime validation¶
- Arity and mapping run before execution. A mismatch is
RowShapeError, including on an empty result. A cached statement that fails the check is re-prepared once. - NULL check. A NULL in a non-nullable position is
RowShapeError. For a named row the message names the field:{type_name}: NULL in column 2 "email" for non-nullable field "email" of `User` (row {k}), withk0-based. Inallandonethe NULL checks run before any constructor. In a stream, earlier rows are yielded first. - Construction. An exception from the constructor or validator is
RowConstructError, aDecodeError, withtype_name,row_indexand the original as__cause__. It wrapsExceptiononly.CancelledError,KeyboardInterruptandSystemExitpass through. - Types are not checked at runtime, except by pydantic.
query_unchecked[T]gets the same runtime checks. - Element types and nullability inside
list[...]are not checked at runtime. A NULL array element decodes toNone.
t-string compilation¶
The constructor captures the template. That step rejects a non-Template, a NUL in static text, a conversion, and
an unsupported format spec, before any IO. PostgreSQL compilation, on the call, emits static text verbatim and the
n-th interpolation as $n counting from 1. The same expression twice is two parameters.
Adjacency. A single space is inserted before $n when the previous character is an identifier character
([A-Za-z0-9_$] or non-ASCII), and after $n when the next character is one. Without it, x{y} would become the
identifier x$1.
query rejects a nested Template, any conversion (including {x=}), and any format spec.
query_unchecked flattens nested templates and renumbers parameters. {x:ident} emits a quoted identifier.
Any other conversion or format spec is TypeError. There is no raw-SQL spec.
Writing guidance:
- IN-lists are
= ANY({seq}), withseqa list bound as an array. - Literal braces are
{{and}}. - Do not interpolate inside a string literal or comment.
LIKE '%{q}%'puts$1inside the literal. Bind the whole pattern. The checker reportsparam-count-mismatch. At runtime the counts disagree and raiseInterfaceError. - Cast a parameter the server cannot infer:
{x}::int. Otherwise Postgres fails with 42P18 or 42P08. - Do not write
$nby hand.
:ident¶
{x:ident} is query_unchecked only. x is a non-empty str, or a non-empty tuple of them joined with ..
- Any
strinstance is accepted, so aStrEnumallowlist works. The first step isstr.encode, strict UTF-8. A lone surrogate isTypeError. Later checks and quoting run on those bytes. Tuples are iterated withtuple.__iter__. - Rejected with
TypeError: a non-strpart, an empty name or part, a part longer than 63 UTF-8 bytes, and a C0 or C1 control character. - The compiler tracks quotes, dollar quotes,
--comments and nested block comments across flattened templates. An:identanywhere but plain code isTypeError("an ident interpolation must not be inside a string literal or comment"). conversionandformat_specare examined only aftertype(x) is str. Astrsubclass is never compared.{x:{spec}}that evaluates to"ident"takes the same path. The checker reports a non-literal format spec in aquery_uncheckedinterpolation asdynamic-query(Call shape).- Quoting makes a name safe as an identifier, not harmless as a choice. Allowlist a name that comes from a user.
Compile limits¶
TypeError before any IO. The connection stays usable.
- A NUL in the SQL text:
TypeError("SQL text must not contain a NUL character"). Static text can hold one, soqueryraises it too. The capture step raises this. - More than 65535 parameters after nested templates are flattened:
TypeError("a query takes at most 65535 parameters, got N"). The PostgreSQL compiler raises this when the method runs.Nis the count at the parameter that crosses the limit. A list bound with= ANY({ids})is one parameter.
Nullability overrides¶
A result column whose name ends in ! is forced NOT NULL, and one ending in ? is forced nullable:
count(*) AS "n!".
- The checker strips the suffix (Overrides).
- Tuple and scalar plans are positional, so the runtime ignores the name, but a NULL in a
!column still raisesRowShapeError. - A named row strips one trailing
!or?before matching."id!"maps to the fieldid. The suffix does not weaken the NULL check. - The described name is longer than 59 bytes and at most 63. The quoted source alias is longer than 63 bytes, ends
in
!or?, and starts with the described name. That isinvalid-override, because Postgres truncated the suffix. An alias of exactly 63 bytes that still ends in the suffix is not reported. A described name of 59 bytes or shorter is not reported. - Type overrides (
"col: T") are not supported (#302).
Errors¶
Defined in rowx_core.errors and re-exported from rowx and rowx.errors.
Error
├── InterfaceError
│ ├── ConnectionBusyError
│ └── PoolTimeoutError
├── DatabaseError # .sqlstate .message .detail .hint .position
├── RowNotFound
├── RowShapeError
├── DecodeError
│ └── RowConstructError # .type_name .row_index
├── EncodeError
└── MigrateError
├── MigrationSourceError # .path .version
│ └── IrreversibleMigrationError
├── MigrationChecksumError # .version .local_checksum .applied_checksum
├── MigrationMissingError # .versions
├── MigrationOrderError # .version .latest
├── DirtyMigrationError # .version
└── MigrationFailedError # .version .path .dirty .applied .error
Construction-time misuse of a template is TypeError. A bad row type at the call is InterfaceError.
DatabaseError.sqlstate is a 5-character str. .position is a 1-based character index, or None.
MigrationFailedError.__cause__ is the DatabaseError. A failure in lock or history-table SQL is that
DatabaseError unwrapped. An invalid table, schema name or revert target is ValueError. A Transaction
argument to Migrator is InterfaceError. No error message or repr may include the DSN password. Sqlstate
subclasses are not provided.