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
24 changes: 24 additions & 0 deletions native/src/main/java/org/humemai/nativelib/NativeExtras.java
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
import com.arcadedb.function.CypherFunctionRegistry;
import com.arcadedb.function.FunctionRegistry;
import com.arcadedb.function.sql.DefaultSQLFunctionFactory;
import com.arcadedb.index.IndexCursor;
import com.arcadedb.index.IndexInternal;
import com.arcadedb.index.TypeIndex;
import com.arcadedb.index.vector.LSMVectorIndex;
Expand All @@ -54,6 +55,8 @@
import com.arcadedb.integration.exporter.Exporter;
import com.arcadedb.integration.importer.Importer;
import com.arcadedb.query.sql.executor.CommandContext;
import com.arcadedb.query.sql.executor.InternalResultSet;
import com.arcadedb.query.sql.executor.ResultInternal;
import com.arcadedb.query.sql.executor.ResultSet;
import com.arcadedb.schema.DocumentType;
import com.arcadedb.schema.EdgeType;
Expand Down Expand Up @@ -284,6 +287,27 @@ public static CCharPointer dbSet(final IsolateThread thread, final long h, final
}
}

// ---- Database.lookupByKey: no statement raises when no index serves the keys, as the engine's call does

/**
* The first record of db.lookupByKey(type, keys, values), as the reply of a statement (none or one row, drained) so that Python reads it
* with the reader of a SELECT. argsJson: {"keys":[property names],"values":[tagged values]}. The engine's own exceptions are the error
* reply, as on the JVM build: IllegalArgumentException when no LSM_TREE or HASH index of the type or a supertype is on exactly those
* properties, SchemaException when the type does not exist.
*/
@CEntryPoint(name = "arcadedb_n_lookup")
public static CCharPointer lookup(final IsolateThread thread, final long dbh, final CCharPointer type, final CCharPointer argsJson) {
try {
final Database db = ArcadeNative.db(dbh);
final Map<String, Object> a = args(argsJson);
final IndexCursor cursor = db.lookupByKey(ArcadeNative.str(type), strings(a.get("keys")), objects(a.get("values")));
return ArcadeNative.out(
replyRows(cursor.hasNext() ? new InternalResultSet(new ResultInternal(cursor.next())) : new InternalResultSet()));
} catch (final Throwable t) {
return ArcadeNative.err(t);
}
}

// ---- vector indexes: what no statement reports (the engine's configuration of an index) and the PQ search no statement runs

