cjm-graph-plugin-sqlite 0.0.8__tar.gz → 0.0.10__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.
Files changed (18) hide show
  1. {cjm_graph_plugin_sqlite-0.0.8/cjm_graph_plugin_sqlite.egg-info → cjm_graph_plugin_sqlite-0.0.10}/PKG-INFO +30 -7
  2. {cjm_graph_plugin_sqlite-0.0.8 → cjm_graph_plugin_sqlite-0.0.10}/README.md +28 -5
  3. cjm_graph_plugin_sqlite-0.0.10/cjm_graph_plugin_sqlite/__init__.py +1 -0
  4. {cjm_graph_plugin_sqlite-0.0.8 → cjm_graph_plugin_sqlite-0.0.10}/cjm_graph_plugin_sqlite/_modidx.py +38 -0
  5. {cjm_graph_plugin_sqlite-0.0.8 → cjm_graph_plugin_sqlite-0.0.10}/cjm_graph_plugin_sqlite/plugin.py +283 -125
  6. {cjm_graph_plugin_sqlite-0.0.8 → cjm_graph_plugin_sqlite-0.0.10/cjm_graph_plugin_sqlite.egg-info}/PKG-INFO +30 -7
  7. cjm_graph_plugin_sqlite-0.0.10/cjm_graph_plugin_sqlite.egg-info/requires.txt +1 -0
  8. {cjm_graph_plugin_sqlite-0.0.8 → cjm_graph_plugin_sqlite-0.0.10}/pyproject.toml +1 -1
  9. cjm_graph_plugin_sqlite-0.0.8/cjm_graph_plugin_sqlite/__init__.py +0 -1
  10. cjm_graph_plugin_sqlite-0.0.8/cjm_graph_plugin_sqlite.egg-info/requires.txt +0 -1
  11. {cjm_graph_plugin_sqlite-0.0.8 → cjm_graph_plugin_sqlite-0.0.10}/LICENSE +0 -0
  12. {cjm_graph_plugin_sqlite-0.0.8 → cjm_graph_plugin_sqlite-0.0.10}/MANIFEST.in +0 -0
  13. {cjm_graph_plugin_sqlite-0.0.8 → cjm_graph_plugin_sqlite-0.0.10}/cjm_graph_plugin_sqlite/meta.py +0 -0
  14. {cjm_graph_plugin_sqlite-0.0.8 → cjm_graph_plugin_sqlite-0.0.10}/cjm_graph_plugin_sqlite.egg-info/SOURCES.txt +0 -0
  15. {cjm_graph_plugin_sqlite-0.0.8 → cjm_graph_plugin_sqlite-0.0.10}/cjm_graph_plugin_sqlite.egg-info/dependency_links.txt +0 -0
  16. {cjm_graph_plugin_sqlite-0.0.8 → cjm_graph_plugin_sqlite-0.0.10}/cjm_graph_plugin_sqlite.egg-info/entry_points.txt +0 -0
  17. {cjm_graph_plugin_sqlite-0.0.8 → cjm_graph_plugin_sqlite-0.0.10}/cjm_graph_plugin_sqlite.egg-info/top_level.txt +0 -0
  18. {cjm_graph_plugin_sqlite-0.0.8 → cjm_graph_plugin_sqlite-0.0.10}/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.8
3
+ Version: 0.0.10
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.9
18
+ Requires-Dist: cjm_graph_plugin_system>=0.0.11
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 appropriate method based on action."
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 (e.g., from backup or another plugin)."
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 appropriate method based on action."
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 (e.g., from backup or another plugin)."
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.10"
@@ -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',
@@ -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
- self._init_db()
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 sqlite3.connect(self._db_path) as con:
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 - Main dispatcher for RemotePluginProxy
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 appropriate method based on action."""
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
- elif action == "query":
301
- # Raw query execution (future enhancement)
302
- query = kwargs.get("query", "")
303
- self.logger.warning(f"Raw query action not fully implemented: {query}")
304
- return {"status": "not_implemented", "query": str(query)}
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
- else:
307
- raise PluginInputError( # SG-47: typed input-validation
308
- f"Unknown action: {action}", fields_invalid=["action"],
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 sqlite3.connect(self._db_path) as con:
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 sqlite3.connect(self._db_path) as con:
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 sqlite3.connect(self._db_path) as con:
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 sqlite3.connect(self._db_path) as con:
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 sqlite3.connect(self._db_path) as con:
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 sqlite3.connect(self._db_path) as con:
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 sqlite3.connect(self._db_path) as con:
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 sqlite3.connect(self._db_path) as con:
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 sqlite3.connect(self._db_path) as con:
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 sqlite3.connect(self._db_path) as con:
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 sqlite3.connect(self._db_path) as con:
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 sqlite3.connect(self._db_path) as con:
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 sqlite3.connect(self._db_path) as con:
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 sqlite3.connect(self._db_path) as con:
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 (e.g., from backup or another plugin)."""
623
- # Reuse bulk add methods (currently uses overwrite via INSERT)
624
- n = self.add_nodes(graph_data.nodes)
625
- e = self.add_edges(graph_data.edges)
626
- return {"nodes_created": len(n), "edges_created": len(e)}
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 sqlite3.connect(self._db_path) as con:
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.8
3
+ Version: 0.0.10
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.9
18
+ Requires-Dist: cjm_graph_plugin_system>=0.0.11
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 appropriate method based on action."
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 (e.g., from backup or another plugin)."
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.11
@@ -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.9']
15
+ dependencies = ['cjm_graph_plugin_system>=0.0.11']
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.8"
@@ -1 +0,0 @@
1
- cjm_graph_plugin_system>=0.0.9