slice 5.3: Neo4jGraph skeleton + docker-gated round-trip tests
- Add neo4j>=5.0 to requirements.txt
- New lore_engine_poc/neo4j_graph.py: Neo4jGraph class implementing
the GraphBackend Protocol skeleton (reified :Relation shape per
ADR 0009):
* Lifecycle: __init__, close, ensure_schema (idempotent
constraints on Entity.name_lower, LoreSource.path,
Relation.edge_id)
* Reads (slice 5.3 ships minimum; 5.4 fills the rest):
by_name, all_names, all_entity_types, entities_of_type,
lore_source, find_edge_by_id (returns None), edges_for_* (stub)
* Writes (slice 5.3 ships entity/source helpers; 5.5 fills rest):
add_entity_of_type (SET n:Label for dynamic label, no APOC
dep on community image), register_name, register_alias (Alias
node + CANONICAL_OF edge), add_lore_source; add/replace_edge/
remove_entity/remove_entity_of_type/rename_entity (stubs)
- _sanitize_label() helper: strip non-[A-Za-z0-9_] from dynamic
labels so SET n:Label is safe to interpolate
- 12 docker-gated tests in test_neo4j_graph.py: connects, schema
idempotent, add_node creates typed node, upsert semantics,
case-insensitive by_name, alias fallback, dynamic label, find by
id returns None, driver reconnect, HTTP probe, bolt protocol v5,
module-scoped fixture reuses driver
- Test gating: skipif(!HAS_DOCKER || !neo4j reachable); module
fixture reuses one driver; function-scoped fixture wipes state
per test so ordering doesn't matter
- Used neo4j GraphDatabase with auth=none (loopback only, per
the plan's NEO4J_AUTH=none)
Suite: 584 -> 596 passed (+12 new tests, 559 baseline preserved)
This commit is contained in:
Binary file not shown.
360
lore_engine_poc/neo4j_graph.py
Normal file
360
lore_engine_poc/neo4j_graph.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
316
tests/test_tools/test_neo4j_graph.py
Normal file
316
tests/test_tools/test_neo4j_graph.py
Normal file
@@ -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"
|
||||
Reference in New Issue
Block a user