Files
synapse/tests/synapse_rust/test_database_postgres.py
T
82841cb702 Add integration tests for the Rust Postgres backend
Add the end-to-end test suite for the Rust DBAPI2 backend, driving the real
tokio-postgres client against a live Postgres server. It is skipped unless
the suite is configured for Postgres (SYNAPSE_POSTGRES), the same switch the
rest of the suite uses.

These cover the behaviours that need a real server rather than the
in-memory fakes the Rust unit tests use: connect (good and bad DSN),
run_interaction (return value, arg/kwarg forwarding, commit on success,
rollback on a Python exception, and recovery after both a Python-raised and
a server-rejected statement, including a constraint violation), cursor
reuse across queries, the fetch_one/fetch_all/fetch_next_batch/rowcount
surface (including batching across a 1000-row result set and the
exhausted-vs-no-active-query error distinction), and value round-trips for
every supported type (NULL, bytea with NUL/high bytes, float4 lossiness,
and an out-of-range int bound to a real int4 column).

A separately-guarded case exercises the libpq default-host fixup by
connecting with a DSN that omits the host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 12:42:05 +01:00

861 lines
32 KiB
Python

# This file is licensed under the Affero General Public License (AGPL) version 3.
#
# Copyright (C) 2026 Element Creations Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# See the GNU Affero General Public License for more details:
# <https://www.gnu.org/licenses/agpl-3.0.html>.
"""Tests for the Rust-implemented, DBAPI2-shaped Postgres ``Connection`` /
``Cursor`` pair exposed as ``synapse.synapse_rust.database.postgres``.
These exercise the real ``tokio-postgres`` backend, so they require a live
Postgres server and are skipped unless the test suite is configured to run
against Postgres (i.e. ``SYNAPSE_POSTGRES`` is set, the same switch used by the
rest of the suite).
"""
import logging
from typing import Any, Optional, cast
from twisted.internet import reactor
from synapse.synapse_rust.database import postgres
from synapse.types import ISynapseReactor
from tests import unittest
from tests.utils import (
POSTGRES_BASE_DB,
POSTGRES_HOST,
POSTGRES_PASSWORD,
POSTGRES_PORT,
POSTGRES_USER,
USE_POSTGRES_FOR_TESTS,
)
def _connect(dsn: str) -> Any:
"""Open a Rust connection, using the global reactor's shared tokio runtime.
The backend takes its runtime handle from the reactor (see the Rust
``connect``); it is started on demand, so this works under trial even though
the reactor itself isn't running during a synchronous test.
"""
return postgres.connect(cast(ISynapseReactor, reactor), dsn)
def _build_dsn() -> str:
"""Build a libpq keyword/value connection string from the test config."""
parts = [f"dbname={POSTGRES_BASE_DB}"]
if POSTGRES_USER is not None:
parts.append(f"user={POSTGRES_USER}")
if POSTGRES_HOST is not None:
parts.append(f"host={POSTGRES_HOST}")
if POSTGRES_PORT is not None:
parts.append(f"port={POSTGRES_PORT}")
if POSTGRES_PASSWORD is not None:
parts.append(f"password={POSTGRES_PASSWORD}")
return " ".join(parts)
def run_interaction(conn: Any, func: Any, *args: Any, **kwargs: Any) -> Any:
"""Run ``func(cursor, *args, **kwargs)`` in a transaction on ``conn``:
commit on success, roll back if it raises.
The shape Synapse's ``new_transaction`` drives the shim with, as a test
harness. (The shim used to expose this itself as
``Connection.run_interaction``, but nothing in production called it.)
The commit sits inside the protected region — as in
``RustConnectionPool._in_transaction`` — so a failed commit also rolls
back; a rollback failure is logged rather than allowed to mask the
original exception.
"""
cursor = conn.cursor()
try:
result = func(cursor, *args, **kwargs)
conn.commit()
return result
except BaseException:
try:
conn.rollback()
except Exception:
logging.getLogger(__name__).warning(
"Rollback failed in test run_interaction", exc_info=True
)
raise
@unittest.skip_unless(
bool(USE_POSTGRES_FOR_TESTS), "requires a Postgres server (set SYNAPSE_POSTGRES)"
)
class PostgresConnectionTestCase(unittest.TestCase):
"""Tests for the Rust Postgres ``Connection`` / ``Cursor``."""
def setUp(self) -> None:
self.conn = _connect(_build_dsn())
def tearDown(self) -> None:
# Explicitly drop the connection to ensure that the underlying Rust
# object is dropped before the Python interpreter shuts down. Otherwise,
# the open connection will block us tearing down the test database.
del self.conn
# ------------------------------------------------------------------
# connect()
# ------------------------------------------------------------------
def test_connect_bad_dsn_raises(self) -> None:
# A syntactically valid but unconnectable DSN should raise rather than
# return a half-open connection.
with self.assertRaises(RuntimeError):
_connect("host=127.0.0.1 port=1 dbname=does_not_exist")
# ------------------------------------------------------------------
# execute() / fetch_one() / fetch_all()
# ------------------------------------------------------------------
def test_fetch_one(self) -> None:
def interaction(cursor: Any) -> Optional[list[Any]]:
cursor.execute("SELECT 42::int, 'hello'::text")
return cursor.fetch_one()
self.assertEqual(run_interaction(self.conn, interaction), (42, "hello"))
def test_fetch_one_returns_none_when_exhausted(self) -> None:
def interaction(cursor: Any) -> list[Any]:
cursor.execute("SELECT 1 WHERE true")
first = cursor.fetch_one()
second = cursor.fetch_one()
return [first, second]
self.assertEqual(run_interaction(self.conn, interaction), [(1,), None])
def test_fetch_all(self) -> None:
def interaction(cursor: Any) -> list[list[Any]]:
cursor.execute(
"""
SELECT * FROM (VALUES (1, 'a'), (2, 'b'), (3, 'c'))
AS v(id, name) ORDER BY id
"""
)
return cursor.fetch_all()
self.assertEqual(
run_interaction(self.conn, interaction),
[(1, "a"), (2, "b"), (3, "c")],
)
def test_fetch_all_empty(self) -> None:
def interaction(cursor: Any) -> list[list[Any]]:
cursor.execute("SELECT 1 WHERE false")
return cursor.fetch_all()
self.assertEqual(run_interaction(self.conn, interaction), [])
def test_fetch_without_query_raises(self) -> None:
"""Calling fetch before execute is an error, not a silent empty result."""
def interaction(cursor: Any) -> None:
cursor.fetch_one()
with self.assertRaises(RuntimeError):
run_interaction(self.conn, interaction)
def test_reuse_cursor_for_multiple_queries(self) -> None:
"""A single cursor can run several queries in sequence."""
def interaction(cursor: Any) -> list[Any]:
results = []
for n in (1, 2, 3):
cursor.execute("SELECT $1::int", [n])
row = cursor.fetch_one()
assert row is not None
results.append(row[0])
return results
self.assertEqual(run_interaction(self.conn, interaction), [1, 2, 3])
# ------------------------------------------------------------------
# fetch_next_batch()
# ------------------------------------------------------------------
def test_fetch_next_batch_returns_first_row(self) -> None:
"""A non-empty result set yields a batch containing at least the first
row, which blocks until available."""
def interaction(cursor: Any) -> list[Any]:
cursor.execute("SELECT 42::int, 'hello'::text")
return cursor.fetch_next_batch()
# The batch must be non-empty and start with the first row. We don't
# assert the exact length: how many further rows are already buffered
# (and so returned without blocking) is timing-dependent.
batch = run_interaction(self.conn, interaction)
self.assertEqual(batch[0], (42, "hello"))
def test_fetch_next_batch_empty_when_no_rows(self) -> None:
"""An empty result set yields an empty batch."""
def interaction(cursor: Any) -> list[Any]:
cursor.execute("SELECT 1 WHERE false")
return cursor.fetch_next_batch()
self.assertEqual(run_interaction(self.conn, interaction), [])
def test_fetch_next_batch_collects_all_rows_across_batches(self) -> None:
"""Looping until an empty batch is returned yields every row exactly
once, in order, regardless of how the rows are split across batches."""
def interaction(cursor: Any) -> list[Any]:
cursor.execute(
"""
SELECT id FROM generate_series(1, 1000) AS s(id) ORDER BY id
"""
)
rows = []
while True:
batch = cursor.fetch_next_batch()
if not batch:
break
rows.extend(batch)
return rows
self.assertEqual(
run_interaction(self.conn, interaction),
[(n,) for n in range(1, 1001)],
)
def test_fetch_next_batch_reports_exhaustion_with_empty_batch(self) -> None:
"""After the last rows, one further call returns an empty batch to
report exhaustion."""
def interaction(cursor: Any) -> list[Any]:
cursor.execute("SELECT 1")
first = cursor.fetch_next_batch()
second = cursor.fetch_next_batch()
return [first, second]
first, second = run_interaction(self.conn, interaction)
self.assertEqual(first, [(1,)])
self.assertEqual(second, [])
def test_fetch_next_batch_capacity_is_not_a_limit(self) -> None:
"""`capacity` is only a buffer hint; a batch may exceed it."""
def interaction(cursor: Any) -> list[Any]:
cursor.execute(
"SELECT id FROM generate_series(1, 100) AS s(id) ORDER BY id"
)
rows = []
while True:
batch = cursor.fetch_next_batch(1)
if not batch:
break
rows.extend(batch)
return rows
self.assertEqual(
run_interaction(self.conn, interaction),
[(n,) for n in range(1, 101)],
)
def test_fetch_next_batch_without_query_raises(self) -> None:
"""Calling fetch_next_batch before execute is an error."""
def interaction(cursor: Any) -> None:
cursor.fetch_next_batch()
with self.assertRaises(RuntimeError):
run_interaction(self.conn, interaction)
def test_fetch_next_batch_interleaves_with_fetch_one(self) -> None:
"""fetch_one and fetch_next_batch share the same underlying stream, so
rows already consumed by one are not seen by the other."""
def interaction(cursor: Any) -> list[Any]:
cursor.execute("SELECT id FROM generate_series(1, 10) AS s(id) ORDER BY id")
first = cursor.fetch_one()
rows = [first]
while True:
batch = cursor.fetch_next_batch()
if not batch:
break
rows.extend(batch)
return rows
self.assertEqual(
run_interaction(self.conn, interaction),
[(n,) for n in range(1, 11)],
)
# ------------------------------------------------------------------
# Fetching after exhaustion is a programming error
# ------------------------------------------------------------------
#
# Once a fetch has *reported* exhaustion (fetch_one -> None,
# fetch_next_batch -> [], or fetch_all returning), the result set is spent.
# Fetching again indicates a bug in the caller, so it raises rather than
# silently returning nothing.
def test_fetch_one_after_exhaustion_raises(self) -> None:
"""A fetch_one after the one that returned None is an error."""
def interaction(cursor: Any) -> None:
cursor.execute("SELECT 1")
self.assertEqual(cursor.fetch_one(), (1,))
self.assertIsNone(cursor.fetch_one()) # reports exhaustion
cursor.fetch_one() # over-fetch
with self.assertRaises(RuntimeError):
run_interaction(self.conn, interaction)
def test_fetch_next_batch_after_exhaustion_raises(self) -> None:
"""A fetch_next_batch after the one that returned [] is an error."""
def interaction(cursor: Any) -> None:
cursor.execute("SELECT 1")
self.assertEqual(cursor.fetch_next_batch(), [(1,)])
self.assertEqual(cursor.fetch_next_batch(), []) # reports exhaustion
cursor.fetch_next_batch() # over-fetch
with self.assertRaises(RuntimeError):
run_interaction(self.conn, interaction)
def test_fetch_all_twice_raises(self) -> None:
"""fetch_all exhausts the result set, so a second fetch_all is an
error."""
def interaction(cursor: Any) -> None:
cursor.execute("SELECT 1")
self.assertEqual(cursor.fetch_all(), [(1,)])
cursor.fetch_all() # over-fetch
with self.assertRaises(RuntimeError):
run_interaction(self.conn, interaction)
def test_fetch_one_after_fetch_all_raises(self) -> None:
"""fetch_all reports exhaustion, so a following fetch_one is an
error rather than None."""
def interaction(cursor: Any) -> None:
cursor.execute("SELECT 1")
cursor.fetch_all()
cursor.fetch_one() # over-fetch
with self.assertRaises(RuntimeError):
run_interaction(self.conn, interaction)
def test_exhausted_error_is_distinct_from_no_query(self) -> None:
"""The exhausted-result error names exhaustion, not a missing query, so
the two programming errors are distinguishable."""
def interaction(cursor: Any) -> None:
cursor.execute("SELECT 1")
cursor.fetch_all()
cursor.fetch_one()
with self.assertRaisesRegex(RuntimeError, "exhausted"):
run_interaction(self.conn, interaction)
# ------------------------------------------------------------------
# Value round-tripping (ToSql + FromSql for each supported type)
# ------------------------------------------------------------------
def test_value_roundtrip_all_types(self) -> None:
"""Each supported type survives a param -> column -> row round trip."""
def interaction(cursor: Any) -> Optional[list[Any]]:
cursor.execute(
"""
CREATE TEMP TABLE types_roundtrip (
c_bool bool,
c_int2 smallint,
c_int4 int,
c_int8 bigint,
c_float4 float4,
c_float8 float8,
c_text text,
c_varchar varchar,
c_bytea bytea
)
"""
)
cursor.execute(
"""
INSERT INTO types_roundtrip VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9)
""",
[
True,
7,
1234,
10**15,
1.5, # exactly representable as float4
3.5,
"hello",
"world",
b"\x00\x01\x02bytes",
],
)
cursor.execute(
"""
SELECT c_bool, c_int2, c_int4, c_int8, c_float4, c_float8,
c_text, c_varchar, c_bytea FROM types_roundtrip
"""
)
return cursor.fetch_one()
self.assertEqual(
run_interaction(self.conn, interaction),
(
True,
7,
1234,
10**15,
1.5,
3.5,
"hello",
"world",
b"\x00\x01\x02bytes",
),
)
def test_null_values_become_none(self) -> None:
def interaction(cursor: Any) -> Optional[list[Any]]:
cursor.execute("SELECT NULL::int, NULL::text, NULL::bool")
return cursor.fetch_one()
self.assertEqual(run_interaction(self.conn, interaction), (None, None, None))
def test_null_param(self) -> None:
def interaction(cursor: Any) -> Optional[list[Any]]:
cursor.execute("SELECT $1::text", [None])
return cursor.fetch_one()
self.assertEqual(run_interaction(self.conn, interaction), (None,))
def test_float4_precision_is_lossy(self) -> None:
"""float4 params are narrowed to f32; document the resulting precision."""
def interaction(cursor: Any) -> Optional[list[Any]]:
cursor.execute("SELECT $1::float4", [0.1])
return cursor.fetch_one()
row = run_interaction(self.conn, interaction)
assert row is not None
# 0.1 is not exactly representable in f32, so it comes back widened.
self.assertAlmostEqual(row[0], 0.1, places=6)
self.assertNotEqual(row[0], 0.1)
def test_unsupported_param_type_raises_type_error(self) -> None:
def interaction(cursor: Any) -> None:
cursor.execute("SELECT $1", [object()])
with self.assertRaises(TypeError):
run_interaction(self.conn, interaction)
def test_int_out_of_range_for_column_raises(self) -> None:
def interaction(cursor: Any) -> None:
# Bind the parameter to a genuine int4 column (rather than casting
# the *result*), so the value is encoded against INT4 and hits the
# range guard in `PgValue::to_sql` rather than some unrelated cast
# error. 10**12 is far too large for int4.
cursor.execute("CREATE TEMP TABLE oor (x int4)")
cursor.execute("INSERT INTO oor VALUES ($1)", [10**12])
with self.assertRaises(RuntimeError):
run_interaction(self.conn, interaction)
# ------------------------------------------------------------------
# rowcount()
# ------------------------------------------------------------------
def test_rowcount_minus_one_before_query(self) -> None:
def interaction(cursor: Any) -> int:
return cursor.rowcount()
self.assertEqual(run_interaction(self.conn, interaction), -1)
def test_rowcount_for_dml(self) -> None:
def interaction(cursor: Any) -> list[int]:
cursor.execute("CREATE TEMP TABLE rc (id int)")
cursor.execute("INSERT INTO rc VALUES (1), (2), (3)")
inserted = cursor.rowcount()
cursor.execute("UPDATE rc SET id = id WHERE id > 1")
updated = cursor.rowcount()
cursor.execute("DELETE FROM rc")
deleted = cursor.rowcount()
return [inserted, updated, deleted]
self.assertEqual(run_interaction(self.conn, interaction), [3, 2, 3])
# ------------------------------------------------------------------
# Transaction handling (COMMIT on success, ROLLBACK on error)
# ------------------------------------------------------------------
def test_commit_persists_changes(self) -> None:
"""A successful interaction commits; changes are visible afterwards."""
table = "rust_pg_test_commit"
try:
def create(cursor: Any) -> None:
cursor.execute(f"CREATE TABLE {table} (id int)")
cursor.execute(f"INSERT INTO {table} VALUES (1), (2)")
run_interaction(self.conn, create)
def read(cursor: Any) -> list[list[Any]]:
cursor.execute(f"SELECT id FROM {table} ORDER BY id")
return cursor.fetch_all()
self.assertEqual(run_interaction(self.conn, read), [(1,), (2,)])
finally:
run_interaction(
self.conn,
lambda cursor: cursor.execute(f"DROP TABLE IF EXISTS {table}"),
)
def test_rollback_on_exception(self) -> None:
"""If the interaction raises, its changes are rolled back."""
table = "rust_pg_test_rollback"
try:
class MarkerError(Exception):
pass
def create_then_fail(cursor: Any) -> None:
cursor.execute(f"CREATE TABLE {table} (id int)")
raise MarkerError()
with self.assertRaises(MarkerError):
run_interaction(self.conn, create_then_fail)
# The table creation should have been rolled back, so the table
# must not exist.
def exists(cursor: Any) -> Optional[list[Any]]:
cursor.execute(
"""
SELECT count(*)::int FROM information_schema.tables
WHERE table_name = $1
""",
[table],
)
return cursor.fetch_one()
self.assertEqual(run_interaction(self.conn, exists), (0,))
finally:
run_interaction(
self.conn,
lambda cursor: cursor.execute(f"DROP TABLE IF EXISTS {table}"),
)
def test_connection_reusable_after_rolled_back_interaction(self) -> None:
"""A failed interaction returns the connection to a clean, usable state."""
class MarkerError(Exception):
pass
def fail(cursor: Any) -> None:
cursor.execute("SELECT 1")
raise MarkerError()
with self.assertRaises(MarkerError):
run_interaction(self.conn, fail)
# The connection should still work for a subsequent interaction.
def ok(cursor: Any) -> Optional[list[Any]]:
cursor.execute("SELECT 99::int")
return cursor.fetch_one()
self.assertEqual(run_interaction(self.conn, ok), (99,))
def test_connection_reusable_after_server_side_error(self) -> None:
"""A *server-rejected* statement rolls back, but leaves the connection
usable — distinct from the Python-exception rollback path above, since
the error originates in Postgres (during prepare/execute)."""
def bad_sql(cursor: Any) -> None:
# A syntax error: the server rejects this during prepare, which
# surfaces as a RuntimeError and rolls the transaction back.
cursor.execute("SELECT FROM WHERE not valid sql")
with self.assertRaises(RuntimeError):
run_interaction(self.conn, bad_sql)
# The transaction was rolled back and the connection handed back clean,
# so a following interaction succeeds.
def ok(cursor: Any) -> Optional[list[Any]]:
cursor.execute("SELECT 7::int")
return cursor.fetch_one()
self.assertEqual(run_interaction(self.conn, ok), (7,))
def test_constraint_violation_rolls_back_and_connection_recovers(self) -> None:
"""A constraint violation mid-transaction aborts and rolls back, and the
connection remains usable afterwards."""
def violate(cursor: Any) -> None:
cursor.execute("CREATE TEMP TABLE uniq (x int primary key)")
cursor.execute("INSERT INTO uniq VALUES (1)")
# Duplicate key. The error is reported by the server while the
# statement's result stream is driven, so we drain it (via
# rowcount) to surface it as a RuntimeError.
cursor.execute("INSERT INTO uniq VALUES (1)")
cursor.rowcount()
with self.assertRaises(RuntimeError):
run_interaction(self.conn, violate)
# TEMP table lived only in the rolled-back transaction; the connection
# itself is fine.
def ok(cursor: Any) -> Optional[list[Any]]:
cursor.execute("SELECT 1::int")
return cursor.fetch_one()
self.assertEqual(run_interaction(self.conn, ok), (1,))
@unittest.skip_unless(
bool(USE_POSTGRES_FOR_TESTS), "requires a Postgres server (set SYNAPSE_POSTGRES)"
)
class PostgresConnectionDrivenTestCase(unittest.TestCase):
"""Tests for the *connection-driven* transaction API.
Where ``PostgresConnectionTestCase`` runs each interaction in its own
transaction via the ``run_interaction`` test harness, these drive the
connection the way Synapse's own
``synapse.storage.database.new_transaction`` does: open a cursor with
``conn.cursor()``, run statements against it, then ``conn.commit()`` /
``conn.rollback()`` at the *connection* level (while the cursor is still
open). They also cover the implicit ``BEGIN``, the context-manager
protocol, ``close()`` and autocommit.
"""
def setUp(self) -> None:
self.conn = _connect(_build_dsn())
def tearDown(self) -> None:
# Drop the connection before the interpreter shuts down (see the note
# in PostgresConnectionTestCase.tearDown).
del self.conn
# -- small helpers ------------------------------------------------------
def _exec_commit(self, sql: str) -> None:
"""Run a single statement and commit it (its own transaction)."""
cursor = self.conn.cursor()
cursor.execute(sql)
self.conn.commit()
def _scalar(self, sql: str) -> Any:
"""Run a query and return the first column of its single row."""
cursor = self.conn.cursor()
cursor.execute(sql)
row = cursor.fetch_one()
assert row is not None
return row[0]
def _table_exists(self, table: str) -> bool:
return (
self._scalar(
"SELECT count(*)::int FROM information_schema.tables "
f"WHERE table_name = '{table}'"
)
== 1
)
# -- commit / rollback at the connection level --------------------------
def test_commit_persists_changes(self) -> None:
"""Statements run against a cursor are persisted by ``conn.commit()``."""
table = "rust_pg_conn_commit"
try:
cursor = self.conn.cursor()
cursor.execute(f"CREATE TABLE {table} (id int)")
cursor.execute(f"INSERT INTO {table} VALUES (1), (2)")
self.conn.commit()
# A fresh cursor on the same connection (new transaction) sees them.
read = self.conn.cursor()
read.execute(f"SELECT id FROM {table} ORDER BY id")
self.assertEqual(read.fetch_all(), [(1,), (2,)])
self.conn.commit()
finally:
self._exec_commit(f"DROP TABLE IF EXISTS {table}")
def test_rollback_discards_changes(self) -> None:
"""``conn.rollback()`` undoes everything since the implicit ``BEGIN``."""
table = "rust_pg_conn_rollback"
try:
cursor = self.conn.cursor()
cursor.execute(f"CREATE TABLE {table} (id int)")
self.conn.rollback()
# The table creation was rolled back.
self.assertFalse(self._table_exists(table))
self.conn.commit()
finally:
self._exec_commit(f"DROP TABLE IF EXISTS {table}")
def test_commit_without_transaction_is_noop(self) -> None:
"""commit/rollback with no statement run yet are harmless no-ops."""
# No execute() has happened, so no implicit BEGIN was issued.
self.conn.commit()
self.conn.rollback()
# The connection is still usable.
self.assertEqual(self._scalar("SELECT 1::int"), 1)
self.conn.commit()
def test_two_cursors_share_one_transaction(self) -> None:
"""Cursors are cheap views over the connection's single transaction:
work done through one is visible to the other and committed together."""
table = "rust_pg_conn_shared"
try:
first = self.conn.cursor()
first.execute(f"CREATE TABLE {table} (id int)")
# A second cursor opened mid-transaction sees the first's work and
# adds to the same transaction.
second = self.conn.cursor()
second.execute(f"INSERT INTO {table} VALUES (7)")
self.conn.commit()
self.assertEqual(self._scalar(f"SELECT id FROM {table}"), 7)
self.conn.commit()
finally:
self._exec_commit(f"DROP TABLE IF EXISTS {table}")
# -- context manager ----------------------------------------------------
def test_context_manager_commits_on_success(self) -> None:
table = "rust_pg_conn_ctx_commit"
try:
self._exec_commit(f"CREATE TABLE {table} (id int)")
with self.conn as conn:
cursor = conn.cursor()
cursor.execute(f"INSERT INTO {table} VALUES (5)")
# Leaving the block committed.
self.assertEqual(self._scalar(f"SELECT id FROM {table}"), 5)
self.conn.commit()
finally:
self._exec_commit(f"DROP TABLE IF EXISTS {table}")
def test_context_manager_rolls_back_on_exception(self) -> None:
table = "rust_pg_conn_ctx_rollback"
class MarkerError(Exception):
pass
# The body lives in a helper (rather than inline) so the unconditional
# ``raise`` doesn't make mypy treat the rest of the test as unreachable.
def insert_then_fail() -> None:
with self.conn as conn:
cursor = conn.cursor()
cursor.execute(f"INSERT INTO {table} VALUES (6)")
raise MarkerError()
try:
self._exec_commit(f"CREATE TABLE {table} (id int)")
with self.assertRaises(MarkerError):
insert_then_fail()
# The exception caused a rollback, so the row isn't there.
self.assertEqual(self._scalar(f"SELECT count(*)::int FROM {table}"), 0)
self.conn.commit()
finally:
self._exec_commit(f"DROP TABLE IF EXISTS {table}")
# -- close() ------------------------------------------------------------
def test_use_after_close_raises(self) -> None:
self.conn.close()
cursor = self.conn.cursor()
with self.assertRaises(RuntimeError):
cursor.execute("SELECT 1")
def test_close_is_idempotent(self) -> None:
self.conn.close()
# A second close is fine.
self.conn.close()
def test_cursor_close_discards_result_set(self) -> None:
cursor = self.conn.cursor()
cursor.execute("SELECT 1")
cursor.close()
# The result set is gone; a fetch now errors ("no active query").
with self.assertRaises(RuntimeError):
cursor.fetch_one()
self.conn.rollback()
# -- autocommit ---------------------------------------------------------
def test_autocommit_persists_without_explicit_commit(self) -> None:
"""In autocommit mode each statement commits on its own, so a later
``rollback()`` doesn't undo it."""
table = "rust_pg_conn_autocommit"
try:
self.conn.set_autocommit(True)
cursor = self.conn.cursor()
cursor.execute(f"CREATE TABLE {table} (id int)")
# No implicit transaction was opened, so this rolls back nothing.
self.conn.rollback()
self.assertTrue(self._table_exists(table))
finally:
self.conn.set_autocommit(True)
self.conn.cursor().execute(f"DROP TABLE IF EXISTS {table}")
def test_set_autocommit_rejected_mid_transaction(self) -> None:
cursor = self.conn.cursor()
cursor.execute("SELECT 1") # opens an implicit transaction
with self.assertRaises(RuntimeError):
self.conn.set_autocommit(True)
self.conn.rollback()
@unittest.skip_unless(
bool(USE_POSTGRES_FOR_TESTS) and POSTGRES_HOST in (None, "", "localhost"),
"requires Postgres reachable on libpq's default host",
)
class PostgresDefaultHostTestCase(unittest.TestCase):
"""Covers the libpq default-host fixup in ``connect``.
When the DSN omits ``host=``, ``tokio-postgres`` would default to localhost,
but Synapse wants libpq's default (honouring ``PGHOST`` / the compiled-in
socket dir). This only runs when the test Postgres is actually reachable on
that default host, so it's guarded separately from the main suite.
"""
def test_connect_without_host_uses_libpq_default(self) -> None:
# A DSN with no host=: connecting at all proves the fixup resolved a
# usable default host rather than tokio-postgres' own localhost guess.
parts = [f"dbname={POSTGRES_BASE_DB}"]
if POSTGRES_USER is not None:
parts.append(f"user={POSTGRES_USER}")
if POSTGRES_PORT is not None:
parts.append(f"port={POSTGRES_PORT}")
if POSTGRES_PASSWORD is not None:
parts.append(f"password={POSTGRES_PASSWORD}")
conn = _connect(" ".join(parts))
try:
self.assertEqual(
run_interaction(conn, lambda cursor: _select_one(cursor)), (1,)
)
finally:
del conn
def _select_one(cursor: Any) -> Optional[list[Any]]:
cursor.execute("SELECT 1::int")
return cursor.fetch_one()