semolina.cursor

DBAPI 2.0 cursor wrapper with Row convenience methods.

SemolinaCursor delegates to any DBAPI 2.0-compatible cursor and adds fetchall_rows(), fetchmany_rows(), and fetchone_row() that convert raw tuples into Row objects using cursor.description column names.

Classes

SemolinaCursor

ADBC cursor wrapper with Row convenience methods.

Module Contents

class semolina.cursor.SemolinaCursor(cursor: Any, conn: Any)[source]

ADBC cursor wrapper with Row convenience methods.

Wraps the ADBC cursor execute() opens, and keeps its DBAPI 2.0 fetch methods. Adds fetchall_rows(), fetchmany_rows(), and fetchone_row(), which return Row objects, and the Arrow and DataFrame readers ADBC provides.

A result is read one way. The first read picks it: the DBAPI fetches (fetchone, fetchmany, fetchall and their _rows variants, which interleave freely), for row in cursor, fetch_record_batch(), iter_into(), or one of the whole-result methods. Reading it a second way raises SemolinaResultConsumedError naming both calls, because the second would start after whatever the first had buffered and silently miss those rows.

Every way of reading rows needs pyarrow, because ADBC reads through a pyarrow reader; each raises SemolinaMissingDependencyError without it. The one exception is fetch_polars().

Context manager support releases the reader, cursor and connection on exit.

fetchall_rows() → list[semolina.results.Row][source]

Fetch all remaining rows as Row objects.

Returns:

List of Row objects with attribute and dict access.

Raises:
fetchone_row() → semolina.results.Row | None[source]

Fetch next row as a Row, or None if exhausted.

Returns:

Row object, or None if no rows remain.

Raises:
fetchmany_rows(size: int = 1) → list[semolina.results.Row][source]

Fetch up to size rows as Row objects.

Parameters:

size – Maximum number of rows to fetch. Defaults to 1.

Returns:

List of Row objects (may be shorter than size).

Raises:
fetchall() → list[tuple[Any, Ellipsis]][source]

Fetch all remaining rows as raw tuples (DBAPI passthrough).

Returns:

List of tuple rows.

Raises:
fetchone() → tuple[Any, Ellipsis] | None[source]

Fetch next row as raw tuple (DBAPI passthrough).

Returns:

Tuple row, or None if exhausted.

Raises:
fetchmany(size: int = 1) → list[tuple[Any, Ellipsis]][source]

Fetch up to size rows as raw tuples (DBAPI passthrough).

Parameters:

size – Maximum number of rows to fetch.

Returns:

List of tuple rows.

Raises:
fetch_arrow_table() → pyarrow.Table[source]

Fetch all remaining rows as a PyArrow Table (ADBC passthrough).

Delegates to the underlying ADBC cursor’s fetch_arrow_table() method for zero-copy Arrow data transfer.

Requires an ADBC-capable cursor (Snowflake, Databricks, or DuckDB pool connections). Not supported by non-ADBC cursors.

Returns:

pyarrow.Table with the query results.

Raises:
  • SemolinaMissingDependencyError – If pyarrow is not installed. ADBC builds this table through a pyarrow.RecordBatchReader; without pyarrow it raises its own ProgrammingError("This API requires PyArrow to be installed"), which names neither Semolina nor the extra that fixes it.

  • SemolinaResultConsumedError – If the result is already being read another way.

  • AttributeError – If the underlying cursor does not support fetch_arrow_table() (e.g. a non-ADBC cursor).

Example

cursor = Sales.query().metrics(Sales.revenue).execute()
table = cursor.fetch_arrow_table()
df = table.to_pandas()
fetch_record_batch() → pyarrow.RecordBatchReader[source]

Fetch the result as a PyArrow RecordBatchReader (ADBC passthrough).

Delegates to the underlying ADBC cursor’s fetch_record_batch() method for lazy, memory-bounded streaming consumption of Arrow data.

Requires an ADBC-capable cursor (Snowflake, Databricks, or DuckDB pool connections). Not supported by non-ADBC cursors.

The reader is this cursor’s way of reading its result: once it is taken, fetchone(), fetch_arrow_table(), iterating the cursor and the rest raise SemolinaResultConsumedError. A repeat call returns the same reader, which the cursor keeps so close() can close it.

The cursor must outlive the reader: consume the reader inside the context manager (or before .close()). See arrow-adbc issue #1893.

Returns:

pyarrow.RecordBatchReader over the query result.

Raises:
  • SemolinaMissingDependencyError – If pyarrow is not installed. The reader is a pyarrow object; ADBC calls its own _requires_pyarrow() here and raises a ProgrammingError that names neither Semolina nor the extra.

  • SemolinaResultConsumedError – If the result is already being read another way.

  • AttributeError – If the underlying cursor does not support fetch_record_batch() (e.g. a non-ADBC cursor).

