mirror of
https://github.com/element-hq/synapse.git
synced 2026-08-14 18:01:13 +00:00
Implement DBAPI2's `executemany`, which Synapse uses for batched writes. The statement is prepared once and run for each parameter set inside the connection's (lazily opened) transaction, so a failure part-way aborts the whole batch. The per-set executions are pipelined — their futures are driven concurrently so tokio_postgres streams the batch onto the connection in one round-trip rather than one per statement. Afterwards `rowcount` reports the total rows affected across all executions (as psycopg2 does) via a new fetchless "command complete" cursor state; there is no result set to fetch or describe. An empty parameter sequence is a no-op that leaves rowcount at -1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1148 lines
45 KiB
Python
1148 lines
45 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 one of our
|
|
# DBAPI2 errors rather than return a half-open connection.
|
|
with self.assertRaises(postgres.Error):
|
|
_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])
|
|
|
|
# ------------------------------------------------------------------
|
|
# executemany()
|
|
# ------------------------------------------------------------------
|
|
|
|
def test_executemany_runs_once_per_param_set(self) -> None:
|
|
"""executemany applies the statement for each parameter set."""
|
|
|
|
def interaction(cursor: Any) -> list[Any]:
|
|
cursor.execute("CREATE TEMP TABLE em (id int, name text)")
|
|
cursor.executemany(
|
|
"INSERT INTO em (id, name) VALUES ($1, $2)",
|
|
[[1, "a"], [2, "b"], [3, "c"]],
|
|
)
|
|
cursor.execute("SELECT id, name FROM em ORDER BY id")
|
|
return cursor.fetch_all()
|
|
|
|
self.assertEqual(
|
|
run_interaction(self.conn, interaction),
|
|
[(1, "a"), (2, "b"), (3, "c")],
|
|
)
|
|
|
|
def test_executemany_rowcount_is_total_affected(self) -> None:
|
|
"""rowcount after executemany is the sum across all executions."""
|
|
|
|
def interaction(cursor: Any) -> int:
|
|
cursor.execute("CREATE TEMP TABLE em (id int)")
|
|
cursor.executemany("INSERT INTO em VALUES ($1)", [[1], [2], [3]])
|
|
return cursor.rowcount()
|
|
|
|
self.assertEqual(run_interaction(self.conn, interaction), 3)
|
|
|
|
def test_executemany_leaves_no_result_set(self) -> None:
|
|
"""executemany produces nothing to describe."""
|
|
|
|
def describe(cursor: Any) -> Any:
|
|
cursor.execute("CREATE TEMP TABLE em (id int)")
|
|
cursor.executemany("INSERT INTO em VALUES ($1)", [[1], [2]])
|
|
return cursor.description()
|
|
|
|
self.assertIsNone(run_interaction(self.conn, describe))
|
|
|
|
def test_executemany_fetch_after_raises(self) -> None:
|
|
"""There is no result set after executemany, so fetching is an error."""
|
|
|
|
def interaction(cursor: Any) -> Any:
|
|
cursor.execute("CREATE TEMP TABLE em (id int)")
|
|
cursor.executemany("INSERT INTO em VALUES ($1)", [[1], [2]])
|
|
cursor.fetch_one()
|
|
|
|
with self.assertRaises(RuntimeError):
|
|
run_interaction(self.conn, interaction)
|
|
|
|
def test_executemany_empty_is_noop(self) -> None:
|
|
"""An empty parameter sequence runs nothing and affects no rows."""
|
|
|
|
def interaction(cursor: Any) -> int:
|
|
cursor.execute("CREATE TEMP TABLE em (id int)")
|
|
cursor.executemany("INSERT INTO em VALUES ($1)", [])
|
|
# Nothing ran, so rowcount is the "unknown" sentinel...
|
|
self.assertEqual(cursor.rowcount(), -1)
|
|
# ...and the table is untouched.
|
|
cursor.execute("SELECT count(*) FROM em")
|
|
row = cursor.fetch_one()
|
|
assert row is not None
|
|
return row[0]
|
|
|
|
self.assertEqual(run_interaction(self.conn, interaction), 0)
|
|
|
|
def test_executemany_rolls_back_on_error(self) -> None:
|
|
"""A failure part-way through executemany aborts the transaction, so
|
|
no rows from the batch survive."""
|
|
|
|
table = "rust_pg_test_executemany_rollback"
|
|
try:
|
|
|
|
def interaction(cursor: Any) -> None:
|
|
cursor.execute(f"CREATE TABLE {table} (id int PRIMARY KEY)")
|
|
# The third set duplicates the first, violating the primary key.
|
|
cursor.executemany(
|
|
f"INSERT INTO {table} VALUES ($1)",
|
|
[[1], [2], [1]],
|
|
)
|
|
|
|
with self.assertRaises(postgres.IntegrityError):
|
|
run_interaction(self.conn, interaction)
|
|
|
|
# The CREATE TABLE and the whole batch were in one transaction that
|
|
# rolled back, so the table should not exist.
|
|
def table_exists(cursor: Any) -> Any:
|
|
cursor.execute("SELECT to_regclass($1)::text", [table])
|
|
row = cursor.fetch_one()
|
|
assert row is not None
|
|
return row[0]
|
|
|
|
self.assertIsNone(run_interaction(self.conn, table_exists))
|
|
finally:
|
|
run_interaction(
|
|
self.conn,
|
|
lambda cursor: cursor.execute(f"DROP TABLE IF EXISTS {table}"),
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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])
|
|
|
|
# This is a client-side encoding error (no SQLSTATE), so it surfaces as
|
|
# a plain DatabaseError -- not an OperationalError, since retrying a
|
|
# deterministic bad value would be pointless.
|
|
with self.assertRaises(postgres.DatabaseError) as ctx:
|
|
run_interaction(self.conn, interaction)
|
|
self.assertNotIsInstance(ctx.exception, postgres.OperationalError)
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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])
|
|
|
|
# ------------------------------------------------------------------
|
|
# description
|
|
# ------------------------------------------------------------------
|
|
|
|
def test_description_is_none_before_query(self) -> None:
|
|
"""A cursor that has not run a query has no description."""
|
|
|
|
def interaction(cursor: Any) -> Any:
|
|
return cursor.description()
|
|
|
|
self.assertIsNone(run_interaction(self.conn, interaction))
|
|
|
|
def test_description_reports_column_names(self) -> None:
|
|
"""A row-returning statement describes its columns; only the name is
|
|
populated, in a PEP-249 7-tuple."""
|
|
|
|
def interaction(cursor: Any) -> Any:
|
|
cursor.execute("SELECT 1 AS a, 'x'::text AS b")
|
|
return cursor.description()
|
|
|
|
description = run_interaction(self.conn, interaction)
|
|
self.assertEqual([col[0] for col in description], ["a", "b"])
|
|
# Each entry is a PEP-249 7-tuple with only the name populated.
|
|
for col in description:
|
|
self.assertEqual(len(col), 7)
|
|
self.assertTrue(all(field is None for field in col[1:]))
|
|
|
|
def test_description_available_after_fetch(self) -> None:
|
|
"""The description survives after the rows have been fetched."""
|
|
|
|
def interaction(cursor: Any) -> Any:
|
|
cursor.execute("SELECT 1 AS a")
|
|
cursor.fetch_all() # exhausts the result set
|
|
return cursor.description()
|
|
|
|
description = run_interaction(self.conn, interaction)
|
|
self.assertEqual([col[0] for col in description], ["a"])
|
|
|
|
def test_description_is_none_for_dml(self) -> None:
|
|
"""A statement that returns no rows (a bare INSERT) has no
|
|
description, matching psycopg2."""
|
|
|
|
def interaction(cursor: Any) -> Any:
|
|
cursor.execute("CREATE TEMP TABLE d (id int)")
|
|
cursor.execute("INSERT INTO d VALUES (1)")
|
|
return cursor.description()
|
|
|
|
self.assertIsNone(run_interaction(self.conn, interaction))
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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 DatabaseError and rolls the transaction back.
|
|
cursor.execute("SELECT FROM WHERE not valid sql")
|
|
|
|
with self.assertRaises(postgres.DatabaseError):
|
|
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 an IntegrityError, the same class
|
|
# (with the same 23505 pgcode) it would carry had it surfaced at
|
|
# execute time.
|
|
cursor.execute("INSERT INTO uniq VALUES (1)")
|
|
cursor.rowcount()
|
|
|
|
with self.assertRaises(postgres.IntegrityError) as ctx:
|
|
run_interaction(self.conn, violate)
|
|
self.assertEqual(ctx.exception.pgcode, "23505")
|
|
|
|
# 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()
|
|
|
|
# -- executescript (multi-statement) ------------------------------------
|
|
|
|
def test_executescript_runs_all_statements(self) -> None:
|
|
"""A `;`-separated script runs every statement (unlike `execute`, which
|
|
prepares a single one)."""
|
|
table = "rust_pg_script"
|
|
try:
|
|
cursor = self.conn.cursor()
|
|
cursor.executescript(
|
|
f"CREATE TABLE {table} (id int); "
|
|
f"INSERT INTO {table} VALUES (1); "
|
|
f"INSERT INTO {table} VALUES (2), (3);"
|
|
)
|
|
self.conn.commit()
|
|
|
|
read = self.conn.cursor()
|
|
read.execute(f"SELECT id FROM {table} ORDER BY id")
|
|
self.assertEqual(read.fetch_all(), [(1,), (2,), (3,)])
|
|
self.conn.commit()
|
|
finally:
|
|
self._exec_commit(f"DROP TABLE IF EXISTS {table}")
|
|
|
|
def test_executescript_leaves_transaction_open_for_caller(self) -> None:
|
|
"""The script runs in the connection's transaction, left open — so a
|
|
following `rollback()` undoes it (it was not autocommitted)."""
|
|
table = "rust_pg_script_open"
|
|
try:
|
|
self.conn.cursor().executescript(f"CREATE TABLE {table} (id int);")
|
|
self.conn.rollback()
|
|
self.assertFalse(self._table_exists(table))
|
|
self.conn.commit()
|
|
finally:
|
|
self._exec_commit(f"DROP TABLE IF EXISTS {table}")
|
|
|
|
def test_executescript_error_surfaces_as_database_error(self) -> None:
|
|
"""A failing statement mid-script surfaces via the exception hierarchy;
|
|
the aborted transaction rolls back cleanly."""
|
|
cursor = self.conn.cursor()
|
|
with self.assertRaises(postgres.DatabaseError):
|
|
cursor.executescript("CREATE TABLE rust_pg_script_bad (id int); NOT SQL;")
|
|
self.conn.rollback()
|
|
# The CREATE was in the same aborted, rolled-back transaction, so it
|
|
# left nothing behind.
|
|
self.assertFalse(self._table_exists("rust_pg_script_bad"))
|
|
self.conn.commit()
|
|
|
|
def test_successive_scripts_share_one_transaction(self) -> None:
|
|
"""Successive `executescript` calls accumulate in the same open
|
|
transaction -- there is no implicit commit between them (unlike
|
|
`sqlite3.executescript`) -- so a single rollback discards them all.
|
|
This is the atomicity `prepare_database` relies on."""
|
|
try:
|
|
cursor = self.conn.cursor()
|
|
cursor.executescript("CREATE TABLE rust_pg_script_a (id int);")
|
|
cursor.executescript("CREATE TABLE rust_pg_script_b (id int);")
|
|
# Nothing was committed between the two calls, so one rollback
|
|
# undoes both.
|
|
self.conn.rollback()
|
|
self.assertFalse(self._table_exists("rust_pg_script_a"))
|
|
self.assertFalse(self._table_exists("rust_pg_script_b"))
|
|
self.conn.commit()
|
|
finally:
|
|
self._exec_commit("DROP TABLE IF EXISTS rust_pg_script_a")
|
|
self._exec_commit("DROP TABLE IF EXISTS rust_pg_script_b")
|
|
|
|
|
|
@unittest.skip_unless(
|
|
bool(USE_POSTGRES_FOR_TESTS), "requires a Postgres server (set SYNAPSE_POSTGRES)"
|
|
)
|
|
class PostgresErrorMappingTestCase(unittest.TestCase):
|
|
"""The DBAPI2 exception hierarchy and the SQLSTATE→exception mapping.
|
|
|
|
Synapse's transaction driver branches on the *type* of the exception a
|
|
database call raises (``OperationalError`` → retry, ``IntegrityError`` →
|
|
retry upserts) and on its ``pgcode`` (``is_deadlock``). These tests check
|
|
the Rust backend raises the right class and carries a ``pgcode``, the way
|
|
psycopg2 does.
|
|
"""
|
|
|
|
def setUp(self) -> None:
|
|
self.conn = _connect(_build_dsn())
|
|
|
|
def tearDown(self) -> None:
|
|
del self.conn
|
|
|
|
def _exec_commit(self, sql: str) -> None:
|
|
"""Run a single statement and commit it (its own transaction)."""
|
|
self.conn.cursor().execute(sql)
|
|
self.conn.commit()
|
|
|
|
# -- the hierarchy exposed on the module --------------------------------
|
|
|
|
def test_module_exposes_dbapi2_hierarchy(self) -> None:
|
|
"""The exception attributes Synapse's engine code and DBAPI2Module
|
|
protocol rely on, with the expected subclass links."""
|
|
self.assertTrue(issubclass(postgres.DatabaseError, postgres.Error))
|
|
self.assertTrue(issubclass(postgres.OperationalError, postgres.DatabaseError))
|
|
self.assertTrue(issubclass(postgres.IntegrityError, postgres.DatabaseError))
|
|
|
|
# -- SQLSTATE → exception class -----------------------------------------
|
|
|
|
def test_unique_violation_is_integrity_error(self) -> None:
|
|
"""A constraint violation raises ``IntegrityError`` with pgcode 23505."""
|
|
table = "rust_pg_err_integrity"
|
|
try:
|
|
self._exec_commit(f"CREATE TABLE {table} (id int PRIMARY KEY)")
|
|
self._exec_commit(f"INSERT INTO {table} VALUES (1)")
|
|
|
|
cursor = self.conn.cursor()
|
|
with self.assertRaises(postgres.IntegrityError) as ctx:
|
|
cursor.execute(f"INSERT INTO {table} VALUES (1)")
|
|
# The INSERT's error is reported while its result stream is
|
|
# driven, so drain it (via rowcount) to surface it.
|
|
cursor.rowcount()
|
|
self.assertEqual(ctx.exception.pgcode, "23505")
|
|
self.conn.rollback()
|
|
finally:
|
|
self._exec_commit(f"DROP TABLE IF EXISTS {table}")
|
|
|
|
def test_undefined_table_is_plain_database_error(self) -> None:
|
|
"""An error we don't single out surfaces as a plain ``DatabaseError``
|
|
(not one of the specialised subclasses), still carrying its pgcode."""
|
|
cursor = self.conn.cursor()
|
|
with self.assertRaises(postgres.DatabaseError) as ctx:
|
|
cursor.execute("SELECT * FROM rust_pg_no_such_table")
|
|
self.assertNotIsInstance(ctx.exception, postgres.OperationalError)
|
|
self.assertNotIsInstance(ctx.exception, postgres.IntegrityError)
|
|
self.assertEqual(ctx.exception.pgcode, "42P01") # undefined_table
|
|
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()
|