# 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",
)
```
