feat(acms): implement graph backend (Blazegraph or Neo4j) #1282
@@ -0,0 +1,117 @@
|
||||
"""Mock Neo4j driver for BDD tests.
|
||||
|
||||
Provides lightweight test doubles for the Neo4j driver, session, and
|
||||
result objects so that Neo4j graph backend tests run without a real
|
||||
Neo4j server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class MockNeo4jRecord:
|
||||
"""Minimal Neo4j record mock."""
|
||||
|
||||
def __init__(self, data: dict[str, Any]) -> None:
|
||||
self._data = data
|
||||
|
||||
def keys(self) -> list[str]:
|
||||
return list(self._data.keys())
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
return self._data[key]
|
||||
|
||||
def items(self) -> Any:
|
||||
return self._data.items()
|
||||
|
||||
|
||||
class MockNeo4jResult:
|
||||
"""Minimal Neo4j result mock that yields pre-configured records."""
|
||||
|
||||
def __init__(self, records: list[dict[str, Any]]) -> None:
|
||||
self._records = [MockNeo4jRecord(r) for r in records]
|
||||
|
||||
def __iter__(self) -> Any:
|
||||
return iter(self._records)
|
||||
|
||||
|
||||
class MockNeo4jSession:
|
||||
"""Minimal Neo4j session mock."""
|
||||
|
||||
def __init__(self, records: list[dict[str, Any]]) -> None:
|
||||
self._records = records
|
||||
self.run_calls: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
def run(self, cypher: str, **params: Any) -> MockNeo4jResult:
|
||||
self.run_calls.append((cypher, params))
|
||||
return MockNeo4jResult(self._records)
|
||||
|
||||
def __enter__(self) -> MockNeo4jSession:
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: Any) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class MockNeo4jDriver:
|
||||
"""Minimal Neo4j driver mock."""
|
||||
|
||||
def __init__(self, records: list[dict[str, Any]] | None = None) -> None:
|
||||
self._records: list[dict[str, Any]] = records or []
|
||||
self._session = MockNeo4jSession(self._records)
|
||||
self.closed = False
|
||||
|
||||
def session(self) -> MockNeo4jSession:
|
||||
return self._session
|
||||
|
||||
def verify_connectivity(self) -> None:
|
||||
pass
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
@property
|
||||
def run_calls(self) -> list[tuple[str, dict[str, Any]]]:
|
||||
return self._session.run_calls
|
||||
|
||||
|
||||
class UnavailableNeo4jDriver:
|
||||
"""Neo4j driver mock that raises ServiceUnavailable on session use."""
|
||||
|
||||
def session(self) -> Any:
|
||||
raise _ServiceUnavailableError("Neo4j is unavailable")
|
||||
|
||||
def verify_connectivity(self) -> None:
|
||||
raise _ServiceUnavailableError("Neo4j is unavailable")
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class _ServiceUnavailableError(Exception):
|
||||
"""Simulates neo4j.exceptions.ServiceUnavailable."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def make_triple_records() -> list[dict[str, Any]]:
|
||||
"""Return a single-record list representing one triple."""
|
||||
return [{"s": "uko:Container", "p": "uko_type", "o": "uko:Class"}]
|
||||
|
||||
|
||||
def make_query_records() -> list[dict[str, Any]]:
|
||||
"""Return records for a SPARQL query result."""
|
||||
return [{"s": "uko:Container", "p": "uko_type", "o": "uko:Class"}]
|
||||
|
||||
|
||||
__all__: list[str] = [
|
||||
"MockNeo4jDriver",
|
||||
"MockNeo4jRecord",
|
||||
"MockNeo4jResult",
|
||||
"MockNeo4jSession",
|
||||
"UnavailableNeo4jDriver",
|
||||
"_ServiceUnavailableError",
|
||||
"make_query_records",
|
||||
"make_triple_records",
|
||||
]
|
||||
@@ -0,0 +1,173 @@
|
||||
Feature: Neo4j Graph Backend
|
||||
As a developer
|
||||
I want a Neo4j-backed graph backend for ACMS
|
||||
So that UKO ontology triples are persisted and queryable in a real graph database
|
||||
|
||||
# ---- Neo4jGraphBackend (read-side) ----
|
||||
|
||||
Scenario: Neo4jGraphBackend satisfies GraphBackend protocol
|
||||
Given a Neo4jGraphBackend instance with mock driver
|
||||
Then the neo4j graph backend should satisfy the GraphBackend protocol
|
||||
|
||||
Scenario: Neo4jGraphBackend sparql_query rejects empty query
|
||||
Given a Neo4jGraphBackend instance with mock driver
|
||||
Then neo4j sparql_query with empty query should raise ValueError
|
||||
|
||||
Scenario: Neo4jGraphBackend get_triples rejects empty subject
|
||||
Given a Neo4jGraphBackend instance with mock driver
|
||||
Then neo4j get_triples with empty subject should raise ValueError
|
||||
|
||||
Scenario: Neo4jGraphBackend traverse rejects empty start
|
||||
Given a Neo4jGraphBackend instance with mock driver
|
||||
Then neo4j traverse with empty start should raise ValueError
|
||||
|
||||
Scenario: Neo4jGraphBackend traverse rejects negative depth
|
||||
Given a Neo4jGraphBackend instance with mock driver
|
||||
Then neo4j traverse with depth -1 should raise ValueError
|
||||
|
||||
Scenario: Neo4jGraphBackend sparql_query returns triples from mock driver
|
||||
Given a Neo4jGraphBackend instance with mock driver returning triples
|
||||
When I run neo4j sparql query "SELECT ?s ?p ?o WHERE { ?s ?p ?o }" with scope "RES01"
|
||||
Then the neo4j graph query result should have 1 triple
|
||||
|
||||
Scenario: Neo4jGraphBackend get_triples returns triples from mock driver
|
||||
Given a Neo4jGraphBackend instance with mock driver returning triples
|
||||
When I get neo4j triples for subject "uko:Container"
|
||||
Then the neo4j graph query result should have 1 triple
|
||||
|
||||
Scenario: Neo4jGraphBackend traverse returns triples from mock driver
|
||||
Given a Neo4jGraphBackend instance with mock driver returning triples
|
||||
When I traverse neo4j from "uko:Container" with depth 2
|
||||
Then the neo4j graph query result should have 1 triple
|
||||
|
||||
Scenario: Neo4jGraphBackend graceful degradation when service unavailable
|
||||
Given a Neo4jGraphBackend instance with unavailable driver
|
||||
When I run neo4j sparql query "SELECT ?s WHERE { ?s a uko:Container }" with scope "RES01"
|
||||
Then the neo4j graph query result should have no triples
|
||||
|
||||
Scenario: Neo4jGraphBackend get_triples graceful degradation
|
||||
Given a Neo4jGraphBackend instance with unavailable driver
|
||||
When I get neo4j triples for subject "uko:Container"
|
||||
Then the neo4j graph query result should have no triples
|
||||
|
||||
Scenario: Neo4jGraphBackend traverse graceful degradation
|
||||
Given a Neo4jGraphBackend instance with unavailable driver
|
||||
When I traverse neo4j from "uko:Container" with depth 2
|
||||
Then the neo4j graph query result should have no triples
|
||||
|
||||
# ---- Neo4jGraphIndexBackend (write-side) ----
|
||||
|
||||
Scenario: Neo4jGraphIndexBackend satisfies GraphIndexBackend protocol
|
||||
Given a Neo4jGraphIndexBackend instance with mock driver
|
||||
Then the neo4j graph index backend should satisfy the GraphIndexBackend protocol
|
||||
|
||||
Scenario: Neo4jGraphIndexBackend add_triple rejects empty project
|
||||
Given a Neo4jGraphIndexBackend instance with mock driver
|
||||
Then neo4j add_triple with empty project should raise ValueError
|
||||
|
||||
Scenario: Neo4jGraphIndexBackend add_triple rejects empty subject
|
||||
Given a Neo4jGraphIndexBackend instance with mock driver
|
||||
Then neo4j add_triple with empty subject should raise ValueError
|
||||
|
||||
Scenario: Neo4jGraphIndexBackend add_triple rejects empty predicate
|
||||
Given a Neo4jGraphIndexBackend instance with mock driver
|
||||
Then neo4j add_triple with empty predicate should raise ValueError
|
||||
|
||||
Scenario: Neo4jGraphIndexBackend add_triple rejects empty obj
|
||||
Given a Neo4jGraphIndexBackend instance with mock driver
|
||||
Then neo4j add_triple with empty obj should raise ValueError
|
||||
|
||||
Scenario: Neo4jGraphIndexBackend query rejects empty project
|
||||
Given a Neo4jGraphIndexBackend instance with mock driver
|
||||
Then neo4j query with empty project should raise ValueError
|
||||
|
||||
Scenario: Neo4jGraphIndexBackend query rejects empty sparql
|
||||
Given a Neo4jGraphIndexBackend instance with mock driver
|
||||
Then neo4j query with empty sparql should raise ValueError
|
||||
|
||||
Scenario: Neo4jGraphIndexBackend remove_triples rejects all-None filters
|
||||
Given a Neo4jGraphIndexBackend instance with mock driver
|
||||
Then neo4j remove_triples with all None filters should raise ValueError
|
||||
|
||||
Scenario: Neo4jGraphIndexBackend remove_triples rejects empty subject filter
|
||||
Given a Neo4jGraphIndexBackend instance with mock driver
|
||||
Then neo4j remove_triples with empty subject filter should raise ValueError
|
||||
|
||||
Scenario: Neo4jGraphIndexBackend add_triple stores triple via mock driver
|
||||
Given a Neo4jGraphIndexBackend instance with mock driver
|
||||
When I add neo4j triple project "local/test" subject "uko:A" predicate "uko:contains" obj "uko:B"
|
||||
Then the neo4j mock driver should have received a run call
|
||||
|
||||
Scenario: Neo4jGraphIndexBackend query returns results from mock driver
|
||||
Given a Neo4jGraphIndexBackend instance with mock driver returning query results
|
||||
When I query neo4j index project "local/test" sparql "SELECT ?s WHERE { ?s a uko:Container }"
|
||||
Then the neo4j index query result should be a list
|
||||
|
||||
Scenario: Neo4jGraphIndexBackend graceful degradation on add_triple
|
||||
Given a Neo4jGraphIndexBackend instance with unavailable driver
|
||||
When I add neo4j triple project "local/test" subject "uko:A" predicate "uko:contains" obj "uko:B"
|
||||
Then no neo4j exception should be raised
|
||||
|
||||
Scenario: Neo4jGraphIndexBackend graceful degradation on query
|
||||
Given a Neo4jGraphIndexBackend instance with unavailable driver
|
||||
When I query neo4j index project "local/test" sparql "SELECT ?s WHERE { ?s a uko:Container }"
|
||||
Then the neo4j index query result should be a list
|
||||
|
||||
Scenario: Neo4jGraphIndexBackend graceful degradation on remove_triples
|
||||
Given a Neo4jGraphIndexBackend instance with unavailable driver
|
||||
When I remove neo4j triples project "local/test" subject "uko:A" predicate None obj None
|
||||
Then no neo4j exception should be raised
|
||||
|
||||
# ---- Factory functions ----
|
||||
|
||||
Scenario: build_graph_backend returns InMemoryGraphBackend when backend is none
|
||||
Given the graph backend config is "none"
|
||||
When I call build_graph_backend
|
||||
Then the neo4j factory result should be an InMemoryGraphBackend
|
||||
|
||||
Scenario: build_graph_backend returns InMemoryGraphBackend when neo4j package missing
|
||||
Given the graph backend config is "neo4j" but neo4j package is unavailable
|
||||
When I call build_graph_backend
|
||||
Then the neo4j factory result should be an InMemoryGraphBackend
|
||||
|
||||
Scenario: build_graph_backend returns InMemoryGraphBackend when connection fails
|
||||
Given the graph backend config is "neo4j" with unreachable server
|
||||
When I call build_graph_backend
|
||||
Then the neo4j factory result should be an InMemoryGraphBackend
|
||||
|
||||
Scenario: build_graph_index_backend returns InMemoryGraphIndexBackend when backend is none
|
||||
Given the graph backend config is "none"
|
||||
When I call build_graph_index_backend
|
||||
Then the neo4j factory result should be an InMemoryGraphIndexBackend
|
||||
|
||||
Scenario: build_graph_index_backend returns InMemoryGraphIndexBackend when neo4j package missing
|
||||
Given the graph backend config is "neo4j" but neo4j package is unavailable
|
||||
When I call build_graph_index_backend
|
||||
Then the neo4j factory result should be an InMemoryGraphIndexBackend
|
||||
|
||||
Scenario: build_graph_index_backend returns InMemoryGraphIndexBackend when connection fails
|
||||
Given the graph backend config is "neo4j" with unreachable server
|
||||
When I call build_graph_index_backend
|
||||
Then the neo4j factory result should be an InMemoryGraphIndexBackend
|
||||
|
||||
# ---- DI Container registration ----
|
||||
|
||||
Scenario: DI container provides a graph_backend satisfying GraphBackend protocol
|
||||
Given the DI container with default config
|
||||
Then the container graph_backend should satisfy the GraphBackend protocol
|
||||
|
||||
Scenario: DI container provides an index_graph_backend satisfying GraphIndexBackend protocol
|
||||
Given the DI container with default config
|
||||
Then the container index_graph_backend should satisfy the GraphIndexBackend protocol
|
||||
|
||||
# ---- SPARQL translation ----
|
||||
|
||||
Scenario: SPARQL to Cypher translation handles unsupported query gracefully
|
||||
Given a Neo4jGraphBackend instance with mock driver returning no results
|
||||
When I run neo4j sparql query "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }" with scope "RES01"
|
||||
Then the neo4j graph query result should have no triples
|
||||
|
||||
Scenario: SPARQL to Cypher translation handles simple SELECT
|
||||
Given a Neo4jGraphBackend instance with mock driver returning triples
|
||||
When I run neo4j sparql query "SELECT ?s ?p ?o WHERE { ?s ?p ?o }" with scope "RES01"
|
||||
Then the neo4j graph query result should have 1 triple
|
||||
@@ -0,0 +1,459 @@
|
||||
"""Step definitions for the Neo4j Graph Backend feature."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
from cleveragents.application.container import get_container, reset_container
|
||||
from cleveragents.application.services.neo4j_graph_backend import (
|
||||
Neo4jGraphBackend,
|
||||
Neo4jGraphIndexBackend,
|
||||
build_graph_backend,
|
||||
build_graph_index_backend,
|
||||
)
|
||||
from cleveragents.domain.models.acms.backends import GraphBackend
|
||||
from cleveragents.domain.models.acms.index_backends import GraphIndexBackend
|
||||
from cleveragents.domain.models.acms.index_stubs import InMemoryGraphIndexBackend
|
||||
from cleveragents.domain.models.acms.stubs import InMemoryGraphBackend
|
||||
from features.mocks.neo4j_mock_driver import (
|
||||
MockNeo4jDriver,
|
||||
UnavailableNeo4jDriver,
|
||||
_ServiceUnavailableError,
|
||||
make_query_records,
|
||||
make_triple_records,
|
||||
)
|
||||
|
||||
__all__: list[str] = []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_read_backend(driver: Any) -> Neo4jGraphBackend:
|
||||
"""Build a Neo4jGraphBackend with a pre-injected driver."""
|
||||
backend = Neo4jGraphBackend(url="bolt://localhost:7687", auth=("neo4j", "test"))
|
||||
backend._driver = driver
|
||||
return backend
|
||||
|
||||
|
||||
def _make_write_backend(driver: Any) -> Neo4jGraphIndexBackend:
|
||||
"""Build a Neo4jGraphIndexBackend with a pre-injected driver."""
|
||||
backend = Neo4jGraphIndexBackend(
|
||||
url="bolt://localhost:7687", auth=("neo4j", "test")
|
||||
)
|
||||
backend._driver = driver
|
||||
return backend
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Neo4jGraphBackend (read-side) steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a Neo4jGraphBackend instance with mock driver")
|
||||
def step_given_neo4j_read_backend_mock(context: Any) -> None:
|
||||
context.neo4j_backend = _make_read_backend(MockNeo4jDriver())
|
||||
|
||||
|
||||
@given("a Neo4jGraphBackend instance with mock driver returning triples")
|
||||
def step_given_neo4j_read_backend_with_triples(context: Any) -> None:
|
||||
context.neo4j_backend = _make_read_backend(MockNeo4jDriver(make_triple_records()))
|
||||
|
||||
|
||||
@given("a Neo4jGraphBackend instance with mock driver returning no results")
|
||||
def step_given_neo4j_read_backend_no_results(context: Any) -> None:
|
||||
context.neo4j_backend = _make_read_backend(MockNeo4jDriver([]))
|
||||
|
||||
|
||||
@given("a Neo4jGraphBackend instance with unavailable driver")
|
||||
def step_given_neo4j_read_backend_unavailable(context: Any) -> None:
|
||||
backend = Neo4jGraphBackend(url="bolt://localhost:7687", auth=("neo4j", "test"))
|
||||
backend._driver = UnavailableNeo4jDriver()
|
||||
# Patch ServiceUnavailable so the backend catches our mock exception
|
||||
import cleveragents.application.services.neo4j_graph_backend as _mod
|
||||
|
||||
context._orig_service_unavailable = _mod._ServiceUnavailable
|
||||
_mod._ServiceUnavailable = _ServiceUnavailableError # type: ignore[assignment]
|
||||
context.neo4j_backend = backend
|
||||
context._neo4j_mod = _mod
|
||||
|
||||
|
||||
@then("the neo4j graph backend should satisfy the GraphBackend protocol")
|
||||
def step_then_neo4j_read_is_protocol(context: Any) -> None:
|
||||
assert isinstance(context.neo4j_backend, GraphBackend)
|
||||
|
||||
|
||||
@then("neo4j sparql_query with empty query should raise ValueError")
|
||||
def step_then_neo4j_sparql_empty_query(context: Any) -> None:
|
||||
try:
|
||||
context.neo4j_backend.sparql_query("", scope=frozenset({"RES01"}))
|
||||
raise AssertionError("Expected ValueError")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
@then("neo4j get_triples with empty subject should raise ValueError")
|
||||
def step_then_neo4j_get_triples_empty_subject(context: Any) -> None:
|
||||
try:
|
||||
context.neo4j_backend.get_triples("")
|
||||
raise AssertionError("Expected ValueError")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
@then("neo4j traverse with empty start should raise ValueError")
|
||||
def step_then_neo4j_traverse_empty_start(context: Any) -> None:
|
||||
try:
|
||||
context.neo4j_backend.traverse("")
|
||||
raise AssertionError("Expected ValueError")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
@then("neo4j traverse with depth {depth:d} should raise ValueError")
|
||||
def step_then_neo4j_traverse_bad_depth(context: Any, depth: int) -> None:
|
||||
try:
|
||||
context.neo4j_backend.traverse("uko:x", depth=depth)
|
||||
raise AssertionError("Expected ValueError")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
@when('I run neo4j sparql query "{query}" with scope "{scope}"')
|
||||
def step_when_neo4j_sparql_query(context: Any, query: str, scope: str) -> None:
|
||||
context.neo4j_graph_result = context.neo4j_backend.sparql_query(
|
||||
query, scope=frozenset({scope})
|
||||
)
|
||||
|
||||
|
||||
@when('I get neo4j triples for subject "{subject}"')
|
||||
def step_when_neo4j_get_triples(context: Any, subject: str) -> None:
|
||||
context.neo4j_graph_result = context.neo4j_backend.get_triples(subject)
|
||||
|
||||
|
||||
@when('I traverse neo4j from "{start}" with depth {depth:d}')
|
||||
def step_when_neo4j_traverse(context: Any, start: str, depth: int) -> None:
|
||||
context.neo4j_graph_result = context.neo4j_backend.traverse(start, depth=depth)
|
||||
|
||||
|
||||
@then("the neo4j graph query result should have no triples")
|
||||
def step_then_neo4j_graph_result_empty(context: Any) -> None:
|
||||
# Restore patched exception if needed
|
||||
if hasattr(context, "_neo4j_mod") and hasattr(context, "_orig_service_unavailable"):
|
||||
context._neo4j_mod._ServiceUnavailable = context._orig_service_unavailable
|
||||
assert context.neo4j_graph_result.triples == []
|
||||
|
||||
|
||||
@then("the neo4j graph query result should have {count:d} triple")
|
||||
def step_then_neo4j_graph_result_count(context: Any, count: int) -> None:
|
||||
assert len(context.neo4j_graph_result.triples) == count
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Neo4jGraphIndexBackend (write-side) steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a Neo4jGraphIndexBackend instance with mock driver")
|
||||
def step_given_neo4j_write_backend_mock(context: Any) -> None:
|
||||
context.neo4j_index_backend = _make_write_backend(MockNeo4jDriver())
|
||||
|
||||
|
||||
@given("a Neo4jGraphIndexBackend instance with mock driver returning query results")
|
||||
def step_given_neo4j_write_backend_with_results(context: Any) -> None:
|
||||
context.neo4j_index_backend = _make_write_backend(
|
||||
MockNeo4jDriver(make_query_records())
|
||||
)
|
||||
|
||||
|
||||
@given("a Neo4jGraphIndexBackend instance with unavailable driver")
|
||||
def step_given_neo4j_write_backend_unavailable(context: Any) -> None:
|
||||
backend = Neo4jGraphIndexBackend(
|
||||
url="bolt://localhost:7687", auth=("neo4j", "test")
|
||||
)
|
||||
backend._driver = UnavailableNeo4jDriver()
|
||||
import cleveragents.application.services.neo4j_graph_backend as _mod
|
||||
|
||||
context._orig_service_unavailable = _mod._ServiceUnavailable
|
||||
_mod._ServiceUnavailable = _ServiceUnavailableError # type: ignore[assignment]
|
||||
context.neo4j_index_backend = backend
|
||||
context._neo4j_mod = _mod
|
||||
|
||||
|
||||
@then("the neo4j graph index backend should satisfy the GraphIndexBackend protocol")
|
||||
def step_then_neo4j_write_is_protocol(context: Any) -> None:
|
||||
assert isinstance(context.neo4j_index_backend, GraphIndexBackend)
|
||||
|
||||
|
||||
@then("neo4j add_triple with empty project should raise ValueError")
|
||||
def step_then_neo4j_add_triple_empty_project(context: Any) -> None:
|
||||
try:
|
||||
context.neo4j_index_backend.add_triple("", "uko:A", "uko:rel", "uko:B")
|
||||
raise AssertionError("Expected ValueError")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
@then("neo4j add_triple with empty subject should raise ValueError")
|
||||
def step_then_neo4j_add_triple_empty_subject(context: Any) -> None:
|
||||
try:
|
||||
context.neo4j_index_backend.add_triple("proj", "", "uko:rel", "uko:B")
|
||||
raise AssertionError("Expected ValueError")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
@then("neo4j add_triple with empty predicate should raise ValueError")
|
||||
def step_then_neo4j_add_triple_empty_predicate(context: Any) -> None:
|
||||
try:
|
||||
context.neo4j_index_backend.add_triple("proj", "uko:A", "", "uko:B")
|
||||
raise AssertionError("Expected ValueError")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
@then("neo4j add_triple with empty obj should raise ValueError")
|
||||
def step_then_neo4j_add_triple_empty_obj(context: Any) -> None:
|
||||
try:
|
||||
context.neo4j_index_backend.add_triple("proj", "uko:A", "uko:rel", "")
|
||||
raise AssertionError("Expected ValueError")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
@then("neo4j query with empty project should raise ValueError")
|
||||
def step_then_neo4j_query_empty_project(context: Any) -> None:
|
||||
try:
|
||||
context.neo4j_index_backend.query("", "SELECT ?s WHERE { ?s a uko:X }")
|
||||
raise AssertionError("Expected ValueError")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
@then("neo4j query with empty sparql should raise ValueError")
|
||||
def step_then_neo4j_query_empty_sparql(context: Any) -> None:
|
||||
try:
|
||||
context.neo4j_index_backend.query("proj", "")
|
||||
raise AssertionError("Expected ValueError")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
@then("neo4j remove_triples with all None filters should raise ValueError")
|
||||
def step_then_neo4j_remove_all_none(context: Any) -> None:
|
||||
try:
|
||||
context.neo4j_index_backend.remove_triples("proj", None, None, None)
|
||||
raise AssertionError("Expected ValueError")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
@then("neo4j remove_triples with empty subject filter should raise ValueError")
|
||||
def step_then_neo4j_remove_empty_subject(context: Any) -> None:
|
||||
try:
|
||||
context.neo4j_index_backend.remove_triples("proj", "", None, None)
|
||||
raise AssertionError("Expected ValueError")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
@when(
|
||||
'I add neo4j triple project "{project}" subject "{subject}" predicate "{predicate}" obj "{obj}"'
|
||||
)
|
||||
def step_when_neo4j_add_triple(
|
||||
context: Any, project: str, subject: str, predicate: str, obj: str
|
||||
) -> None:
|
||||
context.neo4j_index_backend.add_triple(project, subject, predicate, obj)
|
||||
context.neo4j_add_triple_called = True
|
||||
|
||||
|
||||
@then("the neo4j mock driver should have received a run call")
|
||||
def step_then_neo4j_driver_run_called(context: Any) -> None:
|
||||
driver = context.neo4j_index_backend._driver
|
||||
assert len(driver.run_calls) > 0
|
||||
|
||||
|
||||
@when('I query neo4j index project "{project}" sparql "{sparql}"')
|
||||
def step_when_neo4j_index_query(context: Any, project: str, sparql: str) -> None:
|
||||
context.neo4j_index_query_result = context.neo4j_index_backend.query(
|
||||
project, sparql
|
||||
)
|
||||
|
||||
|
||||
@then("the neo4j index query result should be a list")
|
||||
def step_then_neo4j_index_query_is_list(context: Any) -> None:
|
||||
# Restore patched exception if needed
|
||||
if hasattr(context, "_neo4j_mod") and hasattr(context, "_orig_service_unavailable"):
|
||||
context._neo4j_mod._ServiceUnavailable = context._orig_service_unavailable
|
||||
assert isinstance(context.neo4j_index_query_result, list)
|
||||
|
||||
|
||||
@when(
|
||||
'I remove neo4j triples project "{project}" subject "{subject}" predicate None obj None'
|
||||
)
|
||||
def step_when_neo4j_remove_triples(context: Any, project: str, subject: str) -> None:
|
||||
context.neo4j_index_backend.remove_triples(project, subject, None, None)
|
||||
context.neo4j_remove_called = True
|
||||
|
||||
|
||||
@then("no neo4j exception should be raised")
|
||||
def step_then_no_exception(context: Any) -> None:
|
||||
# Restore patched exception if needed
|
||||
if hasattr(context, "_neo4j_mod") and hasattr(context, "_orig_service_unavailable"):
|
||||
context._neo4j_mod._ServiceUnavailable = context._orig_service_unavailable
|
||||
# If we got here without exception, the test passes
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Factory function steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _MockConfigService:
|
||||
"""Minimal ConfigService mock for factory function tests."""
|
||||
|
||||
def __init__(
|
||||
self, backend: str, url: str = "bolt://localhost:7687", auth: str = "neo4j:test"
|
||||
) -> None:
|
||||
self._backend = backend
|
||||
self._url = url
|
||||
self._auth = auth
|
||||
|
||||
def resolve(self, key: str) -> Any:
|
||||
result = MagicMock()
|
||||
if key == "index.graph.backend":
|
||||
result.value = self._backend
|
||||
elif key == "index.graph.neo4j-url":
|
||||
result.value = self._url
|
||||
elif key == "index.graph.neo4j-auth":
|
||||
result.value = self._auth
|
||||
else:
|
||||
result.value = None
|
||||
return result
|
||||
|
||||
|
||||
@given('the graph backend config is "none"')
|
||||
def step_given_config_none(context: Any) -> None:
|
||||
context.mock_config = _MockConfigService("none")
|
||||
|
||||
|
||||
@given('the graph backend config is "neo4j" but neo4j package is unavailable')
|
||||
def step_given_config_neo4j_no_package(context: Any) -> None:
|
||||
context.mock_config = _MockConfigService("neo4j")
|
||||
context.neo4j_unavailable = True
|
||||
|
||||
|
||||
@given('the graph backend config is "neo4j" with unreachable server')
|
||||
def step_given_config_neo4j_unreachable(context: Any) -> None:
|
||||
context.mock_config = _MockConfigService("neo4j", url="bolt://unreachable:9999")
|
||||
context.neo4j_unreachable = True
|
||||
|
||||
|
||||
@when("I call build_graph_backend")
|
||||
def step_when_build_graph_backend(context: Any) -> None:
|
||||
if getattr(context, "neo4j_unavailable", False):
|
||||
import cleveragents.application.services.neo4j_graph_backend as _mod
|
||||
|
||||
orig = _mod._NEO4J_AVAILABLE
|
||||
_mod._NEO4J_AVAILABLE = False
|
||||
try:
|
||||
context.factory_result = build_graph_backend(context.mock_config)
|
||||
finally:
|
||||
_mod._NEO4J_AVAILABLE = orig
|
||||
elif getattr(context, "neo4j_unreachable", False):
|
||||
import cleveragents.application.services.neo4j_graph_backend as _mod
|
||||
|
||||
orig_available = _mod._NEO4J_AVAILABLE
|
||||
orig_gdb = _mod._GraphDatabase
|
||||
# Patch GraphDatabase.driver to raise on verify_connectivity
|
||||
mock_driver = MagicMock()
|
||||
mock_driver.verify_connectivity.side_effect = Exception("Connection refused")
|
||||
mock_gdb = MagicMock()
|
||||
mock_gdb.driver.return_value = mock_driver
|
||||
_mod._GraphDatabase = mock_gdb
|
||||
_mod._NEO4J_AVAILABLE = True
|
||||
try:
|
||||
context.factory_result = build_graph_backend(context.mock_config)
|
||||
finally:
|
||||
_mod._GraphDatabase = orig_gdb
|
||||
_mod._NEO4J_AVAILABLE = orig_available
|
||||
else:
|
||||
context.factory_result = build_graph_backend(context.mock_config)
|
||||
|
||||
|
||||
@when("I call build_graph_index_backend")
|
||||
def step_when_build_graph_index_backend(context: Any) -> None:
|
||||
if getattr(context, "neo4j_unavailable", False):
|
||||
import cleveragents.application.services.neo4j_graph_backend as _mod
|
||||
|
||||
orig = _mod._NEO4J_AVAILABLE
|
||||
_mod._NEO4J_AVAILABLE = False
|
||||
try:
|
||||
context.factory_result = build_graph_index_backend(context.mock_config)
|
||||
finally:
|
||||
_mod._NEO4J_AVAILABLE = orig
|
||||
elif getattr(context, "neo4j_unreachable", False):
|
||||
import cleveragents.application.services.neo4j_graph_backend as _mod
|
||||
|
||||
orig_available = _mod._NEO4J_AVAILABLE
|
||||
orig_gdb = _mod._GraphDatabase
|
||||
mock_driver = MagicMock()
|
||||
mock_driver.verify_connectivity.side_effect = Exception("Connection refused")
|
||||
mock_gdb = MagicMock()
|
||||
mock_gdb.driver.return_value = mock_driver
|
||||
_mod._GraphDatabase = mock_gdb
|
||||
_mod._NEO4J_AVAILABLE = True
|
||||
try:
|
||||
context.factory_result = build_graph_index_backend(context.mock_config)
|
||||
finally:
|
||||
_mod._GraphDatabase = orig_gdb
|
||||
_mod._NEO4J_AVAILABLE = orig_available
|
||||
else:
|
||||
context.factory_result = build_graph_index_backend(context.mock_config)
|
||||
|
||||
|
||||
@then("the neo4j factory result should be an InMemoryGraphBackend")
|
||||
def step_then_result_is_in_memory_graph(context: Any) -> None:
|
||||
assert isinstance(context.factory_result, InMemoryGraphBackend), (
|
||||
f"Expected InMemoryGraphBackend, got {type(context.factory_result)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the neo4j factory result should be an InMemoryGraphIndexBackend")
|
||||
def step_then_result_is_in_memory_graph_index(context: Any) -> None:
|
||||
assert isinstance(context.factory_result, InMemoryGraphIndexBackend), (
|
||||
f"Expected InMemoryGraphIndexBackend, got {type(context.factory_result)}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DI Container steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("the DI container with default config")
|
||||
def step_given_container_default(context: Any) -> None:
|
||||
reset_container()
|
||||
context.container = get_container()
|
||||
|
||||
|
||||
@then("the container graph_backend should satisfy the GraphBackend protocol")
|
||||
def step_then_container_graph_backend_protocol(context: Any) -> None:
|
||||
backend = context.container.graph_backend()
|
||||
assert isinstance(backend, GraphBackend), (
|
||||
f"Expected GraphBackend, got {type(backend)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the container index_graph_backend should satisfy the GraphIndexBackend protocol")
|
||||
def step_then_container_index_graph_backend_protocol(context: Any) -> None:
|
||||
backend = context.container.index_graph_backend()
|
||||
assert isinstance(backend, GraphIndexBackend), (
|
||||
f"Expected GraphIndexBackend, got {type(backend)}"
|
||||
)
|
||||
@@ -54,6 +54,10 @@ from cleveragents.application.services.fix_then_revalidate import (
|
||||
from cleveragents.application.services.multi_project_service import (
|
||||
MultiProjectService,
|
||||
)
|
||||
from cleveragents.application.services.neo4j_graph_backend import (
|
||||
build_graph_backend,
|
||||
build_graph_index_backend,
|
||||
)
|
||||
from cleveragents.application.services.plan_lifecycle_service import (
|
||||
PlanLifecycleService,
|
||||
)
|
||||
@@ -82,11 +86,9 @@ from cleveragents.application.services.vector_store_service import VectorStoreSe
|
||||
from cleveragents.config.settings import Settings, get_settings
|
||||
from cleveragents.domain.models.acms.analyzers import AnalyzerRegistry
|
||||
from cleveragents.domain.models.acms.index_stubs import (
|
||||
InMemoryGraphIndexBackend,
|
||||
InMemoryTextIndexBackend,
|
||||
)
|
||||
from cleveragents.domain.models.acms.stubs import (
|
||||
InMemoryGraphBackend,
|
||||
InMemoryTextBackend,
|
||||
)
|
||||
from cleveragents.domain.providers.ai_provider import AIProviderInterface
|
||||
@@ -747,7 +749,13 @@ class Container(containers.DeclarativeContainer):
|
||||
build_vector_backend,
|
||||
vector_store_service=acms_vector_store_service,
|
||||
)
|
||||
graph_backend = providers.Singleton(InMemoryGraphBackend)
|
||||
# Graph backend: Neo4j when index.graph.backend=neo4j, else in-memory stub.
|
||||
# Graceful degradation: falls back to InMemoryGraphBackend when Neo4j is
|
||||
# unavailable or not configured.
|
||||
graph_backend = providers.Singleton(
|
||||
build_graph_backend,
|
||||
config_service=config_service,
|
||||
)
|
||||
|
||||
# ACMS UKO Indexer — write-side index backends (#578)
|
||||
analyzer_registry = providers.Singleton(_build_analyzer_registry)
|
||||
@@ -756,7 +764,11 @@ class Container(containers.DeclarativeContainer):
|
||||
build_vector_index_backend,
|
||||
vector_store_service=acms_vector_store_service,
|
||||
)
|
||||
index_graph_backend = providers.Singleton(InMemoryGraphIndexBackend)
|
||||
# Graph index backend: Neo4j when configured, else in-memory stub.
|
||||
index_graph_backend = providers.Singleton(
|
||||
build_graph_index_backend,
|
||||
config_service=config_service,
|
||||
)
|
||||
uko_indexer = providers.Singleton(
|
||||
UKOIndexer,
|
||||
analyzer_registry=analyzer_registry,
|
||||
|
||||
@@ -0,0 +1,785 @@
|
||||
"""Neo4j-backed ACMS graph backend adapters.
|
||||
|
||||
Bridges the ACMS read/write graph backend protocols to a Neo4j graph
|
||||
database. The Neo4j driver is imported lazily so that the package
|
||||
remains importable even when ``neo4j`` is not installed.
|
||||
|
||||
When Neo4j is unavailable (driver missing or connection refused) the
|
||||
factory functions fall back to the in-memory stubs, providing graceful
|
||||
degradation.
|
||||
|
||||
Configuration keys (read via :class:`ConfigService`):
|
||||
``index.graph.backend`` — must be ``"neo4j"`` to activate
|
||||
``index.graph.neo4j-url`` — bolt/neo4j URL, e.g. ``bolt://localhost:7687``
|
||||
``index.graph.neo4j-auth``— ``user:password`` string
|
||||
|
||||
Based on ``docs/specification.md`` > ACMS > Backend Abstraction Layer
|
||||
and ADR-014.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import structlog
|
||||
|
||||
from cleveragents.domain.models.acms.backends import GraphBackend, GraphResult
|
||||
from cleveragents.domain.models.acms.index_backends import GraphIndexBackend
|
||||
from cleveragents.domain.models.acms.index_stubs import InMemoryGraphIndexBackend
|
||||
from cleveragents.domain.models.acms.stubs import InMemoryGraphBackend
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lazy driver import
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
try:
|
||||
from neo4j import GraphDatabase as _GraphDatabase # type: ignore[import-untyped]
|
||||
|
||||
_NEO4J_AVAILABLE = True
|
||||
try:
|
||||
from neo4j.exceptions import ( # type: ignore[import-untyped]
|
||||
ServiceUnavailable as _ServiceUnavailable,
|
||||
)
|
||||
except ImportError:
|
||||
_ServiceUnavailable = Exception # type: ignore[assignment,misc]
|
||||
except ImportError:
|
||||
_GraphDatabase = None # type: ignore[assignment,misc]
|
||||
_ServiceUnavailable = Exception # type: ignore[assignment,misc]
|
||||
_NEO4J_AVAILABLE = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _require_non_empty(value: str, name: str) -> str:
|
||||
"""Return stripped *value* or raise ``ValueError`` if empty."""
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise ValueError(f"{name} must be a non-empty string")
|
||||
return stripped
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SPARQL → Cypher translation helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Minimal SPARQL SELECT pattern: SELECT ?vars WHERE { triple_patterns }
|
||||
_SPARQL_SELECT_RE = re.compile(
|
||||
r"SELECT\s+(?P<vars>.*?)\s+WHERE\s*\{(?P<body>.*?)\}",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
# Triple pattern: subject predicate object (URIs or ?vars)
|
||||
_TRIPLE_RE = re.compile(
|
||||
r"(?P<s>[?<\w][^\s]*)\s+(?P<p>[?<\w][^\s]*)\s+(?P<o>[?<\w][^\s]*)\s*[.;]?",
|
||||
)
|
||||
|
||||
|
||||
def _uri_to_cypher(term: str) -> str:
|
||||
"""Convert a SPARQL term to a Cypher string literal or variable."""
|
||||
term = term.strip().rstrip(".")
|
||||
if term.startswith("?"):
|
||||
return term # variable — keep as-is for mapping
|
||||
if term.startswith("<") and term.endswith(">"):
|
||||
return f'"{term[1:-1]}"'
|
||||
# Prefixed name or bare literal
|
||||
return f'"{term}"'
|
||||
|
||||
|
||||
def _sparql_to_cypher(sparql: str, scope: frozenset[str]) -> tuple[str, dict[str, Any]]:
|
||||
"""Translate a simple SPARQL SELECT to a Cypher MATCH query.
|
||||
|
||||
Only handles the subset of SPARQL used by UKO ontology queries:
|
||||
``SELECT ?vars WHERE { triple_patterns }``. Complex SPARQL
|
||||
(OPTIONAL, UNION, FILTER, etc.) is passed through as a raw Cypher
|
||||
comment so that the caller receives an empty result rather than
|
||||
crashing.
|
||||
|
||||
Returns:
|
||||
A ``(cypher_query, params)`` tuple.
|
||||
"""
|
||||
m = _SPARQL_SELECT_RE.search(sparql)
|
||||
if not m:
|
||||
# Unsupported SPARQL — return a no-op query
|
||||
logger.warning(
|
||||
"neo4j.sparql_translation.unsupported",
|
||||
sparql=sparql[:200],
|
||||
)
|
||||
return "MATCH (n) WHERE false RETURN n", {}
|
||||
|
||||
body = m.group("body").strip()
|
||||
triples = _TRIPLE_RE.findall(body)
|
||||
if not triples:
|
||||
return "MATCH (n) WHERE false RETURN n", {}
|
||||
|
||||
params: dict[str, Any] = {}
|
||||
match_clauses: list[str] = []
|
||||
return_vars: set[str] = set()
|
||||
|
||||
for idx, (s, p, o) in enumerate(triples):
|
||||
s_cypher = _uri_to_cypher(s)
|
||||
p_cypher = _uri_to_cypher(p)
|
||||
o_cypher = _uri_to_cypher(o)
|
||||
|
||||
s_var = f"s{idx}"
|
||||
p_var = f"p{idx}"
|
||||
o_var = f"o{idx}"
|
||||
|
||||
# Build MATCH clause
|
||||
if s_cypher.startswith("?"):
|
||||
s_expr = f"({s_var})"
|
||||
return_vars.add(s_var)
|
||||
params[s_var] = None
|
||||
else:
|
||||
s_expr = f"({s_var} {{uri: $s{idx}_val}})"
|
||||
params[f"s{idx}_val"] = s_cypher.strip('"')
|
||||
|
||||
if o_cypher.startswith("?"):
|
||||
o_expr = f"({o_var})"
|
||||
return_vars.add(o_var)
|
||||
else:
|
||||
o_expr = f"({o_var} {{uri: $o{idx}_val}})"
|
||||
params[f"o{idx}_val"] = o_cypher.strip('"')
|
||||
|
||||
rel_type = (
|
||||
p_cypher.strip('"').replace(":", "_").replace("/", "_").replace("-", "_")
|
||||
)
|
||||
if p_cypher.startswith("?"):
|
||||
rel_expr = f"-[{p_var}]->"
|
||||
return_vars.add(p_var)
|
||||
else:
|
||||
rel_expr = f"-[:{rel_type}]->"
|
||||
|
||||
match_clauses.append(f"MATCH {s_expr}{rel_expr}{o_expr}")
|
||||
|
||||
# Scope filter — restrict to nodes whose project property is in scope
|
||||
scope_filter = ""
|
||||
if scope:
|
||||
scope_list = list(scope)
|
||||
params["scope"] = scope_list
|
||||
scope_filter = " WHERE s0.project IN $scope"
|
||||
|
||||
return_clause = (
|
||||
"RETURN " + ", ".join(f"{v}.uri AS {v}" for v in sorted(return_vars))
|
||||
if return_vars
|
||||
else "RETURN s0.uri AS s0, p0, o0.uri AS o0"
|
||||
)
|
||||
|
||||
cypher = "\n".join(match_clauses) + scope_filter + "\n" + return_clause
|
||||
return cypher, params
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Neo4jGraphBackend (read-side)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Neo4jGraphBackend:
|
||||
"""Read-side ACMS graph backend backed by Neo4j.
|
||||
|
||||
Implements the :class:`GraphBackend` protocol. SPARQL queries are
|
||||
translated to Cypher via a minimal translator; ``get_triples`` and
|
||||
``traverse`` use native Cypher directly.
|
||||
|
||||
Args:
|
||||
url: Neo4j bolt URL (e.g. ``bolt://localhost:7687``).
|
||||
auth: ``(user, password)`` tuple.
|
||||
"""
|
||||
|
||||
def __init__(self, url: str, auth: tuple[str, str]) -> None:
|
||||
self._url = url
|
||||
self._auth = auth
|
||||
self._driver: Any = None
|
||||
|
||||
def _get_driver(self) -> Any:
|
||||
"""Return (or lazily create) the Neo4j driver."""
|
||||
if self._driver is None:
|
||||
if _GraphDatabase is None:
|
||||
raise RuntimeError("neo4j driver is not installed")
|
||||
self._driver = _GraphDatabase.driver(self._url, auth=self._auth)
|
||||
return self._driver
|
||||
|
||||
def sparql_query(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
scope: frozenset[str],
|
||||
) -> GraphResult:
|
||||
"""Execute a SPARQL query translated to Cypher within *scope*.
|
||||
|
||||
Args:
|
||||
query: SPARQL query string. Must be non-empty.
|
||||
scope: Frozenset of resource ULIDs to restrict results to.
|
||||
|
||||
Returns:
|
||||
A :class:`GraphResult` containing matched triples.
|
||||
|
||||
Raises:
|
||||
ValueError: If *query* is empty.
|
||||
"""
|
||||
if not query:
|
||||
raise ValueError("query must be a non-empty string")
|
||||
try:
|
||||
cypher, params = _sparql_to_cypher(query, scope)
|
||||
driver = self._get_driver()
|
||||
triples: list[tuple[str, str, str]] = []
|
||||
with driver.session() as session:
|
||||
result = session.run(cypher, **params)
|
||||
for record in result:
|
||||
keys = list(record.keys())
|
||||
if len(keys) >= 3:
|
||||
triples.append(
|
||||
(
|
||||
str(record[keys[0]] or ""),
|
||||
str(record[keys[1]] or ""),
|
||||
str(record[keys[2]] or ""),
|
||||
)
|
||||
)
|
||||
elif len(keys) == 2:
|
||||
triples.append(
|
||||
(str(record[keys[0]] or ""), "", str(record[keys[1]] or ""))
|
||||
)
|
||||
return GraphResult(triples=triples)
|
||||
except _ServiceUnavailable:
|
||||
logger.warning(
|
||||
"neo4j.graph_backend.unavailable",
|
||||
url=self._url,
|
||||
operation="sparql_query",
|
||||
)
|
||||
return GraphResult()
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"neo4j.graph_backend.error",
|
||||
operation="sparql_query",
|
||||
error=str(exc),
|
||||
)
|
||||
return GraphResult()
|
||||
|
||||
def get_triples(
|
||||
self,
|
||||
subject: str,
|
||||
) -> GraphResult:
|
||||
"""Retrieve all triples for a given *subject*.
|
||||
|
||||
Args:
|
||||
subject: The UKO URI of the subject node. Must be non-empty.
|
||||
|
||||
Returns:
|
||||
A :class:`GraphResult` containing the subject's triples.
|
||||
|
||||
Raises:
|
||||
ValueError: If *subject* is empty.
|
||||
"""
|
||||
if not subject:
|
||||
raise ValueError("subject must be a non-empty string")
|
||||
try:
|
||||
driver = self._get_driver()
|
||||
cypher = (
|
||||
"MATCH (s {uri: $subject})-[r]->(o) "
|
||||
"RETURN s.uri AS s, type(r) AS p, o.uri AS o"
|
||||
)
|
||||
triples: list[tuple[str, str, str]] = []
|
||||
with driver.session() as session:
|
||||
result = session.run(cypher, subject=subject)
|
||||
for record in result:
|
||||
triples.append(
|
||||
(
|
||||
str(record["s"] or ""),
|
||||
str(record["p"] or ""),
|
||||
str(record["o"] or ""),
|
||||
)
|
||||
)
|
||||
return GraphResult(triples=triples)
|
||||
except _ServiceUnavailable:
|
||||
logger.warning(
|
||||
"neo4j.graph_backend.unavailable",
|
||||
url=self._url,
|
||||
operation="get_triples",
|
||||
)
|
||||
return GraphResult()
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"neo4j.graph_backend.error",
|
||||
operation="get_triples",
|
||||
error=str(exc),
|
||||
)
|
||||
return GraphResult()
|
||||
|
||||
def traverse(
|
||||
self,
|
||||
start: str,
|
||||
*,
|
||||
depth: int = 2,
|
||||
) -> GraphResult:
|
||||
"""Traverse the graph from *start* up to *depth* hops.
|
||||
|
||||
Args:
|
||||
start: UKO URI to begin traversal from. Must be non-empty.
|
||||
depth: Maximum number of hops from *start*. Must be
|
||||
non-negative.
|
||||
|
||||
Returns:
|
||||
A :class:`GraphResult` containing discovered triples.
|
||||
|
||||
Raises:
|
||||
ValueError: If *start* is empty or *depth* is negative.
|
||||
"""
|
||||
if not start:
|
||||
raise ValueError("start must be a non-empty string")
|
||||
if depth < 0:
|
||||
raise ValueError(f"depth must be non-negative, got {depth}")
|
||||
try:
|
||||
driver = self._get_driver()
|
||||
cypher = (
|
||||
"MATCH path = (s {uri: $start})-[*1..$depth]->(o) "
|
||||
"UNWIND relationships(path) AS r "
|
||||
"RETURN startNode(r).uri AS s, type(r) AS p, endNode(r).uri AS o"
|
||||
)
|
||||
triples: list[tuple[str, str, str]] = []
|
||||
seen: set[tuple[str, str, str]] = set()
|
||||
with driver.session() as session:
|
||||
result = session.run(cypher, start=start, depth=depth)
|
||||
for record in result:
|
||||
triple = (
|
||||
str(record["s"] or ""),
|
||||
str(record["p"] or ""),
|
||||
str(record["o"] or ""),
|
||||
)
|
||||
if triple not in seen:
|
||||
seen.add(triple)
|
||||
triples.append(triple)
|
||||
return GraphResult(triples=triples)
|
||||
except _ServiceUnavailable:
|
||||
logger.warning(
|
||||
"neo4j.graph_backend.unavailable",
|
||||
url=self._url,
|
||||
operation="traverse",
|
||||
)
|
||||
return GraphResult()
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"neo4j.graph_backend.error",
|
||||
operation="traverse",
|
||||
error=str(exc),
|
||||
)
|
||||
return GraphResult()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the underlying Neo4j driver connection."""
|
||||
if self._driver is not None:
|
||||
try:
|
||||
self._driver.close()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self._driver = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Neo4jGraphIndexBackend (write-side)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Neo4jGraphIndexBackend:
|
||||
"""Write-side ACMS graph index backend backed by Neo4j.
|
||||
|
||||
Implements the :class:`GraphIndexBackend` protocol. Each triple is
|
||||
stored as two ``Resource`` nodes connected by a typed relationship.
|
||||
The ``project`` property on each node enables project-scoped queries.
|
||||
|
||||
Args:
|
||||
url: Neo4j bolt URL.
|
||||
auth: ``(user, password)`` tuple.
|
||||
"""
|
||||
|
||||
def __init__(self, url: str, auth: tuple[str, str]) -> None:
|
||||
self._url = url
|
||||
self._auth = auth
|
||||
self._driver: Any = None
|
||||
|
||||
def _get_driver(self) -> Any:
|
||||
"""Return (or lazily create) the Neo4j driver."""
|
||||
if self._driver is None:
|
||||
if _GraphDatabase is None:
|
||||
raise RuntimeError("neo4j driver is not installed")
|
||||
self._driver = _GraphDatabase.driver(self._url, auth=self._auth)
|
||||
return self._driver
|
||||
|
||||
def add_triple(
|
||||
self,
|
||||
project: str,
|
||||
subject: str,
|
||||
predicate: str,
|
||||
obj: str,
|
||||
) -> None:
|
||||
"""Add a single triple to the Neo4j graph.
|
||||
|
||||
Uses ``MERGE`` to avoid duplicate nodes/relationships.
|
||||
|
||||
Args:
|
||||
project: Namespaced project name.
|
||||
subject: Subject URI.
|
||||
predicate: Predicate URI (used as relationship type).
|
||||
obj: Object URI or literal value.
|
||||
|
||||
Raises:
|
||||
ValueError: If any argument is empty or whitespace-only.
|
||||
"""
|
||||
_require_non_empty(project, "project")
|
||||
_require_non_empty(subject, "subject")
|
||||
_require_non_empty(predicate, "predicate")
|
||||
_require_non_empty(obj, "obj")
|
||||
|
||||
# Relationship types in Cypher must be identifiers
|
||||
rel_type = (
|
||||
predicate.replace(":", "_")
|
||||
.replace("/", "_")
|
||||
.replace("-", "_")
|
||||
.replace(".", "_")
|
||||
)
|
||||
# Ensure rel_type starts with a letter
|
||||
if rel_type and not rel_type[0].isalpha():
|
||||
rel_type = "R_" + rel_type
|
||||
|
||||
try:
|
||||
driver = self._get_driver()
|
||||
rel_merge = (
|
||||
f"MERGE (s)-[:{rel_type} "
|
||||
"{{predicate: $predicate, project: $project}}]->(o)"
|
||||
)
|
||||
cypher = (
|
||||
"MERGE (s:Resource {uri: $subject, project: $project}) "
|
||||
"MERGE (o:Resource {uri: $obj, project: $project}) " + rel_merge
|
||||
)
|
||||
with driver.session() as session:
|
||||
session.run(
|
||||
cypher,
|
||||
subject=subject,
|
||||
obj=obj,
|
||||
predicate=predicate,
|
||||
project=project,
|
||||
)
|
||||
logger.debug(
|
||||
"neo4j.index.triple_added",
|
||||
project=project,
|
||||
subject=subject,
|
||||
predicate=predicate,
|
||||
)
|
||||
except _ServiceUnavailable:
|
||||
logger.warning(
|
||||
"neo4j.graph_index_backend.unavailable",
|
||||
url=self._url,
|
||||
operation="add_triple",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"neo4j.graph_index_backend.error",
|
||||
operation="add_triple",
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
def query(
|
||||
self,
|
||||
project: str,
|
||||
sparql: str,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Execute a SPARQL query translated to Cypher.
|
||||
|
||||
Args:
|
||||
project: Namespaced project name.
|
||||
sparql: SPARQL query string.
|
||||
|
||||
Returns:
|
||||
List of binding dictionaries.
|
||||
|
||||
Raises:
|
||||
ValueError: If *project* or *sparql* is empty or
|
||||
whitespace-only.
|
||||
"""
|
||||
_require_non_empty(project, "project")
|
||||
_require_non_empty(sparql, "sparql")
|
||||
|
||||
try:
|
||||
cypher, params = _sparql_to_cypher(sparql, frozenset({project}))
|
||||
driver = self._get_driver()
|
||||
results: list[dict[str, str]] = []
|
||||
with driver.session() as session:
|
||||
result = session.run(cypher, **params)
|
||||
for record in result:
|
||||
results.append({k: str(v or "") for k, v in record.items()})
|
||||
return results
|
||||
except _ServiceUnavailable:
|
||||
logger.warning(
|
||||
"neo4j.graph_index_backend.unavailable",
|
||||
url=self._url,
|
||||
operation="query",
|
||||
)
|
||||
return []
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"neo4j.graph_index_backend.error",
|
||||
operation="query",
|
||||
error=str(exc),
|
||||
)
|
||||
return []
|
||||
|
||||
def remove_triples(
|
||||
self,
|
||||
project: str,
|
||||
subject: str | None,
|
||||
predicate: str | None,
|
||||
obj: str | None,
|
||||
) -> None:
|
||||
"""Remove triples matching the given pattern.
|
||||
|
||||
Args:
|
||||
project: Namespaced project name.
|
||||
subject: Subject URI filter, or ``None`` for wildcard.
|
||||
predicate: Predicate URI filter, or ``None`` for wildcard.
|
||||
obj: Object filter, or ``None`` for wildcard.
|
||||
|
||||
Raises:
|
||||
ValueError: If *project* is empty or whitespace-only, or
|
||||
all three filters are ``None``.
|
||||
"""
|
||||
_require_non_empty(project, "project")
|
||||
if subject is None and predicate is None and obj is None:
|
||||
raise ValueError(
|
||||
"At least one of subject, predicate, obj must be "
|
||||
"non-None to avoid deleting all triples"
|
||||
)
|
||||
if subject is not None and not subject.strip():
|
||||
raise ValueError("subject filter must be non-empty if provided")
|
||||
if predicate is not None and not predicate.strip():
|
||||
raise ValueError("predicate filter must be non-empty if provided")
|
||||
if obj is not None and not obj.strip():
|
||||
raise ValueError("obj filter must be non-empty if provided")
|
||||
|
||||
try:
|
||||
driver = self._get_driver()
|
||||
# Build WHERE conditions
|
||||
conditions: list[str] = ["r.project = $project"]
|
||||
params: dict[str, Any] = {"project": project}
|
||||
if subject is not None:
|
||||
conditions.append("s.uri = $subject")
|
||||
params["subject"] = subject
|
||||
if predicate is not None:
|
||||
conditions.append("r.predicate = $predicate")
|
||||
params["predicate"] = predicate
|
||||
if obj is not None:
|
||||
conditions.append("o.uri = $obj")
|
||||
params["obj"] = obj
|
||||
|
||||
where_clause = " AND ".join(conditions)
|
||||
cypher = (
|
||||
f"MATCH (s:Resource)-[r]->(o:Resource) WHERE {where_clause} DELETE r"
|
||||
)
|
||||
with driver.session() as session:
|
||||
session.run(cypher, **params)
|
||||
logger.debug(
|
||||
"neo4j.index.triples_removed",
|
||||
project=project,
|
||||
subject=subject,
|
||||
predicate=predicate,
|
||||
obj=obj,
|
||||
)
|
||||
except _ServiceUnavailable:
|
||||
logger.warning(
|
||||
"neo4j.graph_index_backend.unavailable",
|
||||
url=self._url,
|
||||
operation="remove_triples",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"neo4j.graph_index_backend.error",
|
||||
operation="remove_triples",
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the underlying Neo4j driver connection."""
|
||||
if self._driver is not None:
|
||||
try:
|
||||
self._driver.close()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self._driver = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Protocol compliance assertions (static verification by Pyright)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_assert_read: type[GraphBackend] = Neo4jGraphBackend # type: ignore[type-abstract]
|
||||
_assert_write: type[GraphIndexBackend] = Neo4jGraphIndexBackend # type: ignore[type-abstract]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Factory functions (used by DI container)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_auth(auth_str: str) -> tuple[str, str]:
|
||||
"""Parse ``user:password`` auth string into a tuple."""
|
||||
if ":" in auth_str:
|
||||
user, _, password = auth_str.partition(":")
|
||||
return user, password
|
||||
return auth_str, ""
|
||||
|
||||
|
||||
def build_graph_backend(config_service: Any) -> Any:
|
||||
"""Return the configured ACMS read-side graph backend.
|
||||
|
||||
Reads ``index.graph.backend``, ``index.graph.neo4j-url``, and
|
||||
``index.graph.neo4j-auth`` from :class:`ConfigService`. Falls back
|
||||
to :class:`InMemoryGraphBackend` when:
|
||||
|
||||
- The configured backend is not ``"neo4j"``
|
||||
- The ``neo4j`` driver package is not installed
|
||||
- The Neo4j server is unreachable at startup
|
||||
|
||||
Args:
|
||||
config_service: A :class:`ConfigService` instance.
|
||||
|
||||
Returns:
|
||||
A :class:`GraphBackend`-compatible object.
|
||||
"""
|
||||
try:
|
||||
backend_cfg = config_service.resolve("index.graph.backend")
|
||||
backend_name: str = str(backend_cfg.value or "none").lower()
|
||||
except Exception:
|
||||
backend_name = "none"
|
||||
|
||||
if backend_name != "neo4j":
|
||||
logger.info(
|
||||
"acms.graph_backend.fallback",
|
||||
configured_backend=backend_name,
|
||||
fallback_backend="InMemoryGraphBackend",
|
||||
)
|
||||
return InMemoryGraphBackend()
|
||||
|
||||
if not _NEO4J_AVAILABLE:
|
||||
logger.warning(
|
||||
"acms.graph_backend.neo4j_unavailable",
|
||||
reason="neo4j package not installed",
|
||||
fallback_backend="InMemoryGraphBackend",
|
||||
)
|
||||
return InMemoryGraphBackend()
|
||||
|
||||
try:
|
||||
url_cfg = config_service.resolve("index.graph.neo4j-url")
|
||||
url: str = str(url_cfg.value or "bolt://localhost:7687")
|
||||
auth_cfg = config_service.resolve("index.graph.neo4j-auth")
|
||||
auth_str: str = str(auth_cfg.value or "neo4j:neo4j")
|
||||
auth = _parse_auth(auth_str)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"acms.graph_backend.config_error",
|
||||
error=str(exc),
|
||||
fallback_backend="InMemoryGraphBackend",
|
||||
)
|
||||
return InMemoryGraphBackend()
|
||||
|
||||
# Probe connectivity — fall back gracefully if Neo4j is down
|
||||
if _GraphDatabase is None:
|
||||
return InMemoryGraphBackend()
|
||||
try:
|
||||
probe_driver = _GraphDatabase.driver(url, auth=auth)
|
||||
probe_driver.verify_connectivity()
|
||||
probe_driver.close()
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"acms.graph_backend.connection_failed",
|
||||
url=url,
|
||||
error=str(exc),
|
||||
fallback_backend="InMemoryGraphBackend",
|
||||
)
|
||||
return InMemoryGraphBackend()
|
||||
|
||||
logger.info("acms.graph_backend.neo4j_activated", url=url)
|
||||
return Neo4jGraphBackend(url=url, auth=auth)
|
||||
|
||||
|
||||
def build_graph_index_backend(config_service: Any) -> Any:
|
||||
"""Return the configured ACMS write-side graph index backend.
|
||||
|
||||
Uses the same configuration keys as :func:`build_graph_backend`.
|
||||
Falls back to :class:`InMemoryGraphIndexBackend` on any error.
|
||||
|
||||
Args:
|
||||
config_service: A :class:`ConfigService` instance.
|
||||
|
||||
Returns:
|
||||
A :class:`GraphIndexBackend`-compatible object.
|
||||
"""
|
||||
try:
|
||||
backend_cfg = config_service.resolve("index.graph.backend")
|
||||
backend_name: str = str(backend_cfg.value or "none").lower()
|
||||
except Exception:
|
||||
backend_name = "none"
|
||||
|
||||
if backend_name != "neo4j":
|
||||
logger.info(
|
||||
"acms.graph_index_backend.fallback",
|
||||
configured_backend=backend_name,
|
||||
fallback_backend="InMemoryGraphIndexBackend",
|
||||
)
|
||||
return InMemoryGraphIndexBackend()
|
||||
|
||||
if not _NEO4J_AVAILABLE:
|
||||
logger.warning(
|
||||
"acms.graph_index_backend.neo4j_unavailable",
|
||||
reason="neo4j package not installed",
|
||||
fallback_backend="InMemoryGraphIndexBackend",
|
||||
)
|
||||
return InMemoryGraphIndexBackend()
|
||||
|
||||
try:
|
||||
url_cfg = config_service.resolve("index.graph.neo4j-url")
|
||||
url: str = str(url_cfg.value or "bolt://localhost:7687")
|
||||
auth_cfg = config_service.resolve("index.graph.neo4j-auth")
|
||||
auth_str: str = str(auth_cfg.value or "neo4j:neo4j")
|
||||
auth = _parse_auth(auth_str)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"acms.graph_index_backend.config_error",
|
||||
error=str(exc),
|
||||
fallback_backend="InMemoryGraphIndexBackend",
|
||||
)
|
||||
return InMemoryGraphIndexBackend()
|
||||
|
||||
# Probe connectivity
|
||||
if _GraphDatabase is None:
|
||||
return InMemoryGraphIndexBackend()
|
||||
try:
|
||||
probe_driver = _GraphDatabase.driver(url, auth=auth)
|
||||
probe_driver.verify_connectivity()
|
||||
probe_driver.close()
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"acms.graph_index_backend.connection_failed",
|
||||
url=url,
|
||||
error=str(exc),
|
||||
fallback_backend="InMemoryGraphIndexBackend",
|
||||
)
|
||||
return InMemoryGraphIndexBackend()
|
||||
|
||||
logger.info("acms.graph_index_backend.neo4j_activated", url=url)
|
||||
return Neo4jGraphIndexBackend(url=url, auth=auth)
|
||||
|
||||
|
||||
__all__: list[str] = [
|
||||
"Neo4jGraphBackend",
|
||||
"Neo4jGraphIndexBackend",
|
||||
"build_graph_backend",
|
||||
"build_graph_index_backend",
|
||||
]
|
||||
@@ -1203,3 +1203,7 @@ create_workspace_snapshot # noqa: B018, F821
|
||||
selective_rollback # noqa: B018, F821
|
||||
archive_artifacts # noqa: B018, F821
|
||||
revert_decisions # noqa: B018, F821
|
||||
|
||||
# Neo4j graph backend — close() is public API for resource cleanup (#872)
|
||||
Neo4jGraphBackend # noqa: B018, F821
|
||||
Neo4jGraphIndexBackend # noqa: B018, F821
|
||||
|
||||
Reference in New Issue
Block a user