diff --git a/lore_engine_poc/.graph.pkl b/lore_engine_poc/.graph.pkl index d0c5200..bf22f0c 100644 Binary files a/lore_engine_poc/.graph.pkl and b/lore_engine_poc/.graph.pkl differ diff --git a/lore_engine_poc/neo4j_graph.py b/lore_engine_poc/neo4j_graph.py new file mode 100644 index 0000000..bcfb646 --- /dev/null +++ b/lore_engine_poc/neo4j_graph.py @@ -0,0 +1,360 @@ +"""Neo4j 5 graph backend (slice 5.3). + +This module ships ``Neo4jGraph`` — a ``GraphBackend``-conformant +implementation of the in-memory graph in :mod:`lore_engine_poc.graph_backend`. + +The Cypher shape follows ADR 0009 (reified ``:Relation`` nodes): + + (:Person {name, name_lower})-[:FROM|TO]->(:Relation + {edge_id, type, valid_from, valid_until, sources, ...})-[:SOURCED_FROM]-> + (:LoreSource {path, ...}) + +Slice 5.3 ships the skeleton: + + * The bolt driver + * ``ensure_schema()`` (constraints + indexes, idempotent) + * ``add_entity_of_type``, ``register_name``, ``register_alias``, + ``by_name``, ``find_edge_by_id`` (the minimum the read tools + need to make 5.4's parity tests viable) + * ``close()`` (driver teardown) + * Stub methods for the rest of the surface — they raise + ``NotImplementedError`` and will be filled in by slices 5.4 + (reads) and 5.5 (writes). + +Subsequent slices: + + 5.4 — Reified :Relation reads; full read-tool parity vs InMemoryGraph + 5.5 — :Relation writes; full-codex round-trip + 5.6 — Mirror-into-Neo4j path in 01_ingest.py + 5.7 — LORE_GRAPH_BACKEND env var plumbed through entry scripts + 5.8 — docker-compose neo4j service +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +# TYPE_CHECKING is enough here — the methods that take an Edge +# argument (slice 5.5+) get the full dataclass shape resolved at +# runtime via ``from .tools import Edge`` inside the function body. +if TYPE_CHECKING: + from .tools import Edge, LoreSource + + +class Neo4jGraph: + """Neo4j 5 implementation of the ``GraphBackend`` Protocol. + + Slice 5.3 ships the skeleton; slice 5.4 fills in the read methods, + slice 5.5 the write methods. The Cypher shape is documented at + the module level (reified ``:Relation`` per ADR 0009). + """ + + def __init__(self, uri: str, *, database: str = "neo4j", auth=None): + # Imported lazily so this module is importable on systems + # where the neo4j driver isn't installed (e.g. CI without + # the docker-gated deps). + from neo4j import GraphDatabase + # Default to no auth for the loopback / docker-gated dev + # setup (per the slice 5 plan: ``NEO4J_AUTH=none``). + self._driver = GraphDatabase.driver(uri, auth=auth) + self._uri = uri + self._database = database + + # -- Lifecycle -------------------------------------------------------- + + def close(self) -> None: + """Close the driver and release the connection pool.""" + self._driver.close() + + def ensure_schema(self) -> None: + """Create constraints + indexes. Idempotent. + + Slice 5.3's index set: + + * Uniqueness on ``(name_lower)`` for ``:Entity`` — the + case-insensitive lookup key. + * Uniqueness on ``(path)`` for ``:LoreSource`` — the path + is the natural id for a source document. + * Uniqueness on ``(edge_id)`` for ``:Relation`` — the + reified relation has a stable per-edge id (slice 5.5 + will exercise this; slice 5.3 reserves the slot). + + Slice 5.4 will add a uniqueness on ``(alias_lower)`` for + ``:Alias`` once that node type lands; for the skeleton we + store aliases as a property on the alias node. + """ + with self._driver.session(database=self._database) as session: + session.run( + "CREATE CONSTRAINT entity_name_lower IF NOT EXISTS " + "FOR (n:Entity) REQUIRE n.name_lower IS UNIQUE" + ) + session.run( + "CREATE CONSTRAINT loresource_path IF NOT EXISTS " + "FOR (n:LoreSource) REQUIRE n.path IS UNIQUE" + ) + session.run( + "CREATE CONSTRAINT relation_edge_id IF NOT EXISTS " + "FOR (n:Relation) REQUIRE n.edge_id IS UNIQUE" + ) + + # -- Read methods (slice 5.3 ships a minimal set; 5.4 fills the rest) - + + def by_name(self, name: str) -> Optional[str]: + """Case-insensitive lookup by ``name_lower``. + + Returns the canonical name (preserving its original casing) + or ``None`` if the name is unknown. + """ + if not name: + return None + nl = name.lower() + with self._driver.session(database=self._database) as session: + result = session.run( + "MATCH (n:Entity {name_lower: $nl}) RETURN n.name AS n", + nl=nl, + ) + record = result.single() + if record is not None: + return record["n"] + # Alias fallback: an ``:Alias`` node has ``alias_lower`` + # and a ``CANONICAL_OF`` edge to the canonical :Entity. + result = session.run( + "MATCH (a:Alias {alias_lower: $nl})-[:CANONICAL_OF]->" + "(e:Entity) RETURN e.name AS n", + nl=nl, + ) + record = result.single() + return record["n"] if record is not None else None + + def all_names(self) -> set[str]: + """All canonical entity names in the graph.""" + with self._driver.session(database=self._database) as session: + result = session.run("MATCH (n:Entity) RETURN n.name AS n") + return {record["n"] for record in result} + + def all_entity_types(self) -> list[str]: + """Distinct dynamic labels (Person, Faction, Location, ...).""" + with self._driver.session(database=self._database) as session: + result = session.run( + "MATCH (n:Entity) UNWIND labels(n) AS l " + "WHERE l <> 'Entity' RETURN DISTINCT l AS l" + ) + return [record["l"] for record in result] + + def entities_of_type(self, type_: str) -> set[str]: + """Names of entities carrying the given dynamic label.""" + # ``CALL`` here is the safe form for dynamic labels — direct + # query string interpolation would be a Cypher-injection + # vector. The APOC alternative (``apoc.cypher.run``) is + # more general; the Cypher-with-labels form is enough for + # the static label set we have. + cypher = ( + f"MATCH (n:`{_sanitize_label(type_)}`) " + f"RETURN n.name AS n" + ) + with self._driver.session(database=self._database) as session: + result = session.run(cypher) + return {record["n"] for record in result} + + def lore_source(self, path: str) -> Optional["LoreSource"]: + """Fetch a ``LoreSource`` by path. Returns None if unknown. + + Slice 5.3 ships the Cypher-only path; the dataclass + round-trip (``LoreSource(...)``) lands in 5.5 with the + first mirror-into-Neo4j test. + """ + from .tools import LoreSource + with self._driver.session(database=self._database) as session: + result = session.run( + "MATCH (n:LoreSource {path: $path}) " + "RETURN n.path AS path, n.name AS name, " + "n.source_type AS source_type, " + "n.reliability AS reliability, " + "n.source_confidence AS source_confidence, " + "n.ingested_at AS ingested_at", + path=path, + ) + record = result.single() + if record is None: + return None + return LoreSource( + path=record["path"], + name=record["name"] or "", + source_type=record["source_type"] or "prose", + reliability=record["reliability"] or "canonical", + source_confidence=record["source_confidence"] or 1.0, + ingested_at=record["ingested_at"] or "", + ) + + def find_edge_by_id(self, edge_id: str) -> Optional["Edge"]: + """Look up an edge by its stable id. Returns None if unknown. + + Slice 5.3 returns None for the skeleton; slice 5.4 + reifies the full Edge shape. + """ + return None + + def edges_for_subject( + self, name: str, relation: Optional[str] = None + ) -> list["Edge"]: + """Edges originating from the named subject. + + Slice 5.3 raises ``NotImplementedError``; slice 5.4 + implements it via the ``:Relation`` reified shape. + """ + raise NotImplementedError( + "Neo4jGraph.edges_for_subject lands in slice 5.4" + ) + + def edges_for_object(self, name: str) -> list["Edge"]: + """Edges terminating at the named object. + + Slice 5.3 raises ``NotImplementedError``; slice 5.4 + implements it via the ``:Relation`` reified shape. + """ + raise NotImplementedError( + "Neo4jGraph.edges_for_object lands in slice 5.4" + ) + + # -- Write methods (slice 5.3 ships the entity/source helpers; 5.5 + # ships the edge add/replace/remove paths) + + def add_entity_of_type(self, name: str, type_: str) -> None: + """Upsert an entity node with the dynamic ``type_`` label. + + Always tags the node with the base ``:Entity`` label so + ``by_name`` / ``all_names`` queries don't have to know + which dynamic labels are in play. The ``name_lower`` index + is maintained here for case-insensitive lookup. + + The dynamic label is interpolated into the Cypher after + ``_sanitize_label`` strips anything outside the + ``[A-Za-z0-9_]`` set, so this is safe. + """ + label = _sanitize_label(type_) + with self._driver.session(database=self._database) as session: + # Use ``SET n:Label`` rather than APOC (community image + # has no APOC). MERGE is the upsert primitive; SET on a + # label is idempotent. + session.run( + f"MERGE (n:Entity {{name_lower: $nl}}) " + f"ON CREATE SET n.name = $name, n.name_lower = $nl " + f"SET n:`{label}`", + nl=name.lower(), + name=name, + ) + + def register_name(self, name: str) -> None: + """Add a name to the canonical set even if it has no edges. + + Same machinery as ``add_entity_of_type`` minus the dynamic + label. Idempotent — re-registering the same name is a + no-op. + """ + with self._driver.session(database=self._database) as session: + session.run( + "MERGE (n:Entity {name_lower: $nl}) " + "ON CREATE SET n.name = $name, n.name_lower = $nl", + nl=name.lower(), + name=name, + ) + + def register_alias(self, canonical: str, alias: str) -> None: + """Bind ``alias`` to ``canonical``. + + The alias is stored as an ``:Alias`` node with a + ``CANONICAL_OF`` edge to the canonical :Entity. The + ``by_name`` fallback query reads this. + """ + with self._driver.session(database=self._database) as session: + # Ensure the canonical entity exists. + session.run( + "MERGE (e:Entity {name_lower: $cnl}) " + "ON CREATE SET e.name = $canonical, e.name_lower = $cnl", + cnl=canonical.lower(), + canonical=canonical, + ) + # Upsert the alias node and link it. + session.run( + "MATCH (e:Entity {name_lower: $cnl}) " + "MERGE (a:Alias {alias_lower: $anl}) " + "ON CREATE SET a.alias = $alias, a.alias_lower = $anl " + "MERGE (a)-[:CANONICAL_OF]->(e)", + cnl=canonical.lower(), + anl=alias.lower(), + alias=alias, + ) + + def add_lore_source(self, source: "LoreSource") -> None: + """Upsert a ``:LoreSource`` node keyed on ``path``. + + Slice 5.3 ships this so ``01_ingest.py --write-neo4j`` + (slice 5.6) has somewhere to land sources. + """ + with self._driver.session(database=self._database) as session: + session.run( + "MERGE (n:LoreSource {path: $path}) " + "ON CREATE SET n.name = $name, n.path = $path, " + "n.source_type = $source_type, " + "n.reliability = $reliability, " + "n.source_confidence = $source_confidence, " + "n.ingested_at = $ingested_at", + path=source.path, + name=source.name, + source_type=source.source_type, + reliability=source.reliability, + source_confidence=source.source_confidence, + ingested_at=source.ingested_at, + ) + + def add(self, edge: "Edge") -> None: + """Upsert a single edge. Slice 5.5 implements this.""" + raise NotImplementedError("Neo4jGraph.add lands in slice 5.5") + + def replace_edge(self, old_id: str, new_edge: "Edge") -> None: + """In-place swap. Slice 5.5 implements this.""" + raise NotImplementedError("Neo4jGraph.replace_edge lands in slice 5.5") + + def remove_entity(self, name: str) -> int: + """Cascade-remove an entity. Slice 5.5 implements this.""" + raise NotImplementedError("Neo4jGraph.remove_entity lands in slice 5.5") + + def remove_entity_of_type(self, name: str, type_: str) -> None: + """Drop a type label. Slice 5.5 implements this.""" + raise NotImplementedError( + "Neo4jGraph.remove_entity_of_type lands in slice 5.5" + ) + + def rename_entity(self, old: str, new: str) -> int: + """Rename; preserve the old name as an alias. Slice 5.5.""" + raise NotImplementedError("Neo4jGraph.rename_entity lands in slice 5.5") + + def resolve_alias(self, alias: str) -> Optional[str]: + """Resolve an alias to its canonical name. Returns None if not aliased.""" + al = alias.lower() + with self._driver.session(database=self._database) as session: + result = session.run( + "MATCH (a:Alias {alias_lower: $al})-[:CANONICAL_OF]->" + "(e:Entity) RETURN e.name AS n", + al=al, + ) + record = result.single() + return record["n"] if record is not None else None + + +def _sanitize_label(label: str) -> str: + """Sanitize a dynamic label for safe interpolation into a Cypher + string. Cypher labels must start with a letter and contain only + letters / digits / underscores; this strips anything else. + + For an untrusted label, slice 5.5 will switch to APOC + ``apoc.create.addLabels``; for the markdown ``"npc"`` → + ``"Person"`` mapping in the schema, the labels are static and + safe. + """ + cleaned = "".join( + ch if (ch.isalnum() or ch == "_") else "_" for ch in label + ) + if not cleaned or not cleaned[0].isalpha(): + cleaned = "L_" + cleaned + return cleaned diff --git a/requirements.txt b/requirements.txt index 0fb3fea..49a6d46 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,6 @@ cognee>=0.1.0 starlette>=0.40 uvicorn>=0.30 httpx>=0.27 + +# Slice 5 — Neo4j 5 graph backend (PEP 544 Protocol + adapter) +neo4j>=5.0 diff --git a/tests/test_tools/test_neo4j_graph.py b/tests/test_tools/test_neo4j_graph.py new file mode 100644 index 0000000..4190f86 --- /dev/null +++ b/tests/test_tools/test_neo4j_graph.py @@ -0,0 +1,316 @@ +"""Tests for the Neo4jGraph adapter (slice 5.3). + +These tests validate the skeleton of the Neo4j graph backend: + + 1. The adapter connects to a running Neo4j 5 instance. + 2. ``ensure_schema()`` is idempotent (re-running it does not error). + 3. The reified ``:Relation`` shape (per ADR 0009) is the substrate + all read/write tools will use. + 4. Case-insensitive ``by_name`` works through a ``name_lower`` index. + 5. The 14-method Protocol surface from ``graph_backend.py`` is + implemented (a subset is exercised here; slice 5.4 covers the + full read parity, slice 5.5 covers writes). + +Tests are gated on a real Neo4j 5 container. If docker is not on PATH +or the bolt URI isn't reachable, all tests are skipped (so CI without +docker still passes). The Neo4j container is started once per test +module via a module-scoped fixture to amortize the 10-30s cold-start +cost across all 12 tests. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import time +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] + +# Default URI for slice-5 docker-compose (slice 5.8 will wire this through +# ``LORE_NEO4J_URI``). Local dev runs Neo4j on the loopback bolt port. +NEO4J_URI = os.environ.get("LORE_NEO4J_URI", "bolt://127.0.0.1:7687") +NEO4J_HTTP = os.environ.get("LORE_NEO4J_HTTP", "http://127.0.0.1:7474") +NEO4J_CONTAINER = "lore-neo4j-test" + + +# --------------------------------------------------------------------------- +# Docker-gating + module-scoped fixture +# --------------------------------------------------------------------------- + + +HAS_DOCKER = shutil.which("docker") is not None + + +def _neo4j_reachable(uri: str) -> bool: + """Bolt-side probe. Imports the driver lazily so this test file is + still importable in environments without the ``neo4j`` package.""" + try: + from neo4j import GraphDatabase + except ImportError: + return False + try: + driver = GraphDatabase.driver(uri) + driver.verify_connectivity() + driver.close() + return True + except Exception: + return False + + +NEO4J_UP = _neo4j_reachable(NEO4J_URI) + + +# Skip the whole module when Neo4j isn't reachable. +pytestmark = pytest.mark.skipif( + not (HAS_DOCKER and NEO4J_UP), + reason="Neo4j 5 container not reachable", +) + + +@pytest.fixture(scope="module") +def _neo4j_driver(): + """One Neo4j driver per test module. The container is up once; + every test gets a freshly-cleaned graph (sliced through a + function-scoped fixture) so test ordering doesn't matter. + """ + from lore_engine_poc.neo4j_graph import Neo4jGraph + g = Neo4jGraph(NEO4J_URI, database="neo4j") + g.ensure_schema() + yield g + g.close() + + +@pytest.fixture() +def neo4j_graph(_neo4j_driver): + """Function-scoped graph: wipe state on entry so each test sees + an empty world. Re-uses the module-scoped driver. + """ + with _neo4j_driver._driver.session(database="neo4j") as session: + session.run("MATCH (n) DETACH DELETE n") + _neo4j_driver.ensure_schema() + return _neo4j_driver + + +# --------------------------------------------------------------------------- +# Test 1 — connects +# --------------------------------------------------------------------------- + + +def test_neo4j_graph_connects(): + """``Neo4jGraph(uri)`` opens a bolt session and a driver that + can round-trip a trivial query. This is the smoke test for the + whole skeleton — if it fails, every other test fails too.""" + from lore_engine_poc.neo4j_graph import Neo4jGraph + g = Neo4jGraph(NEO4J_URI) + try: + with g._driver.session() as session: + result = session.run("RETURN 1 AS n") + assert result.single()["n"] == 1 + finally: + g.close() + + +# --------------------------------------------------------------------------- +# Test 2 — ensure_schema is idempotent +# --------------------------------------------------------------------------- + + +def test_ensure_schema_is_idempotent(neo4j_graph): + """``ensure_schema()`` creates the constraints + indexes needed + for the reified ``:Relation`` shape. Calling it twice in a row + must not raise (Neo4j ``IF NOT EXISTS`` semantics).""" + neo4j_graph.ensure_schema() + # Second call must also succeed. + neo4j_graph.ensure_schema() + + +# --------------------------------------------------------------------------- +# Test 3 — add_node creates a :Person node +# --------------------------------------------------------------------------- + + +def test_add_node_creates_typed_node(neo4j_graph): + """``add_node(name, label)`` upserts an entity node with the + dynamic label (Person, Faction, Location, etc.). The name_lower + index makes case-insensitive lookup possible.""" + from lore_engine_poc.parsers import LoreSource + neo4j_graph.add_entity_of_type("Aldric", "Person") + # Round-trip via Cypher: a single :Person node with the right name. + with neo4j_graph._driver.session() as session: + result = session.run( + "MATCH (n:Person {name: $name}) RETURN n.name_lower AS nl", + name="Aldric", + ) + record = result.single() + assert record is not None, "no :Person node found for 'Aldric'" + assert record["nl"] == "aldric" + + +# --------------------------------------------------------------------------- +# Test 4 — add_node is upsert +# --------------------------------------------------------------------------- + + +def test_add_node_is_upsert(neo4j_graph): + """Adding the same (name, label) twice yields exactly one node. + Neo4j's MERGE under the hood; the contract is observable at the + query level (count of matching nodes == 1).""" + neo4j_graph.add_entity_of_type("Bob", "Person") + neo4j_graph.add_entity_of_type("Bob", "Person") + with neo4j_graph._driver.session() as session: + result = session.run( + "MATCH (n:Person {name: $name}) RETURN count(n) AS c", + name="Bob", + ) + assert result.single()["c"] == 1 + + +# --------------------------------------------------------------------------- +# Test 5 — by_name is case-insensitive +# --------------------------------------------------------------------------- + + +def test_by_name_is_case_insensitive(neo4j_graph): + """``by_name`` lowercases the query and matches against the + ``name_lower`` property. The original cased form is preserved + on the node so display output stays readable.""" + neo4j_graph.register_name("Roland Raventhorne") + assert neo4j_graph.by_name("roland raventhorne") == "Roland Raventhorne" + assert neo4j_graph.by_name("ROLAND RAVENTHORNE") == "Roland Raventhorne" + + +# --------------------------------------------------------------------------- +# Test 6 — by_name falls back to alias +# --------------------------------------------------------------------------- + + +def test_by_name_falls_back_to_alias(neo4j_graph): + """``by_name(alias)`` resolves through ``:HAS_ALIAS`` edges (or + the alias map in slice 5.4). The alias's canonical name is + returned even when the canonical name itself is absent from + the index.""" + neo4j_graph.register_name("Sir Roland") + neo4j_graph.register_alias("Sir Roland", "Roland Raventhorne") + assert neo4j_graph.by_name("Roland Raventhorne") == "Sir Roland" + + +# --------------------------------------------------------------------------- +# Test 7 — add_entity_of_type populates the dynamic label +# --------------------------------------------------------------------------- + + +def test_add_entity_of_type_uses_dynamic_label(neo4j_graph): + """``add_entity_of_type(name, type_)`` creates a node with the + label matching ``type_``. Markdown ``"npc"`` → ``"Person"``; + YAML ``"Person"`` → ``"Person"``.""" + neo4j_graph.add_entity_of_type("Voldramir", "Location") + with neo4j_graph._driver.session() as session: + result = session.run( + "MATCH (n:Location {name: $name}) RETURN n.name AS n", + name="Voldramir", + ) + assert result.single() is not None + + +# --------------------------------------------------------------------------- +# Test 8 — find_edge_by_id returns None for unknown id +# --------------------------------------------------------------------------- + + +def test_find_edge_by_id_returns_none_for_unknown(neo4j_graph): + """Unknown ids resolve to None, not raise. This matches the + InMemoryGraph contract that read tools rely on for non-existent + edges.""" + assert neo4j_graph.find_edge_by_id("e-deadbeef") is None + + +# --------------------------------------------------------------------------- +# Test 9 — driver reconnect after a short sleep (sanity for connection pool) +# --------------------------------------------------------------------------- + + +def test_driver_reconnect_after_sleep(neo4j_graph): + """The driver maintains a pool; a query that follows a short + pause must still succeed. Slice 5.8 will exercise this across + a container restart; here we just verify the pool survives.""" + with neo4j_graph._driver.session() as session: + r1 = session.run("RETURN 1 AS n").single()["n"] + time.sleep(0.2) + with neo4j_graph._driver.session() as session: + r2 = session.run("RETURN 2 AS n").single()["n"] + assert r1 == 1 + assert r2 == 2 + + +# --------------------------------------------------------------------------- +# Test 10 — container HTTP probe (control-plane reachable) +# --------------------------------------------------------------------------- + + +def test_neo4j_http_endpoint_reachable(): + """The HTTP endpoint (``http://127.0.0.1:7474``) returns a JSON + payload that names the bolt-direct URI. This is the health probe + slice 5.8 will use in docker-compose.""" + try: + import httpx + except ImportError: + pytest.skip("httpx not installed") + resp = httpx.get(NEO4J_HTTP, timeout=2.0) + assert resp.status_code == 200 + body = resp.json() + assert body["bolt_direct"].startswith("bolt://") + assert body["neo4j_version"].startswith("5.") + + +# --------------------------------------------------------------------------- +# Test 11 — bolt protocol version is 5.x +# --------------------------------------------------------------------------- + + +def test_bolt_protocol_version_is_5x(): + """The official driver advertises a 5.x protocol version. Slice 5 + commits to Neo4j 5 (per ADR 0008); this test fails fast if the + wrong container is wired up.""" + from neo4j import GraphDatabase + driver = GraphDatabase.driver(NEO4J_URI) + try: + # Driver-internal version metadata; no public API for it, so + # we rely on the driver package's own ``__version__`` and the + # negotiated protocol via the bolt handshake. The simplest + # observable is a server version pull from a session. + with driver.session() as session: + result = session.run("CALL dbms.components() YIELD name, versions, edition") + for record in result: + if record["name"] == "Neo4j Kernel": + version_str = record["versions"][0] + major = int(version_str.split(".")[0]) + assert major >= 5, ( + f"expected Neo4j 5.x; got {version_str!r}" + ) + return + pytest.fail("Neo4j Kernel component not returned by dbms.components()") + finally: + driver.close() + + +# --------------------------------------------------------------------------- +# Test 12 — module-scoped fixture reuses one container +# --------------------------------------------------------------------------- + + +def test_module_scoped_fixture_reuses_driver(neo4j_graph): + """The fixture yields the *same* driver across tests in the module. + Two queries through the same driver must hit the same backing + database — i.e. data added by one test is visible to the next.""" + neo4j_graph.register_name("Vivi") + with neo4j_graph._driver.session() as session: + result = session.run( + "MATCH (n) WHERE n.name_lower = 'vivi' RETURN n.name AS n" + ) + record = result.single() + assert record is not None + assert record["n"] == "Vivi" \ No newline at end of file