|
1
|
""" |
|
2
|
GraphStore — thin wrapper over FalkorDB (SQLite or Redis backend). |
|
3
|
|
|
4
|
Usage: |
|
5
|
# SQLite (local, zero-infra) |
|
6
|
store = GraphStore.sqlite(".navegador/graph.db") |
|
7
|
|
|
8
|
# Redis-backed FalkorDB (production) |
|
9
|
store = GraphStore.redis("redis://localhost:6379") |
|
10
|
""" |
|
11
|
|
|
12
|
import logging |
|
13
|
from pathlib import Path |
|
14
|
from typing import Any |
|
15
|
|
|
16
|
logger = logging.getLogger(__name__) |
|
17
|
|
|
18
|
|
|
19
|
class GraphStore: |
|
20
|
""" |
|
21
|
Wraps a FalkorDB graph, providing helpers for navegador node/edge operations. |
|
22
|
|
|
23
|
The underlying graph is named "navegador" within the database. |
|
24
|
""" |
|
25
|
|
|
26
|
GRAPH_NAME = "navegador" |
|
27
|
|
|
28
|
def __init__(self, client: Any) -> None: |
|
29
|
self._client = client |
|
30
|
self._graph = client.select_graph(self.GRAPH_NAME) |
|
31
|
|
|
32
|
# ── Constructors ────────────────────────────────────────────────────────── |
|
33
|
|
|
34
|
@classmethod |
|
35
|
def sqlite(cls, db_path: str | Path = ".navegador/graph.db") -> "GraphStore": |
|
36
|
""" |
|
37
|
Open a SQLite-backed FalkorDB graph via falkordblite (zero-infra). |
|
38
|
|
|
39
|
Requires: pip install FalkorDB falkordblite |
|
40
|
""" |
|
41
|
try: |
|
42
|
from redislite import FalkorDB # type: ignore[import] # provided by falkordblite |
|
43
|
except ImportError as e: |
|
44
|
raise ImportError( |
|
45
|
"Install graph dependencies: pip install FalkorDB falkordblite" |
|
46
|
) from e |
|
47
|
|
|
48
|
db_path = Path(db_path) |
|
49
|
db_path.parent.mkdir(parents=True, exist_ok=True) |
|
50
|
client = FalkorDB(str(db_path)) |
|
51
|
logger.info("GraphStore opened (SQLite/falkordblite): %s", db_path) |
|
52
|
return cls(client) |
|
53
|
|
|
54
|
@classmethod |
|
55
|
def redis(cls, url: str = "redis://localhost:6379") -> "GraphStore": |
|
56
|
""" |
|
57
|
Open a Redis-backed FalkorDB graph (production use). |
|
58
|
|
|
59
|
Requires: pip install FalkorDB redis |
|
60
|
""" |
|
61
|
try: |
|
62
|
import falkordb # type: ignore[import] |
|
63
|
except ImportError as e: |
|
64
|
raise ImportError("Install falkordb: pip install FalkorDB redis") from e |
|
65
|
|
|
66
|
client = falkordb.FalkorDB.from_url(url) |
|
67
|
logger.info("GraphStore opened (Redis): %s", url) |
|
68
|
return cls(client) |
|
69
|
|
|
70
|
# ── Core operations ─────────────────────────────────────────────────────── |
|
71
|
|
|
72
|
def query(self, cypher: str, params: dict[str, Any] | None = None) -> Any: |
|
73
|
"""Execute a raw Cypher query and return the result.""" |
|
74
|
return self._graph.query(cypher, params or {}) |
|
75
|
|
|
76
|
def create_node(self, label: str, props: dict[str, Any]) -> None: |
|
77
|
"""Upsert a node by (label, name[, file_path]).""" |
|
78
|
# Ensure merge key fields exist |
|
79
|
props.setdefault("name", "") |
|
80
|
props.setdefault("file_path", "") |
|
81
|
# Filter out None values — FalkorDB rejects them as params |
|
82
|
props = {k: ("" if v is None else v) for k, v in props.items()} |
|
83
|
prop_str = ", ".join(f"n.{k} = ${k}" for k in props) |
|
84
|
cypher = f"MERGE (n:{label} {{name: $name, file_path: $file_path}}) SET {prop_str}" |
|
85
|
self.query(cypher, props) |
|
86
|
|
|
87
|
def create_edge( |
|
88
|
self, |
|
89
|
from_label: str, |
|
90
|
from_key: dict[str, Any], |
|
91
|
edge_type: str, |
|
92
|
to_label: str, |
|
93
|
to_key: dict[str, Any], |
|
94
|
props: dict[str, Any] | None = None, |
|
95
|
) -> None: |
|
96
|
"""Create a directed edge between two nodes, merging if it already exists.""" |
|
97
|
from_match = ", ".join(f"{k}: $from_{k}" for k in from_key) |
|
98
|
to_match = ", ".join(f"{k}: $to_{k}" for k in to_key) |
|
99
|
prop_set = "" |
|
100
|
if props: |
|
101
|
prop_set = " SET " + ", ".join(f"r.{k} = $p_{k}" for k in props) |
|
102
|
|
|
103
|
cypher = ( |
|
104
|
f"MATCH (a:{from_label} {{{from_match}}}), (b:{to_label} {{{to_match}}}) " |
|
105
|
f"MERGE (a)-[r:{edge_type}]->(b){prop_set}" |
|
106
|
) |
|
107
|
params = {f"from_{k}": v for k, v in from_key.items()} |
|
108
|
params.update({f"to_{k}": v for k, v in to_key.items()}) |
|
109
|
if props: |
|
110
|
params.update({f"p_{k}": v for k, v in props.items()}) |
|
111
|
|
|
112
|
self.query(cypher, params) |
|
113
|
|
|
114
|
def clear(self) -> None: |
|
115
|
"""Delete all nodes and edges in the graph.""" |
|
116
|
self.query("MATCH (n) DETACH DELETE n") |
|
117
|
logger.info("Graph cleared") |
|
118
|
|
|
119
|
def node_count(self) -> int: |
|
120
|
result = self.query("MATCH (n) RETURN count(n) AS c") |
|
121
|
return result.result_set[0][0] if result.result_set else 0 |
|
122
|
|
|
123
|
def edge_count(self) -> int: |
|
124
|
result = self.query("MATCH ()-[r]->() RETURN count(r) AS c") |
|
125
|
return result.result_set[0][0] if result.result_set else 0 |
|
126
|
|