-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathsqlite_graph.py
More file actions
152 lines (122 loc) · 5.45 KB
/
sqlite_graph.py
File metadata and controls
152 lines (122 loc) · 5.45 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
"""SqliteGraphBackend — SQLite backend with graph traversal extensions.
Phase D of the Graphiti-style pluggable backend strategy. Subclasses
:class:`SQLiteBackend` and adds the :class:`GraphTraversal` protocol
methods (``shortest_path``, ``find_by_type_hierarchy``, ``pattern_match``)
so the zero-infra SQLite store can serve as the new default backend.
Storage is inherited unchanged — same ``syn_nodes`` / ``syn_edges``
tables, same FTS5 virtual table, same recursive-CTE ``get_neighbors``.
Use this class when you want graph traversal on top of SQLite; use the
parent :class:`SQLiteBackend` when CRUD + FTS is enough.
Why subclass rather than add to SQLiteBackend directly:
1. Keeps the minimal CRUD backend minimal for users who don't need
traversal (smaller mental surface, no accidental recursion cost).
2. Makes intent explicit at construction time — ``SqliteGraphBackend``
tells the reader "this instance is expected to support multi-hop
reasoning".
3. Lets us iterate on the traversal implementation without risking
the stability of the base class that's been shipping since v0.5.
Kuzu parity notes:
- :meth:`shortest_path` uses Python-level BFS calling ``get_neighbors``
once per depth layer. For mid-sized corpora (~20K nodes, depth ≤ 3)
this is fast enough. Future work can push it into a single recursive
CTE if measurement shows it's a hotspot.
- :meth:`find_by_type_hierarchy` is currently a flat ``list_nodes(kind=)``
call, matching Kuzu's behaviour ("hierarchy expansion TBD").
- :meth:`pattern_match` raises ``NotImplementedError``. Cypher pattern
matching is not meaningfully expressible in SQL without a full
parser; callers who need it should use :class:`KuzuBackend`.
Example::
from synaptic.backends.sqlite_graph import SqliteGraphBackend
backend = SqliteGraphBackend("graph.db")
await backend.connect()
# All SQLiteBackend methods work
await backend.save_node(node)
# Plus GraphTraversal extensions
path = await backend.shortest_path(a.id, b.id, max_depth=4)
"""
from __future__ import annotations
from synaptic.backends.sqlite import SQLiteBackend
from synaptic.models import Edge, Node
class SqliteGraphBackend(SQLiteBackend):
"""SQLite storage + GraphTraversal protocol.
See module docstring for rationale. Construction, schema, and all
CRUD methods are inherited from :class:`SQLiteBackend`.
"""
__slots__ = () # inherits _conn, _path from SQLiteBackend
# --- GraphTraversal protocol ---
async def shortest_path(
self,
from_id: str,
to_id: str,
*,
max_depth: int = 5,
) -> list[tuple[Node, Edge]]:
"""BFS shortest path from ``from_id`` to ``to_id``.
Returns the ordered sequence of ``(node, edge)`` tuples along
the path, **excluding the start node**. Matches the Kuzu
backend's return contract so callers can swap backends without
changing their code.
Args:
from_id: Source node id.
to_id: Target node id.
max_depth: Maximum edges to traverse. Defaults to 5.
Returns:
A list of ``(node, edge)`` pairs describing the path. Empty
list if ``from_id == to_id`` or if no path exists within
``max_depth``.
"""
if from_id == to_id:
return []
depth_cap = max(1, int(max_depth))
visited: set[str] = {from_id}
# BFS queue entries are (current_node_id, path_so_far).
frontier: list[tuple[str, list[tuple[Node, Edge]]]] = [(from_id, [])]
for _ in range(depth_cap):
next_frontier: list[tuple[str, list[tuple[Node, Edge]]]] = []
for current, path in frontier:
hops = await self.get_neighbors(current, depth=1)
for node, edge in hops:
if node.id in visited:
continue
new_path = [*path, (node, edge)]
if node.id == to_id:
return new_path
visited.add(node.id)
next_frontier.append((node.id, new_path))
if not next_frontier:
break
frontier = next_frontier
return []
async def find_by_type_hierarchy(
self,
type_name: str,
*,
limit: int = 50,
) -> list[Node]:
"""Find nodes whose ``kind`` equals ``type_name``.
Currently flat — does not walk an is-a hierarchy. This matches
Kuzu's current behaviour; hierarchy expansion is planned once
the runtime ontology registry wiring stabilizes.
"""
return await self.list_nodes(kind=type_name, limit=limit)
async def pattern_match(
self,
pattern: str,
*,
limit: int = 20,
) -> list[dict[str, object]]:
"""Cypher pattern matching is not supported on SQLite.
Raises ``NotImplementedError`` with a clear redirect. For
multi-hop reasoning on SQLite, combine :meth:`get_neighbors` and
:meth:`shortest_path` with Python-level filtering. If you need
full openCypher, use :class:`synaptic.backends.kuzu.KuzuBackend`.
Args:
pattern: Ignored.
limit: Ignored.
"""
msg = (
"SqliteGraphBackend does not support Cypher pattern_match. "
"Use get_neighbors + shortest_path for traversal, or switch "
"to KuzuBackend for Cypher queries."
)
raise NotImplementedError(msg)