Example

with Sales.query().metrics(Sales.revenue).execute() as cursor:
    reader = cursor.fetch_record_batch()
    for batch in reader:
        process(batch)
fetch_df() → pandas.DataFrame[source]

Fetch all remaining rows as a pandas DataFrame (ADBC passthrough).

Delegates to the underlying ADBC cursor’s fetch_df(), which reads the result through a pyarrow reader — self.reader.read_pandas() — so this path needs both pyarrow and pandas. Semolina converts nothing itself: ADBC’s implementation is already cancellation-aware, so a long fetch stays interruptible.

Requires an ADBC-capable cursor (Snowflake, Databricks, or DuckDB pool connections). Not supported by non-ADBC cursors.

Reads the whole result, like fetch_arrow_table(), so any other way of reading the same cursor afterwards raises SemolinaResultConsumedError.

A DECIMAL metric arrives as an object column holding decimal.Decimal values — pandas has no native decimal dtype, so precision survives but the column is untyped. fetch_polars() does better; see its note.

Returns:

pandas.DataFrame with the query results.

Raises:

Example

with Sales.query().metrics(Sales.revenue).execute() as cursor:
    df = cursor.fetch_df()
df.head()
fetch_polars() → polars.DataFrame[source]

Fetch all remaining rows as a polars DataFrame (ADBC passthrough).

Delegates to the underlying ADBC cursor’s fetch_polars(), which hands polars the result’s raw Arrow PyCapsule stream — polars.from_arrow(self.fetch_arrow()) — and never touches pyarrow. This method is therefore guarded on polars only: requiring pyarrow here would refuse a call that works on a polars-and-no-pyarrow install.

Requires an ADBC-capable cursor (Snowflake, Databricks, or DuckDB pool connections). Not supported by non-ADBC cursors.

This must be the first read on the cursor. After any other way of reading it — iterating, fetch_record_batch(), fetch_arrow_table(), into() — this raises SemolinaResultConsumedError, naming the earlier call. ADBC’s implementation takes the cursor’s Arrow stream handle and leaves None behind, so calling fetch_polars() twice reaches the driver’s own ProgrammingError("Result set has been closed or consumed"). Reading description first is safe; it does not import the stream.

A DECIMAL metric keeps its precision and its type: polars 1.43.2 gives a warehouse decimal128(38, 2) column a native Decimal(precision=38, scale=2) dtype holding decimal.Decimal values, measured on this project’s own type-fidelity probe. That is better than fetch_df(), where the same column falls back to an untyped object dtype. One condition, recorded because it is reachable in principle and not in practice: polars was measured raising a Rust PanicException on a decimal256 column, and no backend Semolina supports has been observed producing one — a Snowflake NUMBER stops at precision 38, and Databricks and DuckDB decimals stop there too.

Returns:

polars.DataFrame with the query results.

Raises:
  • SemolinaMissingDependencyError – If polars is not installed. ADBC does a bare import polars inside the fetch, so without this guard the caller gets a ModuleNotFoundError raised several frames deep in someone else’s module.

  • SemolinaResultConsumedError – If the result is already being read another way.

  • AttributeError – If the underlying cursor does not support fetch_polars() (e.g. a non-ADBC cursor).

Example

with Sales.query().metrics(Sales.revenue).execute() as cursor:
    df = cursor.fetch_polars()
df.head()
into(model: type[_M], *, validate: bool = False) → list[_M][source]

Convert the whole result into a list of Pydantic model instances.

Columns are matched to fields by name, resolved through arrowmodel’s own key rule — validation_alias, then alias, then the field name. Result columns the model does not declare are ignored, so one DTO can serve several queries. A declared field with no matching column is an error unless it carries a default.

Any Pydantic BaseModel subclass works; inheriting from arrowmodel.ArrowModel is not required and buys nothing here.

validate selects between two coherent behaviours, and the schema check follows it (see semolina.dto.check_result_schema()):

  • validate=False (default) — types must match. Instances are built with model_construct, which converts nothing, so an annotation that disagreed with its column would leave a wrong-typed value sitting in the field. The structural check runs before any row moves and raises SemolinaSchemaMismatchError instead, naming every offending field at once. Nothing is coerced; nothing is per-value; the cost is one schema comparison.

  • validate=True — types are coerced. Pydantic’s full pipeline runs per row at roughly 2-5x the cost, converting where it legally can (decimal128 into a float field, int64 into float) and raising ValidationError where it cannot (decimal128 into int). The structural type comparison is skipped, because Pydantic owns the decision and refusing first would block narrowings that work.

Column presence is checked on both settings — a required field with no matching column is an error either way, since no amount of coercion invents a column.

Reads the whole result, like fetch_arrow_table(), so any other way of reading the same cursor afterwards raises SemolinaResultConsumedError.

