feat(graph): add Apache AGE backend integration with configuration, registration, tests and documentation (#311)

This commit is contained in:
Sameer Kadam
2026-02-15 15:57:07 +05:30
committed by GitHub
parent b42bfaef09
commit 20755e69e2
8 changed files with 2369 additions and 3 deletions
+14 -3
View File
@@ -137,6 +137,7 @@ print(f"Found {len(precedents)} precedents")
- **Docling Support** — Document parsing with table extraction (PDF, DOCX, PPTX, XLSX)
- **AWS Neptune** — Amazon Neptune graph database support with IAM authentication
- **Apache AGE** — PostgreSQL graph extension backend (openCypher via SQL)
- **Custom Ontology Import** — Import existing ontologies (OWL, RDF, Turtle, JSON-LD)
> **Built for environments where every answer must be explainable and governed.**
@@ -284,7 +285,8 @@ The **semantic gap** is the fundamental disconnect between what AI systems can p
- 📐 **OWL Ontologies** — HermiT/Pellet validated, custom ontology import support
- 🔢 **Vector Embeddings** — FastEmbed by default
- ☁️ **AWS Neptune** — Amazon Neptune graph database support
- 🔍 **Provenance** — Every AI response links back to:
- **Apache AGE** — PostgreSQL graph extension with openCypher support
- 🔍 **Provenance** — Every AI response links back to:
- 📄 Source documents
- 🏷️ Extracted entities & relations
- 📐 Ontology rules applied
@@ -510,13 +512,13 @@ results = vector_store.search(query="supply chain", top_k=5)
### Graph Store & Triplet Store
> **Neo4j, FalkorDB, Amazon Neptune** • **SPARQL queries** • **RDF triplets**
> **Neo4j, FalkorDB, Amazon Neptune, Apache AGE** • **SPARQL queries** • **RDF triplets**
```python
from semantica.graph_store import GraphStore
from semantica.triplet_store import TripletStore
# Graph Store (Neo4j, FalkorDB)
# Graph Store (Neo4j, FalkorDB, Apache AGE)
graph_store = GraphStore(backend="neo4j", uri="bolt://localhost:7687", user="neo4j", password="password")
graph_store.add_nodes([{"id": "n1", "labels": ["Person"], "properties": {"name": "Alice"}}])
@@ -538,6 +540,15 @@ neptune_store.add_nodes([
# Query Operations
result = neptune_store.execute_query("MATCH (p:Person) RETURN p.name, p.age")
# Apache AGE Graph Store (PostgreSQL + openCypher)
age_store = GraphStore(
backend="age",
connection_string="host=localhost dbname=agedb user=postgres password=secret",
graph_name="semantica",
)
age_store.connect()
age_store.create_node(labels=["Person"], properties={"name": "Alice", "age": 30})
# Triplet Store (Blazegraph, Jena, RDF4J)
triplet_store = TripletStore(backend="blazegraph", endpoint="http://localhost:9999/blazegraph")
triplet_store.add_triplet({"subject": "Alice", "predicate": "knows", "object": "Bob"})
+243
View File
@@ -0,0 +1,243 @@
# Apache AGE Graph Store
**Backend**: PostgreSQL + [Apache AGE](https://age.apache.org/)
**Driver**: `psycopg2`
Apache AGE is a PostgreSQL extension that adds graph database functionality, enabling you to run openCypher queries alongside traditional SQL. This backend lets Semantica use AGE as a property graph store with the same interface as Neo4j and FalkorDB.
---
## Prerequisites
| Component | Version |
|-----------|---------|
| PostgreSQL | 12+ |
| Apache AGE | 1.4+ (compiled and installed) |
| psycopg2 | 2.9+ |
```bash
pip install psycopg2-binary
```
> **Note**: Apache AGE must be compiled and installed into your PostgreSQL instance. See the [AGE installation guide](https://age.apache.org/age-manual/master/intro/setup.html).
---
## Quick Start
```python
from semantica.graph_store import GraphStore
# Using the unified GraphStore facade
store = GraphStore(
backend="age",
connection_string="host=localhost dbname=agedb user=postgres password=secret",
graph_name="semantica",
)
store.connect()
# Create nodes
alice = store.create_node(labels=["Person"], properties={"name": "Alice", "age": 30})
bob = store.create_node(labels=["Person"], properties={"name": "Bob", "age": 25})
# Create relationship
rel = store.create_relationship(alice["id"], bob["id"], "KNOWS", {"since": 2023})
# Query
result = store.execute_query("MATCH (p:Person) RETURN p", cols="p agtype")
print(result["records"])
store.close()
```
### Direct Usage (without facade)
```python
from semantica.graph_store.age_store import ApacheAgeStore
store = ApacheAgeStore(
connection_string="host=localhost dbname=agedb user=postgres password=secret",
graph_name="my_graph",
)
store.connect()
node = store.create_node(["Entity"], {"semantica_id": "ent-001", "value": "test"})
print(node)
# {"id": 844424930131969, "labels": ["Entity"], "properties": {"semantica_id": "ent-001", "value": "test"}}
store.close()
```
---
## Configuration
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `GRAPH_STORE_AGE_CONNECTION_STRING` | PostgreSQL connection string | `host=localhost dbname=agedb user=postgres password=postgres` |
| `GRAPH_STORE_AGE_GRAPH_NAME` | AGE graph name | `semantica` |
### Programmatic Configuration
```python
from semantica.graph_store.config import graph_store_config
graph_store_config.set("age_connection_string", "host=db.example.com dbname=prod_age user=app")
graph_store_config.set("age_graph_name", "production")
```
---
## Connection & Initialization
On `connect()`, the store performs idempotent setup:
1. `CREATE EXTENSION IF NOT EXISTS age;`
2. `LOAD 'age';`
3. `SET search_path = ag_catalog, "$user", public;`
4. Creates the named graph if it does not already exist.
This is safe to call repeatedly.
---
## ID Handling
Apache AGE auto-generates internal vertex/edge IDs (large integers). These are **not** the same as any semantic or application-level ID you may want to assign.
| Concept | Description |
|---------|-------------|
| **AGE internal ID** | Auto-generated by AGE. Exposed as `"id"` in all returned dicts. Used in `delete_node()`, `get_node()`, etc. |
| **Semantic ID** | Application-level identifier. Store it in the `semantica_id` property. |
```python
node = store.create_node(
labels=["Document"],
properties={"semantica_id": "doc-abc-123", "title": "My Doc"},
)
# node["id"] → AGE internal ID (e.g., 844424930131969)
# node["properties"]["semantica_id"] → "doc-abc-123"
```
> **Important**: Never mix AGE internal IDs with semantic IDs. Use `node["id"]` for graph operations (delete, update, traverse) and `node["properties"]["semantica_id"]` for application-level lookups.
---
## Label Handling
AGE supports exactly **one label per vertex**. Semantica handles this transparently:
- `labels[0]` → used as the primary AGE vertex label.
- `labels[1:]` → stored in a `labels` property array on the vertex.
When reading nodes, the store reconstructs the full label list automatically.
```python
node = store.create_node(
labels=["Person", "Employee", "Admin"],
properties={"name": "Alice"},
)
# In AGE: vertex with label "Person" and property labels=["Employee", "Admin"]
# Returned: {"id": ..., "labels": ["Person", "Employee", "Admin"], "properties": {"name": "Alice"}}
```
---
## Cypher Query Execution
All Cypher queries are executed via AGE's SQL wrapper:
```sql
SELECT * FROM cypher('graph_name', $$ <cypher_query> $$) AS (col1 agtype, ...);
```
### Parameter Substitution
AGE does not support `$param` style binding inside `cypher()` calls. The store safely converts parameters to Cypher literals with proper escaping:
```python
result = store.execute_query(
"MATCH (p:Person) WHERE p.age > $min_age RETURN p",
parameters={"min_age": 25},
cols="p agtype",
)
```
### Column Specification
For custom queries, pass the `cols` option to specify the `AS` clause:
```python
result = store.execute_query(
"MATCH (a)-[r]->(b) RETURN a, r, b",
cols="a agtype, r agtype, b agtype",
)
```
If omitted, the store attempts to infer columns from the `RETURN` clause.
---
## Transactions
The store uses explicit PostgreSQL transactions:
- **Success** → `COMMIT`
- **Exception** → `ROLLBACK`, then re-raise as `ProcessingError`
- No silent failures
---
## API Reference
All methods match the standard Semantica graph store backend interface:
| Method | Description |
|--------|-------------|
| `connect(**options)` | Connect and initialize AGE |
| `close()` | Close the connection |
| `create_node(labels, properties)` | Create a vertex |
| `create_nodes(nodes)` | Batch create vertices |
| `get_node(node_id)` | Get vertex by AGE ID |
| `get_nodes(labels, properties, limit)` | Query vertices |
| `update_node(node_id, properties, merge)` | Update vertex properties |
| `delete_node(node_id, detach)` | Delete a vertex |
| `create_relationship(start_id, end_id, type, properties)` | Create an edge |
| `get_relationships(node_id, rel_type, direction, limit)` | Query edges |
| `delete_relationship(rel_id)` | Delete an edge |
| `execute_query(query, parameters)` | Run arbitrary Cypher |
| `get_neighbors(node_id, rel_type, direction, depth)` | Graph traversal |
| `shortest_path(start_id, end_id, rel_type, max_depth)` | Path finding |
| `create_index(label, property_name, index_type)` | Create a PostgreSQL index |
| `get_stats()` | Graph statistics |
---
## Docker Setup
```yaml
services:
age:
image: apache/age:latest
ports:
- "5432:5432"
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: secret
POSTGRES_DB: agedb
```
```bash
docker compose up -d
```
Then connect:
```python
store = GraphStore(
backend="age",
connection_string="host=localhost port=5432 dbname=agedb user=postgres password=secret",
)
```
+3
View File
@@ -122,6 +122,7 @@ Author: Semantica Contributors
License: MIT
"""
from .age_store import ApacheAgeStore
from .amazon_neptune import (
AmazonNeptuneStore,
NeptuneAuthTokenManager,
@@ -172,6 +173,8 @@ __all__ = [
"Neo4jStore",
"Neo4jDriver",
"Neo4jTransaction",
# Apache AGE
"ApacheAgeStore",
# Amazon Neptune
"AmazonNeptuneStore",
"NeptuneAuthTokenManager",
File diff suppressed because it is too large Load Diff
+18
View File
@@ -138,6 +138,9 @@ class GraphStoreConfig:
"AWS_ACCESS_KEY_ID": "neptune_access_key",
"AWS_SECRET_ACCESS_KEY": "neptune_secret_key",
"AWS_SESSION_TOKEN": "neptune_session_token",
# Apache AGE settings
"GRAPH_STORE_AGE_CONNECTION_STRING": "age_connection_string",
"GRAPH_STORE_AGE_GRAPH_NAME": "age_graph_name",
}
for env_var, config_key in env_mappings.items():
@@ -199,6 +202,9 @@ class GraphStoreConfig:
"neptune_access_key": None,
"neptune_secret_key": None,
"neptune_session_token": None,
# Apache AGE defaults
"age_connection_string": "host=localhost dbname=agedb user=postgres password=postgres",
"age_graph_name": "semantica",
}
for key, default_value in defaults.items():
@@ -315,6 +321,18 @@ class GraphStoreConfig:
"session_token": self._config.get("neptune_session_token"),
}
def get_age_config(self) -> Dict[str, Any]:
"""
Get Apache AGE-specific configuration.
Returns:
Apache AGE configuration dictionary
"""
return {
"connection_string": self._config.get("age_connection_string"),
"graph_name": self._config.get("age_graph_name"),
}
def reset(self) -> None:
"""Reset configuration to defaults."""
self._config.clear()
+7
View File
@@ -583,6 +583,13 @@ class GraphStore:
neptune_config.update(self.config)
self._store_backend = AmazonNeptuneStore(**neptune_config)
elif self.backend == "age" or self.backend == "apache_age":
from .age_store import ApacheAgeStore
age_config = graph_store_config.get_age_config()
age_config.update(self.config)
self._store_backend = ApacheAgeStore(**age_config)
else:
raise ValidationError(f"Unknown backend: {self.backend}")
View File
+772
View File
@@ -0,0 +1,772 @@
"""
Tests for Apache AGE Store Module
Tests cover:
- Node CRUD (create, read, update, delete)
- Relationship CRUD
- Query execution
- Graph traversal (get_neighbors, shortest_path)
- Transaction rollback on error
- Multi-label handling
- ID separation (AGE internal vs semantic)
- Input validation / sanitisation
- Stats retrieval
- Index creation
The psycopg2 database layer is fully mocked to enable offline testing.
"""
import json
import unittest
from typing import Any, Dict, List, Optional
from unittest.mock import MagicMock, call, patch
# ---------------------------------------------------------------------------
# Mock psycopg2 before importing the module under test so that ``PSYCOPG2_AVAILABLE``
# is ``True`` inside age_store.
# ---------------------------------------------------------------------------
import sys
_mock_psycopg2 = MagicMock()
_mock_psycopg2_extras = MagicMock()
sys.modules["psycopg2"] = _mock_psycopg2
sys.modules["psycopg2.extras"] = _mock_psycopg2_extras
from semantica.graph_store.age_store import (
ApacheAgeStore,
_edge_to_rel_dict,
_parse_agtype,
_props_to_cypher_literal,
_sanitize_label,
_sanitize_rel_type,
_value_to_cypher_literal,
_vertex_to_node_dict,
)
from semantica.utils.exceptions import ProcessingError, ValidationError
# ---------------------------------------------------------------------------
# Helper fixtures
# ---------------------------------------------------------------------------
def _make_vertex_agtype(vid: int, label: str, props: Dict[str, Any]) -> str:
"""Return a string mimicking AGE agtype vertex output."""
obj = {"id": vid, "label": label, "properties": props}
return json.dumps(obj) + "::vertex"
def _make_edge_agtype(
eid: int, label: str, start_id: int, end_id: int, props: Dict[str, Any]
) -> str:
"""Return a string mimicking AGE agtype edge output."""
obj = {
"id": eid,
"label": label,
"start_id": start_id,
"end_id": end_id,
"properties": props,
}
return json.dumps(obj) + "::edge"
# ---------------------------------------------------------------------------
# Unit tests — helpers
# ---------------------------------------------------------------------------
class TestHelpers(unittest.TestCase):
"""Tests for module-level helper functions."""
# -- _sanitize_label --------------------------------------------------
def test_sanitize_label_valid(self):
self.assertEqual(_sanitize_label("Person"), "Person")
self.assertEqual(_sanitize_label("_hidden"), "_hidden")
self.assertEqual(_sanitize_label("Rel_Type2"), "Rel_Type2")
def test_sanitize_label_invalid(self):
with self.assertRaises(ValidationError):
_sanitize_label("123bad")
with self.assertRaises(ValidationError):
_sanitize_label("no spaces")
with self.assertRaises(ValidationError):
_sanitize_label("no-dashes")
# -- _sanitize_rel_type -----------------------------------------------
def test_sanitize_rel_type_valid(self):
self.assertEqual(_sanitize_rel_type("KNOWS"), "KNOWS")
def test_sanitize_rel_type_invalid(self):
with self.assertRaises(ValidationError):
_sanitize_rel_type("bad type!")
# -- _value_to_cypher_literal -----------------------------------------
def test_literal_none(self):
self.assertEqual(_value_to_cypher_literal(None), "null")
def test_literal_bool(self):
self.assertEqual(_value_to_cypher_literal(True), "true")
self.assertEqual(_value_to_cypher_literal(False), "false")
def test_literal_int(self):
self.assertEqual(_value_to_cypher_literal(42), "42")
def test_literal_float(self):
self.assertIn("3.14", _value_to_cypher_literal(3.14))
def test_literal_string(self):
self.assertEqual(_value_to_cypher_literal("hello"), "'hello'")
def test_literal_string_escape(self):
result = _value_to_cypher_literal("it's a \"test\"")
self.assertIn("\\'", result)
def test_literal_list(self):
result = _value_to_cypher_literal([1, "a"])
self.assertEqual(result, "[1, 'a']")
def test_literal_dict(self):
result = _value_to_cypher_literal({"x": 1})
self.assertEqual(result, "{x: 1}")
# -- _props_to_cypher_literal -----------------------------------------
def test_props_empty(self):
self.assertEqual(_props_to_cypher_literal({}), "{}")
def test_props_simple(self):
result = _props_to_cypher_literal({"name": "Alice", "age": 30})
self.assertIn("name: 'Alice'", result)
self.assertIn("age: 30", result)
def test_props_invalid_key(self):
with self.assertRaises(ValidationError):
_props_to_cypher_literal({"bad key!": 1})
# -- _parse_agtype ----------------------------------------------------
def test_parse_agtype_none(self):
self.assertIsNone(_parse_agtype(None))
def test_parse_agtype_vertex(self):
text = '{"id": 1, "label": "Person", "properties": {"name": "Alice"}}::vertex'
result = _parse_agtype(text)
self.assertEqual(result["id"], 1)
self.assertEqual(result["label"], "Person")
def test_parse_agtype_edge(self):
text = '{"id": 10, "label": "KNOWS", "start_id": 1, "end_id": 2, "properties": {}}::edge'
result = _parse_agtype(text)
self.assertEqual(result["id"], 10)
self.assertEqual(result["start_id"], 1)
def test_parse_agtype_numeric(self):
self.assertEqual(_parse_agtype("42::numeric"), 42)
self.assertEqual(_parse_agtype("3.14::float"), 3.14)
def test_parse_agtype_boolean(self):
self.assertTrue(_parse_agtype("true::boolean"))
self.assertFalse(_parse_agtype("false::boolean"))
def test_parse_agtype_plain_json(self):
self.assertEqual(_parse_agtype('{"a": 1}'), {"a": 1})
def test_parse_agtype_non_string(self):
self.assertEqual(_parse_agtype(99), 99)
# -- _vertex_to_node_dict ---------------------------------------------
def test_vertex_to_node_dict_basic(self):
vertex = {"id": 5, "label": "Person", "properties": {"name": "Alice"}}
result = _vertex_to_node_dict(vertex)
self.assertEqual(result["id"], 5)
self.assertEqual(result["labels"], ["Person"])
self.assertEqual(result["properties"]["name"], "Alice")
def test_vertex_to_node_dict_extra_labels(self):
vertex = {
"id": 7,
"label": "Person",
"properties": {"name": "Bob", "labels": ["Employee", "Admin"]},
}
result = _vertex_to_node_dict(vertex)
self.assertEqual(result["labels"], ["Person", "Employee", "Admin"])
# 'labels' property should be removed from properties
self.assertNotIn("labels", result["properties"])
def test_vertex_to_node_dict_non_dict(self):
result = _vertex_to_node_dict("not a dict")
self.assertIsNone(result["id"])
# -- _edge_to_rel_dict ------------------------------------------------
def test_edge_to_rel_dict_basic(self):
edge = {
"id": 10,
"label": "KNOWS",
"start_id": 1,
"end_id": 2,
"properties": {"since": 2020},
}
result = _edge_to_rel_dict(edge)
self.assertEqual(result["id"], 10)
self.assertEqual(result["type"], "KNOWS")
self.assertEqual(result["start_node_id"], 1)
self.assertEqual(result["end_node_id"], 2)
self.assertEqual(result["properties"]["since"], 2020)
def test_edge_to_rel_dict_non_dict(self):
result = _edge_to_rel_dict(42)
self.assertIsNone(result["id"])
# ---------------------------------------------------------------------------
# Unit tests — ApacheAgeStore with mocked DB
# ---------------------------------------------------------------------------
class TestApacheAgeStore(unittest.TestCase):
"""Tests for ApacheAgeStore with a fully mocked psycopg2 connection."""
def setUp(self):
"""Set up a store with a mocked PostgreSQL connection."""
self.mock_conn = MagicMock()
self.mock_conn.closed = False
self.mock_cursor = MagicMock()
self.mock_conn.cursor.return_value.__enter__ = MagicMock(
return_value=self.mock_cursor
)
self.mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
# Patch psycopg2.connect to return the mock connection
_mock_psycopg2.connect.return_value = self.mock_conn
self.store = ApacheAgeStore(
connection_string="host=localhost dbname=testdb user=test password=test",
graph_name="test_graph",
)
# Simulate a successful connect (graph already exists)
self.mock_cursor.fetchone.return_value = (1,) # graph exists
self.store.connect()
# Reset mock call history after connect
self.mock_cursor.reset_mock()
self.mock_conn.reset_mock()
self.mock_conn.closed = False
def tearDown(self):
self.store.close()
# -- connect ----------------------------------------------------------
def test_connect_creates_extension_and_graph(self):
"""connect() should run idempotent setup commands."""
store = ApacheAgeStore(
connection_string="host=localhost dbname=agedb user=test",
graph_name="new_graph",
)
# Graph does NOT exist yet
self.mock_cursor.fetchone.return_value = (0,)
result = store.connect()
self.assertTrue(result)
# Verify setup SQL was executed
executed = [
str(c) for c in self.mock_cursor.execute.call_args_list
]
setup_text = " ".join(executed)
self.assertIn("CREATE EXTENSION IF NOT EXISTS age", setup_text)
self.assertIn("LOAD 'age'", setup_text)
self.assertIn("search_path", setup_text)
self.assertIn("create_graph", setup_text)
def test_connect_idempotent_existing_graph(self):
"""connect() should skip create_graph if graph already exists."""
store = ApacheAgeStore(
connection_string="host=localhost dbname=agedb user=test",
graph_name="existing_graph",
)
self.mock_cursor.fetchone.return_value = (1,) # graph exists
result = store.connect()
self.assertTrue(result)
executed = [
str(c) for c in self.mock_cursor.execute.call_args_list
]
setup_text = " ".join(executed)
# create_graph should NOT be called (graph count=1)
self.assertNotIn("create_graph", setup_text.split("ag_graph")[1] if "ag_graph" in setup_text else "")
# -- create_node ------------------------------------------------------
def test_create_node_single_label(self):
"""create_node with one label should use it as AGE label."""
vertex_str = _make_vertex_agtype(100, "Person", {"name": "Alice"})
self.mock_cursor.fetchall.return_value = [(vertex_str,)]
node = self.store.create_node(
labels=["Person"], properties={"name": "Alice"}
)
self.assertEqual(node["id"], 100)
self.assertEqual(node["labels"], ["Person"])
self.assertEqual(node["properties"]["name"], "Alice")
def test_create_node_multiple_labels(self):
"""Additional labels beyond the first are stored as property array."""
vertex_str = _make_vertex_agtype(
101, "Person", {"name": "Bob", "labels": ["Employee", "Admin"]}
)
self.mock_cursor.fetchall.return_value = [(vertex_str,)]
node = self.store.create_node(
labels=["Person", "Employee", "Admin"],
properties={"name": "Bob"},
)
self.assertEqual(node["id"], 101)
self.assertIn("Person", node["labels"])
self.assertIn("Employee", node["labels"])
self.assertIn("Admin", node["labels"])
# 'labels' property should be moved out of properties
self.assertNotIn("labels", node["properties"])
def test_create_node_with_semantica_id(self):
"""semantica_id in properties should be preserved."""
vertex_str = _make_vertex_agtype(
102, "Entity", {"semantica_id": "abc-123", "value": "test"}
)
self.mock_cursor.fetchall.return_value = [(vertex_str,)]
node = self.store.create_node(
labels=["Entity"],
properties={"semantica_id": "abc-123", "value": "test"},
)
self.assertEqual(node["id"], 102) # AGE internal ID
self.assertEqual(node["properties"]["semantica_id"], "abc-123")
def test_create_node_empty_labels_raises(self):
"""create_node with empty labels should raise ValidationError."""
with self.assertRaises(ValidationError):
self.store.create_node(labels=[], properties={"name": "X"})
def test_create_node_invalid_label_raises(self):
"""create_node with invalid label should raise ValidationError."""
with self.assertRaises(ValidationError):
self.store.create_node(labels=["bad label!"], properties={})
# -- create_nodes -----------------------------------------------------
def test_create_nodes_batch(self):
"""create_nodes should create multiple nodes."""
responses = [
[(_make_vertex_agtype(200, "Person", {"name": "A"}),)],
[(_make_vertex_agtype(201, "Person", {"name": "B"}),)],
]
self.mock_cursor.fetchall.side_effect = responses
nodes_data = [
{"labels": ["Person"], "properties": {"name": "A"}},
{"labels": ["Person"], "properties": {"name": "B"}},
]
result = self.store.create_nodes(nodes_data)
self.assertEqual(len(result), 2)
self.assertEqual(result[0]["id"], 200)
self.assertEqual(result[1]["id"], 201)
# -- get_node ---------------------------------------------------------
def test_get_node_found(self):
vertex_str = _make_vertex_agtype(100, "Person", {"name": "Alice"})
self.mock_cursor.fetchall.return_value = [(vertex_str,)]
node = self.store.get_node(100)
self.assertIsNotNone(node)
self.assertEqual(node["id"], 100)
self.assertEqual(node["properties"]["name"], "Alice")
def test_get_node_not_found(self):
self.mock_cursor.fetchall.return_value = []
node = self.store.get_node(999)
self.assertIsNone(node)
# -- get_nodes --------------------------------------------------------
def test_get_nodes_with_label_filter(self):
vertex_str = _make_vertex_agtype(100, "Person", {"name": "Alice"})
self.mock_cursor.fetchall.return_value = [(vertex_str,)]
nodes = self.store.get_nodes(labels=["Person"])
self.assertEqual(len(nodes), 1)
self.assertEqual(nodes[0]["labels"], ["Person"])
def test_get_nodes_with_property_filter(self):
vertex_str = _make_vertex_agtype(100, "Person", {"name": "Alice"})
self.mock_cursor.fetchall.return_value = [(vertex_str,)]
nodes = self.store.get_nodes(properties={"name": "Alice"})
self.assertEqual(len(nodes), 1)
def test_get_nodes_empty(self):
self.mock_cursor.fetchall.return_value = []
nodes = self.store.get_nodes()
self.assertEqual(nodes, [])
# -- update_node ------------------------------------------------------
def test_update_node_merge(self):
vertex_str = _make_vertex_agtype(100, "Person", {"name": "Alice", "age": 31})
self.mock_cursor.fetchall.return_value = [(vertex_str,)]
node = self.store.update_node(100, {"age": 31}, merge=True)
self.assertEqual(node["id"], 100)
self.assertEqual(node["properties"]["age"], 31)
self.assertEqual(node["properties"]["name"], "Alice")
# Verify the Cypher used += for merge
executed_sql = self.mock_cursor.execute.call_args[0][0]
self.assertIn("+=", executed_sql)
def test_update_node_replace(self):
vertex_str = _make_vertex_agtype(100, "Person", {"age": 31})
self.mock_cursor.fetchall.return_value = [(vertex_str,)]
node = self.store.update_node(100, {"age": 31}, merge=False)
self.assertEqual(node["properties"]["age"], 31)
# Verify SET n = (not +=) for replace
executed_sql = self.mock_cursor.execute.call_args[0][0]
self.assertIn("SET n =", executed_sql)
self.assertNotIn("+=", executed_sql)
def test_update_node_not_found(self):
self.mock_cursor.fetchall.return_value = []
with self.assertRaises(ProcessingError):
self.store.update_node(999, {"age": 31})
# -- delete_node ------------------------------------------------------
def test_delete_node_detach(self):
self.mock_cursor.fetchall.return_value = []
result = self.store.delete_node(100, detach=True)
self.assertTrue(result)
executed_sql = self.mock_cursor.execute.call_args[0][0]
self.assertIn("DETACH DELETE", executed_sql)
def test_delete_node_no_detach(self):
self.mock_cursor.fetchall.return_value = []
result = self.store.delete_node(100, detach=False)
self.assertTrue(result)
executed_sql = self.mock_cursor.execute.call_args[0][0]
self.assertIn("DELETE", executed_sql)
self.assertNotIn("DETACH", executed_sql)
# -- create_relationship ----------------------------------------------
def test_create_relationship(self):
edge_str = _make_edge_agtype(500, "KNOWS", 100, 200, {"since": 2023})
self.mock_cursor.fetchall.return_value = [(edge_str,)]
rel = self.store.create_relationship(100, 200, "KNOWS", {"since": 2023})
self.assertEqual(rel["id"], 500)
self.assertEqual(rel["type"], "KNOWS")
self.assertEqual(rel["start_node_id"], 100)
self.assertEqual(rel["end_node_id"], 200)
self.assertEqual(rel["properties"]["since"], 2023)
def test_create_relationship_no_properties(self):
edge_str = _make_edge_agtype(501, "FOLLOWS", 100, 200, {})
self.mock_cursor.fetchall.return_value = [(edge_str,)]
rel = self.store.create_relationship(100, 200, "FOLLOWS")
self.assertEqual(rel["type"], "FOLLOWS")
self.assertEqual(rel["properties"], {})
def test_create_relationship_invalid_type_raises(self):
with self.assertRaises(ValidationError):
self.store.create_relationship(100, 200, "BAD TYPE!")
# -- get_relationships ------------------------------------------------
def test_get_relationships_outgoing(self):
edge_str = _make_edge_agtype(500, "KNOWS", 100, 200, {})
self.mock_cursor.fetchall.return_value = [(edge_str,)]
rels = self.store.get_relationships(
node_id=100, rel_type="KNOWS", direction="out"
)
self.assertEqual(len(rels), 1)
self.assertEqual(rels[0]["type"], "KNOWS")
def test_get_relationships_incoming(self):
edge_str = _make_edge_agtype(501, "KNOWS", 200, 100, {})
self.mock_cursor.fetchall.return_value = [(edge_str,)]
rels = self.store.get_relationships(
node_id=100, direction="in"
)
self.assertEqual(len(rels), 1)
def test_get_relationships_all(self):
self.mock_cursor.fetchall.return_value = []
rels = self.store.get_relationships()
self.assertEqual(rels, [])
# -- delete_relationship ----------------------------------------------
def test_delete_relationship(self):
self.mock_cursor.fetchall.return_value = []
result = self.store.delete_relationship(500)
self.assertTrue(result)
# -- execute_query ----------------------------------------------------
def test_execute_query_basic(self):
"""execute_query should return Neo4jStore-compatible result dict."""
self.mock_cursor.description = [("n",)]
vertex_str = _make_vertex_agtype(100, "Person", {"name": "Alice"})
self.mock_cursor.fetchall.return_value = [(vertex_str,)]
result = self.store.execute_query(
"MATCH (n:Person) RETURN n", cols="n agtype"
)
self.assertTrue(result["success"])
self.assertEqual(len(result["records"]), 1)
self.assertIn("keys", result)
self.assertIn("metadata", result)
self.assertEqual(result["metadata"]["query"], "MATCH (n:Person) RETURN n")
def test_execute_query_with_parameters(self):
"""Parameters should be substituted as safe literals."""
self.mock_cursor.description = [("count",)]
self.mock_cursor.fetchall.return_value = [("5::numeric",)]
result = self.store.execute_query(
"MATCH (n) WHERE n.age > $min_age RETURN count(n) AS count",
parameters={"min_age": 25},
cols="count agtype",
)
self.assertTrue(result["success"])
# Check the SQL that was executed contained the literal, not $min_age
executed_sql = self.mock_cursor.execute.call_args[0][0]
self.assertIn("25", executed_sql)
self.assertNotIn("$min_age", executed_sql)
def test_execute_query_empty_result(self):
self.mock_cursor.description = []
self.mock_cursor.fetchall.return_value = []
result = self.store.execute_query("MATCH (n) RETURN n", cols="n agtype")
self.assertTrue(result["success"])
self.assertEqual(result["records"], [])
# -- get_neighbors ----------------------------------------------------
def test_get_neighbors_out(self):
vertex_str = _make_vertex_agtype(200, "Person", {"name": "Bob"})
self.mock_cursor.fetchall.return_value = [(vertex_str,)]
neighbors = self.store.get_neighbors(
node_id=100, rel_type="KNOWS", direction="out", depth=2
)
self.assertEqual(len(neighbors), 1)
self.assertEqual(neighbors[0]["id"], 200)
def test_get_neighbors_both(self):
self.mock_cursor.fetchall.return_value = []
neighbors = self.store.get_neighbors(node_id=100)
self.assertEqual(neighbors, [])
# -- shortest_path ----------------------------------------------------
def test_shortest_path_found(self):
"""When a path is found, it should be returned as dict with nodes/relationships."""
path_list = [
{"id": 1, "label": "Person", "properties": {"name": "A"}},
{"id": 10, "label": "KNOWS", "start_id": 1, "end_id": 2, "properties": {}},
{"id": 2, "label": "Person", "properties": {"name": "B"}},
]
path_str = json.dumps(path_list) + "::path"
self.mock_cursor.fetchall.return_value = [(path_str,)]
result = self.store.shortest_path(1, 2)
self.assertIsNotNone(result)
self.assertEqual(result["length"], 1)
self.assertEqual(len(result["nodes"]), 2)
self.assertEqual(len(result["relationships"]), 1)
def test_shortest_path_not_found(self):
self.mock_cursor.fetchall.return_value = []
result = self.store.shortest_path(1, 999)
self.assertIsNone(result)
# -- create_index -----------------------------------------------------
def test_create_index(self):
result = self.store.create_index("Person", "name", "btree")
self.assertTrue(result)
executed_sql = self.mock_cursor.execute.call_args[0][0]
self.assertIn("CREATE INDEX IF NOT EXISTS", executed_sql)
self.assertIn("Person", executed_sql)
self.assertIn("name", executed_sql)
def test_create_index_invalid_property_raises(self):
with self.assertRaises(ValidationError):
self.store.create_index("Person", "bad name!", "btree")
# -- get_stats --------------------------------------------------------
def test_get_stats(self):
"""get_stats should return structured dict."""
# Mock call sequence:
# 1. node count cypher → fetchall
# 2. relationship count cypher → fetchall
# 3. label catalog query → fetchall (pg cursor)
# then per-label cypher → fetchall
# 4. edge type catalog query → fetchall (pg cursor)
# then per-type cypher → fetchall
call_count = [0]
fetch_responses = [
[("42::numeric",)], # node count
[("10::numeric",)], # relationship count
[("5::numeric",)], # label count for Person
[("10::numeric",)], # edge count for KNOWS
]
cursor_fetch_responses = [
(1,), # ensure_connection: ag_graph check (not used here)
[("Person",)], # label catalog
[("KNOWS",)], # edge type catalog
]
def mock_fetchall():
idx = call_count[0]
call_count[0] += 1
if idx < len(fetch_responses):
return fetch_responses[idx]
return []
def mock_cursor_fetchall():
# Returns for the catalog queries
if not hasattr(mock_cursor_fetchall, "_idx"):
mock_cursor_fetchall._idx = 0
idx = mock_cursor_fetchall._idx
mock_cursor_fetchall._idx += 1
if idx < len(cursor_fetch_responses):
return cursor_fetch_responses[idx]
return []
self.mock_cursor.fetchall.side_effect = mock_fetchall
self.mock_cursor.fetchone.return_value = (1,)
stats = self.store.get_stats()
self.assertIn("node_count", stats)
self.assertIn("relationship_count", stats)
self.assertIn("label_counts", stats)
self.assertIn("relationship_type_counts", stats)
# -- Transaction rollback ---------------------------------------------
def test_cypher_execution_rollback_on_error(self):
"""If a query fails, the connection should be rolled back."""
self.mock_cursor.execute.side_effect = Exception("SQL error")
with self.assertRaises(ProcessingError):
self.store.get_node(100)
self.mock_conn.rollback.assert_called()
def test_create_node_db_error_raises(self):
"""Database errors during create_node should raise ProcessingError."""
self.mock_cursor.execute.side_effect = Exception("Disk full")
with self.assertRaises(ProcessingError):
self.store.create_node(["Test"], {"key": "val"})
# -- close ------------------------------------------------------------
def test_close(self):
self.store.close()
self.assertIsNone(self.store._conn)
def test_close_idempotent(self):
"""Calling close() twice should not raise."""
self.store.close()
self.store.close() # Should not raise
# ---------------------------------------------------------------------------
# Integration-style test with GraphStore facade
# ---------------------------------------------------------------------------
class TestGraphStoreFacadeAge(unittest.TestCase):
"""Test that GraphStore(backend='age') initialises ApacheAgeStore."""
@patch("semantica.graph_store.age_store.ApacheAgeStore", autospec=True)
def test_age_backend_initialisation(self, MockAgeStore):
"""GraphStore should instantiate ApacheAgeStore for 'age' backend."""
from semantica.graph_store.graph_store import GraphStore
mock_instance = MagicMock()
MockAgeStore.return_value = mock_instance
store = GraphStore(backend="age")
self.assertIs(store._store_backend, mock_instance)
@patch("semantica.graph_store.age_store.ApacheAgeStore", autospec=True)
def test_apache_age_backend_alias(self, MockAgeStore):
"""GraphStore should accept 'apache_age' as backend alias."""
from semantica.graph_store.graph_store import GraphStore
mock_instance = MagicMock()
MockAgeStore.return_value = mock_instance
store = GraphStore(backend="apache_age")
self.assertIs(store._store_backend, mock_instance)
# ---------------------------------------------------------------------------
# Return format conformance tests
# ---------------------------------------------------------------------------
class TestReturnFormatConformance(unittest.TestCase):
"""Verify that returned dicts match Neo4jStore structure exactly."""
def test_node_return_keys(self):
vertex = {"id": 1, "label": "X", "properties": {"a": 1}}
result = _vertex_to_node_dict(vertex)
self.assertSetEqual(set(result.keys()), {"id", "labels", "properties"})
def test_relationship_return_keys(self):
edge = {"id": 1, "label": "R", "start_id": 2, "end_id": 3, "properties": {}}
result = _edge_to_rel_dict(edge)
self.assertSetEqual(
set(result.keys()),
{"id", "type", "start_node_id", "end_node_id", "properties"},
)
if __name__ == "__main__":
unittest.main()