cjm-capability-graph-sqlite 0.0.26__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cjm_capability_graph_sqlite/__init__.py +1 -0
- cjm_capability_graph_sqlite/capability.py +849 -0
- cjm_capability_graph_sqlite/query_translation.py +313 -0
- cjm_capability_graph_sqlite-0.0.26.dist-info/METADATA +50 -0
- cjm_capability_graph_sqlite-0.0.26.dist-info/RECORD +8 -0
- cjm_capability_graph_sqlite-0.0.26.dist-info/WHEEL +5 -0
- cjm_capability_graph_sqlite-0.0.26.dist-info/licenses/LICENSE +201 -0
- cjm_capability_graph_sqlite-0.0.26.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,849 @@
|
|
|
1
|
+
"""Capability implementation for Context Graph using SQLite.
|
|
2
|
+
|
|
3
|
+
Stage 8 (Option C / PILLAR 1c): the tool re-bases onto ToolCapability. The
|
|
4
|
+
graph store IS this tool's persistence, so unlike the compute tools there is
|
|
5
|
+
no cache bookend to lift out -- GenericGraphStorageAdapter is a pure
|
|
6
|
+
wire-normalize + forward adapter. The domain base
|
|
7
|
+
cjm_graph_capability_system.GraphCapability dissolves; the data nouns come
|
|
8
|
+
straight from cjm_context_graph_primitives (graph-capability-system only
|
|
9
|
+
re-exported them). get_plugin_metadata is gone -- name/version derive from
|
|
10
|
+
importlib.metadata and the default db_path from the substrate-injected
|
|
11
|
+
per-capability CAPABILITY_DATA_DIR.
|
|
12
|
+
|
|
13
|
+
Stage 4: the typed query surface (expressions + results from the
|
|
14
|
+
data-primitives lib; per-backend translation owned by THIS capability)."""
|
|
15
|
+
|
|
16
|
+
import dataclasses
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import sqlite3
|
|
21
|
+
import time
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
24
|
+
|
|
25
|
+
from cjm_capability_graph_sqlite.query_translation import translate_edge_query, translate_node_query
|
|
26
|
+
from cjm_context_graph_primitives.graph import GraphContext, GraphEdge, GraphNode
|
|
27
|
+
from cjm_context_graph_primitives.provenance import SourceRef
|
|
28
|
+
from cjm_context_graph_primitives.query import (EdgeQuery, EdgeQueryResult, NodeQuery,
|
|
29
|
+
NodeQueryResult, RawQuery, RawQueryResult)
|
|
30
|
+
from cjm_substrate.core.capability import ToolCapability
|
|
31
|
+
from cjm_substrate.core.errors import CapabilityInputError
|
|
32
|
+
from cjm_substrate.utils.validation import (config_to_dict, dataclass_to_jsonschema, dict_to_config,
|
|
33
|
+
SCHEMA_DESC, SCHEMA_TITLE)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class SQLiteGraphCapabilityConfig:
|
|
38
|
+
"""Configuration for SQLite Graph Capability."""
|
|
39
|
+
db_path: Optional[str] = field(
|
|
40
|
+
default=None,
|
|
41
|
+
metadata={
|
|
42
|
+
SCHEMA_TITLE: "Database Path",
|
|
43
|
+
SCHEMA_DESC: "Absolute path to SQLite DB. If None, uses default env path."
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
readonly: bool = field(
|
|
47
|
+
default=False,
|
|
48
|
+
metadata={
|
|
49
|
+
SCHEMA_TITLE: "Read Only",
|
|
50
|
+
SCHEMA_DESC: "Open database in read-only mode."
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SQLiteGraphCapability(ToolCapability):
|
|
56
|
+
"""Local, file-backed Context Graph implementation using SQLite.
|
|
57
|
+
|
|
58
|
+
Stores nodes and edges in relational tables with JSON payloads for
|
|
59
|
+
properties::
|
|
60
|
+
|
|
61
|
+
CREATE TABLE nodes (
|
|
62
|
+
id TEXT PRIMARY KEY,
|
|
63
|
+
label TEXT NOT NULL,
|
|
64
|
+
properties JSON,
|
|
65
|
+
sources JSON,
|
|
66
|
+
created_at REAL,
|
|
67
|
+
updated_at REAL
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
CREATE TABLE edges ( -- foreign keys give cascade delete
|
|
71
|
+
id TEXT PRIMARY KEY,
|
|
72
|
+
source_id TEXT NOT NULL,
|
|
73
|
+
target_id TEXT NOT NULL,
|
|
74
|
+
relation_type TEXT NOT NULL,
|
|
75
|
+
properties JSON,
|
|
76
|
+
created_at REAL,
|
|
77
|
+
updated_at REAL,
|
|
78
|
+
FOREIGN KEY(source_id) REFERENCES nodes(id) ON DELETE CASCADE,
|
|
79
|
+
FOREIGN KEY(target_id) REFERENCES nodes(id) ON DELETE CASCADE
|
|
80
|
+
);
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
config_class = SQLiteGraphCapabilityConfig
|
|
84
|
+
|
|
85
|
+
def __init__(self):
|
|
86
|
+
self.logger = logging.getLogger(f"{__name__}.{type(self).__name__}")
|
|
87
|
+
self.config: SQLiteGraphCapabilityConfig = None
|
|
88
|
+
self._db_path: str = None
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def name(self) -> str: # Tool identity, derived from the installed distribution (PILLAR 1c)
|
|
92
|
+
"""Get the tool name (the installed distribution name)."""
|
|
93
|
+
from importlib.metadata import metadata, packages_distributions
|
|
94
|
+
# `__package__` is None in a notebook/__main__ context, so guard the
|
|
95
|
+
# ffmpeg-template derivation with the known package module name.
|
|
96
|
+
pkg = __package__ or "cjm_capability_graph_sqlite"
|
|
97
|
+
dist = (packages_distributions().get(pkg) or [pkg.replace("_", "-")])[0]
|
|
98
|
+
return metadata(dist)["Name"]
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def version(self) -> str: # Tool version
|
|
102
|
+
"""Get the tool version string."""
|
|
103
|
+
from cjm_capability_graph_sqlite import __version__
|
|
104
|
+
return __version__
|
|
105
|
+
|
|
106
|
+
def get_current_config(self) -> Dict[str, Any]: # Current configuration as dictionary
|
|
107
|
+
"""Return current configuration state, with db_path RESOLVED to the effective path.
|
|
108
|
+
|
|
109
|
+
The raw config's db_path may be None/'' (manifest-default load) while the
|
|
110
|
+
capability resolves the real store at initialize (config override or the
|
|
111
|
+
substrate data-dir convention). Downstream cores derive run provenance and
|
|
112
|
+
the SIDECAR WRITE-JOURNAL path from this echo — reporting the raw None sent
|
|
113
|
+
pipeline writes UNJOURNALED (found live 2026-07-14, first journaled ingestion
|
|
114
|
+
run; the 6dfe00e9 scaffolding-as-defaults family / D19 lesson)."""
|
|
115
|
+
if not self.config:
|
|
116
|
+
return {}
|
|
117
|
+
cfg = config_to_dict(self.config)
|
|
118
|
+
if getattr(self, "_db_path", None):
|
|
119
|
+
cfg["db_path"] = str(self._db_path)
|
|
120
|
+
return cfg
|
|
121
|
+
|
|
122
|
+
def get_config_schema(self) -> Dict[str, Any]: # JSON Schema for configuration
|
|
123
|
+
"""Return JSON Schema for UI generation."""
|
|
124
|
+
return dataclass_to_jsonschema(SQLiteGraphCapabilityConfig)
|
|
125
|
+
|
|
126
|
+
def initialize(
|
|
127
|
+
self,
|
|
128
|
+
config: Optional[Any] = None # Configuration dataclass, dict, or None
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Initialize DB connection and schema."""
|
|
131
|
+
self.config = dict_to_config(SQLiteGraphCapabilityConfig, config or {})
|
|
132
|
+
|
|
133
|
+
# Determine DB path (config override > substrate-injected default).
|
|
134
|
+
# The graph store IS this tool's persistence (unlike the compute tools
|
|
135
|
+
# whose cache moved to the adapter), so db_path stays a TOOL concern.
|
|
136
|
+
# The substrate owns the per-capability data-dir convention: it injects
|
|
137
|
+
# CAPABILITY_DATA_DIR (= CJM_CAPABILITY_DATA_DIR/<capability-name>), so the tool
|
|
138
|
+
# consumes that join rather than re-deriving it (PILLAR 1c verify-(c)).
|
|
139
|
+
self._db_path = self.config.db_path or self._default_db_path()
|
|
140
|
+
|
|
141
|
+
self.logger.info(f"Initializing SQLite Graph at: {self._db_path}")
|
|
142
|
+
# SG-41: a read-only graph must pre-exist; skip schema creation (the
|
|
143
|
+
# read-only connection cannot run CREATE TABLE).
|
|
144
|
+
if not self.config.readonly:
|
|
145
|
+
self._init_db()
|
|
146
|
+
|
|
147
|
+
def _default_db_path(self) -> str: # Default graph-store path (substrate-owned per-capability data dir)
|
|
148
|
+
"""Resolve the default DB path from the substrate-injected per-capability
|
|
149
|
+
`CAPABILITY_DATA_DIR` (= `CJM_CAPABILITY_DATA_DIR/<capability-name>`). Falls back
|
|
150
|
+
to the same convention for direct/un-proxied use (e.g. notebook tests)."""
|
|
151
|
+
data_dir = os.environ.get("CAPABILITY_DATA_DIR")
|
|
152
|
+
if not data_dir:
|
|
153
|
+
root = os.environ.get(
|
|
154
|
+
"CJM_CAPABILITY_DATA_DIR", os.path.join(os.getcwd(), ".cjm", "data")
|
|
155
|
+
)
|
|
156
|
+
data_dir = os.path.join(root, "cjm-capability-graph-sqlite")
|
|
157
|
+
os.makedirs(data_dir, exist_ok=True)
|
|
158
|
+
return os.path.join(data_dir, "context_graph.db")
|
|
159
|
+
|
|
160
|
+
def _init_db(self) -> None:
|
|
161
|
+
"""Create tables and indices."""
|
|
162
|
+
with self._connect() as con:
|
|
163
|
+
# Enable Foreign Keys
|
|
164
|
+
con.execute("PRAGMA foreign_keys = ON;")
|
|
165
|
+
|
|
166
|
+
# Nodes Table
|
|
167
|
+
con.execute("""
|
|
168
|
+
CREATE TABLE IF NOT EXISTS nodes (
|
|
169
|
+
id TEXT PRIMARY KEY,
|
|
170
|
+
label TEXT NOT NULL,
|
|
171
|
+
properties JSON,
|
|
172
|
+
sources JSON,
|
|
173
|
+
created_at REAL,
|
|
174
|
+
updated_at REAL
|
|
175
|
+
)
|
|
176
|
+
""")
|
|
177
|
+
con.execute("CREATE INDEX IF NOT EXISTS idx_nodes_label ON nodes(label);")
|
|
178
|
+
|
|
179
|
+
# Edges Table
|
|
180
|
+
con.execute("""
|
|
181
|
+
CREATE TABLE IF NOT EXISTS edges (
|
|
182
|
+
id TEXT PRIMARY KEY,
|
|
183
|
+
source_id TEXT NOT NULL,
|
|
184
|
+
target_id TEXT NOT NULL,
|
|
185
|
+
relation_type TEXT NOT NULL,
|
|
186
|
+
properties JSON,
|
|
187
|
+
created_at REAL,
|
|
188
|
+
updated_at REAL,
|
|
189
|
+
FOREIGN KEY(source_id) REFERENCES nodes(id) ON DELETE CASCADE,
|
|
190
|
+
FOREIGN KEY(target_id) REFERENCES nodes(id) ON DELETE CASCADE
|
|
191
|
+
)
|
|
192
|
+
""")
|
|
193
|
+
con.execute("CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_id);")
|
|
194
|
+
con.execute("CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_id);")
|
|
195
|
+
con.execute("CREATE INDEX IF NOT EXISTS idx_edges_type ON edges(relation_type);")
|
|
196
|
+
# Stage 4 (H7): composite endpoint+type indexes — the typed
|
|
197
|
+
# translation's correlated EXISTS probes are (endpoint=?,
|
|
198
|
+
# relation_type=?); without these the planner picks the
|
|
199
|
+
# low-cardinality relation_type index and SCANS (162s -> ms at
|
|
200
|
+
# 13k-segment corpus scale). IF NOT EXISTS = existing DBs pick
|
|
201
|
+
# them up on the next initialize.
|
|
202
|
+
con.execute("CREATE INDEX IF NOT EXISTS idx_edges_source_type ON edges(source_id, relation_type);")
|
|
203
|
+
con.execute("CREATE INDEX IF NOT EXISTS idx_edges_target_type ON edges(target_id, relation_type);")
|
|
204
|
+
# H7 companion: planner statistics. Without sqlite_stat1 the
|
|
205
|
+
# correlated-probe plans pick the WRONG composite index (the
|
|
206
|
+
# target-side probe scans a whole document's PART_OF rows per
|
|
207
|
+
# outer row: 5s; source-side probe: 8ms at corpus scale).
|
|
208
|
+
# Cheap (~40ms at 13k segments); writable opens only. Guarded:
|
|
209
|
+
# stats are an OPTIMIZATION — a corrupted DB must still LOAD so
|
|
210
|
+
# integrity_check (the designed G3 gate) can diagnose it, never
|
|
211
|
+
# fail at initialize via a stats side-effect.
|
|
212
|
+
try:
|
|
213
|
+
con.execute("ANALYZE;")
|
|
214
|
+
except sqlite3.DatabaseError as e:
|
|
215
|
+
logging.warning(f"ANALYZE skipped (run integrity_check): {e}")
|
|
216
|
+
|
|
217
|
+
def _connect(self) -> sqlite3.Connection: # Open DB connection (honors readonly; sets WAL + FKs)
|
|
218
|
+
"""Open a SQLite connection honoring the `readonly` config (SG-41).
|
|
219
|
+
|
|
220
|
+
Read-only config opens the DB with URI `mode=ro` so any write raises at
|
|
221
|
+
the SQLite layer. Read-write connections assert `journal_mode=WAL` (better
|
|
222
|
+
concurrent-reader behavior; persists at the DB-file level) on each open.
|
|
223
|
+
Foreign keys are enabled on every connection.
|
|
224
|
+
"""
|
|
225
|
+
if self.config and self.config.readonly:
|
|
226
|
+
con = sqlite3.connect(f"file:{self._db_path}?mode=ro", uri=True)
|
|
227
|
+
else:
|
|
228
|
+
con = sqlite3.connect(self._db_path)
|
|
229
|
+
con.execute("PRAGMA journal_mode=WAL;")
|
|
230
|
+
con.execute("PRAGMA foreign_keys = ON;")
|
|
231
|
+
return con
|
|
232
|
+
|
|
233
|
+
# -------------------------------------------------------------------------
|
|
234
|
+
# Helpers
|
|
235
|
+
# -------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
def _row_to_node(
|
|
238
|
+
self,
|
|
239
|
+
row: Tuple # DB row: (id, label, properties_json, sources_json, created_at, updated_at)
|
|
240
|
+
) -> GraphNode: # Reconstructed GraphNode
|
|
241
|
+
"""Convert DB row to GraphNode DTO."""
|
|
242
|
+
props = json.loads(row[2]) if row[2] else {}
|
|
243
|
+
sources_raw = json.loads(row[3]) if row[3] else []
|
|
244
|
+
sources = [SourceRef.from_dict(s) for s in sources_raw]
|
|
245
|
+
return GraphNode(
|
|
246
|
+
id=row[0], label=row[1], properties=props, sources=sources,
|
|
247
|
+
created_at=row[4] if len(row) > 4 else None,
|
|
248
|
+
updated_at=row[5] if len(row) > 5 else None,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def _row_to_edge(
|
|
252
|
+
self,
|
|
253
|
+
row: Tuple # DB row: (id, source_id, target_id, relation_type, properties_json, created_at, updated_at)
|
|
254
|
+
) -> GraphEdge: # Reconstructed GraphEdge
|
|
255
|
+
"""Convert DB row to GraphEdge DTO."""
|
|
256
|
+
props = json.loads(row[4]) if row[4] else {}
|
|
257
|
+
return GraphEdge(
|
|
258
|
+
id=row[0], source_id=row[1], target_id=row[2],
|
|
259
|
+
relation_type=row[3], properties=props,
|
|
260
|
+
created_at=row[5] if len(row) > 5 else None,
|
|
261
|
+
updated_at=row[6] if len(row) > 6 else None,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def _dict_to_node(
|
|
265
|
+
self,
|
|
266
|
+
data: Dict[str, Any] # Node data as dictionary
|
|
267
|
+
) -> GraphNode: # Reconstructed GraphNode
|
|
268
|
+
"""Convert dictionary to GraphNode (nested sources via GraphNode.from_dict)."""
|
|
269
|
+
return GraphNode.from_dict(data)
|
|
270
|
+
|
|
271
|
+
def _dict_to_edge(
|
|
272
|
+
self,
|
|
273
|
+
data: Dict[str, Any] # Edge data as dictionary
|
|
274
|
+
) -> GraphEdge: # Reconstructed GraphEdge
|
|
275
|
+
"""Convert dictionary to GraphEdge."""
|
|
276
|
+
return GraphEdge(
|
|
277
|
+
id=data["id"],
|
|
278
|
+
source_id=data["source_id"],
|
|
279
|
+
target_id=data["target_id"],
|
|
280
|
+
relation_type=data["relation_type"],
|
|
281
|
+
properties=data.get("properties", {}),
|
|
282
|
+
created_at=data.get("created_at"),
|
|
283
|
+
updated_at=data.get("updated_at"),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
# -------------------------------------------------------------------------
|
|
287
|
+
# CREATE
|
|
288
|
+
# -------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
def add_nodes(
|
|
291
|
+
self,
|
|
292
|
+
nodes: List[GraphNode] # Nodes to create
|
|
293
|
+
) -> List[str]: # Created node IDs
|
|
294
|
+
"""Bulk create nodes."""
|
|
295
|
+
ids = []
|
|
296
|
+
now = time.time()
|
|
297
|
+
with self._connect() as con:
|
|
298
|
+
for n in nodes:
|
|
299
|
+
sources_json = json.dumps([s.to_dict() for s in n.sources])
|
|
300
|
+
props_json = json.dumps(n.properties)
|
|
301
|
+
try:
|
|
302
|
+
con.execute(
|
|
303
|
+
"INSERT INTO nodes (id, label, properties, sources, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
304
|
+
(n.id, n.label, props_json, sources_json, now, now)
|
|
305
|
+
)
|
|
306
|
+
ids.append(n.id)
|
|
307
|
+
except sqlite3.IntegrityError:
|
|
308
|
+
self.logger.warning(f"Node ID collision or error: {n.id}")
|
|
309
|
+
return ids
|
|
310
|
+
|
|
311
|
+
def add_edges(
|
|
312
|
+
self,
|
|
313
|
+
edges: List[GraphEdge] # Edges to create
|
|
314
|
+
) -> List[str]: # Created edge IDs
|
|
315
|
+
"""Bulk create edges."""
|
|
316
|
+
ids = []
|
|
317
|
+
now = time.time()
|
|
318
|
+
with self._connect() as con:
|
|
319
|
+
con.execute("PRAGMA foreign_keys = ON;")
|
|
320
|
+
for e in edges:
|
|
321
|
+
props_json = json.dumps(e.properties)
|
|
322
|
+
try:
|
|
323
|
+
con.execute(
|
|
324
|
+
"INSERT INTO edges (id, source_id, target_id, relation_type, properties, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
325
|
+
(e.id, e.source_id, e.target_id, e.relation_type, props_json, now, now)
|
|
326
|
+
)
|
|
327
|
+
ids.append(e.id)
|
|
328
|
+
except sqlite3.IntegrityError as err:
|
|
329
|
+
self.logger.warning(f"Edge creation error (likely missing node): {err}")
|
|
330
|
+
return ids
|
|
331
|
+
|
|
332
|
+
# -------------------------------------------------------------------------
|
|
333
|
+
# READ
|
|
334
|
+
# -------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
def get_node(
|
|
337
|
+
self,
|
|
338
|
+
node_id: str # UUID of node to retrieve
|
|
339
|
+
) -> Optional[GraphNode]: # Node or None if not found
|
|
340
|
+
"""Get a single node by ID."""
|
|
341
|
+
with self._connect() as con:
|
|
342
|
+
cur = con.execute(
|
|
343
|
+
"SELECT id, label, properties, sources, created_at, updated_at FROM nodes WHERE id = ?",
|
|
344
|
+
(node_id,)
|
|
345
|
+
)
|
|
346
|
+
row = cur.fetchone()
|
|
347
|
+
return self._row_to_node(row) if row else None
|
|
348
|
+
|
|
349
|
+
def get_edge(
|
|
350
|
+
self,
|
|
351
|
+
edge_id: str # UUID of edge to retrieve
|
|
352
|
+
) -> Optional[GraphEdge]: # Edge or None if not found
|
|
353
|
+
"""Get a single edge by ID."""
|
|
354
|
+
with self._connect() as con:
|
|
355
|
+
cur = con.execute(
|
|
356
|
+
"SELECT id, source_id, target_id, relation_type, properties, created_at, updated_at FROM edges WHERE id = ?",
|
|
357
|
+
(edge_id,)
|
|
358
|
+
)
|
|
359
|
+
row = cur.fetchone()
|
|
360
|
+
return self._row_to_edge(row) if row else None
|
|
361
|
+
|
|
362
|
+
def find_nodes_by_source(
|
|
363
|
+
self,
|
|
364
|
+
source_ref: SourceRef # External resource reference
|
|
365
|
+
) -> List[GraphNode]: # Nodes attached to this source
|
|
366
|
+
"""Find all nodes linked to a specific external resource."""
|
|
367
|
+
# Content-hash-primary reverse index (CR-19): identity = content_hash
|
|
368
|
+
# (SQL match); the locator is verified Python-side, which keeps the SQL
|
|
369
|
+
# free of per-locator-kind coupling. Slice is NOT compared: the hash is
|
|
370
|
+
# over the consumed (sliced) content, so hash equality already pins it.
|
|
371
|
+
query = """
|
|
372
|
+
SELECT DISTINCT n.id, n.label, n.properties, n.sources, n.created_at, n.updated_at
|
|
373
|
+
FROM nodes n, json_each(n.sources) as src
|
|
374
|
+
WHERE json_extract(src.value, '$.content_hash') = ?
|
|
375
|
+
"""
|
|
376
|
+
results = []
|
|
377
|
+
with self._connect() as con:
|
|
378
|
+
cur = con.execute(query, (source_ref.content_hash,))
|
|
379
|
+
for row in cur:
|
|
380
|
+
node = self._row_to_node(row)
|
|
381
|
+
if any(r.content_hash == source_ref.content_hash
|
|
382
|
+
and r.locator == source_ref.locator for r in node.sources):
|
|
383
|
+
results.append(node)
|
|
384
|
+
return results
|
|
385
|
+
|
|
386
|
+
def find_nodes_by_label(
|
|
387
|
+
self,
|
|
388
|
+
label: str, # Node label to search for
|
|
389
|
+
limit: int = 100 # Max results
|
|
390
|
+
) -> List[GraphNode]: # Matching nodes
|
|
391
|
+
"""Find nodes by label."""
|
|
392
|
+
results = []
|
|
393
|
+
with self._connect() as con:
|
|
394
|
+
cur = con.execute(
|
|
395
|
+
"SELECT id, label, properties, sources, created_at, updated_at FROM nodes WHERE label = ? LIMIT ?",
|
|
396
|
+
(label, limit)
|
|
397
|
+
)
|
|
398
|
+
for row in cur:
|
|
399
|
+
results.append(self._row_to_node(row))
|
|
400
|
+
return results
|
|
401
|
+
|
|
402
|
+
def get_context(
|
|
403
|
+
self,
|
|
404
|
+
node_id: str, # Starting node UUID
|
|
405
|
+
depth: int = 1, # Traversal depth (1 = immediate neighbors)
|
|
406
|
+
filter_labels: Optional[List[str]] = None # Only include nodes with these labels
|
|
407
|
+
) -> GraphContext: # Subgraph containing node and its neighborhood
|
|
408
|
+
"""Get the neighborhood of a specific node."""
|
|
409
|
+
# For depth=1, use simple query; for deeper, use recursive CTE
|
|
410
|
+
edge_ids = []
|
|
411
|
+
with self._connect() as con:
|
|
412
|
+
if depth == 1:
|
|
413
|
+
cur = con.execute(
|
|
414
|
+
"SELECT id FROM edges WHERE source_id = ? OR target_id = ?",
|
|
415
|
+
(node_id, node_id)
|
|
416
|
+
)
|
|
417
|
+
else:
|
|
418
|
+
# Recursive CTE for multi-hop traversal
|
|
419
|
+
query = """
|
|
420
|
+
WITH RECURSIVE traversal(edge_id, node_id, depth) AS (
|
|
421
|
+
-- Base case: edges connected to the start node
|
|
422
|
+
SELECT id,
|
|
423
|
+
CASE WHEN source_id = ? THEN target_id ELSE source_id END,
|
|
424
|
+
1
|
|
425
|
+
FROM edges
|
|
426
|
+
WHERE source_id = ? OR target_id = ?
|
|
427
|
+
|
|
428
|
+
UNION
|
|
429
|
+
|
|
430
|
+
-- Recursive step: edges connected to discovered nodes
|
|
431
|
+
SELECT e.id,
|
|
432
|
+
CASE WHEN e.source_id = t.node_id THEN e.target_id ELSE e.source_id END,
|
|
433
|
+
t.depth + 1
|
|
434
|
+
FROM edges e
|
|
435
|
+
JOIN traversal t ON (e.source_id = t.node_id OR e.target_id = t.node_id)
|
|
436
|
+
WHERE t.depth < ?
|
|
437
|
+
)
|
|
438
|
+
SELECT DISTINCT edge_id FROM traversal;
|
|
439
|
+
"""
|
|
440
|
+
cur = con.execute(query, (node_id, node_id, node_id, depth))
|
|
441
|
+
|
|
442
|
+
edge_ids = [row[0] for row in cur.fetchall()]
|
|
443
|
+
|
|
444
|
+
# Fetch full Edge objects
|
|
445
|
+
edges = []
|
|
446
|
+
node_ids_in_context = {node_id} # Always include the center
|
|
447
|
+
|
|
448
|
+
if edge_ids:
|
|
449
|
+
placeholders = ','.join('?' for _ in edge_ids)
|
|
450
|
+
with self._connect() as con:
|
|
451
|
+
cur = con.execute(
|
|
452
|
+
f"SELECT id, source_id, target_id, relation_type, properties, created_at, updated_at FROM edges WHERE id IN ({placeholders})",
|
|
453
|
+
tuple(edge_ids)
|
|
454
|
+
)
|
|
455
|
+
for row in cur:
|
|
456
|
+
e = self._row_to_edge(row)
|
|
457
|
+
edges.append(e)
|
|
458
|
+
node_ids_in_context.add(e.source_id)
|
|
459
|
+
node_ids_in_context.add(e.target_id)
|
|
460
|
+
|
|
461
|
+
# Fetch full Node objects
|
|
462
|
+
nodes = []
|
|
463
|
+
if node_ids_in_context:
|
|
464
|
+
placeholders = ','.join('?' for _ in node_ids_in_context)
|
|
465
|
+
with self._connect() as con:
|
|
466
|
+
sql = f"SELECT id, label, properties, sources, created_at, updated_at FROM nodes WHERE id IN ({placeholders})"
|
|
467
|
+
|
|
468
|
+
# Apply optional label filtering
|
|
469
|
+
params = list(node_ids_in_context)
|
|
470
|
+
if filter_labels:
|
|
471
|
+
sql += f" AND label IN ({','.join('?' for _ in filter_labels)})"
|
|
472
|
+
params.extend(filter_labels)
|
|
473
|
+
|
|
474
|
+
cur = con.execute(sql, tuple(params))
|
|
475
|
+
for row in cur:
|
|
476
|
+
nodes.append(self._row_to_node(row))
|
|
477
|
+
|
|
478
|
+
return GraphContext(
|
|
479
|
+
nodes=nodes,
|
|
480
|
+
edges=edges,
|
|
481
|
+
metadata={"depth": depth, "center": node_id}
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
# -------------------------------------------------------------------------
|
|
485
|
+
# UPDATE
|
|
486
|
+
# -------------------------------------------------------------------------
|
|
487
|
+
|
|
488
|
+
def update_node(
|
|
489
|
+
self,
|
|
490
|
+
node_id: str, # UUID of node to update
|
|
491
|
+
properties: Dict[str, Any] # Properties to merge/update
|
|
492
|
+
) -> bool: # True if successful
|
|
493
|
+
"""Partial update of node properties."""
|
|
494
|
+
with self._connect() as con:
|
|
495
|
+
# Fetch existing to merge
|
|
496
|
+
cur = con.execute("SELECT properties FROM nodes WHERE id = ?", (node_id,))
|
|
497
|
+
row = cur.fetchone()
|
|
498
|
+
if not row:
|
|
499
|
+
return False
|
|
500
|
+
|
|
501
|
+
existing = json.loads(row[0]) if row[0] else {}
|
|
502
|
+
existing.update(properties) # Merge
|
|
503
|
+
|
|
504
|
+
con.execute(
|
|
505
|
+
"UPDATE nodes SET properties = ?, updated_at = ? WHERE id = ?",
|
|
506
|
+
(json.dumps(existing), time.time(), node_id)
|
|
507
|
+
)
|
|
508
|
+
return True
|
|
509
|
+
|
|
510
|
+
def update_edge(
|
|
511
|
+
self,
|
|
512
|
+
edge_id: str, # UUID of edge to update
|
|
513
|
+
properties: Dict[str, Any] # Properties to merge/update
|
|
514
|
+
) -> bool: # True if successful
|
|
515
|
+
"""Partial update of edge properties."""
|
|
516
|
+
with self._connect() as con:
|
|
517
|
+
cur = con.execute("SELECT properties FROM edges WHERE id = ?", (edge_id,))
|
|
518
|
+
row = cur.fetchone()
|
|
519
|
+
if not row:
|
|
520
|
+
return False
|
|
521
|
+
|
|
522
|
+
existing = json.loads(row[0]) if row[0] else {}
|
|
523
|
+
existing.update(properties)
|
|
524
|
+
|
|
525
|
+
con.execute(
|
|
526
|
+
"UPDATE edges SET properties = ?, updated_at = ? WHERE id = ?",
|
|
527
|
+
(json.dumps(existing), time.time(), edge_id)
|
|
528
|
+
)
|
|
529
|
+
return True
|
|
530
|
+
|
|
531
|
+
# -------------------------------------------------------------------------
|
|
532
|
+
# DELETE
|
|
533
|
+
# -------------------------------------------------------------------------
|
|
534
|
+
|
|
535
|
+
def delete_nodes(
|
|
536
|
+
self,
|
|
537
|
+
node_ids: List[str], # UUIDs of nodes to delete
|
|
538
|
+
cascade: bool = True # Also delete connected edges
|
|
539
|
+
) -> int: # Number of nodes deleted
|
|
540
|
+
"""Delete nodes (and optionally connected edges)."""
|
|
541
|
+
with self._connect() as con:
|
|
542
|
+
if cascade:
|
|
543
|
+
con.execute("PRAGMA foreign_keys = ON;") # Ensures cascade works
|
|
544
|
+
else:
|
|
545
|
+
con.execute("PRAGMA foreign_keys = OFF;")
|
|
546
|
+
|
|
547
|
+
placeholders = ','.join('?' for _ in node_ids)
|
|
548
|
+
cur = con.execute(
|
|
549
|
+
f"DELETE FROM nodes WHERE id IN ({placeholders})",
|
|
550
|
+
tuple(node_ids)
|
|
551
|
+
)
|
|
552
|
+
return cur.rowcount
|
|
553
|
+
|
|
554
|
+
def delete_edges(
|
|
555
|
+
self,
|
|
556
|
+
edge_ids: List[str] # UUIDs of edges to delete
|
|
557
|
+
) -> int: # Number of edges deleted
|
|
558
|
+
"""Delete edges."""
|
|
559
|
+
with self._connect() as con:
|
|
560
|
+
placeholders = ','.join('?' for _ in edge_ids)
|
|
561
|
+
cur = con.execute(
|
|
562
|
+
f"DELETE FROM edges WHERE id IN ({placeholders})",
|
|
563
|
+
tuple(edge_ids)
|
|
564
|
+
)
|
|
565
|
+
return cur.rowcount
|
|
566
|
+
|
|
567
|
+
# -------------------------------------------------------------------------
|
|
568
|
+
# LIFECYCLE & INTROSPECTION
|
|
569
|
+
# -------------------------------------------------------------------------
|
|
570
|
+
|
|
571
|
+
def get_schema(self) -> Dict[str, Any]: # Graph schema/ontology
|
|
572
|
+
"""Return the current ontology/schema of the graph."""
|
|
573
|
+
schema = {"node_labels": [], "edge_types": [], "counts": {}}
|
|
574
|
+
with self._connect() as con:
|
|
575
|
+
# Labels
|
|
576
|
+
cur = con.execute("SELECT DISTINCT label FROM nodes")
|
|
577
|
+
schema["node_labels"] = [r[0] for r in cur.fetchall()]
|
|
578
|
+
|
|
579
|
+
# Types
|
|
580
|
+
cur = con.execute("SELECT DISTINCT relation_type FROM edges")
|
|
581
|
+
schema["edge_types"] = [r[0] for r in cur.fetchall()]
|
|
582
|
+
|
|
583
|
+
# Counts
|
|
584
|
+
cur = con.execute("SELECT label, COUNT(*) FROM nodes GROUP BY label")
|
|
585
|
+
for row in cur:
|
|
586
|
+
schema["counts"][row[0]] = row[1]
|
|
587
|
+
|
|
588
|
+
return schema
|
|
589
|
+
|
|
590
|
+
def import_graph(
|
|
591
|
+
self,
|
|
592
|
+
graph_data: GraphContext, # Data to import
|
|
593
|
+
merge_strategy: str = "overwrite" # "overwrite", "skip", or "merge"
|
|
594
|
+
) -> Dict[str, int]: # Import statistics {nodes_created, edges_created, merge_strategy}
|
|
595
|
+
"""Bulk import a GraphContext honoring merge_strategy (SG-41).
|
|
596
|
+
|
|
597
|
+
On id-conflict: "skip" keeps the existing row untouched; "overwrite"
|
|
598
|
+
replaces its mutable fields with the incoming values; "merge" unions
|
|
599
|
+
properties (incoming wins per key) and unions node sources by identity.
|
|
600
|
+
Brand-new ids are always inserted. The `nodes_created`/`edges_created`
|
|
601
|
+
counts report rows written (inserted or updated).
|
|
602
|
+
"""
|
|
603
|
+
if merge_strategy not in ("overwrite", "skip", "merge"):
|
|
604
|
+
raise CapabilityInputError( # SG-47: typed input-validation
|
|
605
|
+
f"Unknown merge_strategy: {merge_strategy}", fields_invalid=["merge_strategy"],
|
|
606
|
+
)
|
|
607
|
+
n = self._import_nodes(graph_data.nodes, merge_strategy)
|
|
608
|
+
e = self._import_edges(graph_data.edges, merge_strategy)
|
|
609
|
+
return {"nodes_created": n, "edges_created": e, "merge_strategy": merge_strategy}
|
|
610
|
+
|
|
611
|
+
def _import_nodes(
|
|
612
|
+
self,
|
|
613
|
+
nodes: List[GraphNode], # Nodes to import
|
|
614
|
+
merge_strategy: str # "overwrite" | "skip" | "merge"
|
|
615
|
+
) -> int: # Count of rows written (inserted or updated)
|
|
616
|
+
"""Import nodes honoring merge_strategy (see import_graph)."""
|
|
617
|
+
now = time.time()
|
|
618
|
+
written = 0
|
|
619
|
+
with self._connect() as con:
|
|
620
|
+
for node in nodes:
|
|
621
|
+
props_json = json.dumps(node.properties)
|
|
622
|
+
sources_json = json.dumps([s.to_dict() for s in node.sources])
|
|
623
|
+
row = con.execute("SELECT properties, sources FROM nodes WHERE id = ?", (node.id,)).fetchone()
|
|
624
|
+
if row is None:
|
|
625
|
+
con.execute(
|
|
626
|
+
"INSERT INTO nodes (id, label, properties, sources, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
627
|
+
(node.id, node.label, props_json, sources_json, now, now),
|
|
628
|
+
)
|
|
629
|
+
written += 1
|
|
630
|
+
elif merge_strategy == "skip":
|
|
631
|
+
continue
|
|
632
|
+
elif merge_strategy == "overwrite":
|
|
633
|
+
con.execute(
|
|
634
|
+
"UPDATE nodes SET label = ?, properties = ?, sources = ?, updated_at = ? WHERE id = ?",
|
|
635
|
+
(node.label, props_json, sources_json, now, node.id),
|
|
636
|
+
)
|
|
637
|
+
written += 1
|
|
638
|
+
else: # merge
|
|
639
|
+
merged_props = json.loads(row[0]) if row[0] else {}
|
|
640
|
+
merged_props.update(node.properties)
|
|
641
|
+
existing_sources = json.loads(row[1]) if row[1] else []
|
|
642
|
+
seen = {json.dumps(s, sort_keys=True) for s in existing_sources}
|
|
643
|
+
for s in node.sources:
|
|
644
|
+
sd = s.to_dict()
|
|
645
|
+
key = json.dumps(sd, sort_keys=True)
|
|
646
|
+
if key not in seen:
|
|
647
|
+
existing_sources.append(sd)
|
|
648
|
+
seen.add(key)
|
|
649
|
+
con.execute(
|
|
650
|
+
"UPDATE nodes SET label = ?, properties = ?, sources = ?, updated_at = ? WHERE id = ?",
|
|
651
|
+
(node.label, json.dumps(merged_props), json.dumps(existing_sources), now, node.id),
|
|
652
|
+
)
|
|
653
|
+
written += 1
|
|
654
|
+
return written
|
|
655
|
+
|
|
656
|
+
def _import_edges(
|
|
657
|
+
self,
|
|
658
|
+
edges: List[GraphEdge], # Edges to import
|
|
659
|
+
merge_strategy: str # "overwrite" | "skip" | "merge"
|
|
660
|
+
) -> int: # Count of rows written (inserted or updated)
|
|
661
|
+
"""Import edges honoring merge_strategy (see import_graph)."""
|
|
662
|
+
now = time.time()
|
|
663
|
+
written = 0
|
|
664
|
+
with self._connect() as con:
|
|
665
|
+
for edge in edges:
|
|
666
|
+
props_json = json.dumps(edge.properties)
|
|
667
|
+
row = con.execute("SELECT properties FROM edges WHERE id = ?", (edge.id,)).fetchone()
|
|
668
|
+
if row is None:
|
|
669
|
+
try:
|
|
670
|
+
con.execute(
|
|
671
|
+
"INSERT INTO edges (id, source_id, target_id, relation_type, properties, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
672
|
+
(edge.id, edge.source_id, edge.target_id, edge.relation_type, props_json, now, now),
|
|
673
|
+
)
|
|
674
|
+
written += 1
|
|
675
|
+
except sqlite3.IntegrityError as err:
|
|
676
|
+
self.logger.warning(f"Edge import error (likely missing node): {err}")
|
|
677
|
+
elif merge_strategy == "skip":
|
|
678
|
+
continue
|
|
679
|
+
elif merge_strategy == "overwrite":
|
|
680
|
+
con.execute(
|
|
681
|
+
"UPDATE edges SET source_id = ?, target_id = ?, relation_type = ?, properties = ?, updated_at = ? WHERE id = ?",
|
|
682
|
+
(edge.source_id, edge.target_id, edge.relation_type, props_json, now, edge.id),
|
|
683
|
+
)
|
|
684
|
+
written += 1
|
|
685
|
+
else: # merge
|
|
686
|
+
merged = json.loads(row[0]) if row[0] else {}
|
|
687
|
+
merged.update(edge.properties)
|
|
688
|
+
con.execute(
|
|
689
|
+
"UPDATE edges SET properties = ?, updated_at = ? WHERE id = ?",
|
|
690
|
+
(json.dumps(merged), now, edge.id),
|
|
691
|
+
)
|
|
692
|
+
written += 1
|
|
693
|
+
return written
|
|
694
|
+
|
|
695
|
+
def export_graph(
|
|
696
|
+
self,
|
|
697
|
+
filter_query: Optional[NodeQuery] = None # Typed node filter; None = whole graph (stage 4; GraphQuery dissolved)
|
|
698
|
+
) -> GraphContext: # Exported subgraph or full graph
|
|
699
|
+
"""Export the whole graph, or the subgraph selected by a typed node
|
|
700
|
+
query (matching nodes + the edges among them)."""
|
|
701
|
+
if filter_query is not None:
|
|
702
|
+
# Force full-node mode regardless of the filter's count/project.
|
|
703
|
+
nq = dataclasses.replace(filter_query, count=False, project=None)
|
|
704
|
+
nodes = self.query_nodes(nq).nodes or []
|
|
705
|
+
ids_json = json.dumps([n.id for n in nodes])
|
|
706
|
+
with self._connect() as con:
|
|
707
|
+
cur = con.execute(
|
|
708
|
+
"SELECT id, source_id, target_id, relation_type, properties, created_at, updated_at "
|
|
709
|
+
"FROM edges WHERE source_id IN (SELECT value FROM json_each(?)) "
|
|
710
|
+
"AND target_id IN (SELECT value FROM json_each(?))",
|
|
711
|
+
(ids_json, ids_json))
|
|
712
|
+
edges = [self._row_to_edge(row) for row in cur]
|
|
713
|
+
return GraphContext(nodes=nodes, edges=edges,
|
|
714
|
+
metadata={"filtered": True, "node_count": len(nodes)})
|
|
715
|
+
|
|
716
|
+
all_nodes = []
|
|
717
|
+
all_edges = []
|
|
718
|
+
|
|
719
|
+
with self._connect() as con:
|
|
720
|
+
cur = con.execute("SELECT id, label, properties, sources, created_at, updated_at FROM nodes")
|
|
721
|
+
for row in cur:
|
|
722
|
+
all_nodes.append(self._row_to_node(row))
|
|
723
|
+
|
|
724
|
+
cur = con.execute("SELECT id, source_id, target_id, relation_type, properties, created_at, updated_at FROM edges")
|
|
725
|
+
for row in cur:
|
|
726
|
+
all_edges.append(self._row_to_edge(row))
|
|
727
|
+
|
|
728
|
+
return GraphContext(nodes=all_nodes, edges=all_edges)
|
|
729
|
+
|
|
730
|
+
def query(
|
|
731
|
+
self,
|
|
732
|
+
sql: str, # A single read-only SELECT (or WITH ... SELECT) statement
|
|
733
|
+
params: Optional[Union[List[Any], Dict[str, Any]]] = None # Bound parameters (positional list or named dict)
|
|
734
|
+
) -> Dict[str, Any]: # {"columns": [...], "rows": [[...]], "row_count": int}
|
|
735
|
+
"""Execute a single read-only SELECT and return its rows (SG-41).
|
|
736
|
+
|
|
737
|
+
Guards reject empty input, multiple statements, and anything not starting
|
|
738
|
+
with SELECT/WITH. The statement runs on a fresh read-only connection
|
|
739
|
+
(URI `mode=ro`), so even a query that slips past the prefix guard cannot
|
|
740
|
+
mutate the database. Bound `params` use SQLite's qmark placeholders.
|
|
741
|
+
"""
|
|
742
|
+
text = (sql or "").strip()
|
|
743
|
+
while text.endswith(";"):
|
|
744
|
+
text = text[:-1].strip()
|
|
745
|
+
if not text:
|
|
746
|
+
raise CapabilityInputError("query requires a non-empty SQL string", fields_invalid=["sql"])
|
|
747
|
+
if ";" in text:
|
|
748
|
+
raise CapabilityInputError("query accepts a single statement only", fields_invalid=["sql"])
|
|
749
|
+
head = text.lstrip("(").split(None, 1)[0].lower()
|
|
750
|
+
if head not in ("select", "with"):
|
|
751
|
+
raise CapabilityInputError(
|
|
752
|
+
"query allows read-only SELECT statements only", fields_invalid=["sql"],
|
|
753
|
+
)
|
|
754
|
+
con = sqlite3.connect(f"file:{self._db_path}?mode=ro", uri=True)
|
|
755
|
+
try:
|
|
756
|
+
# Positional (list) or named (dict) parameters both bind.
|
|
757
|
+
_bind = params if isinstance(params, dict) else (tuple(params) if params else ())
|
|
758
|
+
cur = con.execute(text, _bind)
|
|
759
|
+
columns = [d[0] for d in cur.description] if cur.description else []
|
|
760
|
+
rows = [list(r) for r in cur.fetchall()]
|
|
761
|
+
finally:
|
|
762
|
+
con.close()
|
|
763
|
+
return {"columns": columns, "rows": rows, "row_count": len(rows)}
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def query_nodes(
|
|
767
|
+
self,
|
|
768
|
+
query: NodeQuery, # Typed node query (the portable, scale-shaped surface)
|
|
769
|
+
) -> NodeQueryResult: # Typed result (nodes / rows / count per query mode)
|
|
770
|
+
"""Execute a typed node query (stage 4) — translated to parameterized SQL
|
|
771
|
+
by `query_translation`, run on a fresh read-only connection (SG-41)."""
|
|
772
|
+
sql, params, mode, keys = translate_node_query(query)
|
|
773
|
+
con = sqlite3.connect(f"file:{self._db_path}?mode=ro", uri=True)
|
|
774
|
+
try:
|
|
775
|
+
cur = con.execute(sql, params)
|
|
776
|
+
if mode == "count":
|
|
777
|
+
return NodeQueryResult(count=cur.fetchone()[0])
|
|
778
|
+
rows = cur.fetchall()
|
|
779
|
+
finally:
|
|
780
|
+
con.close()
|
|
781
|
+
if mode == "full":
|
|
782
|
+
return NodeQueryResult(nodes=[self._row_to_node(r) for r in rows])
|
|
783
|
+
out = []
|
|
784
|
+
for r in rows:
|
|
785
|
+
d = dict(zip(keys, r))
|
|
786
|
+
if "sources" in d and d["sources"]:
|
|
787
|
+
d["sources"] = json.loads(d["sources"]) # ref dicts (CR-19 shape)
|
|
788
|
+
out.append(d)
|
|
789
|
+
return NodeQueryResult(rows=out)
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def query_edges(
|
|
793
|
+
self,
|
|
794
|
+
query: EdgeQuery, # Typed edge query
|
|
795
|
+
) -> EdgeQueryResult: # Typed result (edges / rows / count per query mode)
|
|
796
|
+
"""Execute a typed edge query (stage 4) — same contract as `query_nodes`."""
|
|
797
|
+
sql, params, mode, keys = translate_edge_query(query)
|
|
798
|
+
con = sqlite3.connect(f"file:{self._db_path}?mode=ro", uri=True)
|
|
799
|
+
try:
|
|
800
|
+
cur = con.execute(sql, params)
|
|
801
|
+
if mode == "count":
|
|
802
|
+
return EdgeQueryResult(count=cur.fetchone()[0])
|
|
803
|
+
rows = cur.fetchall()
|
|
804
|
+
finally:
|
|
805
|
+
con.close()
|
|
806
|
+
if mode == "full":
|
|
807
|
+
return EdgeQueryResult(edges=[self._row_to_edge(r) for r in rows])
|
|
808
|
+
return EdgeQueryResult(rows=[dict(zip(keys, r)) for r in rows])
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def raw_query(
|
|
812
|
+
self,
|
|
813
|
+
query: RawQuery, # The marked, backend-coupled raw escape (backend REQUIRED)
|
|
814
|
+
) -> RawQueryResult: # Tabular backend-shaped result
|
|
815
|
+
"""Execute the raw escape — refuses backend mismatches (non-portable by
|
|
816
|
+
construction); the read-only single-statement guards are `query`'s (SG-41).
|
|
817
|
+
Recurring raw patterns are promotion candidates into the typed surface."""
|
|
818
|
+
if query.backend != "sqlite":
|
|
819
|
+
raise CapabilityInputError(
|
|
820
|
+
f"RawQuery backend {query.backend!r} refused by sqlite-graph "
|
|
821
|
+
f"(this backend is 'sqlite'; raw queries are non-portable by construction)",
|
|
822
|
+
fields_invalid=["backend"],
|
|
823
|
+
)
|
|
824
|
+
res = self.query(sql=query.text, params=query.params)
|
|
825
|
+
return RawQueryResult(columns=res["columns"], rows=res["rows"],
|
|
826
|
+
row_count=res["row_count"], backend="sqlite")
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def integrity_check(self) -> Dict[str, Any]: # {"ok": bool, "errors": [...], "backend": "sqlite"}
|
|
830
|
+
"""Backend self-check (stage 4; the G3 corruption find institutionalized):
|
|
831
|
+
`PRAGMA quick_check` on a fresh read-only connection so loop-backs and
|
|
832
|
+
stress tests assert storage health cheaply and corruption FAILS LOUDLY."""
|
|
833
|
+
con = sqlite3.connect(f"file:{self._db_path}?mode=ro", uri=True)
|
|
834
|
+
try:
|
|
835
|
+
rows = [r[0] for r in con.execute("PRAGMA quick_check")]
|
|
836
|
+
except sqlite3.DatabaseError as e:
|
|
837
|
+
# Severe corruption makes quick_check itself raise — still a STRUCTURED
|
|
838
|
+
# verdict (loop-backs assert ok cheaply; loud, never silent).
|
|
839
|
+
rows = [f"{type(e).__name__}: {e}"]
|
|
840
|
+
finally:
|
|
841
|
+
con.close()
|
|
842
|
+
ok = rows == ["ok"]
|
|
843
|
+
return {"ok": ok, "errors": [] if ok else rows, "backend": "sqlite"}
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
SQLiteGraphCapability.query_nodes = query_nodes
|
|
847
|
+
SQLiteGraphCapability.query_edges = query_edges
|
|
848
|
+
SQLiteGraphCapability.raw_query = raw_query
|
|
849
|
+
SQLiteGraphCapability.integrity_check = integrity_check
|