cjm-graph-plugin-sqlite 0.0.9__tar.gz → 0.0.11__tar.gz
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_graph_plugin_sqlite-0.0.9/cjm_graph_plugin_sqlite.egg-info → cjm_graph_plugin_sqlite-0.0.11}/PKG-INFO +30 -7
- {cjm_graph_plugin_sqlite-0.0.9 → cjm_graph_plugin_sqlite-0.0.11}/README.md +28 -5
- cjm_graph_plugin_sqlite-0.0.11/cjm_graph_plugin_sqlite/__init__.py +1 -0
- {cjm_graph_plugin_sqlite-0.0.9 → cjm_graph_plugin_sqlite-0.0.11}/cjm_graph_plugin_sqlite/_modidx.py +38 -0
- {cjm_graph_plugin_sqlite-0.0.9 → cjm_graph_plugin_sqlite-0.0.11}/cjm_graph_plugin_sqlite/plugin.py +283 -125
- {cjm_graph_plugin_sqlite-0.0.9 → cjm_graph_plugin_sqlite-0.0.11/cjm_graph_plugin_sqlite.egg-info}/PKG-INFO +30 -7
- cjm_graph_plugin_sqlite-0.0.11/cjm_graph_plugin_sqlite.egg-info/requires.txt +1 -0
- {cjm_graph_plugin_sqlite-0.0.9 → cjm_graph_plugin_sqlite-0.0.11}/pyproject.toml +1 -1
- cjm_graph_plugin_sqlite-0.0.9/cjm_graph_plugin_sqlite/__init__.py +0 -1
- cjm_graph_plugin_sqlite-0.0.9/cjm_graph_plugin_sqlite.egg-info/requires.txt +0 -1
- {cjm_graph_plugin_sqlite-0.0.9 → cjm_graph_plugin_sqlite-0.0.11}/LICENSE +0 -0
- {cjm_graph_plugin_sqlite-0.0.9 → cjm_graph_plugin_sqlite-0.0.11}/MANIFEST.in +0 -0
- {cjm_graph_plugin_sqlite-0.0.9 → cjm_graph_plugin_sqlite-0.0.11}/cjm_graph_plugin_sqlite/meta.py +0 -0
- {cjm_graph_plugin_sqlite-0.0.9 → cjm_graph_plugin_sqlite-0.0.11}/cjm_graph_plugin_sqlite.egg-info/SOURCES.txt +0 -0
- {cjm_graph_plugin_sqlite-0.0.9 → cjm_graph_plugin_sqlite-0.0.11}/cjm_graph_plugin_sqlite.egg-info/dependency_links.txt +0 -0
- {cjm_graph_plugin_sqlite-0.0.9 → cjm_graph_plugin_sqlite-0.0.11}/cjm_graph_plugin_sqlite.egg-info/entry_points.txt +0 -0
- {cjm_graph_plugin_sqlite-0.0.9 → cjm_graph_plugin_sqlite-0.0.11}/cjm_graph_plugin_sqlite.egg-info/top_level.txt +0 -0
- {cjm_graph_plugin_sqlite-0.0.9 → cjm_graph_plugin_sqlite-0.0.11}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cjm-graph-plugin-sqlite
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.11
|
|
4
4
|
Summary: A local, file-backed Context Graph worker for the cjm-plugin-system that implements graph storage, traversal, and querying using SQLite.
|
|
5
5
|
Author-email: "Christian J. Mills" <9126128+cj-mills@users.noreply.github.com>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -15,7 +15,7 @@ Classifier: Programming Language :: Python :: 3 :: Only
|
|
|
15
15
|
Requires-Python: >=3.12
|
|
16
16
|
Description-Content-Type: text/markdown
|
|
17
17
|
License-File: LICENSE
|
|
18
|
-
Requires-Dist: cjm_graph_plugin_system>=0.0.
|
|
18
|
+
Requires-Dist: cjm_graph_plugin_system>=0.0.12
|
|
19
19
|
Dynamic: license-file
|
|
20
20
|
|
|
21
21
|
# cjm-graph-plugin-sqlite
|
|
@@ -41,8 +41,8 @@ Total: 2 notebooks
|
|
|
41
41
|
|
|
42
42
|
``` mermaid
|
|
43
43
|
graph LR
|
|
44
|
-
meta[meta<br/>Metadata]
|
|
45
|
-
plugin[plugin<br/>SQLite Graph Plugin]
|
|
44
|
+
meta["meta<br/>Metadata"]
|
|
45
|
+
plugin["plugin<br/>SQLite Graph Plugin"]
|
|
46
46
|
|
|
47
47
|
plugin --> meta
|
|
48
48
|
```
|
|
@@ -164,7 +164,12 @@ class SQLiteGraphPlugin:
|
|
|
164
164
|
action: str = "get_schema", # Action to perform
|
|
165
165
|
**kwargs
|
|
166
166
|
) -> Dict[str, Any]: # JSON-serializable result
|
|
167
|
-
"Dispatch to
|
|
167
|
+
"Dispatch to the `@plugin_action`-tagged handler for `action` (SG-44).
|
|
168
|
+
|
|
169
|
+
Handlers are discovered by walking the class MRO for methods carrying a
|
|
170
|
+
`_plugin_action` tag (the same source `supported_actions` is built from
|
|
171
|
+
via `collect_plugin_actions`). Replaces the prior hand-maintained
|
|
172
|
+
if/elif chain."
|
|
168
173
|
|
|
169
174
|
def add_nodes(
|
|
170
175
|
self,
|
|
@@ -247,8 +252,14 @@ class SQLiteGraphPlugin:
|
|
|
247
252
|
self,
|
|
248
253
|
graph_data: GraphContext, # Data to import
|
|
249
254
|
merge_strategy: str = "overwrite" # "overwrite", "skip", or "merge"
|
|
250
|
-
) -> Dict[str, int]: # Import statistics {nodes_created, edges_created,
|
|
251
|
-
"Bulk import a GraphContext
|
|
255
|
+
) -> Dict[str, int]: # Import statistics {nodes_created, edges_created, merge_strategy}
|
|
256
|
+
"Bulk import a GraphContext honoring merge_strategy (SG-41).
|
|
257
|
+
|
|
258
|
+
On id-conflict: "skip" keeps the existing row untouched; "overwrite"
|
|
259
|
+
replaces its mutable fields with the incoming values; "merge" unions
|
|
260
|
+
properties (incoming wins per key) and unions node sources by identity.
|
|
261
|
+
Brand-new ids are always inserted. The `nodes_created`/`edges_created`
|
|
262
|
+
counts report rows written (inserted or updated)."
|
|
252
263
|
|
|
253
264
|
def export_graph(
|
|
254
265
|
self,
|
|
@@ -256,6 +267,18 @@ class SQLiteGraphPlugin:
|
|
|
256
267
|
) -> GraphContext: # Exported subgraph or full graph
|
|
257
268
|
"Export the entire graph or a filtered subset."
|
|
258
269
|
|
|
270
|
+
def query(
|
|
271
|
+
self,
|
|
272
|
+
sql: str, # A single read-only SELECT (or WITH ... SELECT) statement
|
|
273
|
+
params: Optional[List[Any]] = None # Bound parameters for the statement
|
|
274
|
+
) -> Dict[str, Any]: # {"columns": [...], "rows": [[...]], "row_count": int}
|
|
275
|
+
"Execute a single read-only SELECT and return its rows (SG-41).
|
|
276
|
+
|
|
277
|
+
Guards reject empty input, multiple statements, and anything not starting
|
|
278
|
+
with SELECT/WITH. The statement runs on a fresh read-only connection
|
|
279
|
+
(URI `mode=ro`), so even a query that slips past the prefix guard cannot
|
|
280
|
+
mutate the database. Bound `params` use SQLite's qmark placeholders."
|
|
281
|
+
|
|
259
282
|
def cleanup(self) -> None
|
|
260
283
|
"Clean up resources."
|
|
261
284
|
```
|
|
@@ -21,8 +21,8 @@ Total: 2 notebooks
|
|
|
21
21
|
|
|
22
22
|
``` mermaid
|
|
23
23
|
graph LR
|
|
24
|
-
meta[meta<br/>Metadata]
|
|
25
|
-
plugin[plugin<br/>SQLite Graph Plugin]
|
|
24
|
+
meta["meta<br/>Metadata"]
|
|
25
|
+
plugin["plugin<br/>SQLite Graph Plugin"]
|
|
26
26
|
|
|
27
27
|
plugin --> meta
|
|
28
28
|
```
|
|
@@ -144,7 +144,12 @@ class SQLiteGraphPlugin:
|
|
|
144
144
|
action: str = "get_schema", # Action to perform
|
|
145
145
|
**kwargs
|
|
146
146
|
) -> Dict[str, Any]: # JSON-serializable result
|
|
147
|
-
"Dispatch to
|
|
147
|
+
"Dispatch to the `@plugin_action`-tagged handler for `action` (SG-44).
|
|
148
|
+
|
|
149
|
+
Handlers are discovered by walking the class MRO for methods carrying a
|
|
150
|
+
`_plugin_action` tag (the same source `supported_actions` is built from
|
|
151
|
+
via `collect_plugin_actions`). Replaces the prior hand-maintained
|
|
152
|
+
if/elif chain."
|
|
148
153
|
|
|
149
154
|
def add_nodes(
|
|
150
155
|
self,
|
|
@@ -227,8 +232,14 @@ class SQLiteGraphPlugin:
|
|
|
227
232
|
self,
|
|
228
233
|
graph_data: GraphContext, # Data to import
|
|
229
234
|
merge_strategy: str = "overwrite" # "overwrite", "skip", or "merge"
|
|
230
|
-
) -> Dict[str, int]: # Import statistics {nodes_created, edges_created,
|
|
231
|
-
"Bulk import a GraphContext
|
|
235
|
+
) -> Dict[str, int]: # Import statistics {nodes_created, edges_created, merge_strategy}
|
|
236
|
+
"Bulk import a GraphContext honoring merge_strategy (SG-41).
|
|
237
|
+
|
|
238
|
+
On id-conflict: "skip" keeps the existing row untouched; "overwrite"
|
|
239
|
+
replaces its mutable fields with the incoming values; "merge" unions
|
|
240
|
+
properties (incoming wins per key) and unions node sources by identity.
|
|
241
|
+
Brand-new ids are always inserted. The `nodes_created`/`edges_created`
|
|
242
|
+
counts report rows written (inserted or updated)."
|
|
232
243
|
|
|
233
244
|
def export_graph(
|
|
234
245
|
self,
|
|
@@ -236,6 +247,18 @@ class SQLiteGraphPlugin:
|
|
|
236
247
|
) -> GraphContext: # Exported subgraph or full graph
|
|
237
248
|
"Export the entire graph or a filtered subset."
|
|
238
249
|
|
|
250
|
+
def query(
|
|
251
|
+
self,
|
|
252
|
+
sql: str, # A single read-only SELECT (or WITH ... SELECT) statement
|
|
253
|
+
params: Optional[List[Any]] = None # Bound parameters for the statement
|
|
254
|
+
) -> Dict[str, Any]: # {"columns": [...], "rows": [[...]], "row_count": int}
|
|
255
|
+
"Execute a single read-only SELECT and return its rows (SG-41).
|
|
256
|
+
|
|
257
|
+
Guards reject empty input, multiple statements, and anything not starting
|
|
258
|
+
with SELECT/WITH. The statement runs on a fresh read-only connection
|
|
259
|
+
(URI `mode=ro`), so even a query that slips past the prefix guard cannot
|
|
260
|
+
mutate the database. Bound `params` use SQLite's qmark placeholders."
|
|
261
|
+
|
|
239
262
|
def cleanup(self) -> None
|
|
240
263
|
"Clean up resources."
|
|
241
264
|
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.11"
|
{cjm_graph_plugin_sqlite-0.0.9 → cjm_graph_plugin_sqlite-0.0.11}/cjm_graph_plugin_sqlite/_modidx.py
RENAMED
|
@@ -11,10 +11,46 @@ d = { 'settings': { 'branch': 'main',
|
|
|
11
11
|
'cjm_graph_plugin_sqlite/plugin.py'),
|
|
12
12
|
'cjm_graph_plugin_sqlite.plugin.SQLiteGraphPlugin.__init__': ( 'plugin.html#sqlitegraphplugin.__init__',
|
|
13
13
|
'cjm_graph_plugin_sqlite/plugin.py'),
|
|
14
|
+
'cjm_graph_plugin_sqlite.plugin.SQLiteGraphPlugin._action_add_edges': ( 'plugin.html#sqlitegraphplugin._action_add_edges',
|
|
15
|
+
'cjm_graph_plugin_sqlite/plugin.py'),
|
|
16
|
+
'cjm_graph_plugin_sqlite.plugin.SQLiteGraphPlugin._action_add_nodes': ( 'plugin.html#sqlitegraphplugin._action_add_nodes',
|
|
17
|
+
'cjm_graph_plugin_sqlite/plugin.py'),
|
|
18
|
+
'cjm_graph_plugin_sqlite.plugin.SQLiteGraphPlugin._action_delete_edges': ( 'plugin.html#sqlitegraphplugin._action_delete_edges',
|
|
19
|
+
'cjm_graph_plugin_sqlite/plugin.py'),
|
|
20
|
+
'cjm_graph_plugin_sqlite.plugin.SQLiteGraphPlugin._action_delete_nodes': ( 'plugin.html#sqlitegraphplugin._action_delete_nodes',
|
|
21
|
+
'cjm_graph_plugin_sqlite/plugin.py'),
|
|
22
|
+
'cjm_graph_plugin_sqlite.plugin.SQLiteGraphPlugin._action_export_graph': ( 'plugin.html#sqlitegraphplugin._action_export_graph',
|
|
23
|
+
'cjm_graph_plugin_sqlite/plugin.py'),
|
|
24
|
+
'cjm_graph_plugin_sqlite.plugin.SQLiteGraphPlugin._action_find_nodes_by_label': ( 'plugin.html#sqlitegraphplugin._action_find_nodes_by_label',
|
|
25
|
+
'cjm_graph_plugin_sqlite/plugin.py'),
|
|
26
|
+
'cjm_graph_plugin_sqlite.plugin.SQLiteGraphPlugin._action_find_nodes_by_source': ( 'plugin.html#sqlitegraphplugin._action_find_nodes_by_source',
|
|
27
|
+
'cjm_graph_plugin_sqlite/plugin.py'),
|
|
28
|
+
'cjm_graph_plugin_sqlite.plugin.SQLiteGraphPlugin._action_get_context': ( 'plugin.html#sqlitegraphplugin._action_get_context',
|
|
29
|
+
'cjm_graph_plugin_sqlite/plugin.py'),
|
|
30
|
+
'cjm_graph_plugin_sqlite.plugin.SQLiteGraphPlugin._action_get_edge': ( 'plugin.html#sqlitegraphplugin._action_get_edge',
|
|
31
|
+
'cjm_graph_plugin_sqlite/plugin.py'),
|
|
32
|
+
'cjm_graph_plugin_sqlite.plugin.SQLiteGraphPlugin._action_get_node': ( 'plugin.html#sqlitegraphplugin._action_get_node',
|
|
33
|
+
'cjm_graph_plugin_sqlite/plugin.py'),
|
|
34
|
+
'cjm_graph_plugin_sqlite.plugin.SQLiteGraphPlugin._action_get_schema': ( 'plugin.html#sqlitegraphplugin._action_get_schema',
|
|
35
|
+
'cjm_graph_plugin_sqlite/plugin.py'),
|
|
36
|
+
'cjm_graph_plugin_sqlite.plugin.SQLiteGraphPlugin._action_import_graph': ( 'plugin.html#sqlitegraphplugin._action_import_graph',
|
|
37
|
+
'cjm_graph_plugin_sqlite/plugin.py'),
|
|
38
|
+
'cjm_graph_plugin_sqlite.plugin.SQLiteGraphPlugin._action_query': ( 'plugin.html#sqlitegraphplugin._action_query',
|
|
39
|
+
'cjm_graph_plugin_sqlite/plugin.py'),
|
|
40
|
+
'cjm_graph_plugin_sqlite.plugin.SQLiteGraphPlugin._action_update_edge': ( 'plugin.html#sqlitegraphplugin._action_update_edge',
|
|
41
|
+
'cjm_graph_plugin_sqlite/plugin.py'),
|
|
42
|
+
'cjm_graph_plugin_sqlite.plugin.SQLiteGraphPlugin._action_update_node': ( 'plugin.html#sqlitegraphplugin._action_update_node',
|
|
43
|
+
'cjm_graph_plugin_sqlite/plugin.py'),
|
|
44
|
+
'cjm_graph_plugin_sqlite.plugin.SQLiteGraphPlugin._connect': ( 'plugin.html#sqlitegraphplugin._connect',
|
|
45
|
+
'cjm_graph_plugin_sqlite/plugin.py'),
|
|
14
46
|
'cjm_graph_plugin_sqlite.plugin.SQLiteGraphPlugin._dict_to_edge': ( 'plugin.html#sqlitegraphplugin._dict_to_edge',
|
|
15
47
|
'cjm_graph_plugin_sqlite/plugin.py'),
|
|
16
48
|
'cjm_graph_plugin_sqlite.plugin.SQLiteGraphPlugin._dict_to_node': ( 'plugin.html#sqlitegraphplugin._dict_to_node',
|
|
17
49
|
'cjm_graph_plugin_sqlite/plugin.py'),
|
|
50
|
+
'cjm_graph_plugin_sqlite.plugin.SQLiteGraphPlugin._import_edges': ( 'plugin.html#sqlitegraphplugin._import_edges',
|
|
51
|
+
'cjm_graph_plugin_sqlite/plugin.py'),
|
|
52
|
+
'cjm_graph_plugin_sqlite.plugin.SQLiteGraphPlugin._import_nodes': ( 'plugin.html#sqlitegraphplugin._import_nodes',
|
|
53
|
+
'cjm_graph_plugin_sqlite/plugin.py'),
|
|
18
54
|
'cjm_graph_plugin_sqlite.plugin.SQLiteGraphPlugin._init_db': ( 'plugin.html#sqlitegraphplugin._init_db',
|
|
19
55
|
'cjm_graph_plugin_sqlite/plugin.py'),
|
|
20
56
|
'cjm_graph_plugin_sqlite.plugin.SQLiteGraphPlugin._row_to_edge': ( 'plugin.html#sqlitegraphplugin._row_to_edge',
|
|
@@ -57,6 +93,8 @@ d = { 'settings': { 'branch': 'main',
|
|
|
57
93
|
'cjm_graph_plugin_sqlite/plugin.py'),
|
|
58
94
|
'cjm_graph_plugin_sqlite.plugin.SQLiteGraphPlugin.name': ( 'plugin.html#sqlitegraphplugin.name',
|
|
59
95
|
'cjm_graph_plugin_sqlite/plugin.py'),
|
|
96
|
+
'cjm_graph_plugin_sqlite.plugin.SQLiteGraphPlugin.query': ( 'plugin.html#sqlitegraphplugin.query',
|
|
97
|
+
'cjm_graph_plugin_sqlite/plugin.py'),
|
|
60
98
|
'cjm_graph_plugin_sqlite.plugin.SQLiteGraphPlugin.update_edge': ( 'plugin.html#sqlitegraphplugin.update_edge',
|
|
61
99
|
'cjm_graph_plugin_sqlite/plugin.py'),
|
|
62
100
|
'cjm_graph_plugin_sqlite.plugin.SQLiteGraphPlugin.update_node': ( 'plugin.html#sqlitegraphplugin.update_node',
|
{cjm_graph_plugin_sqlite-0.0.9 → cjm_graph_plugin_sqlite-0.0.11}/cjm_graph_plugin_sqlite/plugin.py
RENAMED
|
@@ -13,6 +13,7 @@ import logging
|
|
|
13
13
|
import os
|
|
14
14
|
import sqlite3
|
|
15
15
|
from cjm_plugin_system.core.errors import PluginInputError
|
|
16
|
+
from cjm_plugin_system.core.interface import plugin_action, collect_plugin_actions
|
|
16
17
|
import time
|
|
17
18
|
from dataclasses import dataclass, field
|
|
18
19
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
@@ -90,11 +91,14 @@ class SQLiteGraphPlugin(GraphPlugin):
|
|
|
90
91
|
self._db_path = self.config.db_path if self.config.db_path else meta_path
|
|
91
92
|
|
|
92
93
|
self.logger.info(f"Initializing SQLite Graph at: {self._db_path}")
|
|
93
|
-
|
|
94
|
+
# SG-41: a read-only graph must pre-exist; skip schema creation (the
|
|
95
|
+
# read-only connection cannot run CREATE TABLE).
|
|
96
|
+
if not self.config.readonly:
|
|
97
|
+
self._init_db()
|
|
94
98
|
|
|
95
99
|
def _init_db(self) -> None:
|
|
96
100
|
"""Create tables and indices."""
|
|
97
|
-
with
|
|
101
|
+
with self._connect() as con:
|
|
98
102
|
# Enable Foreign Keys
|
|
99
103
|
con.execute("PRAGMA foreign_keys = ON;")
|
|
100
104
|
|
|
@@ -129,6 +133,22 @@ class SQLiteGraphPlugin(GraphPlugin):
|
|
|
129
133
|
con.execute("CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_id);")
|
|
130
134
|
con.execute("CREATE INDEX IF NOT EXISTS idx_edges_type ON edges(relation_type);")
|
|
131
135
|
|
|
136
|
+
def _connect(self) -> sqlite3.Connection: # Open DB connection (honors readonly; sets WAL + FKs)
|
|
137
|
+
"""Open a SQLite connection honoring the `readonly` config (SG-41).
|
|
138
|
+
|
|
139
|
+
Read-only config opens the DB with URI `mode=ro` so any write raises at
|
|
140
|
+
the SQLite layer. Read-write connections assert `journal_mode=WAL` (better
|
|
141
|
+
concurrent-reader behavior; persists at the DB-file level) on each open.
|
|
142
|
+
Foreign keys are enabled on every connection.
|
|
143
|
+
"""
|
|
144
|
+
if self.config and self.config.readonly:
|
|
145
|
+
con = sqlite3.connect(f"file:{self._db_path}?mode=ro", uri=True)
|
|
146
|
+
else:
|
|
147
|
+
con = sqlite3.connect(self._db_path)
|
|
148
|
+
con.execute("PRAGMA journal_mode=WAL;")
|
|
149
|
+
con.execute("PRAGMA foreign_keys = ON;")
|
|
150
|
+
return con
|
|
151
|
+
|
|
132
152
|
# -------------------------------------------------------------------------
|
|
133
153
|
# Helpers
|
|
134
154
|
# -------------------------------------------------------------------------
|
|
@@ -196,7 +216,7 @@ class SQLiteGraphPlugin(GraphPlugin):
|
|
|
196
216
|
)
|
|
197
217
|
|
|
198
218
|
# -------------------------------------------------------------------------
|
|
199
|
-
# EXECUTE -
|
|
219
|
+
# EXECUTE - @plugin_action dispatcher (SG-44)
|
|
200
220
|
# -------------------------------------------------------------------------
|
|
201
221
|
|
|
202
222
|
def execute(
|
|
@@ -204,109 +224,116 @@ class SQLiteGraphPlugin(GraphPlugin):
|
|
|
204
224
|
action: str = "get_schema", # Action to perform
|
|
205
225
|
**kwargs
|
|
206
226
|
) -> Dict[str, Any]: # JSON-serializable result
|
|
207
|
-
"""Dispatch to
|
|
208
|
-
|
|
209
|
-
if action == "get_schema":
|
|
210
|
-
return self.get_schema()
|
|
211
|
-
|
|
212
|
-
elif action == "add_nodes":
|
|
213
|
-
# Convert dicts to GraphNode objects
|
|
214
|
-
nodes_data = kwargs.get("nodes", [])
|
|
215
|
-
nodes = []
|
|
216
|
-
for n in nodes_data:
|
|
217
|
-
if isinstance(n, dict):
|
|
218
|
-
nodes.append(self._dict_to_node(n))
|
|
219
|
-
else:
|
|
220
|
-
nodes.append(n)
|
|
221
|
-
ids = self.add_nodes(nodes)
|
|
222
|
-
return {"created_ids": ids, "count": len(ids)}
|
|
223
|
-
|
|
224
|
-
elif action == "add_edges":
|
|
225
|
-
edges_data = kwargs.get("edges", [])
|
|
226
|
-
edges = []
|
|
227
|
-
for e in edges_data:
|
|
228
|
-
if isinstance(e, dict):
|
|
229
|
-
edges.append(self._dict_to_edge(e))
|
|
230
|
-
else:
|
|
231
|
-
edges.append(e)
|
|
232
|
-
ids = self.add_edges(edges)
|
|
233
|
-
return {"created_ids": ids, "count": len(ids)}
|
|
234
|
-
|
|
235
|
-
elif action == "get_node":
|
|
236
|
-
node = self.get_node(kwargs["node_id"])
|
|
237
|
-
return {"node": node.to_dict() if node else None}
|
|
238
|
-
|
|
239
|
-
elif action == "get_edge":
|
|
240
|
-
edge = self.get_edge(kwargs["edge_id"])
|
|
241
|
-
return {"edge": edge.to_dict() if edge else None}
|
|
242
|
-
|
|
243
|
-
elif action == "get_context":
|
|
244
|
-
ctx = self.get_context(
|
|
245
|
-
kwargs["node_id"],
|
|
246
|
-
depth=kwargs.get("depth", 1),
|
|
247
|
-
filter_labels=kwargs.get("filter_labels")
|
|
248
|
-
)
|
|
249
|
-
return ctx.to_dict()
|
|
250
|
-
|
|
251
|
-
elif action == "find_nodes_by_source":
|
|
252
|
-
ref_data = kwargs["source_ref"]
|
|
253
|
-
if isinstance(ref_data, dict):
|
|
254
|
-
ref = SourceRef(**ref_data)
|
|
255
|
-
else:
|
|
256
|
-
ref = ref_data
|
|
257
|
-
nodes = self.find_nodes_by_source(ref)
|
|
258
|
-
return {"nodes": [n.to_dict() for n in nodes], "count": len(nodes)}
|
|
259
|
-
|
|
260
|
-
elif action == "find_nodes_by_label":
|
|
261
|
-
nodes = self.find_nodes_by_label(
|
|
262
|
-
kwargs["label"],
|
|
263
|
-
limit=kwargs.get("limit", 100)
|
|
264
|
-
)
|
|
265
|
-
return {"nodes": [n.to_dict() for n in nodes], "count": len(nodes)}
|
|
266
|
-
|
|
267
|
-
elif action == "update_node":
|
|
268
|
-
success = self.update_node(kwargs["node_id"], kwargs["properties"])
|
|
269
|
-
return {"success": success}
|
|
270
|
-
|
|
271
|
-
elif action == "update_edge":
|
|
272
|
-
success = self.update_edge(kwargs["edge_id"], kwargs["properties"])
|
|
273
|
-
return {"success": success}
|
|
274
|
-
|
|
275
|
-
elif action == "delete_nodes":
|
|
276
|
-
count = self.delete_nodes(
|
|
277
|
-
kwargs["node_ids"],
|
|
278
|
-
cascade=kwargs.get("cascade", True)
|
|
279
|
-
)
|
|
280
|
-
return {"deleted_count": count}
|
|
281
|
-
|
|
282
|
-
elif action == "delete_edges":
|
|
283
|
-
count = self.delete_edges(kwargs["edge_ids"])
|
|
284
|
-
return {"deleted_count": count}
|
|
285
|
-
|
|
286
|
-
elif action == "import_graph":
|
|
287
|
-
graph_data = kwargs["graph_data"]
|
|
288
|
-
if isinstance(graph_data, dict):
|
|
289
|
-
graph_data = GraphContext.from_dict(graph_data)
|
|
290
|
-
stats = self.import_graph(
|
|
291
|
-
graph_data,
|
|
292
|
-
merge_strategy=kwargs.get("merge_strategy", "overwrite")
|
|
293
|
-
)
|
|
294
|
-
return stats
|
|
295
|
-
|
|
296
|
-
elif action == "export_graph":
|
|
297
|
-
ctx = self.export_graph(filter_query=kwargs.get("filter_query"))
|
|
298
|
-
return ctx.to_dict()
|
|
227
|
+
"""Dispatch to the `@plugin_action`-tagged handler for `action` (SG-44).
|
|
299
228
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
229
|
+
Handlers are discovered by walking the class MRO for methods carrying a
|
|
230
|
+
`_plugin_action` tag (the same source `supported_actions` is built from
|
|
231
|
+
via `collect_plugin_actions`). Replaces the prior hand-maintained
|
|
232
|
+
if/elif chain.
|
|
233
|
+
"""
|
|
234
|
+
for klass in type(self).__mro__:
|
|
235
|
+
for attr in vars(klass).values():
|
|
236
|
+
if getattr(attr, "_plugin_action", None) == action:
|
|
237
|
+
return attr(self, **kwargs)
|
|
238
|
+
raise PluginInputError( # SG-47: typed input-validation
|
|
239
|
+
f"Unknown action: {action}", fields_invalid=["action"],
|
|
240
|
+
)
|
|
305
241
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
242
|
+
@plugin_action("get_schema")
|
|
243
|
+
def _action_get_schema(self, **kwargs) -> Dict[str, Any]:
|
|
244
|
+
"""Action wrapper -> get_schema()."""
|
|
245
|
+
return self.get_schema()
|
|
246
|
+
|
|
247
|
+
@plugin_action("add_nodes")
|
|
248
|
+
def _action_add_nodes(self, **kwargs) -> Dict[str, Any]:
|
|
249
|
+
"""Action wrapper -> add_nodes() (accepts dicts or GraphNode)."""
|
|
250
|
+
nodes_data = kwargs.get("nodes", [])
|
|
251
|
+
nodes = [self._dict_to_node(n) if isinstance(n, dict) else n for n in nodes_data]
|
|
252
|
+
ids = self.add_nodes(nodes)
|
|
253
|
+
return {"created_ids": ids, "count": len(ids)}
|
|
254
|
+
|
|
255
|
+
@plugin_action("add_edges")
|
|
256
|
+
def _action_add_edges(self, **kwargs) -> Dict[str, Any]:
|
|
257
|
+
"""Action wrapper -> add_edges() (accepts dicts or GraphEdge)."""
|
|
258
|
+
edges_data = kwargs.get("edges", [])
|
|
259
|
+
edges = [self._dict_to_edge(e) if isinstance(e, dict) else e for e in edges_data]
|
|
260
|
+
ids = self.add_edges(edges)
|
|
261
|
+
return {"created_ids": ids, "count": len(ids)}
|
|
262
|
+
|
|
263
|
+
@plugin_action("get_node")
|
|
264
|
+
def _action_get_node(self, **kwargs) -> Dict[str, Any]:
|
|
265
|
+
"""Action wrapper -> get_node()."""
|
|
266
|
+
node = self.get_node(kwargs["node_id"])
|
|
267
|
+
return {"node": node.to_dict() if node else None}
|
|
268
|
+
|
|
269
|
+
@plugin_action("get_edge")
|
|
270
|
+
def _action_get_edge(self, **kwargs) -> Dict[str, Any]:
|
|
271
|
+
"""Action wrapper -> get_edge()."""
|
|
272
|
+
edge = self.get_edge(kwargs["edge_id"])
|
|
273
|
+
return {"edge": edge.to_dict() if edge else None}
|
|
274
|
+
|
|
275
|
+
@plugin_action("get_context")
|
|
276
|
+
def _action_get_context(self, **kwargs) -> Dict[str, Any]:
|
|
277
|
+
"""Action wrapper -> get_context()."""
|
|
278
|
+
ctx = self.get_context(
|
|
279
|
+
kwargs["node_id"],
|
|
280
|
+
depth=kwargs.get("depth", 1),
|
|
281
|
+
filter_labels=kwargs.get("filter_labels"),
|
|
282
|
+
)
|
|
283
|
+
return ctx.to_dict()
|
|
284
|
+
|
|
285
|
+
@plugin_action("find_nodes_by_source")
|
|
286
|
+
def _action_find_nodes_by_source(self, **kwargs) -> Dict[str, Any]:
|
|
287
|
+
"""Action wrapper -> find_nodes_by_source()."""
|
|
288
|
+
ref_data = kwargs["source_ref"]
|
|
289
|
+
ref = SourceRef(**ref_data) if isinstance(ref_data, dict) else ref_data
|
|
290
|
+
nodes = self.find_nodes_by_source(ref)
|
|
291
|
+
return {"nodes": [n.to_dict() for n in nodes], "count": len(nodes)}
|
|
292
|
+
|
|
293
|
+
@plugin_action("find_nodes_by_label")
|
|
294
|
+
def _action_find_nodes_by_label(self, **kwargs) -> Dict[str, Any]:
|
|
295
|
+
"""Action wrapper -> find_nodes_by_label()."""
|
|
296
|
+
nodes = self.find_nodes_by_label(kwargs["label"], limit=kwargs.get("limit", 100))
|
|
297
|
+
return {"nodes": [n.to_dict() for n in nodes], "count": len(nodes)}
|
|
298
|
+
|
|
299
|
+
@plugin_action("update_node")
|
|
300
|
+
def _action_update_node(self, **kwargs) -> Dict[str, Any]:
|
|
301
|
+
"""Action wrapper -> update_node()."""
|
|
302
|
+
return {"success": self.update_node(kwargs["node_id"], kwargs["properties"])}
|
|
303
|
+
|
|
304
|
+
@plugin_action("update_edge")
|
|
305
|
+
def _action_update_edge(self, **kwargs) -> Dict[str, Any]:
|
|
306
|
+
"""Action wrapper -> update_edge()."""
|
|
307
|
+
return {"success": self.update_edge(kwargs["edge_id"], kwargs["properties"])}
|
|
308
|
+
|
|
309
|
+
@plugin_action("delete_nodes")
|
|
310
|
+
def _action_delete_nodes(self, **kwargs) -> Dict[str, Any]:
|
|
311
|
+
"""Action wrapper -> delete_nodes()."""
|
|
312
|
+
count = self.delete_nodes(kwargs["node_ids"], cascade=kwargs.get("cascade", True))
|
|
313
|
+
return {"deleted_count": count}
|
|
314
|
+
|
|
315
|
+
@plugin_action("delete_edges")
|
|
316
|
+
def _action_delete_edges(self, **kwargs) -> Dict[str, Any]:
|
|
317
|
+
"""Action wrapper -> delete_edges()."""
|
|
318
|
+
return {"deleted_count": self.delete_edges(kwargs["edge_ids"])}
|
|
319
|
+
|
|
320
|
+
@plugin_action("import_graph")
|
|
321
|
+
def _action_import_graph(self, **kwargs) -> Dict[str, Any]:
|
|
322
|
+
"""Action wrapper -> import_graph() (dict or GraphContext)."""
|
|
323
|
+
graph_data = kwargs["graph_data"]
|
|
324
|
+
if isinstance(graph_data, dict):
|
|
325
|
+
graph_data = GraphContext.from_dict(graph_data)
|
|
326
|
+
return self.import_graph(graph_data, merge_strategy=kwargs.get("merge_strategy", "overwrite"))
|
|
327
|
+
|
|
328
|
+
@plugin_action("export_graph")
|
|
329
|
+
def _action_export_graph(self, **kwargs) -> Dict[str, Any]:
|
|
330
|
+
"""Action wrapper -> export_graph()."""
|
|
331
|
+
return self.export_graph(filter_query=kwargs.get("filter_query")).to_dict()
|
|
332
|
+
|
|
333
|
+
@plugin_action("query")
|
|
334
|
+
def _action_query(self, **kwargs) -> Dict[str, Any]:
|
|
335
|
+
"""Action wrapper -> query() (read-only SELECT)."""
|
|
336
|
+
return self.query(kwargs.get("sql", kwargs.get("query", "")), params=kwargs.get("params"))
|
|
310
337
|
|
|
311
338
|
# -------------------------------------------------------------------------
|
|
312
339
|
# CREATE
|
|
@@ -319,7 +346,7 @@ class SQLiteGraphPlugin(GraphPlugin):
|
|
|
319
346
|
"""Bulk create nodes."""
|
|
320
347
|
ids = []
|
|
321
348
|
now = time.time()
|
|
322
|
-
with
|
|
349
|
+
with self._connect() as con:
|
|
323
350
|
for n in nodes:
|
|
324
351
|
sources_json = json.dumps([s.to_dict() for s in n.sources])
|
|
325
352
|
props_json = json.dumps(n.properties)
|
|
@@ -340,7 +367,7 @@ class SQLiteGraphPlugin(GraphPlugin):
|
|
|
340
367
|
"""Bulk create edges."""
|
|
341
368
|
ids = []
|
|
342
369
|
now = time.time()
|
|
343
|
-
with
|
|
370
|
+
with self._connect() as con:
|
|
344
371
|
con.execute("PRAGMA foreign_keys = ON;")
|
|
345
372
|
for e in edges:
|
|
346
373
|
props_json = json.dumps(e.properties)
|
|
@@ -363,7 +390,7 @@ class SQLiteGraphPlugin(GraphPlugin):
|
|
|
363
390
|
node_id: str # UUID of node to retrieve
|
|
364
391
|
) -> Optional[GraphNode]: # Node or None if not found
|
|
365
392
|
"""Get a single node by ID."""
|
|
366
|
-
with
|
|
393
|
+
with self._connect() as con:
|
|
367
394
|
cur = con.execute(
|
|
368
395
|
"SELECT id, label, properties, sources, created_at, updated_at FROM nodes WHERE id = ?",
|
|
369
396
|
(node_id,)
|
|
@@ -376,7 +403,7 @@ class SQLiteGraphPlugin(GraphPlugin):
|
|
|
376
403
|
edge_id: str # UUID of edge to retrieve
|
|
377
404
|
) -> Optional[GraphEdge]: # Edge or None if not found
|
|
378
405
|
"""Get a single edge by ID."""
|
|
379
|
-
with
|
|
406
|
+
with self._connect() as con:
|
|
380
407
|
cur = con.execute(
|
|
381
408
|
"SELECT id, source_id, target_id, relation_type, properties, created_at, updated_at FROM edges WHERE id = ?",
|
|
382
409
|
(edge_id,)
|
|
@@ -404,7 +431,7 @@ class SQLiteGraphPlugin(GraphPlugin):
|
|
|
404
431
|
params.append(source_ref.segment_slice)
|
|
405
432
|
|
|
406
433
|
results = []
|
|
407
|
-
with
|
|
434
|
+
with self._connect() as con:
|
|
408
435
|
cur = con.execute(query, tuple(params))
|
|
409
436
|
for row in cur:
|
|
410
437
|
results.append(self._row_to_node(row))
|
|
@@ -417,7 +444,7 @@ class SQLiteGraphPlugin(GraphPlugin):
|
|
|
417
444
|
) -> List[GraphNode]: # Matching nodes
|
|
418
445
|
"""Find nodes by label."""
|
|
419
446
|
results = []
|
|
420
|
-
with
|
|
447
|
+
with self._connect() as con:
|
|
421
448
|
cur = con.execute(
|
|
422
449
|
"SELECT id, label, properties, sources, created_at, updated_at FROM nodes WHERE label = ? LIMIT ?",
|
|
423
450
|
(label, limit)
|
|
@@ -435,7 +462,7 @@ class SQLiteGraphPlugin(GraphPlugin):
|
|
|
435
462
|
"""Get the neighborhood of a specific node."""
|
|
436
463
|
# For depth=1, use simple query; for deeper, use recursive CTE
|
|
437
464
|
edge_ids = []
|
|
438
|
-
with
|
|
465
|
+
with self._connect() as con:
|
|
439
466
|
if depth == 1:
|
|
440
467
|
cur = con.execute(
|
|
441
468
|
"SELECT id FROM edges WHERE source_id = ? OR target_id = ?",
|
|
@@ -474,7 +501,7 @@ class SQLiteGraphPlugin(GraphPlugin):
|
|
|
474
501
|
|
|
475
502
|
if edge_ids:
|
|
476
503
|
placeholders = ','.join('?' for _ in edge_ids)
|
|
477
|
-
with
|
|
504
|
+
with self._connect() as con:
|
|
478
505
|
cur = con.execute(
|
|
479
506
|
f"SELECT id, source_id, target_id, relation_type, properties, created_at, updated_at FROM edges WHERE id IN ({placeholders})",
|
|
480
507
|
tuple(edge_ids)
|
|
@@ -489,7 +516,7 @@ class SQLiteGraphPlugin(GraphPlugin):
|
|
|
489
516
|
nodes = []
|
|
490
517
|
if node_ids_in_context:
|
|
491
518
|
placeholders = ','.join('?' for _ in node_ids_in_context)
|
|
492
|
-
with
|
|
519
|
+
with self._connect() as con:
|
|
493
520
|
sql = f"SELECT id, label, properties, sources, created_at, updated_at FROM nodes WHERE id IN ({placeholders})"
|
|
494
521
|
|
|
495
522
|
# Apply optional label filtering
|
|
@@ -518,7 +545,7 @@ class SQLiteGraphPlugin(GraphPlugin):
|
|
|
518
545
|
properties: Dict[str, Any] # Properties to merge/update
|
|
519
546
|
) -> bool: # True if successful
|
|
520
547
|
"""Partial update of node properties."""
|
|
521
|
-
with
|
|
548
|
+
with self._connect() as con:
|
|
522
549
|
# Fetch existing to merge
|
|
523
550
|
cur = con.execute("SELECT properties FROM nodes WHERE id = ?", (node_id,))
|
|
524
551
|
row = cur.fetchone()
|
|
@@ -540,7 +567,7 @@ class SQLiteGraphPlugin(GraphPlugin):
|
|
|
540
567
|
properties: Dict[str, Any] # Properties to merge/update
|
|
541
568
|
) -> bool: # True if successful
|
|
542
569
|
"""Partial update of edge properties."""
|
|
543
|
-
with
|
|
570
|
+
with self._connect() as con:
|
|
544
571
|
cur = con.execute("SELECT properties FROM edges WHERE id = ?", (edge_id,))
|
|
545
572
|
row = cur.fetchone()
|
|
546
573
|
if not row:
|
|
@@ -565,7 +592,7 @@ class SQLiteGraphPlugin(GraphPlugin):
|
|
|
565
592
|
cascade: bool = True # Also delete connected edges
|
|
566
593
|
) -> int: # Number of nodes deleted
|
|
567
594
|
"""Delete nodes (and optionally connected edges)."""
|
|
568
|
-
with
|
|
595
|
+
with self._connect() as con:
|
|
569
596
|
if cascade:
|
|
570
597
|
con.execute("PRAGMA foreign_keys = ON;") # Ensures cascade works
|
|
571
598
|
else:
|
|
@@ -583,7 +610,7 @@ class SQLiteGraphPlugin(GraphPlugin):
|
|
|
583
610
|
edge_ids: List[str] # UUIDs of edges to delete
|
|
584
611
|
) -> int: # Number of edges deleted
|
|
585
612
|
"""Delete edges."""
|
|
586
|
-
with
|
|
613
|
+
with self._connect() as con:
|
|
587
614
|
placeholders = ','.join('?' for _ in edge_ids)
|
|
588
615
|
cur = con.execute(
|
|
589
616
|
f"DELETE FROM edges WHERE id IN ({placeholders})",
|
|
@@ -598,7 +625,7 @@ class SQLiteGraphPlugin(GraphPlugin):
|
|
|
598
625
|
def get_schema(self) -> Dict[str, Any]: # Graph schema/ontology
|
|
599
626
|
"""Return the current ontology/schema of the graph."""
|
|
600
627
|
schema = {"node_labels": [], "edge_types": [], "counts": {}}
|
|
601
|
-
with
|
|
628
|
+
with self._connect() as con:
|
|
602
629
|
# Labels
|
|
603
630
|
cur = con.execute("SELECT DISTINCT label FROM nodes")
|
|
604
631
|
schema["node_labels"] = [r[0] for r in cur.fetchall()]
|
|
@@ -618,12 +645,106 @@ class SQLiteGraphPlugin(GraphPlugin):
|
|
|
618
645
|
self,
|
|
619
646
|
graph_data: GraphContext, # Data to import
|
|
620
647
|
merge_strategy: str = "overwrite" # "overwrite", "skip", or "merge"
|
|
621
|
-
) -> Dict[str, int]: # Import statistics {nodes_created, edges_created,
|
|
622
|
-
"""Bulk import a GraphContext
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
648
|
+
) -> Dict[str, int]: # Import statistics {nodes_created, edges_created, merge_strategy}
|
|
649
|
+
"""Bulk import a GraphContext honoring merge_strategy (SG-41).
|
|
650
|
+
|
|
651
|
+
On id-conflict: "skip" keeps the existing row untouched; "overwrite"
|
|
652
|
+
replaces its mutable fields with the incoming values; "merge" unions
|
|
653
|
+
properties (incoming wins per key) and unions node sources by identity.
|
|
654
|
+
Brand-new ids are always inserted. The `nodes_created`/`edges_created`
|
|
655
|
+
counts report rows written (inserted or updated).
|
|
656
|
+
"""
|
|
657
|
+
if merge_strategy not in ("overwrite", "skip", "merge"):
|
|
658
|
+
raise PluginInputError( # SG-47: typed input-validation
|
|
659
|
+
f"Unknown merge_strategy: {merge_strategy}", fields_invalid=["merge_strategy"],
|
|
660
|
+
)
|
|
661
|
+
n = self._import_nodes(graph_data.nodes, merge_strategy)
|
|
662
|
+
e = self._import_edges(graph_data.edges, merge_strategy)
|
|
663
|
+
return {"nodes_created": n, "edges_created": e, "merge_strategy": merge_strategy}
|
|
664
|
+
|
|
665
|
+
def _import_nodes(
|
|
666
|
+
self,
|
|
667
|
+
nodes: List[GraphNode], # Nodes to import
|
|
668
|
+
merge_strategy: str # "overwrite" | "skip" | "merge"
|
|
669
|
+
) -> int: # Count of rows written (inserted or updated)
|
|
670
|
+
"""Import nodes honoring merge_strategy (see import_graph)."""
|
|
671
|
+
now = time.time()
|
|
672
|
+
written = 0
|
|
673
|
+
with self._connect() as con:
|
|
674
|
+
for node in nodes:
|
|
675
|
+
props_json = json.dumps(node.properties)
|
|
676
|
+
sources_json = json.dumps([s.to_dict() for s in node.sources])
|
|
677
|
+
row = con.execute("SELECT properties, sources FROM nodes WHERE id = ?", (node.id,)).fetchone()
|
|
678
|
+
if row is None:
|
|
679
|
+
con.execute(
|
|
680
|
+
"INSERT INTO nodes (id, label, properties, sources, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
681
|
+
(node.id, node.label, props_json, sources_json, now, now),
|
|
682
|
+
)
|
|
683
|
+
written += 1
|
|
684
|
+
elif merge_strategy == "skip":
|
|
685
|
+
continue
|
|
686
|
+
elif merge_strategy == "overwrite":
|
|
687
|
+
con.execute(
|
|
688
|
+
"UPDATE nodes SET label = ?, properties = ?, sources = ?, updated_at = ? WHERE id = ?",
|
|
689
|
+
(node.label, props_json, sources_json, now, node.id),
|
|
690
|
+
)
|
|
691
|
+
written += 1
|
|
692
|
+
else: # merge
|
|
693
|
+
merged_props = json.loads(row[0]) if row[0] else {}
|
|
694
|
+
merged_props.update(node.properties)
|
|
695
|
+
existing_sources = json.loads(row[1]) if row[1] else []
|
|
696
|
+
seen = {(s.get("plugin_name"), s.get("row_id"), s.get("segment_slice")) for s in existing_sources}
|
|
697
|
+
for s in node.sources:
|
|
698
|
+
sd = s.to_dict()
|
|
699
|
+
key = (sd.get("plugin_name"), sd.get("row_id"), sd.get("segment_slice"))
|
|
700
|
+
if key not in seen:
|
|
701
|
+
existing_sources.append(sd)
|
|
702
|
+
seen.add(key)
|
|
703
|
+
con.execute(
|
|
704
|
+
"UPDATE nodes SET label = ?, properties = ?, sources = ?, updated_at = ? WHERE id = ?",
|
|
705
|
+
(node.label, json.dumps(merged_props), json.dumps(existing_sources), now, node.id),
|
|
706
|
+
)
|
|
707
|
+
written += 1
|
|
708
|
+
return written
|
|
709
|
+
|
|
710
|
+
def _import_edges(
|
|
711
|
+
self,
|
|
712
|
+
edges: List[GraphEdge], # Edges to import
|
|
713
|
+
merge_strategy: str # "overwrite" | "skip" | "merge"
|
|
714
|
+
) -> int: # Count of rows written (inserted or updated)
|
|
715
|
+
"""Import edges honoring merge_strategy (see import_graph)."""
|
|
716
|
+
now = time.time()
|
|
717
|
+
written = 0
|
|
718
|
+
with self._connect() as con:
|
|
719
|
+
for edge in edges:
|
|
720
|
+
props_json = json.dumps(edge.properties)
|
|
721
|
+
row = con.execute("SELECT properties FROM edges WHERE id = ?", (edge.id,)).fetchone()
|
|
722
|
+
if row is None:
|
|
723
|
+
try:
|
|
724
|
+
con.execute(
|
|
725
|
+
"INSERT INTO edges (id, source_id, target_id, relation_type, properties, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
726
|
+
(edge.id, edge.source_id, edge.target_id, edge.relation_type, props_json, now, now),
|
|
727
|
+
)
|
|
728
|
+
written += 1
|
|
729
|
+
except sqlite3.IntegrityError as err:
|
|
730
|
+
self.logger.warning(f"Edge import error (likely missing node): {err}")
|
|
731
|
+
elif merge_strategy == "skip":
|
|
732
|
+
continue
|
|
733
|
+
elif merge_strategy == "overwrite":
|
|
734
|
+
con.execute(
|
|
735
|
+
"UPDATE edges SET source_id = ?, target_id = ?, relation_type = ?, properties = ?, updated_at = ? WHERE id = ?",
|
|
736
|
+
(edge.source_id, edge.target_id, edge.relation_type, props_json, now, edge.id),
|
|
737
|
+
)
|
|
738
|
+
written += 1
|
|
739
|
+
else: # merge
|
|
740
|
+
merged = json.loads(row[0]) if row[0] else {}
|
|
741
|
+
merged.update(edge.properties)
|
|
742
|
+
con.execute(
|
|
743
|
+
"UPDATE edges SET properties = ?, updated_at = ? WHERE id = ?",
|
|
744
|
+
(json.dumps(merged), now, edge.id),
|
|
745
|
+
)
|
|
746
|
+
written += 1
|
|
747
|
+
return written
|
|
627
748
|
|
|
628
749
|
def export_graph(
|
|
629
750
|
self,
|
|
@@ -634,7 +755,7 @@ class SQLiteGraphPlugin(GraphPlugin):
|
|
|
634
755
|
all_nodes = []
|
|
635
756
|
all_edges = []
|
|
636
757
|
|
|
637
|
-
with
|
|
758
|
+
with self._connect() as con:
|
|
638
759
|
cur = con.execute("SELECT id, label, properties, sources, created_at, updated_at FROM nodes")
|
|
639
760
|
for row in cur:
|
|
640
761
|
all_nodes.append(self._row_to_node(row))
|
|
@@ -645,7 +766,44 @@ class SQLiteGraphPlugin(GraphPlugin):
|
|
|
645
766
|
|
|
646
767
|
return GraphContext(nodes=all_nodes, edges=all_edges)
|
|
647
768
|
|
|
769
|
+
def query(
|
|
770
|
+
self,
|
|
771
|
+
sql: str, # A single read-only SELECT (or WITH ... SELECT) statement
|
|
772
|
+
params: Optional[List[Any]] = None # Bound parameters for the statement
|
|
773
|
+
) -> Dict[str, Any]: # {"columns": [...], "rows": [[...]], "row_count": int}
|
|
774
|
+
"""Execute a single read-only SELECT and return its rows (SG-41).
|
|
775
|
+
|
|
776
|
+
Guards reject empty input, multiple statements, and anything not starting
|
|
777
|
+
with SELECT/WITH. The statement runs on a fresh read-only connection
|
|
778
|
+
(URI `mode=ro`), so even a query that slips past the prefix guard cannot
|
|
779
|
+
mutate the database. Bound `params` use SQLite's qmark placeholders.
|
|
780
|
+
"""
|
|
781
|
+
text = (sql or "").strip()
|
|
782
|
+
while text.endswith(";"):
|
|
783
|
+
text = text[:-1].strip()
|
|
784
|
+
if not text:
|
|
785
|
+
raise PluginInputError("query requires a non-empty SQL string", fields_invalid=["sql"])
|
|
786
|
+
if ";" in text:
|
|
787
|
+
raise PluginInputError("query accepts a single statement only", fields_invalid=["sql"])
|
|
788
|
+
head = text.lstrip("(").split(None, 1)[0].lower()
|
|
789
|
+
if head not in ("select", "with"):
|
|
790
|
+
raise PluginInputError(
|
|
791
|
+
"query allows read-only SELECT statements only", fields_invalid=["sql"],
|
|
792
|
+
)
|
|
793
|
+
con = sqlite3.connect(f"file:{self._db_path}?mode=ro", uri=True)
|
|
794
|
+
try:
|
|
795
|
+
cur = con.execute(text, tuple(params) if params else ())
|
|
796
|
+
columns = [d[0] for d in cur.description] if cur.description else []
|
|
797
|
+
rows = [list(r) for r in cur.fetchall()]
|
|
798
|
+
finally:
|
|
799
|
+
con.close()
|
|
800
|
+
return {"columns": columns, "rows": rows, "row_count": len(rows)}
|
|
801
|
+
|
|
648
802
|
def cleanup(self) -> None:
|
|
649
803
|
"""Clean up resources."""
|
|
650
804
|
# SQLite connections are managed via context managers, nothing to do here
|
|
651
805
|
pass
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
SQLiteGraphPlugin.supported_actions = collect_plugin_actions(SQLiteGraphPlugin)
|
|
809
|
+
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cjm-graph-plugin-sqlite
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.11
|
|
4
4
|
Summary: A local, file-backed Context Graph worker for the cjm-plugin-system that implements graph storage, traversal, and querying using SQLite.
|
|
5
5
|
Author-email: "Christian J. Mills" <9126128+cj-mills@users.noreply.github.com>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -15,7 +15,7 @@ Classifier: Programming Language :: Python :: 3 :: Only
|
|
|
15
15
|
Requires-Python: >=3.12
|
|
16
16
|
Description-Content-Type: text/markdown
|
|
17
17
|
License-File: LICENSE
|
|
18
|
-
Requires-Dist: cjm_graph_plugin_system>=0.0.
|
|
18
|
+
Requires-Dist: cjm_graph_plugin_system>=0.0.12
|
|
19
19
|
Dynamic: license-file
|
|
20
20
|
|
|
21
21
|
# cjm-graph-plugin-sqlite
|
|
@@ -41,8 +41,8 @@ Total: 2 notebooks
|
|
|
41
41
|
|
|
42
42
|
``` mermaid
|
|
43
43
|
graph LR
|
|
44
|
-
meta[meta<br/>Metadata]
|
|
45
|
-
plugin[plugin<br/>SQLite Graph Plugin]
|
|
44
|
+
meta["meta<br/>Metadata"]
|
|
45
|
+
plugin["plugin<br/>SQLite Graph Plugin"]
|
|
46
46
|
|
|
47
47
|
plugin --> meta
|
|
48
48
|
```
|
|
@@ -164,7 +164,12 @@ class SQLiteGraphPlugin:
|
|
|
164
164
|
action: str = "get_schema", # Action to perform
|
|
165
165
|
**kwargs
|
|
166
166
|
) -> Dict[str, Any]: # JSON-serializable result
|
|
167
|
-
"Dispatch to
|
|
167
|
+
"Dispatch to the `@plugin_action`-tagged handler for `action` (SG-44).
|
|
168
|
+
|
|
169
|
+
Handlers are discovered by walking the class MRO for methods carrying a
|
|
170
|
+
`_plugin_action` tag (the same source `supported_actions` is built from
|
|
171
|
+
via `collect_plugin_actions`). Replaces the prior hand-maintained
|
|
172
|
+
if/elif chain."
|
|
168
173
|
|
|
169
174
|
def add_nodes(
|
|
170
175
|
self,
|
|
@@ -247,8 +252,14 @@ class SQLiteGraphPlugin:
|
|
|
247
252
|
self,
|
|
248
253
|
graph_data: GraphContext, # Data to import
|
|
249
254
|
merge_strategy: str = "overwrite" # "overwrite", "skip", or "merge"
|
|
250
|
-
) -> Dict[str, int]: # Import statistics {nodes_created, edges_created,
|
|
251
|
-
"Bulk import a GraphContext
|
|
255
|
+
) -> Dict[str, int]: # Import statistics {nodes_created, edges_created, merge_strategy}
|
|
256
|
+
"Bulk import a GraphContext honoring merge_strategy (SG-41).
|
|
257
|
+
|
|
258
|
+
On id-conflict: "skip" keeps the existing row untouched; "overwrite"
|
|
259
|
+
replaces its mutable fields with the incoming values; "merge" unions
|
|
260
|
+
properties (incoming wins per key) and unions node sources by identity.
|
|
261
|
+
Brand-new ids are always inserted. The `nodes_created`/`edges_created`
|
|
262
|
+
counts report rows written (inserted or updated)."
|
|
252
263
|
|
|
253
264
|
def export_graph(
|
|
254
265
|
self,
|
|
@@ -256,6 +267,18 @@ class SQLiteGraphPlugin:
|
|
|
256
267
|
) -> GraphContext: # Exported subgraph or full graph
|
|
257
268
|
"Export the entire graph or a filtered subset."
|
|
258
269
|
|
|
270
|
+
def query(
|
|
271
|
+
self,
|
|
272
|
+
sql: str, # A single read-only SELECT (or WITH ... SELECT) statement
|
|
273
|
+
params: Optional[List[Any]] = None # Bound parameters for the statement
|
|
274
|
+
) -> Dict[str, Any]: # {"columns": [...], "rows": [[...]], "row_count": int}
|
|
275
|
+
"Execute a single read-only SELECT and return its rows (SG-41).
|
|
276
|
+
|
|
277
|
+
Guards reject empty input, multiple statements, and anything not starting
|
|
278
|
+
with SELECT/WITH. The statement runs on a fresh read-only connection
|
|
279
|
+
(URI `mode=ro`), so even a query that slips past the prefix guard cannot
|
|
280
|
+
mutate the database. Bound `params` use SQLite's qmark placeholders."
|
|
281
|
+
|
|
259
282
|
def cleanup(self) -> None
|
|
260
283
|
"Clean up resources."
|
|
261
284
|
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cjm_graph_plugin_system>=0.0.12
|
|
@@ -12,7 +12,7 @@ license = {text = "Apache-2.0"}
|
|
|
12
12
|
authors = [{name = "Christian J. Mills", email = "9126128+cj-mills@users.noreply.github.com"}]
|
|
13
13
|
keywords = ['nbdev', 'jupyter', 'notebook', 'python']
|
|
14
14
|
classifiers = ["Natural Language :: English", "Intended Audience :: Developers", "Development Status :: 3 - Alpha", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only"]
|
|
15
|
-
dependencies = ['cjm_graph_plugin_system>=0.0.
|
|
15
|
+
dependencies = ['cjm_graph_plugin_system>=0.0.12']
|
|
16
16
|
|
|
17
17
|
[project.urls]
|
|
18
18
|
Repository = "https://github.com/cj-mills/cjm-graph-plugin-sqlite"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.0.9"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
cjm_graph_plugin_system>=0.0.10
|
|
File without changes
|
|
File without changes
|
{cjm_graph_plugin_sqlite-0.0.9 → cjm_graph_plugin_sqlite-0.0.11}/cjm_graph_plugin_sqlite/meta.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|