Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/modules/ROOT/pages/how-to/databases-and-queries.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
62 changes: 62 additions & 0 deletions src/arcadedb/_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)):
Expand Down
124 changes: 124 additions & 0 deletions tests/test_dict_with_positional.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""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):
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):
with db.transaction():
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):
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]


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
14 changes: 8 additions & 6 deletions tests/test_parameter_binding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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()
Expand Down
Loading