diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c619862..1fcef1c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,6 +81,8 @@ jobs: python3 tests/pool_and_row.py python3 tests/transaction.py python3 tests/type_conversion_bugs.py + python3 tests/native_binding.py + python3 tests/native_array_binding.py - name: Concurrency correctness harness (live Postgres) run: python3 benchmarks/bench_concurrency_correctness.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d3736c6..a528e0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,11 +16,14 @@ All notable changes to PostPyro will be documented in this file. - Float parameters no longer lose precision to a forced `f32` downcast before binding. - `Row.keys()`/`values()`/`items()`/`to_dict()`/`get()`/`__iter__`/`__repr__` are implemented (previously documented but missing). - Binding a plain `None` into a non-text column (e.g. `INT4`, `FLOAT8`) no longer raises a Postgres type-mismatch error - it's now bound as an untyped NULL and Postgres infers the real column type, the same way `NULL` binds in other drivers. -- Reading a column of a type the driver doesn't yet decode (e.g. `BYTEA`, arrays) now raises `NotSupportedError` naming the type, instead of silently returning `None` as if the value were a real SQL NULL. +- Reading a column of a type the driver doesn't yet decode (e.g. `INET`, custom/enum types) now raises `NotSupportedError` naming the type, instead of silently returning `None` as if the value were a real SQL NULL. - `NUMERIC` columns decode to `decimal.Decimal` with full precision (previously fell into the same silent-`None` bug above). ### Added +- Native parameter binding for non-primitive Python types: `datetime.datetime` (naive → `TIMESTAMP`, tz-aware → `TIMESTAMPTZ` normalized to UTC), `datetime.date` → `DATE`, `datetime.time` → `TIME`, `uuid.UUID` → `UUID`, `decimal.Decimal` → `NUMERIC` (exact, no float round-trip), `dict` → `JSON`/`JSONB`, a homogeneous `list`/`tuple` → a native Postgres array (`bool[]`/`int8[]`/`float8[]`/`text[]`, mixed/nested ones fall back to `JSON`/`JSONB`), and `bytes`/`bytearray`/`memoryview` → `BYTEA`. These no longer require `str()` plus a manual `$1::type` cast in the SQL text (a tz-aware `datetime.time` still raises `NotSupportedError`; a `set`/`frozenset` or other JSON-incompatible object raises `DataError` naming the type). +- `BOOL[]`/`INT2[]`/`INT4[]`/`INT8[]`/`FLOAT4[]`/`FLOAT8[]`/`TEXT[]`-family columns decode to Python `list` (previously any array raised `NotSupportedError`). +- `BYTEA` columns decode to Python `bytes` (previously raised `NotSupportedError`). - TLS-capable connections (via `sqlx`'s `rustls` backend) - the old driver hardcoded `NoTls`. ## [1.0.0] - 2025-10-03 diff --git a/PostPyro_documentation.md b/PostPyro_documentation.md index 3a11f79..2a4be3c 100644 --- a/PostPyro_documentation.md +++ b/PostPyro_documentation.md @@ -8,7 +8,7 @@ PostPyro is an async PostgreSQL driver for Python built with PyO3/`pyo3-asyncio` This is the full reference - every method, type, and exception. If you just want a query running, [`README.md`](README.md) has a shorter quick start. -PostPyro runs `sqlx`'s binary protocol under PyO3, so every `await` releases the GIL for the duration of the wait instead of blocking the interpreter. Rust's ownership system rules out the memory-safety bugs (leaks, segfaults) that hand-rolled C bindings are prone to. TLS is available through `sqlx`'s `rustls` backend, and errors surface through a DB-API 2.0-flavored exception hierarchy rather than raw `sqlx` errors. The type system covers booleans, integers, floats, `NUMERIC`, text, dates/times, UUIDs, and JSON/JSONB automatically - see the [type table](#supported-type-conversions) for exactly what's decodable today (arrays, `BYTEA`, and network types aren't yet). +PostPyro runs `sqlx`'s binary protocol under PyO3, so every `await` releases the GIL for the duration of the wait instead of blocking the interpreter. Rust's ownership system rules out the memory-safety bugs (leaks, segfaults) that hand-rolled C bindings are prone to. TLS is available through `sqlx`'s `rustls` backend, and errors surface through a DB-API 2.0-flavored exception hierarchy rather than raw `sqlx` errors. The type system covers booleans, integers, floats, `NUMERIC`, text, dates/times, UUIDs, and JSON/JSONB automatically - see the [type table](#supported-type-conversions) for exactly what's decodable today (arrays and network types aren't yet). ## Installation @@ -309,39 +309,94 @@ PostPyro automatically converts between Python and PostgreSQL types. | `TIMESTAMPTZ` | `datetime.datetime` | With timezone info (UTC) | | `UUID` | `str` | `'550e8400-e29b-41d4-a716-446655440000'` | | `JSON`, `JSONB` | `dict`, `list`, etc. | `{"key": "value"}`, `[1, 2, 3]` | +| `BYTEA` | `bytes` | `b"\x00\x01"` | +| `BOOL[]`, `INT2[]`/`INT4[]`/`INT8[]`, `FLOAT4[]`/`FLOAT8[]`, `TEXT[]`/`VARCHAR[]`/`CHAR[]`/`BPCHAR[]`/`NAME[]` | `list` | `[1, 2, 3]`, `["a", "b"]` | + +### Binding Parameters + +Parameters auto-convert both directions for the table above - you can pass +Python values **and** bind them natively, with no `$1::type` cast in the SQL +text: + +| Python parameter | Binds as | +| ------------------------------------------------------ | --------------------------------- | +| `bool` | `BOOLEAN` | +| `int` | `BIGINT` | +| `float` | `DOUBLE PRECISION` | +| `str` | `TEXT` | +| `None` | untyped `NULL` (server infers) | +| `datetime.datetime` (naive) | `TIMESTAMP` | +| `datetime.datetime` (tz-aware) | `TIMESTAMPTZ` (normalized to UTC) | +| `datetime.date` | `DATE` | +| `datetime.time` (naive) | `TIME` | +| `uuid.UUID` | `UUID` | +| `decimal.Decimal` | `NUMERIC` (exact) | +| `dict` | `JSON`/`JSONB` | +| `list`, `tuple` (homogeneous `bool`/`int`/`float`/`str`, `None` allowed) | `bool[]`/`int8[]`/`float8[]`/`text[]` | +| `list`, `tuple` (mixed types, nested) | `JSON`/`JSONB` | +| `bytes`, `bytearray`, `memoryview` | `BYTEA` | + +**A homogeneous list/tuple binds as a Postgres array**, matching +`asyncpg`'s default and avoiding the JSON `int4[]` cast dance: + +```python +await pool.execute( + "INSERT INTO tags (id, names, scores) VALUES ($1, $2, $3)", + [1, ["a", "b"], [10, 20, None]], # None -> NULL array element +) +``` -Any other type (`BYTEA`, arrays, `INET`/`CIDR`, custom/enum types, etc.) isn't -decodable yet - reading such a column raises `PostPyro.NotSupportedError` -naming the type, rather than silently returning the wrong value or `None`. +A `list`/`tuple` that isn't homogeneous (mixed types, or nesting - `dict` +always) falls back to JSON/JSONB via the same conversion, symmetric with +`json.dumps`/the JSON read side. To force a specific array element type +narrower than what auto-conversion picks (`int` -> `int8[]`, `float` -> +`float8[]`), cast in the SQL text (`$1::int4[]`) the same way scalar `int` +casts to `int4`. + +Two intentional limitations, both loud rather than silent: a +`datetime.time` carrying `tzinfo` raises `NotSupportedError` (Postgres +`TIMETZ` is legacy; bind a TIMESTAMPTZ datetime instead), and JSON params +nested deeper than 128 levels raise `DataError` (a self-referential dict +would otherwise overflow the Rust stack - an abort, not an exception). +`Decimal("NaN")`/`Decimal("Infinity")` are also rejected (Postgres NUMERIC +accepts `NaN`, but BigDecimal has no such values - bind the string +`'NaN'::numeric` if you need it). A `set`/`frozenset` also raises +`DataError` naming the type, same as any other JSON-incompatible object. + +`int` binds as `BIGINT` within the signed 64-bit range (`-2**63` to +`2**63 - 1`); an `int` outside that range raises an extraction error rather +than falling back to `TEXT`. Cast in SQL (`$1::int4`) when a narrower column +needs an exact match. Anything not in this table (e.g. an `INET` value) +falls back to `str(obj)` as `TEXT` - pass those as strings with an explicit +cast (`$1::inet`). ### Type Usage Example -Binding parameters only auto-converts `bool`/`int`/`float`/`str`/`None` - -anything else (a `date`, `datetime`, `uuid.UUID`, `dict`, ...) has no -automatic Python→Postgres conversion. Pass it as a string and add an -explicit Postgres cast in the SQL text instead: +Binding parameters auto-converts everything in the table above - pass real +Python objects (`datetime`, `uuid.UUID`, `Decimal`, `dict`, `bytes`, ...) and +PostPyro sends them with the right Postgres wire type. A string-plus-cast +(`"2023-12-25"` + `$5::date`) also still works when you already have text: ```python -import json import uuid -from datetime import date +from datetime import datetime, date +from decimal import Decimal -# Insert various types - non-primitive values go in as strings, with an -# explicit ::type cast telling Postgres what to parse them as. +# Native objects bind directly - no ::type casts needed: await pool.execute(""" INSERT INTO mixed_types ( bool_col, int_col, float_col, text_col, date_col, timestamp_col, uuid_col, json_col - ) VALUES ($1, $2, $3, $4, $5::date, $6::timestamp, $7::uuid, $8::jsonb) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) """, [ - True, # boolean - auto-converted - 42, # integer - auto-converted - 3.14159, # float - auto-converted - "Hello PostgreSQL", # text - auto-converted - "2023-12-25", # date - string + ::date cast - "2023-12-25 14:30:00", # timestamp - string + ::timestamp cast - str(uuid.uuid4()), # uuid - string + ::uuid cast - json.dumps({"name": "John", "scores": [85, 92, 78]}), # json - string + ::jsonb cast + True, # boolean - auto-converted + 42, # integer - auto-converted + 3.14159, # float - auto-converted + "Hello PostgreSQL", # text - auto-converted + date(2023, 12, 25), # date - native + datetime(2023, 12, 25, 14, 30), # timestamp - native (naive) + uuid.uuid4(), # uuid - native + {"name": "John", "scores": [85, 92, 78]}, # json/jsonb - native ]) # Reading back decodes to the right Python type automatically (this diff --git a/python/PostPyro/__init__.pyi b/python/PostPyro/__init__.pyi index 7bbfb34..c45022c 100644 --- a/python/PostPyro/__init__.pyi +++ b/python/PostPyro/__init__.pyi @@ -86,7 +86,7 @@ class Pool: """An async connection pool (`sqlx::PgPool`); obtain via the module-level `connect()`, never constructed directly.""" async def execute(self, query: str, params: Optional[List[Any]] = None) -> int: - """Runs a statement on a pooled connection and returns rows affected. `None` params bind as untyped SQL NULL, inferred from context.""" + """Runs a statement on a pooled connection and returns rows affected. `bool`/`int`/`float`/`str`/`None`/`datetime`/`date`/`time`/`uuid.UUID`/`Decimal`/`dict`/`bytes`/`bytearray` params bind natively with the right Postgres wire type (naive datetime as TIMESTAMP, tz-aware as TIMESTAMPTZ, dict as JSON); a homogeneous `list`/`tuple` binds as a native Postgres array, a mixed/nested one as JSON; other objects fall back to `str()` as TEXT.""" ... async def query(self, query: str, params: Optional[List[Any]] = None) -> List[Row]: """Runs a query on a pooled connection and returns all matching rows.""" diff --git a/src/types.rs b/src/types.rs index 4743a30..01d919a 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,13 +1,132 @@ use bigdecimal::BigDecimal; use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, NaiveTime, Timelike, Utc}; use pyo3::prelude::*; -use pyo3::types::{PyBool, PyDict, PyFloat, PyInt, PyList, PyString}; +use pyo3::sync::GILOnceCell; +use pyo3::types::{ + PyBool, PyByteArray, PyBytes, PyDict, PyFloat, PyFrozenSet, PyInt, PyList, PySet, PyString, + PyTuple, +}; use sqlx::postgres::PgRow; use sqlx::postgres::types::Oid; use sqlx::{Column, Postgres, Row as SqlxRow, TypeInfo}; +use std::str::FromStr; use uuid::Uuid; -use crate::error::{map_db_error, NotSupportedError}; +use crate::error::{map_db_error, DataError, NotSupportedError}; + +/// Python stdlib classes used to recognize bindable parameter types. +/// +/// We can't `downcast::()` for this: pyo3 compiles its own +/// `PyDate`/`PyTime`/`PyDateTime` wrapper API out under `abi3` +/// (`#[cfg(not(Py_LIMITED_API))]` - same finding already documented in +/// `naive_date_to_py` below), and there are no pyo3 wrappers at all for +/// `uuid.UUID` or `decimal.Decimal`. So we hold the class objects themselves +/// (fetched once, `GILOnceCell` mediating with the GIL) and check with +/// Python-level `isinstance`, which also honors subclasses the way users +/// expect (e.g. a `datetime` subclass instance binds as a datetime). +static PY_DATETIME: GILOnceCell> = GILOnceCell::new(); +static PY_DATE: GILOnceCell> = GILOnceCell::new(); +static PY_TIME: GILOnceCell> = GILOnceCell::new(); +static PY_UUID: GILOnceCell> = GILOnceCell::new(); +static PY_DECIMAL: GILOnceCell> = GILOnceCell::new(); +static PY_MEMORYVIEW: GILOnceCell> = GILOnceCell::new(); + +fn py_class<'py>( + py: Python<'py>, + cell: &'static GILOnceCell>, + module: &str, + name: &str, +) -> PyResult<&'py PyAny> { + let cls = cell.get_or_try_init(py, || -> PyResult> { + Ok(py.import(module)?.getattr(name)?.into_py(py)) + })?; + Ok(cls.as_ref(py)) +} + +fn py_isinstance( + py: Python, + obj: &PyAny, + cell: &'static GILOnceCell>, + module: &str, + name: &str, +) -> PyResult { + let cls = py_class(py, cell, module, name)?; + obj.is_instance(cls) +} + +/// Maximum nesting depth accepted when converting a Python container to a +/// JSON value. A self-referential dict (easily built by accident) would +/// otherwise recurse until the Rust stack overflows - an abort, not a +/// catchable Python exception. Python's own json.dumps dies on the same +/// input via its recursion guard; this is our shallower equivalent. +const MAX_JSON_DEPTH: usize = 128; + +/// Convert a Python dict/list/tuple into a `serde_json::Value` for binding +/// against JSON/JSONB columns. Mirrors the read side (`json_to_py` below) +/// in reverse. Python ints beyond JSON's exact range (i64/u64) raise +/// `DataError` rather than silently degrading to a lossy float - the read +/// side hands such numbers through as strings, but guessing on the write +/// side would corrupt data, so it fails loudly instead. +fn py_to_json(obj: &PyAny) -> PyResult { + py_to_json_depth(obj, 0) +} + +fn py_to_json_depth(obj: &PyAny, depth: usize) -> PyResult { + if depth > MAX_JSON_DEPTH { + return Err(DataError::new_err(format!( + "parameter nesting exceeds {} levels - too deep to bind as JSON (self-referential container?)", + MAX_JSON_DEPTH + ))); + } + if obj.is_none() { + Ok(serde_json::Value::Null) + } else if let Ok(b) = obj.downcast::() { + Ok(serde_json::Value::Bool(b.extract::()?)) + } else if let Ok(i) = obj.downcast::() { + if let Ok(v) = i.extract::() { + Ok(serde_json::Value::from(v)) + } else if let Ok(v) = i.extract::() { + Ok(serde_json::Value::from(v)) + } else { + Err(DataError::new_err( + "JSON integer is outside the exact range JSON can represent (i64/u64) - bind it as a string or numeric instead", + )) + } + } else if let Ok(f) = obj.downcast::() { + Ok(serde_json::Value::from(f.extract::()?)) + } else if let Ok(s) = obj.downcast::() { + Ok(serde_json::Value::from(s.extract::()?)) + } else if let Ok(l) = obj.downcast::() { + let mut items = Vec::with_capacity(l.len()); + for item in l.iter() { + items.push(py_to_json_depth(item, depth + 1)?); + } + Ok(serde_json::Value::Array(items)) + } else if let Ok(t) = obj.downcast::() { + let mut items = Vec::with_capacity(t.len()); + for item in t.iter() { + items.push(py_to_json_depth(item, depth + 1)?); + } + Ok(serde_json::Value::Array(items)) + } else if let Ok(d) = obj.downcast::() { + let mut map = serde_json::Map::new(); + for (k, v) in d.iter() { + // JSON object keys are strings; non-string keys are stringified + // (same as json.dumps does for int keys), not an error. Note a + // collision ({1: "a", "1": "b"}) silently keeps the last value, + // exactly like json.dumps. + let key = k.str()?.extract::()?; + map.insert(key, py_to_json_depth(v, depth + 1)?); + } + Ok(serde_json::Value::Object(map)) + } else { + let type_name = obj.get_type().name().ok().unwrap_or("unknown"); + Err(DataError::new_err(format!( + "cannot bind a {} object to a JSON/JSONB parameter - pass a dict/list/tuple/str/int/float/bool/None", + type_name + ))) + } +} /// A bind value for SQL `NULL` that declares its Postgres parameter type as /// OID 0 ("unspecified") instead of a concrete type. @@ -45,6 +164,105 @@ impl<'q> sqlx::Encode<'q, Postgres> for UnspecifiedNull { } } +/// Read an int attribute off a Python object (e.g. `dt.year`, `d.month`) as +/// whatever integer type the caller needs. Generic over `T` rather than one +/// hand-written function per width - chrono's constructors take `i32` for +/// year (can be negative/large) but `u32` for month/day/hour/minute/second/ +/// microsecond, and a per-width copy is exactly the kind of duplication that +/// lets a wrong-width call site go unnoticed (as happened here: an earlier +/// version of this function only had an `i32` variant, silently passing +/// `i32` everywhere chrono actually wanted `u32`, which type inference on a +/// generic function catches at the call site instead). +fn get_attr<'py, T: pyo3::FromPyObject<'py>>(obj: &'py PyAny, attr: &str) -> PyResult { + obj.getattr(attr)?.extract::() +} + +/// Which Postgres temporal type a Python `datetime.datetime` binds as: +/// naive wall-clock -> TIMESTAMP, timezone-aware -> TIMESTAMPTZ (instant +/// normalized to UTC). Splitting them keeps the wire type matching the +/// column type instead of relying on the server's implicit +/// timestamptz->timestamp cast under the session timezone. +enum PyDateTimeParam { + Timestamp(NaiveDateTime), + Timestamptz(DateTime), +} + +/// Python `datetime.datetime` -> the right sqlx bind parameter. Attributes +/// are read through the plain `PyAny` API because pyo3 compiles its chrono +/// `FromPyObject` impls out under `abi3` (same constraint as the decode +/// side below). +/// +/// The offset is read via `datetime.utcoffset()` on the datetime itself - +/// NOT `tzinfo.utcoffset()`, which takes the datetime as an argument and +/// would raise `TypeError` (and, for a DST-observing zone, has no +/// offset-independent answer at all). +fn py_datetime_to_param(obj: &PyAny) -> PyResult { + let naive = NaiveDateTime::new( + NaiveDate::from_ymd_opt( + get_attr::(obj, "year")?, + get_attr::(obj, "month")?, + get_attr::(obj, "day")?, + ) + .ok_or_else(|| DataError::new_err("invalid date component in datetime parameter"))?, + NaiveTime::from_hms_micro_opt( + get_attr::(obj, "hour")?, + get_attr::(obj, "minute")?, + get_attr::(obj, "second")?, + get_attr::(obj, "microsecond")?, + ) + .ok_or_else(|| DataError::new_err("invalid time component in datetime parameter"))?, + ); + // utcoffset() -> timedelta, or None when the tzinfo can't determine the + // offset for this instant - treat that like naive rather than guessing. + let offset = obj.call_method0("utcoffset")?; + if offset.is_none() { + return Ok(PyDateTimeParam::Timestamp(naive)); + } + let total_seconds = offset.call_method0("total_seconds")?.extract::()?; + if total_seconds != total_seconds.trunc() || total_seconds.abs() > 86_399.0 { + return Err(DataError::new_err(format!( + "datetime parameter has an invalid utcoffset ({} seconds) - Postgres offsets are whole seconds within +/-24h", + total_seconds + ))); + } + let fixed = chrono::FixedOffset::east_opt(total_seconds as i32) + .ok_or_else(|| DataError::new_err("datetime parameter utcoffset out of range"))?; + Ok(PyDateTimeParam::Timestamptz(DateTime::from_naive_utc_and_offset( + naive - fixed, + Utc, + ))) +} + +/// Python `datetime.date` -> `chrono::NaiveDate`. +fn py_date_to_chrono(obj: &PyAny) -> PyResult { + NaiveDate::from_ymd_opt( + get_attr::(obj, "year")?, + get_attr::(obj, "month")?, + get_attr::(obj, "day")?, + ) + .ok_or_else(|| DataError::new_err("invalid date parameter")) +} + +/// Python `datetime.time` -> `chrono::NaiveTime`. +fn py_time_to_chrono(obj: &PyAny) -> PyResult { + let t = NaiveTime::from_hms_micro_opt( + get_attr::(obj, "hour")?, + get_attr::(obj, "minute")?, + get_attr::(obj, "second")?, + get_attr::(obj, "microsecond")?, + ) + .ok_or_else(|| DataError::new_err("invalid time parameter"))?; + // A time with tzinfo != None binds as its naive wall-clock value; Postgres + // has TIMETZ but sqlx's chrono support maps NaiveTime to TIME only, and + // TIMETZ is documented by Postgres itself as mostly for legacy use. + if !obj.getattr("tzinfo")?.is_none() { + return Err(NotSupportedError::new_err( + "bind a tz-aware datetime.time as a TIMESTAMPTZ datetime instead, or strip tzinfo - PostPyro binds times as TIME (no TIMETZ support)", + )); + } + Ok(t) +} + /// Bind a Python parameter list onto a query, one `.bind()` call per /// parameter. sqlx only exposes `.bind()` (not a standalone Arguments /// builder) for the default `Query` returned by @@ -73,21 +291,60 @@ impl<'q> sqlx::Encode<'q, Postgres> for UnspecifiedNull { /// concurrently on the same SQL text): key the cache on (SQL text, argument /// type fingerprint) instead of SQL text alone. /// -/// Anything that isn't `bool`/`int`/`float`/`str`/`None` falls through to -/// `str(obj)` and binds as TEXT (see the `else` arm below) - there is no -/// automatic conversion for `date`/`datetime`/`uuid.UUID`/`dict`/etc., unlike -/// the read side's `pg_value_to_py`. Pass those as strings with an explicit -/// Postgres cast in the SQL text instead (`$1::date`, `$1::uuid`, -/// `$1::jsonb`) - see `tests/type_conversion_bugs.py` for working examples. +/// Beyond the primitives, the types Python users actually hold bind natively +/// (matching sqlx's own type map - see docs.rs/sqlx postgres::types): +/// `datetime.datetime` -> TIMESTAMP/TIMESTAMPTZ, `datetime.date` -> DATE, +/// `datetime.time` -> TIME, `uuid.UUID` -> UUID, `decimal.Decimal` -> +/// NUMERIC, `bytes`/`bytearray`/`memoryview` -> BYTEA. A timezone-aware +/// datetime binds as TIMESTAMPTZ (utc offset applied), a naive one as +/// TIMESTAMP - Postgres stores both correctly without any `$1::type` cast in +/// the SQL text. +/// +/// A `list`/`tuple` of a single primitive type (`bool`/`int`/`float`/`str`, +/// `None` entries allowed) binds as a real Postgres array of that type - see +/// `py_sequence_to_array`. `dict`, and any list/tuple that isn't a +/// homogeneous primitive sequence (mixed types, nesting), binds as +/// JSON/JSONB via `py_to_json` instead. +/// +/// ponytail: like the untyped-NULL and naive/aware-datetime hazards +/// documented above, an array's element type (bool[] vs. int8[] vs. text[]) +/// isn't accounted for in the persistent-statement cache key either - the +/// same SQL text called once with an int array and later with a string +/// array for the same placeholder hits the identical "incorrect binary data +/// format" failure mode. Same upgrade path: key the cache on argument type +/// fingerprint, not SQL text alone, if this becomes a real collision. +/// +/// Anything else still falls through to `str(obj)` and binds as TEXT (see +/// the last arm below) - e.g. pass `INET` values as strings with an +/// explicit `$1::inet` cast. See `tests/native_binding.py` and +/// `tests/native_array_binding.py` for working examples of every +/// natively-bound type. pub fn bind_params<'q>( query: sqlx::query::Query<'q, Postgres, sqlx::postgres::PgArguments>, py: Python, params: &[PyObject], ) -> PyResult> { let mut has_null = false; + // DATE, naive datetime (TIMESTAMP), and aware datetime (TIMESTAMPTZ) are + // three different wire sizes/formats bindable from overlapping Python + // types (date/datetime, and datetime again depending on tzinfo). sqlx's + // cache is keyed on SQL text only (see the long comment above), so a SQL + // text first prepared from one of these types would collide with a later + // call using a different one for the same placeholder - "incorrect + // binary data format" - even though any single call only ever sees one + // of the three. (An earlier version of this flag only caught two + // datetime kinds appearing *together in one call*, which doesn't guard + // the actual common case: separate calls to the same SQL text, one + // naive/aware/date at a time.) Same hazard shape as the NULL case below; + // same fix: drop persistence for every call that binds any of the three. + let mut has_ambiguous_temporal = false; let mut query = query; for obj in params { let obj_ref = obj.as_ref(py); + // ORDER MATTERS: datetime must be checked before date - + // datetime.datetime is a subclass of datetime.date, and isinstance + // (deliberately used here for subclass support) would otherwise bind + // every datetime as a bare DATE, silently dropping its time. if obj.is_none(py) { has_null = true; query = query.bind(UnspecifiedNull); @@ -99,12 +356,184 @@ pub fn bind_params<'q>( query = query.bind(f.extract::()?) } else if let Ok(s) = obj_ref.downcast::() { query = query.bind(s.extract::()?) + } else if py_isinstance(py, obj_ref, &PY_DATETIME, "datetime", "datetime")? { + match py_datetime_to_param(obj_ref)? { + PyDateTimeParam::Timestamp(naive) => { + has_ambiguous_temporal = true; + query = query.bind(naive) + } + PyDateTimeParam::Timestamptz(utc) => { + has_ambiguous_temporal = true; + query = query.bind(utc) + } + } + } else if py_isinstance(py, obj_ref, &PY_DATE, "datetime", "date")? { + has_ambiguous_temporal = true; + let d = py_date_to_chrono(obj_ref)?; + query = query.bind(d) + } else if py_isinstance(py, obj_ref, &PY_TIME, "datetime", "time")? { + let t = py_time_to_chrono(obj_ref)?; + query = query.bind(t) + } else if py_isinstance(py, obj_ref, &PY_UUID, "uuid", "UUID")? { + // Parse from the string form - pyo3 has no FromPyObject for + // uuid::Uuid under this crate's feature set (and extracting via + // an assumed impl would simply not compile). + let u = Uuid::parse_str(&obj_ref.str()?.extract::()?) + .map_err(|e| DataError::new_err(format!("invalid UUID parameter: {}", e)))?; + query = query.bind(u) + } else if py_isinstance(py, obj_ref, &PY_DECIMAL, "decimal", "Decimal")? { + let d = BigDecimal::from_str(&obj_ref.str()?.extract::()?) + .map_err(|e| DataError::new_err(format!("invalid Decimal parameter: {}", e)))?; + query = query.bind(d) + } else if let Some(items) = list_or_tuple_items(obj_ref) { + // A homogeneous bool/int/float/str list/tuple binds as a real + // Postgres array; anything else (mixed types, nested + // containers) falls through to the same JSON conversion a + // dict gets, below - see py_sequence_to_array. + query = match py_sequence_to_array(&items)? { + Some(array) => bind_array(query, array), + None => query.bind(py_to_json(obj_ref)?), + } + } else if obj_ref.downcast::().is_ok() + || obj_ref.downcast::().is_ok() + || obj_ref.downcast::().is_ok() + { + // set/frozenset route through py_to_json purely to hit its + // existing "not JSON-mappable" error arm with the right message + // - neither ever succeeds here, since py_to_json has no match + // arm for either. Without this, a bare set/frozenset parameter + // fell through to the str(obj) TEXT fallback below instead of + // raising - silently binding e.g. "{1, 2, 3}" as text, which + // then corrupts a prepared statement already cached with a + // JSONB-typed placeholder for the same SQL text (Postgres: + // "unsupported jsonb version number", not a catchable DataError). + let json = py_to_json(obj_ref)?; + query = query.bind(json) + } else if let Ok(b) = obj_ref.downcast::() { + // Owned Vec (not the &[u8] borrow): the local borrow can't + // outlive 'q on the returned Query, and sqlx copies into the + // argument buffer either way. + query = query.bind(b.as_bytes().to_vec()) + } else if let Ok(b) = obj_ref.downcast::() { + // to_vec() copies first - see the safety notes on as_bytes(): + // holding the borrow across sqlx's async encode path could race + // a concurrent Python-side resize of the same bytearray. + query = query.bind(b.to_vec()) + } else if py_isinstance(py, obj_ref, &PY_MEMORYVIEW, "builtins", "memoryview")? { + // memoryview sits next to bytes/bytearray in users' minds; + // letting it fall through to the TEXT fallback would silently + // bind the repr ("") as the value. pyo3 0.20 + // has no typed `PyMemoryView` wrapper, so go through the same + // isinstance + method-call route as datetime/uuid/decimal above: + // `tobytes()` is a real memoryview method, no buffer-protocol + // API (also abi3-restricted) needed. + let b = obj_ref.call_method0("tobytes")?; + query = query.bind(b.downcast::()?.as_bytes().to_vec()) } else { let s = obj_ref.str()?.extract::()?; query = query.bind(s) }; } - Ok(query.persistent(!has_null)) + // Non-persistent whenever any parameter's wire type depends on runtime + // context the SQL text doesn't capture: an untyped NULL (OID 0), or any + // DATE/TIMESTAMP/TIMESTAMPTZ binding (see has_ambiguous_temporal above). + Ok(query.persistent(!(has_null || has_ambiguous_temporal))) +} + +fn list_or_tuple_items(obj: &PyAny) -> Option> { + if let Ok(l) = obj.downcast::() { + Some(l.iter().collect()) + } else if let Ok(t) = obj.downcast::() { + Some(t.iter().collect()) + } else { + None + } +} + +/// A homogeneous Python list/tuple, ready to bind as a real Postgres array +/// (`bool[]`/`int8[]`/`float8[]`/`text[]`) instead of falling back to the +/// same JSON conversion a dict gets. `None` elements become SQL NULL entries +/// in the array, matching how a bare `None` parameter already binds as SQL +/// NULL. +enum PyArrayParam { + Bool(Vec>), + Int(Vec>), + Float(Vec>), + Text(Vec>), +} + +/// Classify a Python list/tuple as a homogeneous primitive array, the same +/// way a bare scalar is classified in `bind_params` above (bool before int, +/// since `bool` is a subclass of `int` in Python). Returns `Ok(None)` - not +/// an error - for anything that doesn't cleanly fit one primitive type +/// (mixed element types, a nested list, a dict, ...), so the caller falls +/// back to `py_to_json` for it instead of hard-failing here. +/// +/// ponytail: an all-`None` list has no element type to infer, so it binds as +/// `text[]` (Postgres's own default array elem type is also `text` for an +/// untyped `ARRAY[NULL]` literal) - add an explicit-type escape hatch (cast +/// in the SQL text) if a caller ever needs a typed all-NULL array. +fn py_sequence_to_array(items: &[&PyAny]) -> PyResult> { + #[derive(PartialEq, Clone, Copy)] + enum Kind { + Bool, + Int, + Float, + Text, + } + + let mut kind: Option = None; + for item in items { + if item.is_none() { + continue; + } + let this_kind = if item.downcast::().is_ok() { + Kind::Bool + } else if item.downcast::().is_ok() { + Kind::Int + } else if item.downcast::().is_ok() { + Kind::Float + } else if item.downcast::().is_ok() { + Kind::Text + } else { + return Ok(None); + }; + match kind { + None => kind = Some(this_kind), + Some(k) if k == this_kind => {} + Some(_) => return Ok(None), + } + } + + Ok(Some(match kind { + None => PyArrayParam::Text(items.iter().map(|_| None).collect()), + Some(Kind::Bool) => PyArrayParam::Bool(extract_array(items, |i| i.extract::())?), + Some(Kind::Int) => PyArrayParam::Int(extract_array(items, |i| i.extract::())?), + Some(Kind::Float) => PyArrayParam::Float(extract_array(items, |i| i.extract::())?), + Some(Kind::Text) => PyArrayParam::Text(extract_array(items, |i| i.extract::())?), + })) +} + +fn extract_array( + items: &[&PyAny], + extract: impl Fn(&PyAny) -> PyResult, +) -> PyResult>> { + items + .iter() + .map(|item| if item.is_none() { Ok(None) } else { extract(item).map(Some) }) + .collect() +} + +fn bind_array<'q>( + query: sqlx::query::Query<'q, Postgres, sqlx::postgres::PgArguments>, + array: PyArrayParam, +) -> sqlx::query::Query<'q, Postgres, sqlx::postgres::PgArguments> { + match array { + PyArrayParam::Bool(v) => query.bind(v), + PyArrayParam::Int(v) => query.bind(v), + PyArrayParam::Float(v) => query.bind(v), + PyArrayParam::Text(v) => query.bind(v), + } } /// Decode one column of a type that has both sqlx's wire decode (`Decode`/`Type`) @@ -142,6 +571,17 @@ fn decode_uuid(py: Python, row: &PgRow, idx: usize) -> PyResult { .unwrap_or_else(|| py.None())) } +/// BYTEA -> Python `bytes`. Not `decode_scalar::>` - pyo3's generic +/// `IntoPy` for `Vec` produces a Python `list` of ints (each byte boxed +/// as its own `int` object), not a `bytes` object; `PyBytes::new` is the +/// actual `bytes` conversion. +fn decode_bytea(py: Python, row: &PgRow, idx: usize) -> PyResult { + let value: Option> = row.try_get(idx).map_err(map_db_error)?; + Ok(value + .map(|b| pyo3::types::PyBytes::new(py, &b).into_py(py)) + .unwrap_or_else(|| py.None())) +} + /// pyo3's `chrono` feature (auto `IntoPy` for chrono types) is compiled out /// under `abi3` (`#![cfg(all(feature = "chrono", not(Py_LIMITED_API)))]` in /// pyo3's own source), and so - less obviously - is pyo3's *own* @@ -277,6 +717,16 @@ pub fn pg_value_to_py(py: Python, row: &PgRow, idx: usize) -> PyResult "FLOAT4" => decode_scalar::(py, row, idx), "FLOAT8" => decode_scalar::(py, row, idx), "TEXT" | "VARCHAR" | "CHAR" | "BPCHAR" | "NAME" => decode_scalar::(py, row, idx), + "BYTEA" => decode_bytea(py, row, idx), + "BOOL[]" => decode_scalar::>>(py, row, idx), + "INT2[]" => decode_scalar::>>(py, row, idx), + "INT4[]" => decode_scalar::>>(py, row, idx), + "INT8[]" => decode_scalar::>>(py, row, idx), + "FLOAT4[]" => decode_scalar::>>(py, row, idx), + "FLOAT8[]" => decode_scalar::>>(py, row, idx), + "TEXT[]" | "VARCHAR[]" | "CHAR[]" | "BPCHAR[]" | "NAME[]" => { + decode_scalar::>>(py, row, idx) + } "NUMERIC" => decode_numeric(py, row, idx), "UUID" => decode_uuid(py, row, idx), "TIMESTAMP" => decode_timestamp(py, row, idx), diff --git a/tests/native_array_binding.py b/tests/native_array_binding.py new file mode 100644 index 0000000..8829318 --- /dev/null +++ b/tests/native_array_binding.py @@ -0,0 +1,102 @@ +"""A homogeneous Python list/tuple binds as a real Postgres array +(bool[]/int8[]/float8[]/text[]) instead of being JSON-encoded - see +py_sequence_to_array in src/types.rs. Mixed-type or nested lists, and any +dict, still bind as JSON/JSONB via py_to_json (tests/native_binding.py +covers that path). A bare set/frozenset must raise DataError, not silently +bind as TEXT against a placeholder another call already prepared as JSONB. + +Same fixture/connection pattern as tests/native_binding.py (a local +Postgres on localhost:5433). +""" + +import asyncio + +import PostPyro + + +async def main(): + pool = await PostPyro.connect("postgresql://postgres:postgres@localhost:5433/postgres", max_size=5) + + await pool.execute("DROP TABLE IF EXISTS native_array_binding_test") + await pool.execute( + """ + CREATE TABLE native_array_binding_test ( + id INT4, + ints INT8[], + floats FLOAT8[], + bools BOOL[], + texts TEXT[], + mixed JSONB + ) + """ + ) + + # === Homogeneous arrays round-trip natively, no cast needed === + await pool.execute( + "INSERT INTO native_array_binding_test (id, ints, floats, bools, texts) VALUES ($1, $2, $3, $4, $5)", + [1, [1, 2, 3], [1.5, 2.5], [True, False, True], ["a", "b", "c"]], + ) + row = await pool.query_one("SELECT ints, floats, bools, texts FROM native_array_binding_test WHERE id = 1") + assert row["ints"] == [1, 2, 3] + assert row["floats"] == [1.5, 2.5] + assert row["bools"] == [True, False, True] + assert row["texts"] == ["a", "b", "c"] + + # === Tuples bind the same way as lists === + await pool.execute( + "INSERT INTO native_array_binding_test (id, ints) VALUES ($1, $2)", + [2, (10, 20, 30)], + ) + row = await pool.query_one("SELECT ints FROM native_array_binding_test WHERE id = 2") + assert row["ints"] == [10, 20, 30] + + # === None entries inside a list become SQL NULL array elements === + await pool.execute( + "INSERT INTO native_array_binding_test (id, ints) VALUES ($1, $2)", + [3, [1, None, 3]], + ) + row = await pool.query_one("SELECT ints FROM native_array_binding_test WHERE id = 3") + assert row["ints"] == [1, None, 3] + + # === Empty list binds as an empty array === + await pool.execute( + "INSERT INTO native_array_binding_test (id, texts) VALUES ($1, $2)", + [4, []], + ) + row = await pool.query_one("SELECT texts FROM native_array_binding_test WHERE id = 4") + assert row["texts"] == [] + + # === Mixed-type list falls back to JSON, not TEXT and not an array === + await pool.execute( + "INSERT INTO native_array_binding_test (id, mixed) VALUES ($1, $2)", + [5, [1, "two", 3.0]], + ) + row = await pool.query_one("SELECT mixed FROM native_array_binding_test WHERE id = 5") + assert row["mixed"] == [1, "two", 3.0] + + # === Nested list falls back to JSON too (not a flat array) === + await pool.execute( + "INSERT INTO native_array_binding_test (id, mixed) VALUES ($1, $2)", + [6, [[1, 2], [3, 4]]], + ) + row = await pool.query_one("SELECT mixed FROM native_array_binding_test WHERE id = 6") + assert row["mixed"] == [[1, 2], [3, 4]] + + # === A bare set/frozenset raises DataError, doesn't corrupt a JSONB + # placeholder already cached from the inserts above === + for bad in ({1, 2, 3}, frozenset({1, 2})): + try: + await pool.execute( + "INSERT INTO native_array_binding_test (id, mixed) VALUES ($1, $2)", + [99, bad], + ) + raise AssertionError(f"expected DataError for {type(bad).__name__} parameter") + except PostPyro.DataError: + pass + + await pool.close() + print("native_array_binding.py: all assertions passed") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/native_binding.py b/tests/native_binding.py new file mode 100644 index 0000000..6509abb --- /dev/null +++ b/tests/native_binding.py @@ -0,0 +1,304 @@ +"""Integration tests: native Python -> Postgres binding for non-primitive types. + +Before this feature, only bool/int/float/str/None bound natively; a +datetime/uuid/Decimal/dict parameter forced str() + a manual $1::type cast +in the SQL text. These tests pin the native paths end to end against a real +Postgres: write with a native Python object, read back through the decoder, +and assert the round trip is exact. + +Same fixture/connection pattern as tests/pool_and_row.py (local Postgres on +localhost:5433). +""" + +import asyncio +import datetime +import decimal +import uuid as uuid_module + +import PostPyro + + +async def main(): + pool = await PostPyro.connect("postgresql://postgres:postgres@localhost:5433/postgres", max_size=5) + + await pool.execute("DROP TABLE IF EXISTS native_binding_test") + await pool.execute( + """ + CREATE TABLE native_binding_test ( + id INT4, + ts TIMESTAMP, + tstz TIMESTAMPTZ, + d DATE, + t TIME, + uid UUID, + price NUMERIC, + j JSONB, + data BYTEA + ) + """ + ) + + # === datetime / date / time / uuid / Decimal / dict / bytes bind natively, + # with NO ::type casts in the SQL text === + naive_dt = datetime.datetime(2024, 3, 15, 10, 30, 0, 123456) + aware_dt = datetime.datetime( + 2024, 3, 15, 10, 30, 0, 123456, tzinfo=datetime.timezone(datetime.timedelta(hours=2), "EET") + ) + test_date = datetime.date(2024, 3, 15) + test_time = datetime.time(10, 30, 0, 123456) + test_uuid = uuid_module.uuid4() + test_price = decimal.Decimal("1234.56") + test_json = {"name": "John", "scores": [85, 92, 78], "nested": {"ok": True, "n": None}} + test_bytes = b"\x00\x01\xff binary \x00 payload" + + await pool.execute( + """ + INSERT INTO native_binding_test (id, ts, tstz, d, t, uid, price, j, data) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + """, + [1, naive_dt, aware_dt, test_date, test_time, test_uuid, test_price, test_json, test_bytes], + ) + + row = await pool.query_one("SELECT * FROM native_binding_test WHERE id = $1", [1]) + + # TIMESTAMP round trip, exact to the microsecond + assert row["ts"] == naive_dt, f"naive datetime round trip: {row['ts']!r}" + assert row["ts"].tzinfo is None + + # TIMESTAMPTZ: aware datetime stored as UTC instant; Postgres returns it + # in the session timezone (UTC here) - compare instants, not wall clocks. + got_tstz = row["tstz"] + assert got_tstz.tzinfo is not None, f"expected aware datetime, got {got_tstz!r}" + assert got_tstz.utcoffset() == datetime.timedelta(0), got_tstz.utcoffset() + assert got_tstz == aware_dt.astimezone(datetime.timezone.utc), ( + f"aware datetime instant changed: sent {aware_dt!r}, got {got_tstz!r}" + ) + + assert row["d"] == test_date, f"date round trip: {row['d']!r}" + assert type(row["d"]) is datetime.date + assert row["t"] == test_time, f"time round trip: {row['t']!r}" + assert row["uid"] == str(test_uuid), f"uuid round trip: {row['uid']!r}" # decode side is str + assert row["price"] == test_price, f"decimal round trip: {row['price']!r}" + assert isinstance(row["price"], decimal.Decimal) + assert row["j"] == test_json, f"json round trip: {row['j']!r}" + assert row["data"] == test_bytes, f"bytea round trip: {row['data']!r}" + assert isinstance(row["data"], bytes) + + # === Native types usable in WHERE clauses too, not just INSERTs === + hit = await pool.query_one("SELECT id FROM native_binding_test WHERE uid = $1", [test_uuid]) + assert hit is not None and hit["id"] == 1, "uuid used as a query parameter" + hit = await pool.query_one("SELECT id FROM native_binding_test WHERE tstz = $1", [aware_dt]) + assert hit is not None and hit["id"] == 1, "aware datetime used as a query parameter" + + # === Aware datetime with a non-UTC offset converts to the same instant === + await pool.execute("DELETE FROM native_binding_test WHERE id = 2") + await pool.execute( + "INSERT INTO native_binding_test (id, tstz) VALUES ($1, $2)", + [2, aware_dt.astimezone(datetime.timezone(datetime.timedelta(hours=-5)))], + ) + row2 = await pool.query_one("SELECT tstz FROM native_binding_test WHERE id = 2") + assert row2["tstz"] == aware_dt.astimezone(datetime.timezone.utc), row2["tstz"] + + # === json.dumps compatibility: dict/list/tuple/None mix, tuple -> JSON array === + await pool.execute("DELETE FROM native_binding_test WHERE id = 3") + await pool.execute( + "INSERT INTO native_binding_test (id, j) VALUES ($1, $2)", + [3, {"list": [1, 2.5, "x", None, True], "tuple": (1, 2), "int_key": {1: "one"}}], + ) + row3 = await pool.query_one("SELECT j FROM native_binding_test WHERE id = 3") + assert row3["j"] == {"list": [1, 2.5, "x", None, True], "tuple": [1, 2], "int_key": {"1": "one"}}, row3["j"] + + # === bytearray and empty bytes bind as BYTEA === + await pool.execute("DELETE FROM native_binding_test WHERE id = 4") + ba = bytearray(b"mutable!") + await pool.execute("INSERT INTO native_binding_test (id, data) VALUES ($1, $2)", [4, ba]) + row4 = await pool.query_one("SELECT data FROM native_binding_test WHERE id = 4") + assert row4["data"] == b"mutable!", row4["data"] + # Mutating the bytearray AFTER the call must not corrupt what was sent. + ba[0] = ord("X") + row4b = await pool.query_one("SELECT data FROM native_binding_test WHERE id = 4") + assert row4b["data"] == b"mutable!", row4b["data"] + + await pool.execute("DELETE FROM native_binding_test WHERE id = 5") + await pool.execute("INSERT INTO native_binding_test (id, data) VALUES ($1, $2)", [5, b""]) + row5 = await pool.query_one("SELECT data FROM native_binding_test WHERE id = 5") + assert row5["data"] == b"", row5["data"] + + # === Within a transaction as well === + tx = await pool.transaction() + async with tx: + await tx.execute( + "INSERT INTO native_binding_test (id, ts, uid, price) VALUES ($1, $2, $3, $4)", + [10, naive_dt, test_uuid, test_price], + ) + row_tx = await pool.query_one("SELECT ts, uid, price FROM native_binding_test WHERE id = 10") + assert row_tx["ts"] == naive_dt and row_tx["uid"] == str(test_uuid) and row_tx["price"] == test_price + + # === DST: same aware wall-clock in a UTC+2 zone at both offsets must + # land on different instants, and each must round trip exactly. This is + # the test that catches calling tzinfo.utcoffset() without the datetime + # (wrong object - TypeError) or ignoring the offset entirely. + zone_plus2 = datetime.timezone(datetime.timedelta(hours=2), "UTC+2") + summer = datetime.datetime(2024, 7, 15, 12, 0, 0, tzinfo=zone_plus2) + winter = datetime.datetime(2024, 1, 15, 12, 0, 0, tzinfo=zone_plus2) + assert summer.utcoffset() == winter.utcoffset(), "fixed-offset zone sanity" + await pool.execute("DELETE FROM native_binding_test WHERE id IN (6, 7)") + await pool.execute( + "INSERT INTO native_binding_test (id, tstz) VALUES ($1, $2), ($3, $4)", + [6, summer, 7, winter], + ) + for rid, sent in ((6, summer), (7, winter)): + got = (await pool.query_one("SELECT tstz FROM native_binding_test WHERE id = $1", [rid]))["tstz"] + assert got == sent.astimezone(datetime.timezone.utc), f"id={rid}: sent {sent!r}, got {got!r}" + + # A real DST-observing zone (us/Eastern-like via two fixed offsets, since + # zoneinfo may be absent on some wheels): offset differs by month, and + # each datetime must convert using ITS OWN offset. + dst_summer = datetime.datetime(2024, 7, 15, 12, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(hours=-4))) + dst_winter = datetime.datetime(2024, 1, 15, 12, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(hours=-5))) + assert dst_summer.utcoffset() != dst_winter.utcoffset() + await pool.execute("DELETE FROM native_binding_test WHERE id IN (8, 9)") + await pool.execute( + "INSERT INTO native_binding_test (id, tstz) VALUES ($1, $2), ($3, $4)", + [8, dst_summer, 9, dst_winter], + ) + for rid, sent in ((8, dst_summer), (9, dst_winter)): + got = (await pool.query_one("SELECT tstz FROM native_binding_test WHERE id = $1", [rid]))["tstz"] + assert got == sent.astimezone(datetime.timezone.utc), f"id={rid}: sent {sent!r}, got {got!r}" + + # === datetime.min / datetime.max extremes round trip exactly === + await pool.execute("DELETE FROM native_binding_test WHERE id = 10") + await pool.execute( + "INSERT INTO native_binding_test (id, ts) VALUES ($1, $2)", + [10, datetime.datetime.min], + ) + got = (await pool.query_one("SELECT ts FROM native_binding_test WHERE id = 10"))["ts"] + assert got == datetime.datetime.min, got + await pool.execute("UPDATE native_binding_test SET ts = $1 WHERE id = 10", [datetime.datetime.max]) + got = (await pool.query_one("SELECT ts FROM native_binding_test WHERE id = 10"))["ts"] + assert got == datetime.datetime.max, got + + # === Decimal extremes: high precision, trailing zeros preserved through + # NUMERIC (read back equal, not float-comparable) === + await pool.execute("DELETE FROM native_binding_test WHERE id = 11") + hi_prec = decimal.Decimal("1234567890.123456789012345678901234567890") + trailing = decimal.Decimal("10.500") + await pool.execute( + "INSERT INTO native_binding_test (id, price) VALUES ($1, $2), ($3, $4)", + [11, hi_prec, 12, trailing], + ) + got = (await pool.query_one("SELECT price FROM native_binding_test WHERE id = 11"))["price"] + assert got == hi_prec, f"high-precision decimal: {got!r}" + got = (await pool.query_one("SELECT price FROM native_binding_test WHERE id = 12"))["price"] + assert got == trailing, f"trailing zeros: {got!r}" + + # === A datetime subclass binds as a datetime (isinstance-based check), + # and deep-but-legal JSON nesting works === + class MyDatetime(datetime.datetime): + pass + + await pool.execute("DELETE FROM native_binding_test WHERE id = 13") + await pool.execute( + "INSERT INTO native_binding_test (id, ts) VALUES ($1, $2)", + [13, MyDatetime(2024, 3, 15, 10, 30, 0, 123456)], + ) + got = (await pool.query_one("SELECT ts FROM native_binding_test WHERE id = 13"))["ts"] + assert got == naive_dt, f"datetime subclass: {got!r}" + + deep = current = {} + for i in range(100): + current["child"] = {} + current = current["child"] + await pool.execute("DELETE FROM native_binding_test WHERE id = 14") + await pool.execute("INSERT INTO native_binding_test (id, j) VALUES ($1, $2)", [14, deep]) + got = (await pool.query_one("SELECT j FROM native_binding_test WHERE id = 14"))["j"] + assert got == deep, "100-level nested JSON" + + # === memoryview binds as BYTEA (repr must never leak in as TEXT) === + await pool.execute("DELETE FROM native_binding_test WHERE id = 15") + await pool.execute( + "INSERT INTO native_binding_test (id, data) VALUES ($1, $2)", + [15, memoryview(b"through memoryview")], + ) + got = (await pool.query_one("SELECT data FROM native_binding_test WHERE id = 15"))["data"] + assert got == b"through memoryview", got + + # === A JSON parameter nested past the guard fails loudly (no stack + # overflow / abort), and an object with a self-referential dict too === + beyond = current = {} + for _ in range(200): + current["child"] = {} + current = current["child"] + try: + await pool.execute("INSERT INTO native_binding_test (id, j) VALUES ($1, $2)", [99, beyond]) + raise AssertionError("expected DataError for over-deep JSON nesting") + except PostPyro.DataError: + pass + + selfref = {} + selfref["self"] = selfref + try: + await pool.execute("INSERT INTO native_binding_test (id, j) VALUES ($1, $2)", [99, selfref]) + raise AssertionError("expected DataError for a self-referential dict") + except PostPyro.DataError: + pass + + # === Separate calls to the same SQL text, one aware then one naive, + # must not poison sqlx's prepared-statement cache (TIMESTAMP vs + # TIMESTAMPTZ wire types for the same parameter slot) === + await pool.execute("DELETE FROM native_binding_test WHERE id IN (16, 17)") + await pool.execute( + "INSERT INTO native_binding_test (id, tstz) VALUES ($1, $2)", + [16, datetime.datetime(2024, 3, 15, 10, 30, 0, tzinfo=datetime.timezone.utc)], + ) + # Same SQL text, now naive (different wire type, same parameter slot). + # With the non-persistent fix this re-prepares; without it Postgres + # rejects the bind or silently misreads the value. + await pool.execute( + "INSERT INTO native_binding_test (id, tstz) VALUES ($1, $2)", + [17, datetime.datetime(2024, 3, 16, 10, 30, 0)], + ) + got16 = (await pool.query_one("SELECT tstz FROM native_binding_test WHERE id = 16"))["tstz"] + got17 = (await pool.query_one("SELECT tstz FROM native_binding_test WHERE id = 17"))["tstz"] + assert got16 == datetime.datetime(2024, 3, 15, 10, 30, 0, tzinfo=datetime.timezone.utc), got16 + # Naive into timestamptz is interpreted in the session zone (UTC here). + assert got17 == datetime.datetime(2024, 3, 16, 10, 30, 0, tzinfo=datetime.timezone.utc), got17 + + # === Loud failures, not silent corruption === + # A time with tzinfo has no native TIME mapping - NotSupportedError, not + # a silent wall-clock interpretation. + try: + await pool.execute( + "INSERT INTO native_binding_test (id, t) VALUES ($1, $2)", + [99, datetime.time(10, 30, tzinfo=datetime.timezone.utc)], + ) + raise AssertionError("expected NotSupportedError for tz-aware time") + except PostPyro.NotSupportedError: + pass + + # A JSON int beyond exact i64/u64 range must fail, not degrade to float. + try: + await pool.execute( + "INSERT INTO native_binding_test (id, j) VALUES ($1, $2)", + [99, {"big": 2**64 + 1}], + ) + raise AssertionError("expected DataError for out-of-range JSON integer") + except PostPyro.DataError: + pass + + # A JSON-incompatible top-level object fails naming the type. + try: + await pool.execute( + "INSERT INTO native_binding_test (id, j) VALUES ($1, $2)", + [99, {1, 2, 3}], # a set is not JSON-mappable + ) + raise AssertionError("expected DataError for a set parameter") + except PostPyro.DataError: + pass + + await pool.execute("DROP TABLE native_binding_test") + await pool.close() + print("OK: native binding (datetime/date/time/uuid/Decimal/JSON/bytes) verified") + + +asyncio.run(main())