From de35a8626267e7ec63ce13e8a45c42c1a67b096f Mon Sep 17 00:00:00 2001 From: Arun Sharma Date: Wed, 30 Sep 2026 18:05:15 -0700 Subject: [PATCH] fix(connection): segregate implicit prepare cache by full list element types _pybind_value_signature keyed list params on the first element only, while C++ prepares the max logical type over ALL elements. Calls whose first row was narrow reused a stale narrower plan, silently dropping rows or truncating values (300 bound as 44, Refs #57). Aggregate element signatures across the whole list (max int width, merged struct fields), mirroring C++ tryGetMaxLogicalType; homogeneous int lists still key as INT64 like pyHomogeneousListType. --- src_py/connection.py | 56 ++++++++++++++++++++-- test/test_pybind_implicit_prepare_cache.py | 21 ++++++++ 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/src_py/connection.py b/src_py/connection.py index 8ca77b3..b528195 100644 --- a/src_py/connection.py +++ b/src_py/connection.py @@ -247,12 +247,58 @@ def _pybind_value_signature(value: Any) -> tuple: tuple((str(k), _pybind_value_signature(v)) for k, v in items), ) if isinstance(value, (list, tuple)): - homogeneous = _pybind_homogeneous_list_signature(value) - if homogeneous is not None: - return homogeneous - if not value: + int_width_order = { + "int8": 0, + "uint8": 1, + "int16": 2, + "uint16": 3, + "int32": 4, + "uint32": 5, + "int64": 6, + } + + def _merge(a: tuple, b: tuple) -> tuple: + # Any/unknown poisoning: fall back to first-seen (keeps old key). + if a == b: + return a + if a == ("any",) or b == ("any",): + return ("any",) + if ( + len(a) == 1 + and len(b) == 1 + and a[0] in int_width_order + and b[0] in int_width_order + ): + # Mirror C++ tryGetMaxLogicalType over list children: the + # prepared type is the max over ALL elements, so the cache key + # must reflect the widest int, not the first element's width. + return a if int_width_order[a[0]] >= int_width_order[b[0]] else b + if a[0] == "list" and b[0] == "list" and len(a) == 2 and len(b) == 2: + return ("list", _merge(a[1], b[1])) + if a[0] == "struct" and b[0] == "struct": + fa, fb = dict(a[1]), dict(b[1]) + if set(fa) != set(fb): + return ("struct", tuple(sorted(set(fa) | set(fb)))) + return ("struct", tuple((k, _merge(fa[k], fb[k])) for k in fa)) + if a[0] == "map" and b[0] == "map": + return ("map", _merge(a[1], b[1]), _merge(a[2], b[2])) + # Heterogeneous / unmergeable: keep first-seen (old behaviour). + return a + + merged: tuple | None = None + for item in value: + if item is None: + continue + sig = _pybind_value_signature(item) + merged = sig if merged is None else _merge(merged, sig) + if merged is None: return ("list", ("any",)) - return ("list", _pybind_value_signature(value[0])) + # Mirror C++ pyHomogeneousListType: a homogeneous int/bool/float list + # prepares as LIST(INT64)/LIST(BOOL)/LIST(DOUBLE) regardless of value + # widths, so normalise the key the same way to avoid duplicate prepares. + if len(merged) == 1 and merged[0] in int_width_order: + return ("list", ("int64",)) + return ("list", merged) return ("unknown", type(value).__name__) diff --git a/test/test_pybind_implicit_prepare_cache.py b/test/test_pybind_implicit_prepare_cache.py index e0466ca..1916580 100644 --- a/test/test_pybind_implicit_prepare_cache.py +++ b/test/test_pybind_implicit_prepare_cache.py @@ -220,3 +220,24 @@ def test_pybind_implicit_prepare_reuses_same_struct_shape( {"st": {"key1": [1, 2, 3], "value": [3, 7, 98]}}, {"st": {"key1": [4, 5, 6], "value": [7, 8, 9]}}, ] + + +def test_pybind_implicit_prepare_segregates_struct_list_by_int_width( + fake_pybind_connection: _FakePybindConnection, +) -> None: + """ + Regression test for issue #57. + + A list-of-struct parameter whose later rows need a wider int type than + the first call's must not reuse the narrower cached statement (values + were silently dropped/truncated through the stale plan). + """ + conn = lb.Connection(_FakeDatabase()) + + conn.execute("UNWIND $rows AS r RETURN r.d", {"rows": [{"d": 0}]}) + conn.execute("UNWIND $rows AS r RETURN r.d", {"rows": [{"d": 1}, {"d": 128}]}) + conn.execute("UNWIND $rows AS r RETURN r.d", {"rows": [{"d": 2}, {"d": 128}]}) + conn.execute("UNWIND $rows AS r RETURN r.d", {"rows": [{"d": 4}, {"d": 300}]}) + + # int8 -> uint8 -> uint8 (reuse) -> int16 : 3 prepared statements. + assert len(fake_pybind_connection.prepare_calls) == 3