Skip to content

Commit c4dc58b

Browse files
committed
fix: add schema fingerprint to pointer cache key for in-place mutation safety
Include a schema fingerprint (shape + column names + dtypes for pandas, field names + types for pyarrow/polars) in the prepared-statement cache key for pointer-type parameters. Without this, mutating a DataFrame in place between execute() calls (e.g. df.drop(columns=[...], inplace=True)) would reuse the cached prepared statement compiled for the original schema, causing data corruption. The cache key tuple expands from 4 to 5 elements: ("pointer", module, type_name, gen) -> ("pointer", module, type_name, gen, schema_fp) The fingerprint is computed fresh on every call; two objects with the same identity and schema share the cache entry, while schema mutations produce a different key and trigger a fresh prepare.
1 parent 93fd1d7 commit c4dc58b

1 file changed

Lines changed: 28 additions & 2 deletions

File tree

src_py/connection.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,31 @@ def _capi_param_signature(parameters: dict[str, Any]) -> tuple:
128128
_pybind_pointer_tracker: dict[int, tuple[int, weakref.ref]] = {}
129129

130130

131+
def _get_pointer_schema_fingerprint(value: Any) -> int:
132+
"""
133+
Compute a hash of the schema for a pandas/pyarrow/polars object.
134+
135+
Two objects with the same shape, column names, and column dtypes
136+
produce the same fingerprint. If the object is mutated in place
137+
(e.g. columns dropped, renamed, or dtypes changed) the fingerprint
138+
changes, causing a cache miss so a fresh prepared statement is
139+
compiled for the new schema.
140+
"""
141+
module_name = type(value).__module__
142+
if module_name == "pandas.core.frame":
143+
cols = list(value.columns)
144+
return hash(
145+
(value.shape, tuple(cols), tuple(str(value[col].dtype) for col in cols))
146+
)
147+
if module_name.startswith("pyarrow"):
148+
schema = value.schema
149+
return hash(tuple((f.name, str(f.type)) for f in schema))
150+
if module_name.startswith("polars"):
151+
schema = value.schema
152+
return hash(tuple(sorted((name, str(dtype)) for name, dtype in schema.items())))
153+
return 0
154+
155+
131156
def _pybind_int_signature(value: int) -> tuple[str]:
132157
if -(2**7) <= value <= 2**7 - 1:
133158
return ("int8",)
@@ -188,19 +213,20 @@ def _pybind_value_signature(value: Any) -> tuple:
188213
if module_name.startswith(("pandas", "polars", "pyarrow")):
189214
global _pybind_pointer_gen, _pybind_pointer_tracker
190215
obj_id = id(value)
216+
fp = _get_pointer_schema_fingerprint(value)
191217
entry = _pybind_pointer_tracker.get(obj_id)
192218
if entry is not None:
193219
gen, weak = entry
194220
if weak() is not None:
195-
return ("pointer", module_name, type(value).__name__, gen)
221+
return ("pointer", module_name, type(value).__name__, gen, fp)
196222
# Original object was GC'd and a new object now occupies the
197223
# same memory address. Purge the stale entry and fall
198224
# through to assign a fresh generation.
199225
del _pybind_pointer_tracker[obj_id]
200226
_pybind_pointer_gen += 1
201227
gen = _pybind_pointer_gen
202228
_pybind_pointer_tracker[obj_id] = (gen, weakref.ref(value))
203-
return ("pointer", module_name, type(value).__name__, gen)
229+
return ("pointer", module_name, type(value).__name__, gen, fp)
204230
if isinstance(value, dict):
205231
items = list(value.items())
206232
if (

0 commit comments

Comments
 (0)