Skip to content

Commit 05defac

Browse files
committed
Handle unresolved JSON parameter types
Serialize to_json parameters only when Python JSON values contain unresolved type shapes such as empty lists, empty dicts, or nulls. This avoids LIST<ANY> failures while keeping homogeneous arrays on the normal typed binding path.
1 parent d5fdcc4 commit 05defac

2 files changed

Lines changed: 112 additions & 3 deletions

File tree

‎src_py/connection.py‎

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,12 +155,84 @@ def _normalize_parameters_for_capi(
155155
normalized_params[key] = "".join(f"\\x{byte:02x}" for byte in binary)
156156
pattern = rf"(?i)(?<!BLOB\()\${re.escape(key)}\b"
157157
normalized_query = re.sub(pattern, f"BLOB(${key})", normalized_query)
158-
elif isinstance(value, str):
159-
pattern = rf"(?i)\bto_json\(\s*\${re.escape(key)}\s*\)"
160-
if re.search(pattern, normalized_query) is not None:
158+
else:
159+
pattern = self._to_json_parameter_pattern(key)
160+
has_to_json_param = re.search(pattern, normalized_query) is not None
161+
if isinstance(value, str) and has_to_json_param:
161162
json.loads(value)
162163
normalized_params[key] = get_capi_module().CAPIJsonParameter(value)
163164
normalized_query = re.sub(pattern, f"${key}", normalized_query)
165+
elif (
166+
has_to_json_param
167+
and self._is_json_serializable_parameter(value)
168+
and self._contains_unresolved_json_type(value)
169+
):
170+
normalized_params[key] = get_capi_module().CAPIJsonParameter(
171+
json.dumps(value, allow_nan=False)
172+
)
173+
normalized_query = re.sub(pattern, f"${key}", normalized_query)
174+
175+
return normalized_query, normalized_params
176+
177+
@staticmethod
178+
def _to_json_parameter_pattern(key: str) -> str:
179+
return rf"(?i)\bto_json\(\s*\${re.escape(key)}\s*\)"
180+
181+
@staticmethod
182+
def _is_json_serializable_parameter(value: Any) -> bool:
183+
return value is None or isinstance(value, (bool, int, float, list, tuple, dict))
184+
185+
@classmethod
186+
def _contains_unresolved_json_type(cls, value: Any) -> bool:
187+
if value is None:
188+
return True
189+
if isinstance(value, (list, tuple)):
190+
return len(value) == 0 or any(
191+
cls._contains_unresolved_json_type(item) for item in value
192+
)
193+
if isinstance(value, dict):
194+
return len(value) == 0 or any(
195+
cls._contains_unresolved_json_type(item) for item in value.values()
196+
)
197+
return False
198+
199+
@staticmethod
200+
def _json_string_literal(value: str) -> str:
201+
return "'" + value.replace("\\", "\\\\").replace("'", "\\u0027") + "'"
202+
203+
def _normalize_parameters_for_pybind(
204+
self,
205+
query: str,
206+
parameters: dict[str, Any],
207+
) -> tuple[str, dict[str, Any]]:
208+
normalized_query = query
209+
normalized_params = dict(parameters)
210+
211+
for key, value in list(normalized_params.items()):
212+
if not isinstance(key, str):
213+
msg = f"Parameter name must be of type string but got {type(key)}"
214+
raise RuntimeError(msg) # noqa: TRY004
215+
216+
pattern = self._to_json_parameter_pattern(key)
217+
if re.search(pattern, normalized_query) is None:
218+
continue
219+
if isinstance(value, str):
220+
json.loads(value)
221+
json_value = value
222+
elif self._is_json_serializable_parameter(
223+
value
224+
) and self._contains_unresolved_json_type(value):
225+
json_value = json.dumps(value, allow_nan=False)
226+
else:
227+
continue
228+
json_expr = f"CAST({self._json_string_literal(json_value)} AS JSON)"
229+
normalized_query = re.sub(
230+
pattern,
231+
lambda _, json_expr=json_expr: json_expr,
232+
normalized_query,
233+
)
234+
if re.search(rf"\${re.escape(key)}\b", normalized_query) is None:
235+
normalized_params.pop(key, None)
164236

165237
return normalized_query, normalized_params
166238

@@ -378,6 +450,7 @@ def _execute_with_pybind(
378450
if len(parameters) == 0:
379451
return py_connection.query(query)
380452

453+
query, parameters = self._normalize_parameters_for_pybind(query, parameters)
381454
prepared = py_connection.prepare(query, parameters)
382455
return py_connection.execute(prepared, parameters)
383456

‎test/test_json.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,39 @@ def test_to_json_string_param_roundtrip(conn_db_empty: ConnDB) -> None:
6060

6161
response_data = json.loads(response.rows_as_dict().get_all()[0]["meta"])
6262
assert response_data == data
63+
64+
65+
def test_to_json_python_param_with_empty_nested_list(conn_db_empty: ConnDB) -> None:
66+
conn, _ = conn_db_empty
67+
conn.execute("""
68+
CREATE NODE TABLE User (id SERIAL PRIMARY KEY, meta JSON);
69+
""")
70+
71+
data = {"tags": []}
72+
73+
response = conn.execute(
74+
"""
75+
CREATE (n:User {meta: to_json($meta)})
76+
RETURN n.id as id, cast(n.meta AS STRING) as meta;
77+
""",
78+
parameters={"meta": data},
79+
)
80+
81+
response_data = json.loads(response.rows_as_dict().get_all()[0]["meta"])
82+
assert response_data == data
83+
84+
85+
def test_to_json_python_param_with_homogeneous_list_uses_typed_binding(
86+
conn_db_empty: ConnDB,
87+
) -> None:
88+
conn, _ = conn_db_empty
89+
query = "CREATE (n:User {meta: to_json($meta)})"
90+
parameters = {"meta": {"tags": [1, 2, 3]}}
91+
92+
normalized_query, normalized_parameters = conn._normalize_parameters_for_pybind(
93+
query,
94+
parameters,
95+
)
96+
97+
assert normalized_query == query
98+
assert normalized_parameters == parameters

0 commit comments

Comments
 (0)