mirror of
https://github.com/element-hq/synapse.git
synced 2026-08-20 01:50:40 +00:00
Add the Python-facing `Connection` / `Cursor` pair and the `connect` factory, implementing enough of the PEP-249 (DBAPI2) shape for Synapse's needs. A `Connection` owns a single `tokio_postgres::Client`. The client *moves* between the connection and an in-flight cursor rather than being shared: it lives in the connection between interactions, is taken out for the duration of a cursor, and is handed back when the cursor finishes. That single-owner baton (an `Option<Client>` slot on each side) makes it structurally impossible to use the connection mid-transaction or to run two overlapping transactions on one socket — both become a clean "already closed" error. The transaction lifecycle (`cursor` opens with `BEGIN`; `finish` COMMIT/ROLLBACKs and hands the client back; `CursorGuard` rolls back an abandoned transaction on drop) is included here. On any transaction-control error the client is dropped rather than returned, closing the socket — safer than handing a possibly-broken connection back to what will become a connection pool. `connect()` parses a libpq-style DSN, blocks until connected, and spawns the long-lived connection task onto the shared runtime (the libpq default-host fixup is a follow-up). The cursor query methods (`execute` / `fetch_one` / `fetch_all` / `fetch_next_batch` / `rowcount`) delegate to the `CursorQueryState` machine. `run_interaction` — the high-level glue that opens a cursor, runs the callback, and commits/rolls back — follows in the next change, so `cursor`/`finish`/`CursorGuard` carry a transitional `#[allow(dead_code)]` until then. Now that the value/helpers/cursor_state modules are consumed internally, their visibility is tightened from `pub` back to private. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1024 lines
38 KiB
Rust
1024 lines
38 KiB
Rust
//! Tracks the result of the cursor's most recent `execute`.
|
|
//!
|
|
//! [`tokio_postgres`] returns rows as a [`RowStream`] that is consumed lazily,
|
|
//! so a cursor only ever holds onto the *current* query's stream plus the
|
|
//! metadata (column names, rowcount) derived from it.
|
|
//!
|
|
//! The lifecycle is modelled as an explicit state machine ([`CursorQueryState`])
|
|
//! so that illegal field combinations are unrepresentable, and so that fetching
|
|
//! after the result set has been reported exhausted is a clean error rather
|
|
//! than undefined behaviour (re-polling a finished stream surfaces as a
|
|
//! spurious "connection closed"). The row stream is [`Fuse`]d, which lets an
|
|
//! exhausted-but-not-yet-reported stream sit safely in the `Active` state: a
|
|
//! fused stream keeps yielding `None` instead of re-polling its completed
|
|
//! inner stream.
|
|
//!
|
|
//! ## Generic over the stream
|
|
//!
|
|
//! The state machine is generic over the underlying stream type `S`, defaulting
|
|
//! to [`RowStream`] (the only stream used in production). The
|
|
//! [`CursorRowStream`] trait abstracts the three things the logic needs from a
|
|
//! stream beyond [`futures::Stream`] itself — the affected-row count, how to
|
|
//! turn a row into a Python tuple, and how to render a stream error as a
|
|
//! `PyErr`. This is what lets the state-machine logic (exhaustion handling,
|
|
//! state transitions, error recovery) be unit-tested against an in-memory fake
|
|
//! stream with no live Postgres server; see the tests at the bottom of the
|
|
//! module.
|
|
|
|
use std::{mem, pin::Pin};
|
|
|
|
use futures::{stream::Fuse, StreamExt, TryStreamExt};
|
|
use pyo3::{
|
|
exceptions::PyRuntimeError,
|
|
marker::Ungil,
|
|
types::{PyInt, PyTuple},
|
|
Bound, PyErr, PyResult, Python,
|
|
};
|
|
use tokio_postgres::RowStream;
|
|
|
|
use crate::database::postgres::{
|
|
helpers::{BlockingPostgres, BlockingPostgresStream as _},
|
|
value::pg_row_to_py,
|
|
};
|
|
|
|
/// The capabilities the cursor state machine needs from the underlying row
|
|
/// stream, beyond [`futures::Stream`] itself.
|
|
///
|
|
/// Implemented for [`tokio_postgres::RowStream`] in production; the unit tests
|
|
/// provide an in-memory implementation so the state-machine logic can be
|
|
/// exercised without a live database.
|
|
///
|
|
/// The bounds (`Send` + [`Ungil`] on the stream and its row/error types) are
|
|
/// what let the rows and errors cross the GIL-release boundary in the
|
|
/// `block_on*` helpers.
|
|
pub trait CursorRowStream:
|
|
futures::Stream<Item = Result<Self::Row, Self::Error>> + Send + Ungil + Sized
|
|
{
|
|
/// A single result row.
|
|
type Row: Send + Ungil;
|
|
/// The stream's error type.
|
|
type Error: Send + Ungil;
|
|
|
|
/// The command tag's affected-row count, valid only once the stream has
|
|
/// been fully drained. Maps to [`RowStream::rows_affected`].
|
|
fn rows_affected(&self) -> Option<u64>;
|
|
|
|
/// Convert a yielded row into a Python tuple, one element per column.
|
|
fn row_to_py<'py>(py: Python<'py>, row: &Self::Row) -> PyResult<Bound<'py, PyTuple>>;
|
|
|
|
/// Build the Python error raised when the stream yields an error.
|
|
fn stream_err(err: &Self::Error) -> PyErr;
|
|
}
|
|
|
|
impl CursorRowStream for RowStream {
|
|
type Row = tokio_postgres::Row;
|
|
type Error = tokio_postgres::Error;
|
|
|
|
fn rows_affected(&self) -> Option<u64> {
|
|
RowStream::rows_affected(self)
|
|
}
|
|
|
|
fn row_to_py<'py>(py: Python<'py>, row: &Self::Row) -> PyResult<Bound<'py, PyTuple>> {
|
|
pg_row_to_py(py, row)
|
|
}
|
|
|
|
fn stream_err(err: &Self::Error) -> PyErr {
|
|
PyRuntimeError::new_err(format!("error fetching row from postgres: {err}"))
|
|
}
|
|
}
|
|
|
|
/// A live row stream, fused so that polling it after completion returns `None`
|
|
/// rather than re-polling the finished inner stream.
|
|
type FusedStream<S> = Pin<Box<Fuse<S>>>;
|
|
|
|
/// The lifecycle of the cursor's most recent query.
|
|
pub enum CursorQueryState<S = RowStream> {
|
|
/// No query has been executed yet, or the previous result set was reset by
|
|
/// the next `execute`. Fetching is an error.
|
|
Idle,
|
|
/// A query is in flight; rows can be fetched. Exhaustion has not yet been
|
|
/// reported to the caller.
|
|
Active {
|
|
/// Live row stream for the current query.
|
|
stream: FusedStream<S>,
|
|
/// Column names for the result set (empty for a DML statement).
|
|
///
|
|
/// TODO: currently write-only; kept to back a future PEP-249
|
|
/// `Cursor.description` accessor.
|
|
description: Vec<String>,
|
|
},
|
|
/// The result set has been fully consumed and exhaustion reported — a fetch
|
|
/// returned `None`/`[]`, or `fetch_all`/`rowcount` drained it. Any further
|
|
/// `fetch_*` is a programming error; `rowcount` still returns the count.
|
|
Closed {
|
|
/// Column names for the result set, carried over from `Active` so they
|
|
/// survive once the rows are gone.
|
|
///
|
|
/// TODO: currently write-only; like `Active::description` it is kept to
|
|
/// back a future PEP-249 `Cursor.description` accessor. `#[allow]`d
|
|
/// until that reader lands rather than dropped, so the column metadata
|
|
/// isn't silently lost at the `Active` -> `Closed` transition.
|
|
#[allow(dead_code)]
|
|
description: Vec<String>,
|
|
/// PEP-249 `rowcount` from the command tag, if it was captured.
|
|
rowcount: Option<u64>,
|
|
},
|
|
}
|
|
|
|
// Implemented by hand rather than derived: a derived `Default` would add an
|
|
// `S: Default` bound (which `RowStream` doesn't satisfy), even though the
|
|
// default state (`Idle`) doesn't depend on `S` at all. The hand-written impl is
|
|
// therefore *not* equivalent to the derive clippy suggests, so we silence it.
|
|
#[allow(clippy::derivable_impls)]
|
|
impl<S> Default for CursorQueryState<S> {
|
|
fn default() -> Self {
|
|
Self::Idle
|
|
}
|
|
}
|
|
|
|
impl<S: CursorRowStream> CursorQueryState<S> {
|
|
/// A fresh state with no query yet run.
|
|
pub fn new() -> Self {
|
|
Self::Idle
|
|
}
|
|
|
|
/// Reset to `Idle`, discarding any previous result set. Called at the
|
|
/// start of every `execute`.
|
|
pub fn new_query(&mut self) {
|
|
*self = Self::Idle;
|
|
}
|
|
|
|
/// Record the stream and column metadata for a newly-started query.
|
|
///
|
|
/// `description` is the list of column names (empty for a DML statement);
|
|
/// taking it as a plain `Vec<String>` rather than the driver's column
|
|
/// metadata keeps this layer decoupled from `tokio_postgres::Column` and so
|
|
/// testable.
|
|
pub fn on_query_start(&mut self, stream: S, description: Vec<String>) {
|
|
*self = Self::Active {
|
|
stream: Box::pin(stream.fuse()),
|
|
description,
|
|
};
|
|
}
|
|
|
|
/// Pull the next row from the stream, or `None` once it's exhausted.
|
|
///
|
|
/// Returning `None` reports exhaustion and moves the cursor to `Closed`, so
|
|
/// a subsequent fetch is an error. On a stream error the cursor is reset to
|
|
/// `Idle` and the error surfaced to Python.
|
|
pub fn fetch_one<'py>(&mut self, py: Python<'py>) -> PyResult<Option<Bound<'py, PyTuple>>> {
|
|
let Self::Active {
|
|
stream,
|
|
description,
|
|
} = self
|
|
else {
|
|
return Err(self.fetch_after_end_err());
|
|
};
|
|
|
|
// `fetch_next_batch` blocks for its first row too, but then uses the
|
|
// non-blocking `get_next_if_ready` to scoop up already-buffered rows
|
|
// without releasing the GIL again. A single fetch has nothing to scoop,
|
|
// so we just block directly rather than bothering with that fast path.
|
|
match stream.as_mut().block_on_next(py) {
|
|
Some(Ok(row)) => Ok(Some(S::row_to_py(py, &row)?)),
|
|
Some(Err(err)) => {
|
|
*self = Self::Idle;
|
|
Err(S::stream_err(&err))
|
|
}
|
|
None => {
|
|
let rowcount = rows_affected(stream);
|
|
*self = Self::Closed {
|
|
description: mem::take(description),
|
|
rowcount,
|
|
};
|
|
Ok(None)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Fetch the next batch of rows.
|
|
///
|
|
/// This method will block on the first row if it's not immediately
|
|
/// available, but will return any additional rows that are also ready
|
|
/// without blocking.
|
|
///
|
|
/// This is a convenience for Python code that wants to avoid the overhead
|
|
/// of calling `fetch_one` repeatedly, but still wants to avoid blocking on
|
|
/// the entire result set.
|
|
///
|
|
/// An empty batch reports exhaustion and moves the cursor to `Closed`, so a
|
|
/// subsequent fetch is an error. Note that the call that returns the final
|
|
/// rows and the call that reports the empty batch may be distinct: a batch
|
|
/// that runs into the end of the stream still returns the rows it has and
|
|
/// leaves the report to the next call. On a stream error the cursor is
|
|
/// reset to `Idle` and the error surfaced to Python.
|
|
pub fn fetch_next_batch<'py>(
|
|
&mut self,
|
|
py: Python<'py>,
|
|
capacity: usize,
|
|
) -> PyResult<Vec<Bound<'py, PyTuple>>> {
|
|
let Self::Active { stream, .. } = self else {
|
|
return Err(self.fetch_after_end_err());
|
|
};
|
|
|
|
match pull_ready_batch(stream, py, capacity) {
|
|
// We have rows to return now; stay `Active`. If the end of the
|
|
// stream was reached while draining, the fused stream will report
|
|
// it as an empty batch on the next call.
|
|
Ok(Some(buffer)) => Ok(buffer),
|
|
// The stream was already exhausted: report it and close. `self` is
|
|
// still `Active`, so re-borrow to move its fields into `Closed`.
|
|
Ok(None) => {
|
|
if let Self::Active {
|
|
stream,
|
|
description,
|
|
} = self
|
|
{
|
|
let rowcount = rows_affected(stream);
|
|
*self = Self::Closed {
|
|
description: mem::take(description),
|
|
rowcount,
|
|
};
|
|
}
|
|
Ok(Vec::new())
|
|
}
|
|
Err(err) => {
|
|
*self = Self::Idle;
|
|
Err(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Collect every remaining row into a `Vec`, draining the stream.
|
|
///
|
|
/// Draining reports exhaustion and moves the cursor to `Closed`, so a
|
|
/// subsequent fetch is an error. On a stream error the cursor is reset to
|
|
/// `Idle` and the error surfaced to Python.
|
|
pub fn fetch_all<'py>(&mut self, py: Python<'py>) -> PyResult<Vec<Bound<'py, PyTuple>>> {
|
|
let Self::Active {
|
|
stream,
|
|
description,
|
|
} = self
|
|
else {
|
|
return Err(self.fetch_after_end_err());
|
|
};
|
|
|
|
let rows = match stream.as_mut().try_collect::<Vec<_>>().block_on(py) {
|
|
Ok(rows) => rows,
|
|
Err(err) => {
|
|
*self = Self::Idle;
|
|
return Err(S::stream_err(&err));
|
|
}
|
|
};
|
|
let rows = rows
|
|
.into_iter()
|
|
.map(|row| S::row_to_py(py, &row))
|
|
.collect::<PyResult<Vec<_>>>()?;
|
|
|
|
let rowcount = rows_affected(stream);
|
|
*self = Self::Closed {
|
|
description: mem::take(description),
|
|
rowcount,
|
|
};
|
|
|
|
Ok(rows)
|
|
}
|
|
|
|
/// Drive a non-row-returning statement's stream to completion, surfacing
|
|
/// any execution error and capturing the affected-row count. A no-op unless
|
|
/// the cursor is `Active`.
|
|
///
|
|
/// `execute` calls this for statements with no result columns
|
|
/// (INSERT/UPDATE/DELETE/DDL without RETURNING). Their `query_raw` stream is
|
|
/// never fetched, but a `query_raw` stream only reports the server's
|
|
/// response — including an error such as a constraint violation, and the
|
|
/// affected-row count — once it is polled. Draining here makes such an error
|
|
/// surface at `execute` time, as psycopg2 does, instead of being silently
|
|
/// lost when the result is never fetched (most visibly under autocommit,
|
|
/// where there is no later `commit` to surface it).
|
|
pub fn finish_no_rows(&mut self, py: Python<'_>) -> PyResult<()> {
|
|
if let Self::Active {
|
|
stream,
|
|
description,
|
|
} = self
|
|
{
|
|
if let Err(err) = drain_stream(stream.as_mut()).block_on(py) {
|
|
*self = Self::Idle;
|
|
return Err(S::stream_err(&err));
|
|
}
|
|
let rowcount = rows_affected(stream);
|
|
*self = Self::Closed {
|
|
description: mem::take(description),
|
|
rowcount,
|
|
};
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Return the affected-row count, draining the stream first if needed.
|
|
///
|
|
/// Unlike the `fetch_*` methods this is always valid: reading the rowcount
|
|
/// of an already-exhausted (`Closed`) cursor returns the captured count
|
|
/// rather than erroring, per PEP-249.
|
|
pub fn rowcount<'py>(&mut self, py: Python<'py>) -> PyResult<Bound<'py, PyInt>> {
|
|
// `rows_affected()` is only valid after the stream is drained, so drain
|
|
// it here. This is OK as in Python the rowcount should only be accessed
|
|
// for queries that DO NOT return rows, e.g. INSERT, UPDATE, DELETE.
|
|
self.finish_no_rows(py)?;
|
|
|
|
match self {
|
|
Self::Closed {
|
|
rowcount: Some(rowcount),
|
|
..
|
|
} => Ok(PyInt::new(py, *rowcount)),
|
|
// No rowcount known: either we're `Idle` (no query run, or reset by
|
|
// an earlier error), or `Closed` with a command tag that carried no
|
|
// count. The stream is always drained by the block above before we
|
|
// get here, so "not yet drained" isn't a case. PEP-249 says -1.
|
|
_ => Ok(PyInt::new(py, -1)),
|
|
}
|
|
}
|
|
|
|
/// The error to raise when a `fetch_*` method finds no rows available,
|
|
/// distinguishing "no query was ever run" from "the result set has already
|
|
/// been exhausted". Only meaningful for the non-`Active` states.
|
|
fn fetch_after_end_err(&self) -> PyErr {
|
|
match self {
|
|
Self::Closed { .. } => {
|
|
PyRuntimeError::new_err("cannot fetch: the result set is already exhausted")
|
|
}
|
|
_ => PyRuntimeError::new_err("no active query"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Pull the first row (blocking until it arrives) plus any rows that are
|
|
/// already buffered, without blocking again.
|
|
///
|
|
/// Returns `Ok(None)` if the stream is already exhausted (there was no first
|
|
/// row). End-of-stream reached while draining the ready rows is *not* reported
|
|
/// here — we return the rows we have and leave the empty-batch report to a
|
|
/// later call; the fused stream makes re-polling safe.
|
|
///
|
|
/// Expects the stream of an `Active` cursor; `fetch_next_batch` is its only
|
|
/// caller and only invokes it in that state. A mid-drain stream error discards
|
|
/// the partially-built buffer and propagates, leaving the caller to reset.
|
|
fn pull_ready_batch<'py, S: CursorRowStream>(
|
|
stream: &mut FusedStream<S>,
|
|
py: Python<'py>,
|
|
capacity: usize,
|
|
) -> PyResult<Option<Vec<Bound<'py, PyTuple>>>> {
|
|
// Wait for at least one row.
|
|
let first = match stream.as_mut().block_on_next(py) {
|
|
Some(Ok(row)) => S::row_to_py(py, &row)?,
|
|
Some(Err(err)) => return Err(S::stream_err(&err)),
|
|
None => return Ok(None),
|
|
};
|
|
|
|
let mut buffer = Vec::with_capacity(capacity);
|
|
buffer.push(first);
|
|
|
|
loop {
|
|
match stream.as_mut().get_next_if_ready() {
|
|
// Not ready yet: return what we have (non-empty — we pushed the
|
|
// first row above).
|
|
None => break,
|
|
// A row is already available.
|
|
Some(Some(Ok(row))) => buffer.push(S::row_to_py(py, &row)?),
|
|
// The stream errored.
|
|
Some(Some(Err(err))) => return Err(S::stream_err(&err)),
|
|
// End of stream: stop, leaving the empty-batch report to the next
|
|
// call.
|
|
Some(None) => break,
|
|
}
|
|
}
|
|
|
|
Ok(Some(buffer))
|
|
}
|
|
|
|
/// The command tag's affected-row count, valid only once the (fused) stream has
|
|
/// been fully drained.
|
|
fn rows_affected<S: CursorRowStream>(stream: &FusedStream<S>) -> Option<u64> {
|
|
// The first `get_ref` unwraps the `Pin`, the second the `Fuse`.
|
|
stream.as_ref().get_ref().get_ref().rows_affected()
|
|
}
|
|
|
|
/// Consume and discard every row of a stream, propagating any error. Used to
|
|
/// reach the trailing command-complete message that carries the rowcount.
|
|
async fn drain_stream<S: CursorRowStream>(mut stream: Pin<&mut Fuse<S>>) -> Result<(), S::Error> {
|
|
while let Some(row) = stream.next().await {
|
|
row?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
//! The state machine is driven here by an in-memory [`FakeStream`] instead
|
|
//! of a live [`RowStream`], so these tests cover the exhaustion handling,
|
|
//! the state transitions, and the error recovery without any Postgres
|
|
//! server. Rows are simple integer tuples; that's enough to assert the
|
|
//! plumbing carries values through `row_to_py`.
|
|
//!
|
|
//! Note the one thing they deliberately *don't* cover: [`FakeStream`]'s
|
|
//! `poll_next` is always `Poll::Ready`, so the GIL-release / blocking path
|
|
//! in the `block_on*` helpers is never exercised here (it has its own tests
|
|
//! in `helpers`). These tests are purely about the state-machine logic.
|
|
|
|
use std::{
|
|
collections::VecDeque,
|
|
task::{Context, Poll},
|
|
};
|
|
|
|
use pyo3::prelude::*;
|
|
|
|
use super::*;
|
|
|
|
/// A result row: a list of integer cells.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
struct FakeRow(Vec<i64>);
|
|
|
|
/// A stream error carrying a message we can assert on.
|
|
#[derive(Debug)]
|
|
struct FakeError(&'static str);
|
|
|
|
/// An in-memory stream of pre-canned items, plus the `rows_affected` value
|
|
/// the command tag would report once it's drained.
|
|
struct FakeStream {
|
|
items: VecDeque<Result<FakeRow, FakeError>>,
|
|
rows_affected: Option<u64>,
|
|
}
|
|
|
|
impl futures::Stream for FakeStream {
|
|
type Item = Result<FakeRow, FakeError>;
|
|
|
|
fn poll_next(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
|
// Every item is immediately available; `None` once drained.
|
|
Poll::Ready(self.items.pop_front())
|
|
}
|
|
}
|
|
|
|
impl CursorRowStream for FakeStream {
|
|
type Row = FakeRow;
|
|
type Error = FakeError;
|
|
|
|
fn rows_affected(&self) -> Option<u64> {
|
|
self.rows_affected
|
|
}
|
|
|
|
fn row_to_py<'py>(py: Python<'py>, row: &FakeRow) -> PyResult<Bound<'py, PyTuple>> {
|
|
PyTuple::new(py, &row.0)
|
|
}
|
|
|
|
fn stream_err(err: &FakeError) -> PyErr {
|
|
PyRuntimeError::new_err(format!("fake stream error: {}", err.0))
|
|
}
|
|
}
|
|
|
|
/// Build an `Active` state over the given rows (all `Ok`) with the given
|
|
/// command-tag rowcount.
|
|
fn active_with(
|
|
rows: Vec<Vec<i64>>,
|
|
rows_affected: Option<u64>,
|
|
) -> CursorQueryState<FakeStream> {
|
|
let mut state = CursorQueryState::<FakeStream>::new();
|
|
state.on_query_start(
|
|
FakeStream {
|
|
items: rows.into_iter().map(|r| Ok(FakeRow(r))).collect(),
|
|
rows_affected,
|
|
},
|
|
vec!["col".to_string()],
|
|
);
|
|
state
|
|
}
|
|
|
|
/// Assert a `Bound<PyTuple>` decodes to the given integer cells.
|
|
fn assert_tuple(tuple: &Bound<'_, PyTuple>, expected: &[i64]) {
|
|
let got: Vec<i64> = tuple.extract().unwrap();
|
|
assert_eq!(got, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn fetch_one_yields_rows_then_reports_and_closes() {
|
|
Python::initialize();
|
|
Python::attach(|py| {
|
|
let mut state = active_with(vec![vec![1], vec![2]], Some(0));
|
|
|
|
assert_tuple(&state.fetch_one(py).unwrap().unwrap(), &[1]);
|
|
assert_tuple(&state.fetch_one(py).unwrap().unwrap(), &[2]);
|
|
// The end of the stream is reported as `None`, closing the cursor.
|
|
assert!(state.fetch_one(py).unwrap().is_none());
|
|
assert!(matches!(state, CursorQueryState::Closed { .. }));
|
|
|
|
// A fetch after exhaustion is a clean, specific error.
|
|
let err = state.fetch_one(py).unwrap_err();
|
|
assert!(err.to_string().contains("already exhausted"), "{err}");
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn fetch_one_on_idle_errors_with_no_active_query() {
|
|
Python::initialize();
|
|
Python::attach(|py| {
|
|
let mut state = CursorQueryState::<FakeStream>::new();
|
|
let err = state.fetch_one(py).unwrap_err();
|
|
assert!(err.to_string().contains("no active query"), "{err}");
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn finish_no_rows_drains_and_captures_rowcount() {
|
|
Python::initialize();
|
|
Python::attach(|py| {
|
|
// A non-row-returning statement: no rows to yield, a command-tag
|
|
// rowcount, and an empty column list.
|
|
let mut state = CursorQueryState::<FakeStream>::new();
|
|
state.on_query_start(
|
|
FakeStream {
|
|
items: VecDeque::new(),
|
|
rows_affected: Some(3),
|
|
},
|
|
vec![],
|
|
);
|
|
|
|
state.finish_no_rows(py).unwrap();
|
|
assert!(matches!(
|
|
state,
|
|
CursorQueryState::Closed {
|
|
rowcount: Some(3),
|
|
..
|
|
}
|
|
));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn finish_no_rows_surfaces_execution_error() {
|
|
Python::initialize();
|
|
Python::attach(|py| {
|
|
// The statement fails server-side (e.g. a constraint violation),
|
|
// surfaced only when the stream is polled — which `finish_no_rows`
|
|
// does, so the error is raised rather than silently swallowed.
|
|
let mut state = CursorQueryState::<FakeStream>::new();
|
|
state.on_query_start(
|
|
FakeStream {
|
|
items: VecDeque::from([Err(FakeError("boom"))]),
|
|
rows_affected: None,
|
|
},
|
|
vec![],
|
|
);
|
|
|
|
let err = state.finish_no_rows(py).unwrap_err();
|
|
assert!(err.to_string().contains("boom"), "{err}");
|
|
// A stream error resets the cursor to `Idle`.
|
|
assert!(matches!(state, CursorQueryState::Idle));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn finish_no_rows_on_idle_is_a_noop() {
|
|
Python::initialize();
|
|
Python::attach(|py| {
|
|
let mut state = CursorQueryState::<FakeStream>::new();
|
|
state.finish_no_rows(py).unwrap();
|
|
assert!(matches!(state, CursorQueryState::Idle));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn fetch_all_drains_and_closes() {
|
|
Python::initialize();
|
|
Python::attach(|py| {
|
|
let mut state = active_with(vec![vec![1, 2], vec![3, 4]], Some(0));
|
|
|
|
let rows = state.fetch_all(py).unwrap();
|
|
assert_eq!(rows.len(), 2);
|
|
assert_tuple(&rows[0], &[1, 2]);
|
|
assert_tuple(&rows[1], &[3, 4]);
|
|
assert!(matches!(state, CursorQueryState::Closed { .. }));
|
|
|
|
// Draining again is an error: the result set is gone.
|
|
assert!(state
|
|
.fetch_all(py)
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("exhausted"));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn fetch_all_on_empty_stream_returns_empty_and_closes() {
|
|
Python::initialize();
|
|
Python::attach(|py| {
|
|
let mut state = active_with(vec![], Some(0));
|
|
assert!(state.fetch_all(py).unwrap().is_empty());
|
|
assert!(matches!(state, CursorQueryState::Closed { .. }));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn fetch_one_after_fetch_all_errors() {
|
|
Python::initialize();
|
|
Python::attach(|py| {
|
|
let mut state = active_with(vec![vec![1]], Some(0));
|
|
let _ = state.fetch_all(py).unwrap();
|
|
assert!(state
|
|
.fetch_one(py)
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("exhausted"));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn fetch_one_then_fetch_all_continues_partially_consumed_stream() {
|
|
Python::initialize();
|
|
Python::attach(|py| {
|
|
let mut state = active_with(vec![vec![1], vec![2], vec![3]], Some(0));
|
|
|
|
// Consume one row with `fetch_one`...
|
|
assert_tuple(&state.fetch_one(py).unwrap().unwrap(), &[1]);
|
|
// ...then `fetch_all` should pick up the *rest*, not restart.
|
|
let rest = state.fetch_all(py).unwrap();
|
|
assert_eq!(rest.len(), 2);
|
|
assert_tuple(&rest[0], &[2]);
|
|
assert_tuple(&rest[1], &[3]);
|
|
assert!(matches!(state, CursorQueryState::Closed { .. }));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn fetch_all_error_resets_to_idle() {
|
|
Python::initialize();
|
|
Python::attach(|py| {
|
|
// First row ok, then an error part-way through the collection.
|
|
let mut state = CursorQueryState::<FakeStream>::new();
|
|
state.on_query_start(
|
|
FakeStream {
|
|
items: VecDeque::from(vec![Ok(FakeRow(vec![1])), Err(FakeError("kaboom"))]),
|
|
rows_affected: Some(0),
|
|
},
|
|
vec!["col".to_string()],
|
|
);
|
|
|
|
let err = state.fetch_all(py).unwrap_err();
|
|
assert!(err.to_string().contains("kaboom"), "{err}");
|
|
// A mid-stream error resets to `Idle`, exactly like `fetch_one`.
|
|
assert!(matches!(state, CursorQueryState::Idle));
|
|
assert!(state
|
|
.fetch_all(py)
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("no active query"));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn rowcount_after_drain_error_resets_to_idle() {
|
|
Python::initialize();
|
|
Python::attach(|py| {
|
|
let mut state = CursorQueryState::<FakeStream>::new();
|
|
state.on_query_start(
|
|
FakeStream {
|
|
items: VecDeque::from(vec![Err(FakeError("drain failed"))]),
|
|
rows_affected: Some(7),
|
|
},
|
|
vec!["col".to_string()],
|
|
);
|
|
|
|
// Draining for the rowcount hits the error and surfaces it.
|
|
let err = state.rowcount(py).unwrap_err();
|
|
assert!(err.to_string().contains("drain failed"), "{err}");
|
|
// The cursor is reset to `Idle`, so no stale count is retained: a
|
|
// subsequent rowcount is the PEP-249 "unknown" sentinel, -1.
|
|
assert!(matches!(state, CursorQueryState::Idle));
|
|
assert_eq!(state.rowcount(py).unwrap().extract::<i64>().unwrap(), -1);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn stream_error_resets_to_idle_and_surfaces() {
|
|
Python::initialize();
|
|
Python::attach(|py| {
|
|
// A stream whose second item is an error.
|
|
let mut state = CursorQueryState::<FakeStream>::new();
|
|
state.on_query_start(
|
|
FakeStream {
|
|
items: VecDeque::from(vec![Ok(FakeRow(vec![1])), Err(FakeError("boom"))]),
|
|
rows_affected: None,
|
|
},
|
|
vec!["col".to_string()],
|
|
);
|
|
|
|
assert_tuple(&state.fetch_one(py).unwrap().unwrap(), &[1]);
|
|
let err = state.fetch_one(py).unwrap_err();
|
|
assert!(err.to_string().contains("boom"), "{err}");
|
|
|
|
// After an error the cursor is reset to `Idle`, so the next fetch
|
|
// reports "no active query" rather than "exhausted".
|
|
assert!(matches!(state, CursorQueryState::Idle));
|
|
assert!(state
|
|
.fetch_one(py)
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("no active query"));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn rowcount_is_minus_one_before_any_query() {
|
|
Python::initialize();
|
|
Python::attach(|py| {
|
|
let mut state = CursorQueryState::<FakeStream>::new();
|
|
assert_eq!(state.rowcount(py).unwrap().extract::<i64>().unwrap(), -1);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn rowcount_drains_and_reports_command_tag() {
|
|
Python::initialize();
|
|
Python::attach(|py| {
|
|
// A DML-style result: no rows, but a command tag of 5 affected rows.
|
|
let mut state = active_with(vec![], Some(5));
|
|
assert_eq!(state.rowcount(py).unwrap().extract::<i64>().unwrap(), 5);
|
|
assert!(matches!(state, CursorQueryState::Closed { .. }));
|
|
|
|
// Reading it again on a `Closed` cursor returns the retained count,
|
|
// rather than erroring like a `fetch_*` would.
|
|
assert_eq!(state.rowcount(py).unwrap().extract::<i64>().unwrap(), 5);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn rowcount_drains_remaining_rows_of_a_select() {
|
|
Python::initialize();
|
|
Python::attach(|py| {
|
|
// Even with rows pending, asking for the rowcount drains them and
|
|
// then reports the command tag.
|
|
let mut state = active_with(vec![vec![1], vec![2]], Some(2));
|
|
assert_eq!(state.rowcount(py).unwrap().extract::<i64>().unwrap(), 2);
|
|
// Stream is drained, so fetching is now an "exhausted" error.
|
|
assert!(state
|
|
.fetch_one(py)
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("exhausted"));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn new_query_resets_an_active_cursor() {
|
|
Python::initialize();
|
|
Python::attach(|py| {
|
|
let mut state = active_with(vec![vec![1]], Some(0));
|
|
state.new_query();
|
|
assert!(matches!(state, CursorQueryState::Idle));
|
|
assert!(state
|
|
.fetch_one(py)
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("no active query"));
|
|
});
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// fetch_next_batch
|
|
// ------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn fetch_next_batch_returns_ready_rows_then_defers_the_empty_report() {
|
|
Python::initialize();
|
|
Python::attach(|py| {
|
|
// With an always-ready stream every row is immediately available,
|
|
// so the first batch drains the whole result set...
|
|
let mut state = active_with(vec![vec![1], vec![2], vec![3]], Some(0));
|
|
let batch = state.fetch_next_batch(py, 100).unwrap();
|
|
assert_eq!(batch.len(), 3);
|
|
assert_tuple(&batch[0], &[1]);
|
|
assert_tuple(&batch[2], &[3]);
|
|
|
|
// ...but the cursor stays `Active`: hitting the end of the stream
|
|
// while draining does *not* report exhaustion in the same call.
|
|
assert!(matches!(state, CursorQueryState::Active { .. }));
|
|
|
|
// The *next* call is the one that reports the empty batch and
|
|
// moves to `Closed`.
|
|
assert!(state.fetch_next_batch(py, 100).unwrap().is_empty());
|
|
assert!(matches!(state, CursorQueryState::Closed { .. }));
|
|
|
|
// And a further fetch is the "already exhausted" error.
|
|
assert!(state
|
|
.fetch_next_batch(py, 100)
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("exhausted"));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn fetch_next_batch_empty_stream_reports_exhaustion_immediately() {
|
|
Python::initialize();
|
|
Python::attach(|py| {
|
|
let mut state = active_with(vec![], Some(0));
|
|
// No first row at all -> empty batch and straight to `Closed`.
|
|
assert!(state.fetch_next_batch(py, 100).unwrap().is_empty());
|
|
assert!(matches!(state, CursorQueryState::Closed { .. }));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn fetch_next_batch_capacity_is_a_hint_not_a_limit() {
|
|
Python::initialize();
|
|
Python::attach(|py| {
|
|
// `capacity` only sizes the buffer; all ready rows are returned
|
|
// even when there are more of them than the hint.
|
|
let mut state = active_with(vec![vec![1], vec![2], vec![3]], Some(0));
|
|
let batch = state.fetch_next_batch(py, 1).unwrap();
|
|
assert_eq!(batch.len(), 3);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn fetch_next_batch_without_query_errors() {
|
|
Python::initialize();
|
|
Python::attach(|py| {
|
|
let mut state = CursorQueryState::<FakeStream>::new();
|
|
assert!(state
|
|
.fetch_next_batch(py, 100)
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("no active query"));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn fetch_next_batch_error_resets_to_idle() {
|
|
Python::initialize();
|
|
Python::attach(|py| {
|
|
let mut state = CursorQueryState::<FakeStream>::new();
|
|
state.on_query_start(
|
|
FakeStream {
|
|
items: VecDeque::from(vec![Err(FakeError("splat"))]),
|
|
rows_affected: Some(0),
|
|
},
|
|
vec!["col".to_string()],
|
|
);
|
|
|
|
let err = state.fetch_next_batch(py, 100).unwrap_err();
|
|
assert!(err.to_string().contains("splat"), "{err}");
|
|
assert!(matches!(state, CursorQueryState::Idle));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn fetch_next_batch_error_after_first_row_discards_buffer_and_resets() {
|
|
Python::initialize();
|
|
Python::attach(|py| {
|
|
// First row is fine, but the stream errors while the batch is still
|
|
// being drained (both items are immediately ready here).
|
|
let mut state = CursorQueryState::<FakeStream>::new();
|
|
state.on_query_start(
|
|
FakeStream {
|
|
items: VecDeque::from(vec![Ok(FakeRow(vec![1])), Err(FakeError("midway"))]),
|
|
rows_affected: Some(0),
|
|
},
|
|
vec!["col".to_string()],
|
|
);
|
|
|
|
// The error wins: the already-buffered first row is discarded
|
|
// rather than returned, and the cursor resets to `Idle`.
|
|
let err = state.fetch_next_batch(py, 100).unwrap_err();
|
|
assert!(err.to_string().contains("midway"), "{err}");
|
|
assert!(matches!(state, CursorQueryState::Idle));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn fetch_next_batch_interleaves_with_fetch_one() {
|
|
Python::initialize();
|
|
Python::attach(|py| {
|
|
let mut state = active_with(vec![vec![1], vec![2], vec![3]], Some(0));
|
|
// Take one row directly...
|
|
assert_tuple(&state.fetch_one(py).unwrap().unwrap(), &[1]);
|
|
// ...then the batch picks up the rest of the (still partially
|
|
// consumed) stream.
|
|
let batch = state.fetch_next_batch(py, 100).unwrap();
|
|
assert_eq!(batch.len(), 2);
|
|
assert_tuple(&batch[0], &[2]);
|
|
assert_tuple(&batch[1], &[3]);
|
|
});
|
|
}
|
|
|
|
/// A stream that can report "not ready yet" mid-way through, so we can
|
|
/// exercise the *partial batch* behaviour the always-ready [`FakeStream`]
|
|
/// can't reach: `fetch_next_batch` should block for the first row, then
|
|
/// return only the rows that are immediately ready.
|
|
struct SteppedStream {
|
|
steps: VecDeque<Step>,
|
|
}
|
|
|
|
enum Step {
|
|
/// A row that is ready right now.
|
|
Ready(FakeRow),
|
|
/// One poll that returns `Pending` (re-waking itself so a blocking poll
|
|
/// makes progress), modelling a row that isn't buffered yet.
|
|
Pending,
|
|
}
|
|
|
|
impl futures::Stream for SteppedStream {
|
|
type Item = Result<FakeRow, FakeError>;
|
|
|
|
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
|
match self.steps.pop_front() {
|
|
Some(Step::Ready(row)) => Poll::Ready(Some(Ok(row))),
|
|
Some(Step::Pending) => {
|
|
// Wake immediately so a blocking `block_on` re-polls and
|
|
// makes progress, while a single `now_or_never` poll still
|
|
// observes `Pending`.
|
|
cx.waker().wake_by_ref();
|
|
Poll::Pending
|
|
}
|
|
None => Poll::Ready(None),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl CursorRowStream for SteppedStream {
|
|
type Row = FakeRow;
|
|
type Error = FakeError;
|
|
|
|
fn rows_affected(&self) -> Option<u64> {
|
|
Some(0)
|
|
}
|
|
|
|
fn row_to_py<'py>(py: Python<'py>, row: &FakeRow) -> PyResult<Bound<'py, PyTuple>> {
|
|
PyTuple::new(py, &row.0)
|
|
}
|
|
|
|
fn stream_err(err: &FakeError) -> PyErr {
|
|
PyRuntimeError::new_err(format!("stepped stream error: {}", err.0))
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn fetch_next_batch_returns_partial_batch_when_next_is_not_ready() {
|
|
Python::initialize();
|
|
Python::attach(|py| {
|
|
// Row 1 is ready; row 2 is "not ready yet" (a Pending poll); row 3
|
|
// is ready behind it.
|
|
let mut state = CursorQueryState::<SteppedStream>::new();
|
|
state.on_query_start(
|
|
SteppedStream {
|
|
steps: VecDeque::from(vec![
|
|
Step::Ready(FakeRow(vec![1])),
|
|
Step::Pending,
|
|
Step::Ready(FakeRow(vec![3])),
|
|
]),
|
|
},
|
|
vec!["col".to_string()],
|
|
);
|
|
|
|
// First batch: got row 1, then the stream wasn't ready, so the
|
|
// batch is returned with just that one row. The cursor stays open.
|
|
let batch = state.fetch_next_batch(py, 100).unwrap();
|
|
assert_eq!(batch.len(), 1);
|
|
assert_tuple(&batch[0], &[1]);
|
|
assert!(matches!(state, CursorQueryState::Active { .. }));
|
|
|
|
// Second batch: the next row is now available.
|
|
let batch = state.fetch_next_batch(py, 100).unwrap();
|
|
assert_eq!(batch.len(), 1);
|
|
assert_tuple(&batch[0], &[3]);
|
|
|
|
// Third call drains to the end and reports the empty batch.
|
|
assert!(state.fetch_next_batch(py, 100).unwrap().is_empty());
|
|
assert!(matches!(state, CursorQueryState::Closed { .. }));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn fetch_next_batch_blocks_for_a_pending_first_row() {
|
|
Python::initialize();
|
|
Python::attach(|py| {
|
|
// The very first poll is `Pending`, so `block_on_next` must take its
|
|
// blocking path to get the first row (rather than the fast path).
|
|
let mut state = CursorQueryState::<SteppedStream>::new();
|
|
state.on_query_start(
|
|
SteppedStream {
|
|
steps: VecDeque::from(vec![
|
|
Step::Pending,
|
|
Step::Ready(FakeRow(vec![1])),
|
|
Step::Ready(FakeRow(vec![2])),
|
|
]),
|
|
},
|
|
vec!["col".to_string()],
|
|
);
|
|
|
|
let batch = state.fetch_next_batch(py, 100).unwrap();
|
|
assert_eq!(batch.len(), 2);
|
|
assert_tuple(&batch[0], &[1]);
|
|
assert_tuple(&batch[1], &[2]);
|
|
});
|
|
}
|
|
}
|