private static String q(final String text) {
Expand Down
27 changes: 20 additions & 7 deletions src/arcadedb/_native/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -680,14 +680,27 @@ def lookup_by_rid(self, rid: str):
return row.get_element()

def lookup_by_key(self, type_name: str, keys: List[str], values: List[Any]):
"""The first record of ``type_name`` whose ``keys`` properties equal ``values``, wrapped, or None.

Index-based, as on the JVM build: the engine's own ``lookupByKey`` runs in the image, so ``keys`` must be exactly the properties of
a LSM_TREE or HASH index of the type or one of its supertypes, in the index's order. Anything else raises ``ArcadeDBError``
(a scan is ``query()`` with a ``WHERE``). No statement has that rule, hence this op and not a ``SELECT``.
"""
self._check_not_closed()
where = " AND ".join(f"`{k}` = ?" for k in keys)
try:
lookup_sql = f"SELECT FROM `{type_name}` WHERE {where} LIMIT 1" # nosec B608 - backtick-quoted identifiers; values go through ? parameters
row = self.query("sql", lookup_sql, list(values)).first()
return row.get_element() if row is not None else None
except ArcadeDBError as e:
raise ArcadeDBError(f"Failed to lookup by key in '{type_name}': {e}") from e
from .extras import _enc

reply = loads(
lib.call(
"arcadedb_n_lookup",
self._h,
lib.b(type_name),
lib.b(_enc({"keys": [str(k) for k in keys], "values": list(values)})),
)
)
if not reply.get("ok"):
raise _error(f"Failed to lookup by key in '{type_name}'", reply)
row = NativeResultSet(self, reply).first()
return row.get_element() if row is not None else None

def insert_many(
self,
Expand Down
1 change: 1 addition & 0 deletions src/arcadedb/_native/lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ class _Params(ctypes.Structure):
"arcadedb_n_diag": [P],
"arcadedb_n_sample_dump": [P],
"arcadedb_n_dbset": [P, L, S, S],
"arcadedb_n_lookup": [P, L, S, S],
"arcadedb_n_gb_create": [P, L, S],
"arcadedb_n_gb": [P, L, S, S],
"arcadedb_n_async": [P, L, S, S],
Expand Down
9 changes: 8 additions & 1 deletion src/arcadedb/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -1320,12 +1320,19 @@ def lookup_by_key(self, type_name: str, keys: List[str], values: List[Any]):

Args:
type_name: Type name
keys: List of property names (must be indexed)
keys: List of property names. They must be exactly the properties of
a LSM_TREE or HASH index of the type or one of its supertypes, in
the index's order; a FULL_TEXT or vector index does not count
values: List of property values

Returns:
The first matching record, wrapped, or None

Raises:
ArcadeDBError: If no such index exists (the call never scans the type;
use ``query()`` with a ``WHERE`` for that), or the type does not exist.
The native and the JVM build raise alike.

Example:
>>> user = db.lookup_by_key("User", ["email"], ["alice@example.com"])
>>> if user is not None:
Expand Down
125 changes: 125 additions & 0 deletions tests/test_lookup_by_key_index.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""lookup_by_key() needs an index on exactly those properties, on both backends.

The engine's lookupByKey answers from an index only. It raises when the type has none on the given property list (in that order), when
the one it has is a FULL_TEXT or vector index, or when the type does not exist. The JVM backend gets that from the engine. The
native backend used to run a ``SELECT ... WHERE`` instead, which scans the type without an index: the same call returned a record on
one wheel and raised on the other. Both raise the same message now, so a program does not depend on which wheel runs it.
"""

import pytest

from arcadedb import ArcadeDBError


@pytest.fixture
def docs(temp_db):
"""Type D with an unindexed integer ``k``, two more properties ``a`` and ``b``, and a text property ``t``; one record."""
temp_db.command("sql", "CREATE DOCUMENT TYPE D")
for prop, kind in (("k", "INTEGER"), ("a", "INTEGER"), ("b", "INTEGER")):
temp_db.command("sql", f"CREATE PROPERTY D.{prop} {kind}")
temp_db.command("sql", "CREATE PROPERTY D.t STRING")
with temp_db.transaction():
temp_db.command(
"sql", "INSERT INTO D SET k = 1, a = 10, b = 20, t = 'hello world'"
)
return temp_db


def test_lookup_on_an_unindexed_key_raises(docs):
with pytest.raises(
ArcadeDBError,
match=r"Failed to lookup by key in 'D'.*No index has been created on type 'D' properties \[k\]",
):
docs.lookup_by_key("D", ["k"], [1])


def test_lookup_on_an_unindexed_key_raises_also_without_a_match(docs):
with pytest.raises(ArcadeDBError, match="No index has been created"):
docs.lookup_by_key("D", ["k"], [99])


@pytest.mark.parametrize(
"index_kind", ["UNIQUE", "NOTUNIQUE", "UNIQUE_HASH", "NOTUNIQUE_HASH"]
)
def test_lookup_works_once_the_key_is_indexed(docs, index_kind):
with pytest.raises(ArcadeDBError, match="No index has been created"):
docs.lookup_by_key("D", ["k"], [1])
docs.command("sql", f"CREATE INDEX ON D (k) {index_kind}")
assert docs.lookup_by_key("D", ["k"], [1]).get("a") == 10
assert docs.lookup_by_key("D", ["k"], [99]) is None


def test_lookup_raises_again_after_the_index_is_dropped(docs):
docs.command("sql", "CREATE INDEX ON D (k) UNIQUE")
assert docs.lookup_by_key("D", ["k"], [1]) is not None
docs.command("sql", "DROP INDEX `D[k]`")
with pytest.raises(ArcadeDBError, match="No index has been created"):
docs.lookup_by_key("D", ["k"], [1])


def test_an_index_on_another_property_does_not_serve_the_lookup(docs):
docs.command("sql", "CREATE INDEX ON D (a) UNIQUE")
with pytest.raises(ArcadeDBError, match=r"properties \[k\]"):
docs.lookup_by_key("D", ["k"], [1])


def test_a_composite_index_serves_exactly_its_property_list(docs):
docs.command("sql", "CREATE INDEX ON D (a, b) NOTUNIQUE")
assert docs.lookup_by_key("D", ["a", "b"], [10, 20]).get("k") == 1
assert docs.lookup_by_key("D", ["a", "b"], [10, 99]) is None
# a prefix of the index, and the same properties in the other order, are not that index
with pytest.raises(ArcadeDBError, match=r"properties \[a\]"):
docs.lookup_by_key("D", ["a"], [10])
with pytest.raises(ArcadeDBError, match=r"properties \[b, a\]"):
docs.lookup_by_key("D", ["b", "a"], [20, 10])


def test_a_full_text_index_does_not_serve_an_exact_key_lookup(docs):
docs.command("sql", "CREATE INDEX ON D (t) FULL_TEXT")
with pytest.raises(
ArcadeDBError,
match=r"No key index has been created on type 'D' properties \[t\].*FULL_TEXT",
):
docs.lookup_by_key("D", ["t"], ["hello"])


def test_a_subtype_is_served_by_the_index_of_any_supertype(temp_db):
temp_db.command("sql", "CREATE DOCUMENT TYPE A")
temp_db.command("sql", "CREATE PROPERTY A.k INTEGER")
temp_db.command("sql", "CREATE DOCUMENT TYPE B EXTENDS A")
temp_db.command("sql", "CREATE DOCUMENT TYPE C EXTENDS B")
with temp_db.transaction():
temp_db.command("sql", "INSERT INTO C SET k = 3, name = 'deep'")
with pytest.raises(ArcadeDBError, match="No index has been created"):
temp_db.lookup_by_key("C", ["k"], [3])
temp_db.command("sql", "CREATE INDEX ON A (k) UNIQUE")
assert temp_db.lookup_by_key("C", ["k"], [3]).get("name") == "deep"
assert temp_db.lookup_by_key("B", ["k"], [3]).get("name") == "deep"
assert temp_db.lookup_by_key("B", ["k"], [4]) is None


def test_a_supertype_is_not_served_by_the_index_of_a_subtype(temp_db):
temp_db.command("sql", "CREATE DOCUMENT TYPE A")
temp_db.command("sql", "CREATE PROPERTY A.k INTEGER")
temp_db.command("sql", "CREATE DOCUMENT TYPE B EXTENDS A")
temp_db.command("sql", "CREATE INDEX ON B (k) UNIQUE")
with temp_db.transaction():
temp_db.command("sql", "INSERT INTO B SET k = 5, name = 'sub'")
assert temp_db.lookup_by_key("B", ["k"], [5]).get("name") == "sub"
with pytest.raises(ArcadeDBError, match="No index has been created"):
temp_db.lookup_by_key("A", ["k"], [5])


@pytest.mark.parametrize("kind", ["VERTEX", "EDGE"])
def test_vertex_and_edge_types_follow_the_same_rule(temp_db, kind):
temp_db.command("sql", f"CREATE {kind} TYPE G")
temp_db.command("sql", "CREATE PROPERTY G.k INTEGER")
with pytest.raises(ArcadeDBError, match="No index has been created"):
temp_db.lookup_by_key("G", ["k"], [1])
temp_db.command("sql", "CREATE INDEX ON G (k) UNIQUE")
assert temp_db.lookup_by_key("G", ["k"], [1]) is None


def test_lookup_on_an_unknown_type_names_the_type_not_the_index(docs):
with pytest.raises(ArcadeDBError, match="'Nope' was not found"):
docs.lookup_by_key("Nope", ["k"], [1])
Loading