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.
@@ -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