From 755385043bf0f4fd238fa7adb31fb834d87f6387 Mon Sep 17 00:00:00 2001 From: Taewoon Kim Date: Sun, 1 Nov 2026 22:47:58 +0900 Subject: [PATCH 1/3] Refuse a lone dict for a statement that has only positional ? placeholders A lone dict (also [d] and (d,)) is the named-parameter map on both builds. The engine's Map overloads bind a positional ? with no key to NULL and nothing raises (ArcadeData/arcadedb#11098), so INSERT ... SET v = ? with {"a": 1} stored a record without v. refuse_dict_for_positionals (called first from refuse_surplus_positionals, which core.py and _native/database.py both call) raises TypeError for SQL and SQLScript when the text has a ? and no :name or $name outside string literals/backtick identifiers and no key is a digit string ({"0": v} keeps binding slot 0 by key). Tests in tests/test_dict_with_positional.py; Parameters docs section updated. Co-Authored-By: Claude Sonnet 5.5 --- .../pages/how-to/databases-and-queries.adoc | 6 +- src/arcadedb/_params.py | 62 +++++++++ tests/test_dict_with_positional.py | 121 ++++++++++++++++++ 3 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 tests/test_dict_with_positional.py diff --git a/docs/modules/ROOT/pages/how-to/databases-and-queries.adoc b/docs/modules/ROOT/pages/how-to/databases-and-queries.adoc index 8c644be..56a9f48 100644 --- a/docs/modules/ROOT/pages/how-to/databases-and-queries.adoc +++ b/docs/modules/ROOT/pages/how-to/databases-and-queries.adoc @@ -207,7 +207,7 @@ total: 2 Bind values as parameters instead of pasting them into the statement text. Identifiers such as type and property names cannot be bound and belong in the text. * Positional: one `?` per value, from the extra arguments (`db.query("sql", q, "Alice", 25)`) or from one list or tuple (`db.query("sql", q, ["Alice", 25])`). -* Named: `:name` in the statement and a dict as the one extra argument. A named parameter can hold a collection, as in `age IN :ages`. +* Named: `:name` in the statement and a dict as the one extra argument. A dict is always the named map, never one positional value. A named parameter can hold a collection, as in `age IN :ages`. * `None` binds as null. A list or tuple on its own is the positional list, so it never binds as one parameter. `db.query("sql", "SELECT FROM V WHERE @rid IN ?", [rid_a, rid_b])` has two values for one `?`. The bindings raise `TypeError` before the statement runs when a SQL or SQLScript call has more positional values than the statement can bind, and since 26.11.1 the engine refuses a mismatched count too (https://github.com/ArcadeData/arcadedb/issues/10721[ArcadeData/arcadedb#10721]). Older engines bound `rid_a`, dropped `rid_b` without a word and returned one row where two were meant. To bind a list as one parameter, bind it by name or wrap it so it is the one positional value: @@ -218,6 +218,10 @@ db.query("sql", "SELECT FROM V WHERE @rid IN :ids", {"ids": [rid_a, rid_b]}) db.query("sql", "SELECT FROM V WHERE @rid IN ?", [[rid_a, rid_b]]) ---- +A dict on its own (also `[d]` and `(d,)`) is the named map, so it cannot fill a `?`. The engine binds a `?` that has no key in the map to null and does not fail: `db.command("sql", "INSERT INTO T SET v = ?", {"a": 1})` would store a record with no `v`. The bindings raise `TypeError` before the statement runs when a SQL or SQLScript call passes a lone dict and the statement has a `?` and no `:name` or `$name` outside string literals. To fix the call, pass a list for a `?` (`[value]`), or write `:name` in the statement and pass `{"name": value}`. A dict that you mean as one value goes in a list together with another positional value, or is bound by name. A dict with a digit-string key (`{"0": value}`) binds positional slot 0 by key, as the engine does, and is not refused. + +The exact-count rule below is about positional values (a list, a tuple or separate arguments); it does not cover a dict. + The bindings' check counts the `?`, `$` and `:` characters of the SQL text, so its count is an upper bound: a `?` inside a string literal counts as a placeholder, and a call that binds is never refused. The engine counts exactly: from 26.11.1 it also refuses fewer values than placeholders, with an error that names both counts (an `ArcadeDBError` wrapping the engine's `CommandSQLParsingException`), where older engines bound null for the missing ones. Pass `None` for a placeholder you mean as null. `db.query` and `db.command` run the bindings' check; the async executor does not, so there the engine's error reaches the error callback. ==== A query vector diff --git a/src/arcadedb/_params.py b/src/arcadedb/_params.py index b0399ed..a763363 100644 --- a/src/arcadedb/_params.py +++ b/src/arcadedb/_params.py @@ -11,9 +11,69 @@ wrongly). A call with more values than that bound has a value no placeholder can take. """ +from collections.abc import Mapping from functools import lru_cache _COUNTED_LANGUAGES = frozenset(("sql", "sqlscript")) +_NAME_START = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_") + + +@lru_cache(maxsize=1024) +def _placeholder_kinds(command: str) -> tuple: + """``(has_positional, has_named)``: a ``?`` and a ``:name`` / ``$name`` in the SQL text outside quotes. + + Skips single- and double-quoted string literals (a backslash escapes the next character, a doubled quote closes and reopens) and + backtick identifiers, so a ``?`` or ``:x`` inside them counts for nothing. It is a scanner, not a SQL parser: comments are not + understood, and ``$1`` (a numbered positional) is neither kind. + """ + positional = named = False + i, n = 0, len(command) + while i < n: + ch = command[i] + if ch in "'\"`": + i += 1 + while i < n and command[i] != ch: + i += 2 if command[i] == "\\" and ch != "`" else 1 + i += 1 + continue + if ch == "?": + positional = True + elif ch in ":$" and i + 1 < n and command[i + 1] in _NAME_START: + named = True + i += 1 + return positional, named + + +def refuse_dict_for_positionals( + name: str, language: str, command: str, args: tuple +) -> None: + """Raise ``TypeError`` when a lone dict is passed to a statement that only has positional ``?`` placeholders. + + A lone dict (also ``[d]`` and ``(d,)``) is the NAMED-parameter map. The engine binds a positional ``?`` that has no key in that + map to null, without a word, so ``INSERT INTO T SET v = ?`` with ``{"a": 1}`` stores no value. The call is refused only when the + language is SQL or SQLScript, the text has a ``?`` and no ``:name`` or ``$name`` outside string literals, and no key is a digit + string (``{"0": v}`` is the engine's way to bind positional slot 0 by key, and it keeps working). + """ + if len(args) != 1 or language.lower() not in _COUNTED_LANGUAGES: + return + values = args[0] + if isinstance(values, (list, tuple)) and len(values) == 1: + values = values[0] + if not isinstance(values, Mapping): + return + positional, named = _placeholder_kinds(command) + if not positional or named: + return + if any(isinstance(k, str) and k.isascii() and k.isdigit() for k in values): + return + raise TypeError( + f"{name}() got a dict, which is the map of NAMED parameters, but the statement has only positional ? placeholders. " + "The engine would bind each ? to null and nothing would fail. " + "A positional ? takes a list with one element per ?, as in [value]. " + 'A dict binds by name: use :name in the statement and pass {"name": value}. ' + "A dict meant as ONE value cannot be the only argument: put it in a list together with another positional value, " + "or bind it by name." + ) @lru_cache(maxsize=1024) @@ -29,7 +89,9 @@ def refuse_surplus_positionals( A lone list or tuple is the positional list itself, so its elements are the values; anything else counts one value per argument. Fewer than two values never mismatch an upper bound of one or more, and a mapping alone is the named form: both return at once. + A lone dict against a statement with only ``?`` placeholders is refused first (``refuse_dict_for_positionals``). """ + refuse_dict_for_positionals(name, language, command, args) if len(args) == 1: values = args[0] if not isinstance(values, (list, tuple)): diff --git a/tests/test_dict_with_positional.py b/tests/test_dict_with_positional.py new file mode 100644 index 0000000..fc6d083 --- /dev/null +++ b/tests/test_dict_with_positional.py @@ -0,0 +1,121 @@ +"""A lone dict is the named-parameter map, so a statement with only ``?`` placeholders is refused, not bound to null. + +``command("sql", "INSERT INTO T SET v = ?", {"a": 1})`` (and ``[{"a": 1}]``, ``({"a": 1},)``) hands the engine a named map. The +engine binds a positional ``?`` that has no key in the map to null and nothing fails (ArcadeData/arcadedb#11098), so the record is +stored without the value. The bindings refuse that call with a TypeError on both builds before it runs. A dict that has a +``:name`` placeholder to bind, or a digit-string key (``{"0": v}`` binds positional slot 0 by key), keeps working. +""" + +import pytest + + +@pytest.fixture +def db(temp_db): + temp_db.command("sql", "CREATE DOCUMENT TYPE T") + with temp_db.transaction(): + for k in range(3): + temp_db.command("sql", "INSERT INTO T SET k = ?, v = 0", k) + return temp_db + + +def _stored(db, k): + return db.query("sql", "SELECT FROM T WHERE k = :k", {"k": k}).to_list() + + +DICT_FORMS = [ + pytest.param(lambda d: (d,), id="dict"), + pytest.param(lambda d: ([d],), id="list-of-dict"), + pytest.param(lambda d: ((d,),), id="tuple-of-dict"), +] + + +@pytest.mark.parametrize("form", DICT_FORMS) +def test_command_refuses_a_lone_dict_for_a_positional_placeholder(db, form): + with pytest.raises(TypeError, match="NAMED parameters"): + with db.transaction(): + db.command("sql", "INSERT INTO T SET k = 9, v = ?", *form({"a": 1})) + assert ( + _stored(db, 9) == [] + ) # nothing was inserted, in particular no record with v = null + + +@pytest.mark.parametrize("form", DICT_FORMS) +def test_query_refuses_a_lone_dict_for_a_positional_placeholder(db, form): + with pytest.raises(TypeError, match="NAMED parameters"): + db.query("sql", "SELECT FROM T WHERE k = ?", *form({"a": 1})) + + +def test_sqlscript_refuses_a_lone_dict_for_a_positional_placeholder(db): + with pytest.raises(TypeError, match="NAMED parameters"): + db.command("sqlscript", "INSERT INTO T SET k = 9, v = ?;", {"a": 1}) + assert _stored(db, 9) == [] + + +def test_an_empty_dict_is_refused_too(db): + with pytest.raises(TypeError, match="NAMED parameters"): + db.query("sql", "SELECT FROM T WHERE k = ?", {}) + + +def test_the_refusal_is_a_wrong_call_not_a_failed_statement(db): + with pytest.raises(TypeError) as caught: + db.query("sql", "SELECT FROM T WHERE k = ?", {"a": 1}) + message = str(caught.value) + assert not message.startswith("Query failed") + assert "[value]" in message and ":name" in message and "list" in message + + +# -- the forms that bind keep binding + + +def test_a_dict_binds_a_named_placeholder(db): + db.command("sql", "INSERT INTO T SET k = 9, v = :v", {"v": 5}) + assert [row["v"] for row in _stored(db, 9)] == [5] + + +def test_a_list_binds_a_positional_placeholder(db): + db.command("sql", "INSERT INTO T SET k = 9, v = ?", [5]) + assert [row["v"] for row in _stored(db, 9)] == [5] + + +def test_a_dict_with_a_question_mark_in_a_string_literal_binds_by_name(db): + for text in ( + "SELECT FROM T WHERE k = :k AND '?' = '?'", + 'SELECT FROM T WHERE k = :k AND "?" = "?"', + ): + rows = db.query("sql", text, {"k": 1}).to_list() + assert [row["k"] for row in rows] == [1], text + + +def test_a_dict_with_a_digit_string_key_binds_the_positional_slot(db): + db.command("sql", "INSERT INTO T SET k = 9, v = ?", {"0": 7}) + assert [row["v"] for row in _stored(db, 9)] == [7] + + +def test_the_named_map_of_opencypher_is_not_checked(db): + rows = db.query("opencypher", "RETURN $a AS a", {"a": 1}).to_list() + assert [row["a"] for row in rows] == [1] + + +# -- the scanner + + +@pytest.mark.parametrize( + "text, expected", + [ + ("SELECT FROM T WHERE k = ?", (True, False)), + ("SELECT FROM T WHERE k = :k", (False, True)), + ("SELECT FROM T WHERE k = $k", (False, True)), + ("SELECT FROM T WHERE k = $1", (False, False)), + ("SELECT FROM T WHERE k = ? AND j = :j", (True, True)), + ("SELECT FROM T WHERE s = '?:x$y'", (False, False)), + ('SELECT FROM T WHERE s = "?:x$y"', (False, False)), + ("SELECT FROM `a?b:c` WHERE k = ?", (True, False)), + ("SELECT FROM T WHERE s = 'it''s' AND k = ?", (True, False)), + ("SELECT FROM T WHERE s = 'a\\'?' AND k = :k", (False, True)), + ("SELECT FROM T WHERE s = 'unterminated ?", (False, False)), + ], +) +def test_the_scanner_skips_literals_and_backtick_identifiers(text, expected): + from arcadedb._params import _placeholder_kinds + + assert _placeholder_kinds(text) == expected From a1c2d755a40958d15816701bc62ad64c17001b4d Mon Sep 17 00:00:00 2001 From: Taewoon Kim Date: Sun, 1 Nov 2026 23:19:33 +0900 Subject: [PATCH 2/3] tests: run the binding controls inside a transaction The three controls (named, positional list, digit-string key) wrote outside a transaction, which the engine refuses (Transaction not begun) on both builds; the repo's tests wrap writes in db.transaction(). Co-Authored-By: Claude Opus 5.5 --- tests/test_dict_with_positional.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_dict_with_positional.py b/tests/test_dict_with_positional.py index fc6d083..6d3008d 100644 --- a/tests/test_dict_with_positional.py +++ b/tests/test_dict_with_positional.py @@ -68,12 +68,14 @@ def test_the_refusal_is_a_wrong_call_not_a_failed_statement(db): def test_a_dict_binds_a_named_placeholder(db): - db.command("sql", "INSERT INTO T SET k = 9, v = :v", {"v": 5}) + with db.transaction(): + db.command("sql", "INSERT INTO T SET k = 9, v = :v", {"v": 5}) assert [row["v"] for row in _stored(db, 9)] == [5] def test_a_list_binds_a_positional_placeholder(db): - db.command("sql", "INSERT INTO T SET k = 9, v = ?", [5]) + with db.transaction(): + db.command("sql", "INSERT INTO T SET k = 9, v = ?", [5]) assert [row["v"] for row in _stored(db, 9)] == [5] @@ -87,7 +89,8 @@ def test_a_dict_with_a_question_mark_in_a_string_literal_binds_by_name(db): def test_a_dict_with_a_digit_string_key_binds_the_positional_slot(db): - db.command("sql", "INSERT INTO T SET k = 9, v = ?", {"0": 7}) + with db.transaction(): + db.command("sql", "INSERT INTO T SET k = 9, v = ?", {"0": 7}) assert [row["v"] for row in _stored(db, 9)] == [7] From 6874d3d441202216e61d52f5e629e162afa8e449 Mon Sep 17 00:00:00 2001 From: Taewoon Kim Date: Sun, 1 Nov 2026 23:49:43 +0900 Subject: [PATCH 3/3] tests: the overload-dispatch test passes its lone dicts to named placeholders test_each_parameter_shape_reaches_one_java_overload sent a lone dict to 'SELECT ?, ?' to check that it reaches the Map overload; the new guard (#184) refuses exactly that call. Its two lone-dict cases now use 'SELECT :v, :w' and 'SELECT :p'; the dispatch it checks is unchanged. Co-Authored-By: Claude Opus 5.5 --- tests/test_parameter_binding.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/test_parameter_binding.py b/tests/test_parameter_binding.py index 1307b49..837bd16 100644 --- a/tests/test_parameter_binding.py +++ b/tests/test_parameter_binding.py @@ -151,10 +151,9 @@ def as_python(value): return {str(key): item for key, item in value.items()} return value - def received(method, *args): - getattr(db, method)( - "sql", "SELECT ?, ?", *args - ) # two ?: the check refuses surplus values + def received(method, *args, sql="SELECT ?, ?"): + # two ?: the check refuses surplus values; a lone dict takes named placeholders (#184) + getattr(db, method)("sql", sql, *args) (arg,) = seen.pop() if isinstance(arg, java_map): return ("Map", as_python(arg)) @@ -167,8 +166,11 @@ def received(method, *args): assert received(method, (None,)) == ("Object[]", [None]) assert received(method, None, 1) == ("Object[]", [None, 1]) assert received(method, []) == ("Object[]", []) - assert received(method, {"v": None, "w": 2}) == ("Map", {"v": None, "w": 2}) - assert received(method, [{"p": 1}]) == ("Map", {"p": 1}) + assert received(method, {"v": None, "w": 2}, sql="SELECT :v, :w") == ( + "Map", + {"v": None, "w": 2}, + ) + assert received(method, [{"p": 1}], sql="SELECT :p") == ("Map", {"p": 1}) assert received(method, {"p": 1}, 2) == ("Object[]", [{"p": 1}, 2]) assert seen == [] db.close()