Rust Postgres backend: value mapping and blocking runtime helpers (#19996)

Lays the two foundations the Rust DBAPI2 backend needs before any
connection code: a Python↔Postgres value-mapping layer, and the helpers
that drive async work from sync Python.

  Value mapping:
- `PgValue` — enum that implements `tokio_postgres::types::ToSql`,
encoding into Postgres' binary wire format per the prepared-statement
column type (so a single Python `int` becomes INT2/INT4/INT8 with range
checks).
- `PythonPgFromSql` — the decode counterpart, turning wire bytes back
into Python objects (SQL NULL → `None`).
- Covers int / float / bool / str / bytes / None; lists etc are left to
a follow-up.

  Blocking helpers:

- `block_on` / `block_on_pg_result` / `block_on_next` drive
`tokio-postgres` futures to completion from sync, GIL-holding Python
methods, releasing the GIL for the wait.

Both modules are `pub` for now so not-yet-consumed items don't trip
clippy's `dead_code` lint; later PRs in the stack tighten visibility.
Everything here is unit-tested with no live Postgres server.
This commit is contained in:
Erik Johnston authored and GitHub committed 2026-10-19 14:16:40 +01:00
1 parent 7d44c81d5c
commit eff0ecb062
10 files changed
+1239 -38

No files matched your search

Generated
+287 -37
View File
@@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
version = 4
[[package]]
name = "aho-corasick"
@@ -91,6 +91,12 @@ version = "3.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.12.1"
@@ -257,6 +263,12 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "fallible-iterator"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7"
[[package]]
name = "fnv"
version = "1.0.7"
@@ -379,7 +391,7 @@ dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
]
@@ -458,6 +470,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
dependencies = [
"digest 0.11.3",
]
[[package]]
name = "http"
version = "1.5.0"
@@ -769,11 +790,12 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "js-sys"
version = "0.3.77"
version = "0.3.105"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f"
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
dependencies = [
"once_cell",
"cfg-if",
"futures-util",
"wasm-bindgen",
]
@@ -795,12 +817,30 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de"
[[package]]
name = "libredox"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d"
dependencies = [
"libc",
]
[[package]]
name = "litemap"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.34"
@@ -813,6 +853,16 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "md-5"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
dependencies = [
"cfg-if",
"digest 0.11.3",
]
[[package]]
name = "memchr"
version = "2.7.5"
@@ -832,10 +882,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
dependencies = [
"libc",
"wasi",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.61.2",
]
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags",
]
[[package]]
name = "objc2-system-configuration"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -848,12 +916,54 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "percent-encoding"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
[[package]]
name = "phf"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_shared",
"serde",
]
[[package]]
name = "phf_shared"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
dependencies = [
"siphasher",
]
[[package]]
name = "pin-project-lite"
version = "0.2.16"
@@ -866,6 +976,35 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
[[package]]
name = "postgres-protocol"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514"
dependencies = [
"base64 0.22.1",
"byteorder",
"bytes",
"fallible-iterator",
"hmac",
"md-5",
"memchr",
"rand",
"sha2",
"stringprep",
]
[[package]]
name = "postgres-types"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be"
dependencies = [
"bytes",
"fallible-iterator",
"postgres-protocol",
]
[[package]]
name = "potential_utf"
version = "0.1.2"
@@ -1063,6 +1202,15 @@ dependencies = [
"rand_core",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "regex"
version = "1.13.1"
@@ -1231,6 +1379,12 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "3.2.0"
@@ -1343,6 +1497,12 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "siphasher"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]]
name = "slab"
version = "0.4.11"
@@ -1381,6 +1541,17 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
[[package]]
name = "stringprep"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
dependencies = [
"unicode-bidi",
"unicode-normalization",
"unicode-properties",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -1430,6 +1601,7 @@ dependencies = [
"log",
"mime",
"once_cell",
"postgres-protocol",
"pyo3",
"pyo3-log",
"pythonize",
@@ -1440,6 +1612,7 @@ dependencies = [
"serde_json",
"sha2",
"tokio",
"tokio-postgres",
"ulid",
]
@@ -1528,6 +1701,32 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-postgres"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3"
dependencies = [
"async-trait",
"byteorder",
"bytes",
"fallible-iterator",
"futures-channel",
"futures-util",
"log",
"parking_lot",
"percent-encoding",
"phf",
"pin-project-lite",
"postgres-protocol",
"postgres-types",
"rand",
"socket2 0.6.5",
"tokio",
"tokio-util",
"whoami",
]
[[package]]
name = "tokio-rustls"
version = "0.26.2"
@@ -1637,12 +1836,33 @@ dependencies = [
"web-time",
]
[[package]]
name = "unicode-bidi"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-ident"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-properties"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -1688,49 +1908,60 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasm-bindgen"
version = "0.2.100"
name = "wasi"
version = "0.14.7+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5"
checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c"
dependencies = [
"wasip2",
]
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasite"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42"
dependencies = [
"wasi 0.14.7+wasi-0.2.4",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6"
dependencies = [
"bumpalo",
"log",
"proc-macro2",
"quote",
"syn 2.0.104",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.50"
version = "0.4.78"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61"
checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928"
dependencies = [
"cfg-if",
"js-sys",
"once_cell",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.100"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407"
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -1738,22 +1969,22 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.100"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.104",
"wasm-bindgen-backend",
"syn 3.0.3",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.100"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d"
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
dependencies = [
"unicode-ident",
]
@@ -1773,9 +2004,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.77"
version = "0.3.105"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2"
checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -1791,6 +2022,19 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "whoami"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c"
dependencies = [
"libc",
"libredox",
"objc2-system-configuration",
"wasite",
"web-sys",
]
[[package]]
name = "windows-link"
version = "0.2.1"
@@ -1888,6 +2132,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "writeable"
version = "0.6.1"
+1
View File
@@ -0,0 +1 @@
Add foundations for a Rust backed database pool and bump minimum supported Rust version to 1.85.0.
+3 -1
View File
@@ -7,7 +7,7 @@ name = "synapse"
version = "0.1.0"
edition = "2021"
rust-version = "1.82.0"
rust-version = "1.85.0"
[lib]
name = "synapse"
@@ -63,8 +63,10 @@ reqwest = { version = "0.12.15", default-features = false, features = [
http-body-util = "0.1.3"
futures = "0.3.31"
tokio = { version = "1.44.2", features = ["rt", "rt-multi-thread"] }
tokio-postgres = "0.7"
once_cell = "1.18.0"
itertools = "0.15.0"
postgres-protocol = "0.6.10"
[build-dependencies]
blake2 = "0.11.0"
+27
View File
@@ -0,0 +1,27 @@
//! Database access implemented in Rust.
//!
//! Currently this provides a single Postgres backend ([`tokio_postgres`]); see
//! the [`postgres`] submodule.
pub mod postgres;
use pyo3::prelude::*;
use pyo3::types::PyModule;
/// Register the `database` submodule (and its per-backend children) on the
/// top-level `synapse_rust` module.
pub fn register_module(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
let child = PyModule::new(py, "database")?;
postgres::register_module(py, &child)?;
m.add_submodule(&child)?;
// We need to manually add the module to sys.modules to make `from
// synapse.synapse_rust import database` work.
py.import("sys")?
.getattr("modules")?
.set_item("synapse.synapse_rust.database", child)?;
Ok(())
}
+241
View File
@@ -0,0 +1,241 @@
/*
* 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>.
*
*/
//! Extension traits for driving [`tokio_postgres`] futures to completion from
//! synchronous, GIL-holding Python code.
//!
//! Both [`BlockingPostgres`] and [`BlockingPostgresStream`] release the GIL
//! (`py.detach`) while blocking on the shared tokio runtime, so other Python
//! threads can make progress (and so the runtime's own connection task can run)
//! while we wait. The [`Ungil`] bounds are what let us hand the future across
//! the `detach` boundary.
//!
//! The stream helper is generic over the underlying stream type rather than
//! hard-wired to [`tokio_postgres::RowStream`], so code built on it can be
//! unit-tested against an in-memory stream with no live database — see
//! [`BlockingPostgresStream`]'s tests.
use std::{
future::Future,
pin::{pin, Pin},
};
use futures::{stream::FusedStream, FutureExt, StreamExt};
use pyo3::{marker::Ungil, PyResult, Python};
use tokio::runtime::Handle;
use crate::database::postgres::pg_err_to_py;
/// Block on a future on a given runtime, releasing the GIL while we wait.
///
/// Note that this drives the future on *this* thread, not on the runtime's
/// worker threads. See [`tokio::runtime::Handle::block_on`] for details.
pub trait BlockingPostgres
where
Self: Future + Sized + Send + Ungil,
Self::Output: Ungil + Send,
{
/// Drive `self` to completion on the given runtime, returning its output.
/// Releases the GIL for the duration so the wait doesn't block other Python
/// threads.
///
/// `handle` is expected to be the server's shared runtime (see
/// [`crate::runtime`]). It is passed in explicitly rather than taken
/// from the thread-local context via [`Handle::current`], so callers work
/// from any Python thread with no need to have *entered* the runtime first.
/// The blocking wait runs on the calling (Python) thread, never on a
/// runtime worker, so it cannot starve the worker threads that complete the
/// future.
fn block_on(self, py: Python<'_>, handle: &Handle) -> Self::Output {
// Check if this future is already ready, and if so return immediately
// without needlessly releasing and reacquiring the GIL.
//
// This is basically the same as `FutureExt::now_or_never`, except we a)
// keep the pinned future to poll again below, and b) enter the runtime
// in case the future needs it.
let mut pin_self = pin!(self);
{
let _guard = handle.enter();
let noop_waker = std::task::Waker::noop();
let mut cx = std::task::Context::from_waker(noop_waker);
match pin_self.poll_unpin(&mut cx) {
std::task::Poll::Ready(val) => return val,
std::task::Poll::Pending => (),
}
}
// Note: this will drive the future to completion on *this* thread, not
// on the runtime's worker threads. See `Handle::block_on` for details.
py.detach(|| handle.block_on(pin_self))
}
}
/// Same as [`BlockingPostgres`], but for futures that yield a
/// [`tokio_postgres::Error`], mapping any error into a Python exception.
pub trait BlockingPostgresResult<T>
where
Self: Future<Output = Result<T, tokio_postgres::Error>> + Sized + Send + Ungil,
Self::Output: Ungil + Send,
{
/// Block on `self` on the given runtime and convert a Postgres error into
/// a `PyErr`.
fn block_on_pg_result(self, py: Python<'_>, handle: &Handle) -> PyResult<T> {
self.block_on(py, handle).map_err(pg_err_to_py)
}
}
// Blanket impls: every suitable future automatically gets `block_on` /
// `block_on_pg_result`, so callers can write `fut.block_on(py, handle)`
// directly.
impl<F> BlockingPostgres for F
where
F: Future + Sized + Send + Ungil,
F::Output: Ungil + Send,
{
}
impl<F, T> BlockingPostgresResult<T> for F
where
F: Future<Output = Result<T, tokio_postgres::Error>> + Sized + Send + Ungil,
F::Output: Ungil + Send,
{
}
/// Pull items from a [`FusedStream`] from synchronous Python code, blocking on
/// a given runtime only when the next item isn't already buffered.
///
/// Implemented for any pinned, fused stream (e.g. `Pin<&mut Fuse<S>>`) whose
/// items can cross the GIL-release boundary, so it can be tested against an
/// in-memory stream as well as a [`tokio_postgres::RowStream`].
///
/// The [`FusedStream`] bound provides [`FusedStream::is_terminated`], so
/// [`Self::block_on_next`] can return `None` after exhaustion without polling
/// the stream again. A bare `Stream` is allowed to panic if polled past
/// completion.
pub trait BlockingPostgresStream
where
Self: FusedStream + Sized + Send + Ungil + Unpin,
Self::Item: Ungil + Send,
{
/// Get the next item from the stream, blocking on the given runtime if
/// necessary.
///
/// If the stream is not ready to yield an item, this will release the GIL
/// and block until the next item is available.
///
/// This method will return `None` if the stream is exhausted.
fn block_on_next(&mut self, py: Python<'_>, handle: &Handle) -> Option<Self::Item> {
if self.is_terminated() {
return None;
}
self.next().block_on(py, handle)
}
}
// Blanket impl over any pinned, fused stream, not just `RowStream`, so the
// tests can use an in-memory stream.
impl<S> BlockingPostgresStream for Pin<&mut S>
where
Self: FusedStream + Send + Ungil + Unpin,
<Self as futures::stream::Stream>::Item: Ungil + Send,
{
}
#[cfg(test)]
mod tests {
//! These tests don't touch Postgres: the future/stream helpers are generic,
//! so we exercise them with plain async blocks and an in-memory stream.
use std::pin::pin;
use futures::stream::{self, StreamExt};
use tokio::runtime::Runtime;
use super::*;
/// A throwaway runtime standing in for the shared one. The helpers take
/// the runtime to block on explicitly, so each test just passes this
/// runtime's handle.
fn test_runtime() -> Runtime {
tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.build()
.unwrap()
}
#[test]
fn block_on_runs_future_and_returns_output() {
Python::initialize();
let rt = test_runtime();
Python::attach(|py| {
assert_eq!(async { 1 + 2 }.block_on(py, rt.handle()), 3);
});
}
#[test]
fn block_on_handles_ready_future() {
Python::initialize();
let rt = test_runtime();
Python::attach(|py| {
// A future that returns ready on the first poll.
let fut = std::future::ready(9);
assert_eq!(fut.block_on(py, rt.handle()), 9);
});
}
#[test]
fn block_on_handles_not_ready_future() {
Python::initialize();
let rt = test_runtime();
Python::attach(|py| {
// A future that requires polling multiple times to complete.
let fut = async {
tokio::task::yield_now().await;
9
};
assert_eq!(fut.block_on(py, rt.handle()), 9);
});
}
#[test]
fn block_on_pg_result_maps_ok_through() {
Python::initialize();
let rt = test_runtime();
Python::attach(|py| {
let ok = async { Ok::<i32, tokio_postgres::Error>(5) };
assert_eq!(ok.block_on_pg_result(py, rt.handle()).unwrap(), 5);
// The error path (mapping a `tokio_postgres::Error` to a `PyErr`)
// isn't covered here, because that error type can't be constructed
// by hand. Exercising it needs a live server.
});
}
#[test]
fn block_on_next() {
Python::initialize();
let rt = test_runtime();
Python::attach(|py| {
// `block_on_next` handles streams that may not be ready on the
// first poll.
let stream = stream::once(async {
tokio::task::yield_now().await;
Ok::<i32, ()>(8)
});
let mut stream = pin!(stream);
assert_eq!(stream.as_mut().block_on_next(py, rt.handle()), Some(Ok(8)));
});
}
}
+36
View File
@@ -0,0 +1,36 @@
//! [`tokio_postgres`]-backed Postgres backend for the Rust `database` module.
//!
//! The driver is async. [`helpers`] drives its futures to completion from
//! synchronous Python code on the shared tokio runtime, and [`value`] converts
//! between Python objects and Postgres' binary wire format.
use pyo3::exceptions::PyRuntimeError;
use pyo3::prelude::*;
use pyo3::types::PyModule;
// `pub` so the items in these submodules count as public API even though
// nothing consumes them yet, which keeps clippy's `dead_code` lint quiet.
// Tighten to private once the connection/cursor code uses them.
pub mod helpers;
pub mod value;
/// Register the (currently empty) `postgres` submodule under the parent
/// `database` module.
pub fn register_module(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
let child = PyModule::new(py, "postgres")?;
m.add_submodule(&child)?;
// We need to manually add the module to sys.modules to make `from
// synapse.synapse_rust.database import postgres` work.
py.import("sys")?
.getattr("modules")?
.set_item("synapse.synapse_rust.database.postgres", child)?;
Ok(())
}
/// Map a [`tokio_postgres`] error into a Python `RuntimeError`.
fn pg_err_to_py(e: tokio_postgres::Error) -> PyErr {
PyRuntimeError::new_err(format!("postgres error: {e}"))
}
+618
View File
@@ -0,0 +1,618 @@
//! Conversions between Python values and the Postgres SQL value
//! representations.
//!
//! Supports int / float / bool / str / bytes / None. Lists (for
//! `ANY($1)`-style queries) and richer types such as json, decimal and
//! timestamps are not yet supported.
//!
//! The mapping is column-type-driven on the way *out* (a single Python `int`
//! becomes `INT2`/`INT4`/`INT8` depending on the column it is bound to) and
//! type-driven on the way *in*. The supported correspondence is:
//!
//! | [`PgValue`] variant | Python type | Postgres column type(s) |
//! |---------------------|-------------|-------------------------------------|
//! | `Null` | `None` | any (encoded as SQL `NULL`) |
//! | `Bool` | `bool` | `BOOL` |
//! | `Int` | `int` | `INT2`, `INT4`, `INT8` |
//! | `Float` | `float` | `FLOAT4`, `FLOAT8` |
//! | `Text` | `str` | `TEXT`, `VARCHAR`, `NAME`, `BPCHAR` |
//! | `Bytea` | `bytes` | `BYTEA` |
//!
//! Both directions share these type lists via the two `accepts` methods, which
//! must stay in sync with the `match` arms below.
use std::error::Error;
use bytes::BytesMut;
use postgres_protocol::types::{
bool_to_sql, bytea_to_sql, float4_to_sql, float8_to_sql, int2_to_sql, int4_to_sql, int8_to_sql,
text_to_sql,
};
use pyo3::exceptions::{PyTypeError, PyValueError};
use pyo3::pybacked::{PyBackedBytes, PyBackedStr};
use pyo3::types::{PyBool, PyByteArray, PyBytes, PyFloat, PyInt, PyString, PyTuple};
use pyo3::{prelude::*, BoundObject};
use tokio_postgres::types::{to_sql_checked, IsNull, ToSql, Type, WrongType};
/// Owned representation of a Python value that we can hand to [`tokio_postgres`]
/// as a [`ToSql`] parameter.
#[derive(Debug)]
pub enum PgValue {
Null,
Bool(bool),
Int(i64),
Float(f64),
Text(PyBackedStr),
Bytea(PyBackedBytes),
}
impl PgValue {
/// Classify a Python object into a [`PgValue`], or error if its type isn't
/// one we know how to send to Postgres.
///
/// * `bool` is classified as [`PgValue::Bool`], never [`PgValue::Int`],
/// even though Python's `bool` is a subclass of `int`.
/// * `int` must fit in an `i64`; a larger Python integer raises an
/// `OverflowError` here, since Postgres has no wider integer type in
/// this mapping.
/// * A type we don't recognise raises `TypeError`.
pub fn from_py(obj: Bound<PyAny>) -> PyResult<Self> {
if obj.is_none() {
return Ok(PgValue::Null);
}
// `bool` must be checked before `int`, since in Python `bool` is a
// subclass of `int` and would otherwise be caught by the `PyInt` arm.
if let Ok(b) = obj.cast::<PyBool>() {
return Ok(PgValue::Bool(b.is_true()));
}
if let Ok(i) = obj.cast::<PyInt>() {
return Ok(PgValue::Int(i.extract::<i64>()?));
}
if let Ok(f) = obj.cast::<PyFloat>() {
return Ok(PgValue::Float(f.value()));
}
// The `cast_into` calls below are a bit awkward because we need to take
// ownership of `obj`, so we need to use `cast_into` and then
// `into_inner` to get the original `obj` back if the cast fails.
let obj = match obj.cast_into::<PyString>() {
Ok(s) => return Ok(PgValue::Text(s.try_into()?)),
Err(err) => err.into_inner(),
};
let obj = match obj.cast_into::<PyBytes>() {
Ok(s) => return Ok(PgValue::Bytea(s.into())),
Err(err) => err.into_inner(),
};
let obj = match obj.cast_into::<PyByteArray>() {
// Annoyingly, PyO3 needs to copy the contents of the byte array
// into a Vec here, so this isn't as cheap as using `PyBytes`.
Ok(s) => return Ok(PgValue::Bytea(s.into())),
Err(err) => err.into_inner(),
};
Err(PyTypeError::new_err(format!(
"unsupported parameter type for postgres: {}",
obj.get_type().name()?,
)))
}
}
// Lets PyO3 extract a `PgValue` directly from a Python argument, e.g. from a
// method that takes `Option<Vec<PgValue>>` as its parameter list.
impl<'a, 'py> FromPyObject<'a, 'py> for PgValue {
type Error = PyErr;
fn extract(obj: Borrowed<'a, 'py, PyAny>) -> Result<Self, Self::Error> {
PgValue::from_py(obj.into_bound())
}
}
/// Serialises a [`PgValue`] into Postgres' binary wire format.
///
/// The target column type (`ty`) is supplied by [`tokio_postgres`] from the
/// prepared statement, so the same `PgValue` (e.g. an `Int`) is encoded
/// differently depending on whether the column is `INT2`/`INT4`/`INT8`. A
/// value that doesn't match the column type yields a [`WrongType`] error.
impl ToSql for PgValue {
fn to_sql(
&self,
ty: &Type,
buf: &mut BytesMut,
) -> Result<IsNull, Box<dyn Error + Sync + Send>> {
match (self, ty) {
(PgValue::Null, _) => Ok(IsNull::Yes),
(&PgValue::Bool(v), &Type::BOOL) => {
bool_to_sql(v, buf);
Ok(IsNull::No)
}
(&PgValue::Int(i), &Type::INT2) => {
let v = i
.try_into()
.map_err(|_| format!("integer {i} out of range for INT2"))?;
int2_to_sql(v, buf);
Ok(IsNull::No)
}
(&PgValue::Int(i), &Type::INT4) => {
let v = i
.try_into()
.map_err(|_| format!("integer {i} out of range for INT4"))?;
int4_to_sql(v, buf);
Ok(IsNull::No)
}
(&PgValue::Int(i), &Type::INT8) => {
int8_to_sql(i, buf);
Ok(IsNull::No)
}
(&PgValue::Float(v), &Type::FLOAT4) => {
// The `as` cast narrows to the nearest f32, losing precision.
// Unlike the integer arms there is no fallible conversion to
// use, since floats have no notion of "out of range", just
// varying precision.
float4_to_sql(v as f32, buf);
Ok(IsNull::No)
}
(&PgValue::Float(v), &Type::FLOAT8) => {
float8_to_sql(v, buf);
Ok(IsNull::No)
}
(PgValue::Text(v), &Type::TEXT | &Type::VARCHAR | &Type::NAME | &Type::BPCHAR) => {
text_to_sql(v, buf);
Ok(IsNull::No)
}
(PgValue::Bytea(v), &Type::BYTEA) => {
bytea_to_sql(v, buf);
Ok(IsNull::No)
}
// If we get here then the caller has passed a value that doesn't
// match the type of the column.
(&PgValue::Bool(_), _) => Err(Box::new(WrongType::new::<bool>(ty.clone()))),
(&PgValue::Int(_), _) => Err(Box::new(WrongType::new::<i64>(ty.clone()))),
(&PgValue::Float(_), _) => Err(Box::new(WrongType::new::<f64>(ty.clone()))),
(&PgValue::Text(_), _) => Err(Box::new(WrongType::new::<&str>(ty.clone()))),
(&PgValue::Bytea(_), _) => Err(Box::new(WrongType::new::<&[u8]>(ty.clone()))),
}
}
fn accepts(ty: &Type) -> bool {
matches!(
*ty,
Type::BOOL
| Type::INT2
| Type::INT4
| Type::INT8
| Type::FLOAT4
| Type::FLOAT8
| Type::TEXT
| Type::VARCHAR
| Type::NAME
| Type::BPCHAR
| Type::BYTEA
)
}
to_sql_checked!();
}
/// Convert a Postgres row into a Python tuple, one element per column.
///
/// Each column is decoded via [`PythonPgFromSql`], so `NULL` becomes `None` and
/// every other supported type becomes its natural Python equivalent.
///
/// Raises `ValueError` (including the column index and its Postgres type) if a
/// column can't be decoded — e.g. its type isn't in [`PythonPgFromSql::accepts`]
/// or the wire bytes are malformed (such as non-UTF-8 data in a `TEXT` column).
pub fn pg_row_to_py<'py>(
py: Python<'py>,
row: &tokio_postgres::Row,
) -> PyResult<Bound<'py, PyTuple>> {
let mut output_row = Vec::with_capacity(row.len());
for idx in 0..row.len() {
let obj: PythonPgFromSql = row.try_get(idx).map_err(|e| {
PyValueError::new_err(format!(
"failed to decode column {idx} (type {}): {e}",
row.columns()[idx].type_()
))
})?;
output_row.push(obj.0);
}
PyTuple::new(py, output_row)
}
/// A decoded column value, ready to drop into a Python tuple. `None` represents
/// SQL `NULL`; otherwise it holds the corresponding Python object.
///
/// Decoding attaches to the Python interpreter to build the object. So this
/// must only run on a thread that already holds the GIL, and never on a tokio
/// runtime thread. It is private so that [`pg_row_to_py`], which takes a
/// `Python<'py>` token, is the only way to reach it.
///
/// Use [`pg_row_to_py`] directly.
struct PythonPgFromSql(pub Option<Py<PyAny>>);
impl<'a> tokio_postgres::types::FromSql<'a> for PythonPgFromSql {
fn from_sql(ty: &Type, raw: &'a [u8]) -> Result<Self, Box<dyn Error + Sync + Send>> {
// Decoding builds Python objects, so we need the GIL. `try_get` (our
// only caller) already runs under it, so this attach is cheap. This
// also only runs on the python thread and *not* the tokio runtime's
// worker threads, so we don't need to blocking wait for the GIL.
//
// Ideally we'd assert that we already hold the GIL, but PyO3 doesn't
// expose that.
Python::attach(|py| Self::from_sql_with_py(py, ty, raw))
}
fn from_sql_null(_ty: &Type) -> Result<Self, Box<dyn Error + Sync + Send>> {
Ok(PythonPgFromSql(None))
}
fn accepts(ty: &Type) -> bool {
matches!(
*ty,
Type::BOOL
| Type::INT2
| Type::INT4
| Type::INT8
| Type::FLOAT4
| Type::FLOAT8
| Type::TEXT
| Type::VARCHAR
| Type::NAME
| Type::BPCHAR
| Type::BYTEA
)
}
}
impl PythonPgFromSql {
/// Decode a non-NULL column value into the matching Python object, given
/// an already-held GIL token.
fn from_sql_with_py(
py: Python<'_>,
ty: &Type,
raw: &[u8],
) -> Result<Self, Box<dyn Error + Sync + Send>> {
let obj = match *ty {
Type::BOOL => {
let b = postgres_protocol::types::bool_from_sql(raw)?;
PyBool::new(py, b).into_any().unbind()
}
Type::INT2 => {
let i = postgres_protocol::types::int2_from_sql(raw)?;
PyInt::new(py, i).into_any().unbind()
}
Type::INT4 => {
let i = postgres_protocol::types::int4_from_sql(raw)?;
PyInt::new(py, i).into_any().unbind()
}
Type::INT8 => {
let i = postgres_protocol::types::int8_from_sql(raw)?;
PyInt::new(py, i).into_any().unbind()
}
Type::FLOAT4 => {
let f = postgres_protocol::types::float4_from_sql(raw)?;
PyFloat::new(py, f.into()).into_any().unbind()
}
Type::FLOAT8 => {
let f = postgres_protocol::types::float8_from_sql(raw)?;
PyFloat::new(py, f).into_any().unbind()
}
Type::TEXT | Type::VARCHAR | Type::NAME | Type::BPCHAR => {
// Note: PyString::from_bytes expects UTF-8, and
// postgres_protocol::types::text_from_sql demonstrates that the
// wire bytes are indeed UTF-8, so this should be safe.
PyString::from_bytes(py, raw)?.into_any().unbind()
}
Type::BYTEA => PyBytes::new(py, raw).into_any().unbind(),
_ => {
// This should never happen, unless the `accepts` method is out
// of sync.
return Err(format!("unsupported column type for postgres: {ty}").into());
}
};
Ok(PythonPgFromSql(Some(obj)))
}
}
#[cfg(test)]
mod tests {
//! These tests exercise the value mapping in isolation — no live Postgres
//! server is needed. The `to_sql` / `from_sql` halves both operate on raw
//! byte buffers, so we can drive them directly with hand-built buffers and
//! a chosen column [`Type`], and the `from_py` classifier just needs a GIL.
use pyo3::IntoPyObjectExt;
use super::*;
/// Encode `value` for column type `ty`, returning the wire bytes and the
/// `IsNull` flag. Panics on a `ToSql` error so callers can assert on the
/// happy path concisely.
fn encode(value: &PgValue, ty: &Type) -> (Vec<u8>, bool) {
let mut buf = BytesMut::new();
let is_null = value.to_sql(ty, &mut buf).expect("encoding should succeed");
(buf.to_vec(), matches!(is_null, IsNull::Yes))
}
/// Like [`encode`], but surfaces the `ToSql` result so a test can assert on
/// the error path (e.g. an out-of-range integer).
fn encode_result(value: &PgValue, ty: &Type) -> Result<(), Box<dyn Error + Sync + Send>> {
let mut buf = BytesMut::new();
value.to_sql(ty, &mut buf).map(|_| ())
}
/// Build a [`PgValue::Text`] from a Rust string. The variant is backed by a
/// Python `str`, so this needs an attached interpreter.
fn text(py: Python<'_>, s: &str) -> PgValue {
PgValue::Text(PyString::new(py, s).try_into().unwrap())
}
/// Build a [`PgValue::Bytea`] from a Rust byte slice. As with [`text`], the
/// variant is backed by a Python `bytes`.
fn bytea(py: Python<'_>, b: &[u8]) -> PgValue {
PgValue::Bytea(PyBytes::new(py, b).into())
}
#[test]
fn from_py_classifies_supported_types() {
Python::initialize();
Python::attach(|py| {
// `None` -> NULL.
assert!(matches!(
PgValue::from_py(py.None().into_bound(py)).unwrap(),
PgValue::Null
));
// `bool` must win over `int` (it is an `int` subclass in Python).
assert!(matches!(
PgValue::from_py(true.into_bound_py_any(py).unwrap()).unwrap(),
PgValue::Bool(true)
));
assert!(matches!(
PgValue::from_py(7i64.into_bound_py_any(py).unwrap()).unwrap(),
PgValue::Int(7)
));
assert!(matches!(
PgValue::from_py(1.5f64.into_bound_py_any(py).unwrap()).unwrap(),
PgValue::Float(1.5)
));
match PgValue::from_py("hello".into_bound_py_any(py).unwrap()).unwrap() {
PgValue::Text(s) => assert_eq!(&*s, "hello"),
other => panic!("expected Text, got {other:?}"),
}
match PgValue::from_py(PyBytes::new(py, b"\x00\xff").into_any()).unwrap() {
PgValue::Bytea(b) => assert_eq!(&*b, b"\x00\xff"),
other => panic!("expected Bytea, got {other:?}"),
}
});
}
#[test]
fn from_py_extracts_via_frompyobject() {
// Check that extracting a `PgValue` from a Python object (the
// `FromPyObject` path) forwards to `from_py`.
Python::initialize();
Python::attach(|py| {
let obj = 7i64.into_pyobject(py).unwrap().into_any();
assert!(matches!(obj.extract::<PgValue>().unwrap(), PgValue::Int(7)));
});
}
#[test]
fn from_py_rejects_unsupported_type() {
Python::initialize();
Python::attach(|py| {
// A list is not a scalar we know how to bind.
let list = pyo3::types::PyList::new(py, [1, 2, 3]).unwrap();
let err = PgValue::from_py(list.into_any()).unwrap_err();
assert!(err.is_instance_of::<PyTypeError>(py));
// The message should name the offending type.
assert!(err.to_string().contains("list"), "got: {err}");
});
}
#[test]
fn to_sql_encodes_each_type_for_its_column() {
// NULL is encoded as "no bytes, IsNull::Yes" regardless of column type.
let (bytes, is_null) = encode(&PgValue::Null, &Type::INT4);
assert!(is_null);
assert!(bytes.is_empty());
// Integers are width-specific: the same `Int` encodes to 2/4/8 bytes
// depending on the column type.
assert_eq!(encode(&PgValue::Int(1), &Type::INT2).0, 1i16.to_be_bytes());
assert_eq!(encode(&PgValue::Int(1), &Type::INT4).0, 1i32.to_be_bytes());
assert_eq!(encode(&PgValue::Int(1), &Type::INT8).0, 1i64.to_be_bytes());
assert_eq!(encode(&PgValue::Bool(true), &Type::BOOL).0, vec![1]);
assert_eq!(encode(&PgValue::Bool(false), &Type::BOOL).0, vec![0]);
assert_eq!(
encode(&PgValue::Float(1.0), &Type::FLOAT8).0,
1.0f64.to_be_bytes()
);
Python::initialize();
Python::attach(|py| {
assert_eq!(encode(&text(py, "hi"), &Type::TEXT).0, b"hi".to_vec());
assert_eq!(encode(&bytea(py, b"\x01\x02"), &Type::BYTEA).0, vec![1, 2]);
});
}
#[test]
fn to_sql_float4_narrows_with_precision_loss() {
// `0.1` is not representable in binary floating point, so the f64 and
// f32 encodings genuinely differ. Encoding to a FLOAT4 column must use
// the (lossy) f32 narrowing, not reinterpret the f64 bytes.
let value = 0.1f64;
assert_eq!(
encode(&PgValue::Float(value), &Type::FLOAT4).0,
(value as f32).to_be_bytes()
);
// And the bytes are not just a truncation of the f64 encoding.
assert_ne!(
encode(&PgValue::Float(value), &Type::FLOAT4).0,
value.to_be_bytes()[..4].to_vec()
);
}
#[test]
fn to_sql_rejects_mismatched_column_type() {
// A value whose type doesn't match the column is a `WrongType` error
// rather than a silent reinterpretation. Hit a few distinct arms.
Python::initialize();
Python::attach(|py| {
let cases: &[(PgValue, Type)] = &[
(PgValue::Int(1), Type::TEXT),
(text(py, "x"), Type::INT4),
(bytea(py, b"x"), Type::TEXT),
(PgValue::Bool(true), Type::INT4),
(PgValue::Float(1.0), Type::INT8),
];
for (value, ty) in cases {
let mut buf = BytesMut::new();
assert!(
value.to_sql(ty, &mut buf).is_err(),
"expected {value:?} -> {ty} to be rejected"
);
}
});
}
#[test]
fn to_sql_integer_width_boundaries() {
// Boundary values for each width encode; one step past the boundary is
// rejected by the `try_into` guards (INT8 spans all of i64, so there is
// nothing out of range for it).
assert!(encode_result(&PgValue::Int(i16::MAX as i64), &Type::INT2).is_ok());
assert!(encode_result(&PgValue::Int(i16::MIN as i64), &Type::INT2).is_ok());
assert!(encode_result(&PgValue::Int(i16::MAX as i64 + 1), &Type::INT2).is_err());
assert!(encode_result(&PgValue::Int(i16::MIN as i64 - 1), &Type::INT2).is_err());
assert!(encode_result(&PgValue::Int(i32::MAX as i64), &Type::INT4).is_ok());
assert!(encode_result(&PgValue::Int(i32::MIN as i64), &Type::INT4).is_ok());
assert!(encode_result(&PgValue::Int(i32::MAX as i64 + 1), &Type::INT4).is_err());
assert!(encode_result(&PgValue::Int(i32::MIN as i64 - 1), &Type::INT4).is_err());
assert_eq!(
encode(&PgValue::Int(i64::MAX), &Type::INT8).0,
i64::MAX.to_be_bytes()
);
assert_eq!(
encode(&PgValue::Int(i64::MIN), &Type::INT8).0,
i64::MIN.to_be_bytes()
);
}
#[test]
fn accepts_lists_match_supported_types() {
// The encode and decode sides must accept exactly the supported column
// types and reject everything else; a drift between the two `accepts`
// lists and the `match` arms would surface here.
for ty in [
Type::BOOL,
Type::INT2,
Type::INT4,
Type::INT8,
Type::FLOAT4,
Type::FLOAT8,
Type::TEXT,
Type::VARCHAR,
Type::NAME,
Type::BPCHAR,
Type::BYTEA,
] {
assert!(<PgValue as ToSql>::accepts(&ty), "ToSql should accept {ty}");
assert!(
<PythonPgFromSql as tokio_postgres::types::FromSql>::accepts(&ty),
"FromSql should accept {ty}"
);
}
for ty in [Type::JSON, Type::TIMESTAMPTZ, Type::UUID] {
assert!(
!<PgValue as ToSql>::accepts(&ty),
"ToSql should reject {ty}"
);
assert!(
!<PythonPgFromSql as tokio_postgres::types::FromSql>::accepts(&ty),
"FromSql should reject {ty}"
);
}
}
#[test]
fn from_sql_decodes_into_python_objects() {
use tokio_postgres::types::FromSql;
Python::initialize();
Python::attach(|py| {
// Each supported type round-trips from its wire bytes to the
// matching Python object. This mirrors the encode test's type list.
let int2 = PythonPgFromSql::from_sql(&Type::INT2, &7i16.to_be_bytes()).unwrap();
assert_eq!(int2.0.unwrap().extract::<i64>(py).unwrap(), 7);
let int4 = PythonPgFromSql::from_sql(&Type::INT4, &42i32.to_be_bytes()).unwrap();
assert_eq!(int4.0.unwrap().extract::<i64>(py).unwrap(), 42);
// A wide INT8 that wouldn't fit in INT4, to prove the width is honoured.
let big = i64::MAX - 1;
let int8 = PythonPgFromSql::from_sql(&Type::INT8, &big.to_be_bytes()).unwrap();
assert_eq!(int8.0.unwrap().extract::<i64>(py).unwrap(), big);
// FLOAT4 decodes via an f32, then widens to a Python float.
let f4 = PythonPgFromSql::from_sql(&Type::FLOAT4, &1.5f32.to_be_bytes()).unwrap();
assert_eq!(f4.0.unwrap().extract::<f64>(py).unwrap(), 1.5);
let f8 = PythonPgFromSql::from_sql(&Type::FLOAT8, &2.5f64.to_be_bytes()).unwrap();
assert_eq!(f8.0.unwrap().extract::<f64>(py).unwrap(), 2.5);
let b = PythonPgFromSql::from_sql(&Type::BOOL, &[1]).unwrap();
assert!(b.0.unwrap().extract::<bool>(py).unwrap());
let text = PythonPgFromSql::from_sql(&Type::TEXT, b"hi").unwrap();
assert_eq!(text.0.unwrap().extract::<String>(py).unwrap(), "hi");
// BYTEA passes raw bytes through unchanged, including NULs and
// non-UTF-8 data — the natural inverse of the encode test.
let bytea = PythonPgFromSql::from_sql(&Type::BYTEA, b"\x00\xff").unwrap();
assert_eq!(
bytea.0.unwrap().extract::<Vec<u8>>(py).unwrap(),
b"\x00\xff"
);
// A SQL NULL decodes to `None` via `from_sql_null`.
let null = PythonPgFromSql::from_sql_null(&Type::INT4).unwrap();
assert!(null.0.is_none());
});
}
#[test]
fn from_sql_rejects_unsupported_column_type() {
use tokio_postgres::types::FromSql;
// The `_ =>` arm in `from_sql_with_py` guards against `accepts` drifting
// out of sync; decoding an unsupported type is an error, not a panic.
Python::initialize();
Python::attach(|_py| {
assert!(PythonPgFromSql::from_sql(&Type::JSON, b"{}").is_err());
});
}
#[test]
fn from_sql_rejects_non_utf8_text() {
use tokio_postgres::types::FromSql;
// TEXT decode goes through `PyString::from_bytes`, which validates
// UTF-8: malformed bytes surface as an error rather than a panic.
Python::initialize();
Python::attach(|_py| {
assert!(PythonPgFromSql::from_sql(&Type::TEXT, b"\xff\xfe").is_err());
});
}
}
+2
View File
@@ -11,6 +11,7 @@ pub mod acl;
pub mod canonical_json;
pub mod clock;
pub mod config;
pub mod database;
pub mod deferred;
pub mod duration;
pub mod errors;
@@ -88,6 +89,7 @@ fn synapse_rust(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
acl::register_module(py, m)?;
logging::context::register_module(py, m)?;
deferred::register_module(py, m)?;
database::register_module(py, m)?;
push::register_module(py, m)?;
events::register_module(py, m)?;
handlers::register_module(py, m)?;
@@ -0,0 +1,11 @@
# 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>.
@@ -0,0 +1,13 @@
# 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>.
# Empty stub file for `synapse::database::postgres` module