Parameters:
  • model – The Pydantic model to build. Any type[BaseModel].

  • validate – Coerce values to the declared annotations through Pydantic’s per-row pipeline, instead of the fast path’s exact-type contract. Passed straight through to arrowmodel. Defaults to False.

Returns:

A list of model instances, one per result row. Empty for a zero-row result.

Raises:
  • SemolinaMissingDependencyError – If pyarrow or arrowmodel is not installed.

  • SemolinaSchemaMismatchError – If the model’s annotations do not describe the result schema.

  • SemolinaResultConsumedError – If the result is already being read another way.

  • pydantic.ValidationError – Under validate=True only, for a value Pydantic cannot convert to its declared annotation. A NULL in a non-optional field is the common one, since the structural check says nothing about nullability.

  • TypeError – From arrowmodel, for a field annotated dict or Mapping against an Arrow map column, which it materializes as a list of pairs instead. The structural check records no verdict on a parameterized generic, so this one stays arrowmodel’s to raise.

  • ValueError – From arrowmodel, naming a required column it could not find. The check above pre-empts this with a better message whenever it has a schema to read, so it survives only where description was None.

Example

import decimal

import pydantic


class SalesDTO(pydantic.BaseModel):
    region: str
    total_order_value: decimal.Decimal


with Sales.query().metrics(Sales.total_order_value).execute() as cursor:
    rows = cursor.into(SalesDTO)
# [SalesDTO(region='US', total_order_value=Decimal('43.25')), ...]
iter_into(model: type[_M], *, validate: bool = False) → collections.abc.Iterator[_M][source]

Stream the result as Pydantic model instances, one at a time.

The streaming sibling of into(). Instances are produced individually, but conversion happens a whole Arrow batch at a time: consuming one instance pulls exactly one batch from the underlying reader, so a result larger than memory is never materialized. Column matching, alias resolution and the treatment of extra or defaulted fields are identical to into() — only the delivery differs.

The schema check happens at the call, not on first iteration. iter_into is a plain method that validates and then returns a generator, rather than a generator function whose body would not run until the first next(). That distinction is the whole point: arrowmodel’s own Model.iter(batch) is a generator function, so a bad DTO handed to it returns quietly and blows up several frames away, inside whatever loop or comprehension eventually consumed it. Here a mismatched DTO raises on the iter_into(...) expression itself, with the traceback pointing at the line that named the wrong type, and before any reader is created or any batch moves.

The returned iterator is this cursor’s way of reading its result: any other way, fetch_record_batch() included, then raises SemolinaResultConsumedError. The cursor must also outlive the iterator: consume it inside the context manager (or before close()). See arrow-adbc issue #1893.

Parameters:
  • model – The Pydantic model to build. Any type[BaseModel].

  • validate – Run Pydantic validation per row instead of the fast path. Set on the converter once and reused for every batch. Defaults to False. Read into()’s note first — validate=True coerces instead of requiring exact types.

Returns:

An Iterator yielding one model instance per result row. Empty for a zero-row result; zero-row batches mid-stream are skipped rather than ending it.

Raises:
  • SemolinaMissingDependencyError – If pyarrow or arrowmodel is not installed. Raised at the call.

  • SemolinaSchemaMismatchError – If the model’s annotations do not describe the result schema. Raised at the call.

  • SemolinaResultConsumedError – If the result is already being read another way.

  • pydantic.ValidationError – Under validate=True only, for a value Pydantic cannot convert to its declared annotation — a NULL in a non-optional field being the common one. Raised from the batch that carries the value, not at the call, since no value has been read yet when the call returns.

  • TypeError – From arrowmodel, for a field annotated dict or Mapping against an Arrow map column. Raised on the first next().

  • ValueError – From arrowmodel, naming a required column it could not find. The check above pre-empts this whenever it has a schema to read, so it survives only where description was None. Raised on the first next().

Example

import decimal

import pydantic


class SalesDTO(pydantic.BaseModel):
    region: str
    total_order_value: decimal.Decimal


with Sales.query().metrics(Sales.total_order_value).execute() as cursor:
    for dto in cursor.iter_into(SalesDTO):
        process(dto)
property description: list[tuple[Any, Ellipsis]] | None[source]

Cursor description passthrough.

Returns:

List of 7-element tuples describing columns, or None before execute.

property rowcount: int[source]

Row count passthrough.

Returns:

Number of rows affected by the last operation.

close() → None[source]

Close the reader, then the cursor, then return the connection to its pool.

The same order and the same failure handling as the async twin’s aclose(). Each step is suppressed narrowly — Exception, not BaseException — so a close that fails cannot keep the connection out of the pool, and cannot replace the exception a with body raised. A connection that fails to close is a pool slot that never comes back, so that one is reported as a ResourceWarning. Idempotent: the closed flag is set first, so a second call is a no-op.