cjm-capability-graph-sqlite 0.0.28__tar.gz → 0.0.30__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_capability_graph_sqlite-0.0.28/cjm_capability_graph_sqlite.egg-info → cjm_capability_graph_sqlite-0.0.30}/PKG-INFO +6 -2
- {cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.30}/README.md +4 -0
- cjm_capability_graph_sqlite-0.0.30/cjm_capability_graph_sqlite/__init__.py +1 -0
- {cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.30}/cjm_capability_graph_sqlite/capability.py +129 -36
- {cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.30}/cjm_capability_graph_sqlite/query_translation.py +44 -10
- {cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.30/cjm_capability_graph_sqlite.egg-info}/PKG-INFO +6 -2
- cjm_capability_graph_sqlite-0.0.30/cjm_capability_graph_sqlite.egg-info/requires.txt +2 -0
- {cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.30}/pyproject.toml +1 -1
- {cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.30}/tests/test_capability.py +95 -4
- {cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.30}/tests/test_query_translation.py +45 -6
- cjm_capability_graph_sqlite-0.0.28/cjm_capability_graph_sqlite/__init__.py +0 -1
- cjm_capability_graph_sqlite-0.0.28/cjm_capability_graph_sqlite.egg-info/requires.txt +0 -2
- {cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.30}/LICENSE +0 -0
- {cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.30}/MANIFEST.in +0 -0
- {cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.30}/cjm_capability_graph_sqlite.egg-info/SOURCES.txt +0 -0
- {cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.30}/cjm_capability_graph_sqlite.egg-info/dependency_links.txt +0 -0
- {cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.30}/cjm_capability_graph_sqlite.egg-info/top_level.txt +0 -0
- {cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.30}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cjm-capability-graph-sqlite
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.30
|
|
4
4
|
Summary: A local, file-backed context-graph storage capability for the cjm-substrate runtime 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 @@ Requires-Python: >=3.12
|
|
|
15
15
|
Description-Content-Type: text/markdown
|
|
16
16
|
License-File: LICENSE
|
|
17
17
|
Requires-Dist: cjm-substrate>=0.0.51
|
|
18
|
-
Requires-Dist: cjm-context-graph-primitives>=0.0.
|
|
18
|
+
Requires-Dist: cjm-context-graph-primitives>=0.0.18
|
|
19
19
|
Dynamic: license-file
|
|
20
20
|
|
|
21
21
|
# cjm-capability-graph-sqlite
|
|
@@ -26,6 +26,7 @@ A local, file-backed context-graph storage capability for the cjm-substrate runt
|
|
|
26
26
|
|
|
27
27
|
## Modules
|
|
28
28
|
|
|
29
|
+
- **`cjm_capability_graph_sqlite.__init__`**
|
|
29
30
|
- **`cjm_capability_graph_sqlite.capability`** — Capability implementation for Context Graph using SQLite.
|
|
30
31
|
- **`cjm_capability_graph_sqlite.query_translation`** — The per-backend translation of the typed query expressions (pass-2 Thread 5; stage 4): NodeQuery/EdgeQuery -> parameterized SQLite SQL over the nodes/edges schema. THIS module is what makes the typed surface portable - the expression is domain- and backend-neutral; every backend tool owns a translation like this one (the ratified stage-4 split: backend owns translation; the adapter stays generic). Pure functions, unit-tested against an in-memory DB with the production schema - no capability runtime needed.
|
|
31
32
|
|
|
@@ -35,13 +36,16 @@ A local, file-backed context-graph storage capability for the cjm-substrate runt
|
|
|
35
36
|
|
|
36
37
|
- `SQLiteGraphCapability` _class_ — Local, file-backed Context Graph implementation using SQLite.
|
|
37
38
|
- `SQLiteGraphCapabilityConfig` _class_ — Configuration for SQLite Graph Capability.
|
|
39
|
+
- `ingest_sources` _function_ — The source HEADs this db was ingested from, on a fresh read-only connection. A db built
|
|
38
40
|
- `integrity_check` _function_ — Backend self-check (stage 4; the G3 corruption find institutionalized):
|
|
39
41
|
- `query_edges` _function_ — Execute a typed edge query (stage 4) — same contract as `query_nodes`.
|
|
40
42
|
- `query_nodes` _function_ — Execute a typed node query (stage 4) — translated to parameterized SQL
|
|
41
43
|
- `raw_query` _function_ — Execute the raw escape — refuses backend mismatches (non-portable by
|
|
44
|
+
- `record_ingest_sources` _function_ — Record the source HEADs this db was ingested from, REPLACING any earlier record
|
|
42
45
|
|
|
43
46
|
### `cjm_capability_graph_sqlite.query_translation`
|
|
44
47
|
|
|
48
|
+
- `decode_projected_row` _function_ — Zip a projected row, decoding every property column from its JSON representation.
|
|
45
49
|
- `translate_edge_query` _function_ — Translate an `EdgeQuery` to parameterized SQLite SQL.
|
|
46
50
|
- `translate_node_query` _function_ — Translate a `NodeQuery` to parameterized SQLite SQL.
|
|
47
51
|
|
|
@@ -6,6 +6,7 @@ A local, file-backed context-graph storage capability for the cjm-substrate runt
|
|
|
6
6
|
|
|
7
7
|
## Modules
|
|
8
8
|
|
|
9
|
+
- **`cjm_capability_graph_sqlite.__init__`**
|
|
9
10
|
- **`cjm_capability_graph_sqlite.capability`** — Capability implementation for Context Graph using SQLite.
|
|
10
11
|
- **`cjm_capability_graph_sqlite.query_translation`** — The per-backend translation of the typed query expressions (pass-2 Thread 5; stage 4): NodeQuery/EdgeQuery -> parameterized SQLite SQL over the nodes/edges schema. THIS module is what makes the typed surface portable - the expression is domain- and backend-neutral; every backend tool owns a translation like this one (the ratified stage-4 split: backend owns translation; the adapter stays generic). Pure functions, unit-tested against an in-memory DB with the production schema - no capability runtime needed.
|
|
11
12
|
|
|
@@ -15,13 +16,16 @@ A local, file-backed context-graph storage capability for the cjm-substrate runt
|
|
|
15
16
|
|
|
16
17
|
- `SQLiteGraphCapability` _class_ — Local, file-backed Context Graph implementation using SQLite.
|
|
17
18
|
- `SQLiteGraphCapabilityConfig` _class_ — Configuration for SQLite Graph Capability.
|
|
19
|
+
- `ingest_sources` _function_ — The source HEADs this db was ingested from, on a fresh read-only connection. A db built
|
|
18
20
|
- `integrity_check` _function_ — Backend self-check (stage 4; the G3 corruption find institutionalized):
|
|
19
21
|
- `query_edges` _function_ — Execute a typed edge query (stage 4) — same contract as `query_nodes`.
|
|
20
22
|
- `query_nodes` _function_ — Execute a typed node query (stage 4) — translated to parameterized SQL
|
|
21
23
|
- `raw_query` _function_ — Execute the raw escape — refuses backend mismatches (non-portable by
|
|
24
|
+
- `record_ingest_sources` _function_ — Record the source HEADs this db was ingested from, REPLACING any earlier record
|
|
22
25
|
|
|
23
26
|
### `cjm_capability_graph_sqlite.query_translation`
|
|
24
27
|
|
|
28
|
+
- `decode_projected_row` _function_ — Zip a projected row, decoding every property column from its JSON representation.
|
|
25
29
|
- `translate_edge_query` _function_ — Translate an `EdgeQuery` to parameterized SQLite SQL.
|
|
26
30
|
- `translate_node_query` _function_ — Translate a `NodeQuery` to parameterized SQLite SQL.
|
|
27
31
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.30"
|
|
@@ -19,10 +19,15 @@ import logging
|
|
|
19
19
|
import os
|
|
20
20
|
import sqlite3
|
|
21
21
|
import time
|
|
22
|
+
from contextlib import contextmanager
|
|
22
23
|
from dataclasses import dataclass, field
|
|
23
|
-
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
24
|
+
from typing import Any, Dict, Iterator, List, Optional, Tuple, Union
|
|
24
25
|
|
|
25
|
-
from cjm_capability_graph_sqlite.query_translation import
|
|
26
|
+
from cjm_capability_graph_sqlite.query_translation import (decode_projected_row,
|
|
27
|
+
EDGE_STRUCTURAL_KEYS,
|
|
28
|
+
NODE_STRUCTURAL_KEYS,
|
|
29
|
+
translate_edge_query,
|
|
30
|
+
translate_node_query)
|
|
26
31
|
from cjm_context_graph_primitives.graph import GraphContext, GraphEdge, GraphNode
|
|
27
32
|
from cjm_context_graph_primitives.provenance import SourceRef
|
|
28
33
|
from cjm_context_graph_primitives.query import (EdgeQuery, EdgeQueryResult, NodeQuery,
|
|
@@ -36,6 +41,7 @@ from cjm_substrate.utils.validation import (config_to_dict, dataclass_to_jsonsch
|
|
|
36
41
|
@dataclass
|
|
37
42
|
class SQLiteGraphCapabilityConfig:
|
|
38
43
|
"""Configuration for SQLite Graph Capability."""
|
|
44
|
+
|
|
39
45
|
db_path: Optional[str] = field(
|
|
40
46
|
default=None,
|
|
41
47
|
metadata={
|
|
@@ -78,6 +84,11 @@ class SQLiteGraphCapability(ToolCapability):
|
|
|
78
84
|
FOREIGN KEY(source_id) REFERENCES nodes(id) ON DELETE CASCADE,
|
|
79
85
|
FOREIGN KEY(target_id) REFERENCES nodes(id) ON DELETE CASCADE
|
|
80
86
|
);
|
|
87
|
+
|
|
88
|
+
CREATE TABLE ingest_sources ( -- the source HEADs the rebuild ingested (DEC a9176261)
|
|
89
|
+
source TEXT PRIMARY KEY,
|
|
90
|
+
head TEXT NOT NULL
|
|
91
|
+
);
|
|
81
92
|
"""
|
|
82
93
|
|
|
83
94
|
config_class = SQLiteGraphCapabilityConfig
|
|
@@ -164,8 +175,9 @@ class SQLiteGraphCapability(ToolCapability):
|
|
|
164
175
|
return os.path.join(data_dir, "context_graph.db")
|
|
165
176
|
|
|
166
177
|
def _init_db(self) -> None:
|
|
167
|
-
"""Create tables and indices.
|
|
168
|
-
|
|
178
|
+
"""Create tables and indices -- the schema only. Statistics are written by the
|
|
179
|
+
paths that change data (`_writing`), never by an open (finding ff3ede3d)."""
|
|
180
|
+
with self._writing() as con:
|
|
169
181
|
# Enable Foreign Keys
|
|
170
182
|
con.execute("PRAGMA foreign_keys = ON;")
|
|
171
183
|
|
|
@@ -180,7 +192,11 @@ class SQLiteGraphCapability(ToolCapability):
|
|
|
180
192
|
updated_at REAL
|
|
181
193
|
)
|
|
182
194
|
""")
|
|
183
|
-
|
|
195
|
+
# (label, id): a label read in id order -- a keyset page, a bounded read -- is a
|
|
196
|
+
# range scan, never a sort of the whole label (findings a107e2f3 / ae2b196e); it
|
|
197
|
+
# serves a label-only lookup too, so the label-only index it replaces is dropped.
|
|
198
|
+
con.execute("CREATE INDEX IF NOT EXISTS idx_nodes_label_id ON nodes(label, id);")
|
|
199
|
+
con.execute("DROP INDEX IF EXISTS idx_nodes_label;")
|
|
184
200
|
|
|
185
201
|
# Edges Table
|
|
186
202
|
con.execute("""
|
|
@@ -207,18 +223,21 @@ class SQLiteGraphCapability(ToolCapability):
|
|
|
207
223
|
# them up on the next initialize.
|
|
208
224
|
con.execute("CREATE INDEX IF NOT EXISTS idx_edges_source_type ON edges(source_id, relation_type);")
|
|
209
225
|
con.execute("CREATE INDEX IF NOT EXISTS idx_edges_target_type ON edges(target_id, relation_type);")
|
|
226
|
+
# The ingest record (DEC a9176261): the source HEADs this db was ingested from —
|
|
227
|
+
# provenance about the db itself, beside the graph and never in it.
|
|
228
|
+
con.execute("""
|
|
229
|
+
CREATE TABLE IF NOT EXISTS ingest_sources (
|
|
230
|
+
source TEXT PRIMARY KEY,
|
|
231
|
+
head TEXT NOT NULL
|
|
232
|
+
)
|
|
233
|
+
""")
|
|
210
234
|
# H7 companion: planner statistics. Without sqlite_stat1 the
|
|
211
235
|
# correlated-probe plans pick the WRONG composite index (the
|
|
212
236
|
# target-side probe scans a whole document's PART_OF rows per
|
|
213
|
-
# outer row: 5s; source-side probe: 8ms at corpus scale).
|
|
214
|
-
#
|
|
215
|
-
#
|
|
216
|
-
#
|
|
217
|
-
# fail at initialize via a stats side-effect.
|
|
218
|
-
try:
|
|
219
|
-
con.execute("ANALYZE;")
|
|
220
|
-
except sqlite3.DatabaseError as e:
|
|
221
|
-
logging.warning(f"ANALYZE skipped (run integrity_check): {e}")
|
|
237
|
+
# outer row: 5s; source-side probe: 8ms at corpus scale). They are
|
|
238
|
+
# kept by `_writing`'s PRAGMA optimize at the close of every write,
|
|
239
|
+
# this one included (a new index is analyzed there) -- no longer a
|
|
240
|
+
# full ANALYZE per open, O(db) on every read (finding ff3ede3d).
|
|
222
241
|
|
|
223
242
|
def _connect(self) -> sqlite3.Connection: # Open DB connection (honors readonly; sets WAL + FKs)
|
|
224
243
|
"""Open a SQLite connection honoring the `readonly` config (SG-41).
|
|
@@ -236,6 +255,28 @@ class SQLiteGraphCapability(ToolCapability):
|
|
|
236
255
|
con.execute("PRAGMA foreign_keys = ON;")
|
|
237
256
|
return con
|
|
238
257
|
|
|
258
|
+
@contextmanager
|
|
259
|
+
def _writing(self) -> Iterator[sqlite3.Connection]: # One unit of write work: commit, planner upkeep, close
|
|
260
|
+
"""Open a connection for one unit of write work: commit on success (roll back on
|
|
261
|
+
error), then keep the planner's statistics current, then close.
|
|
262
|
+
|
|
263
|
+
Statistics are written on the paths that change data, never on an open (finding
|
|
264
|
+
ff3ede3d: a read paid a full ANALYZE and took the write lock). PRAGMA optimize
|
|
265
|
+
analyzes a table only when it was never analyzed, gained an index, or moved ~10x
|
|
266
|
+
in size since, so a steady-state write pays a no-op check and a fresh rebuild db
|
|
267
|
+
gains statistics as it fills. Guarded: statistics are an OPTIMIZATION — a corrupted
|
|
268
|
+
DB must still answer integrity_check (the designed G3 gate)."""
|
|
269
|
+
con = self._connect()
|
|
270
|
+
try:
|
|
271
|
+
with con:
|
|
272
|
+
yield con
|
|
273
|
+
try:
|
|
274
|
+
con.execute("PRAGMA optimize=0x10002;")
|
|
275
|
+
except sqlite3.DatabaseError as e:
|
|
276
|
+
logging.warning(f"PRAGMA optimize skipped (run integrity_check): {e}")
|
|
277
|
+
finally:
|
|
278
|
+
con.close()
|
|
279
|
+
|
|
239
280
|
# -------------------------------------------------------------------------
|
|
240
281
|
# Helpers
|
|
241
282
|
# -------------------------------------------------------------------------
|
|
@@ -304,7 +345,7 @@ class SQLiteGraphCapability(ToolCapability):
|
|
|
304
345
|
stamping now() here clamped every rebuilt node to rebuild time)."""
|
|
305
346
|
ids = []
|
|
306
347
|
now = time.time()
|
|
307
|
-
with self.
|
|
348
|
+
with self._writing() as con:
|
|
308
349
|
for n in nodes:
|
|
309
350
|
sources_json = json.dumps([s.to_dict() for s in n.sources])
|
|
310
351
|
props_json = json.dumps(n.properties)
|
|
@@ -336,7 +377,7 @@ class SQLiteGraphCapability(ToolCapability):
|
|
|
336
377
|
ids = []
|
|
337
378
|
now = time.time()
|
|
338
379
|
missing: List[Tuple[str, str, str]] = [] # (source_id, target_id, relation_type) of FK-skipped edges
|
|
339
|
-
with self.
|
|
380
|
+
with self._writing() as con:
|
|
340
381
|
con.execute("PRAGMA foreign_keys = ON;")
|
|
341
382
|
for e in edges:
|
|
342
383
|
props_json = json.dumps(e.properties)
|
|
@@ -419,11 +460,13 @@ class SQLiteGraphCapability(ToolCapability):
|
|
|
419
460
|
label: str, # Node label to search for
|
|
420
461
|
limit: int = 100 # Max results
|
|
421
462
|
) -> List[GraphNode]: # Matching nodes
|
|
422
|
-
"""Find nodes by label
|
|
463
|
+
"""Find nodes by label, in id order: a bounded read sorts before it limits, so a
|
|
464
|
+
page is never an arbitrary subset (finding a107e2f3)."""
|
|
423
465
|
results = []
|
|
424
466
|
with self._connect() as con:
|
|
425
467
|
cur = con.execute(
|
|
426
|
-
"SELECT id, label, properties, sources, created_at, updated_at FROM nodes
|
|
468
|
+
"SELECT id, label, properties, sources, created_at, updated_at FROM nodes "
|
|
469
|
+
"WHERE label = ? ORDER BY id LIMIT ?",
|
|
427
470
|
(label, limit)
|
|
428
471
|
)
|
|
429
472
|
for row in cur:
|
|
@@ -529,7 +572,7 @@ class SQLiteGraphCapability(ToolCapability):
|
|
|
529
572
|
to rebuild time)."""
|
|
530
573
|
properties = dict(properties)
|
|
531
574
|
stamp = properties.pop("updated_at", None)
|
|
532
|
-
with self.
|
|
575
|
+
with self._writing() as con:
|
|
533
576
|
# Fetch existing to merge
|
|
534
577
|
cur = con.execute("SELECT properties FROM nodes WHERE id = ?", (node_id,))
|
|
535
578
|
row = cur.fetchone()
|
|
@@ -550,8 +593,14 @@ class SQLiteGraphCapability(ToolCapability):
|
|
|
550
593
|
edge_id: str, # UUID of edge to update
|
|
551
594
|
properties: Dict[str, Any] # Properties to merge/update
|
|
552
595
|
) -> bool: # True if successful
|
|
553
|
-
"""Partial update of edge properties.
|
|
554
|
-
|
|
596
|
+
"""Partial update of edge properties.
|
|
597
|
+
|
|
598
|
+
A reserved `updated_at` key in `properties` sets the COLUMN instead of now()
|
|
599
|
+
and never lands in the JSON blob — the op clock's stamp (design 8f6f2343),
|
|
600
|
+
exactly as `update_node` takes it."""
|
|
601
|
+
properties = dict(properties)
|
|
602
|
+
stamp = properties.pop("updated_at", None)
|
|
603
|
+
with self._writing() as con:
|
|
555
604
|
cur = con.execute("SELECT properties FROM edges WHERE id = ?", (edge_id,))
|
|
556
605
|
row = cur.fetchone()
|
|
557
606
|
if not row:
|
|
@@ -562,7 +611,7 @@ class SQLiteGraphCapability(ToolCapability):
|
|
|
562
611
|
|
|
563
612
|
con.execute(
|
|
564
613
|
"UPDATE edges SET properties = ?, updated_at = ? WHERE id = ?",
|
|
565
|
-
(json.dumps(existing), time.time(), edge_id)
|
|
614
|
+
(json.dumps(existing), stamp if stamp is not None else time.time(), edge_id)
|
|
566
615
|
)
|
|
567
616
|
return True
|
|
568
617
|
|
|
@@ -576,7 +625,7 @@ class SQLiteGraphCapability(ToolCapability):
|
|
|
576
625
|
cascade: bool = True # Also delete connected edges
|
|
577
626
|
) -> int: # Number of nodes deleted
|
|
578
627
|
"""Delete nodes (and optionally connected edges)."""
|
|
579
|
-
with self.
|
|
628
|
+
with self._writing() as con:
|
|
580
629
|
if cascade:
|
|
581
630
|
con.execute("PRAGMA foreign_keys = ON;") # Ensures cascade works
|
|
582
631
|
else:
|
|
@@ -594,7 +643,7 @@ class SQLiteGraphCapability(ToolCapability):
|
|
|
594
643
|
edge_ids: List[str] # UUIDs of edges to delete
|
|
595
644
|
) -> int: # Number of edges deleted
|
|
596
645
|
"""Delete edges."""
|
|
597
|
-
with self.
|
|
646
|
+
with self._writing() as con:
|
|
598
647
|
placeholders = ','.join('?' for _ in edge_ids)
|
|
599
648
|
cur = con.execute(
|
|
600
649
|
f"DELETE FROM edges WHERE id IN ({placeholders})",
|
|
@@ -651,10 +700,11 @@ class SQLiteGraphCapability(ToolCapability):
|
|
|
651
700
|
nodes: List[GraphNode], # Nodes to import
|
|
652
701
|
merge_strategy: str # "overwrite" | "skip" | "merge"
|
|
653
702
|
) -> int: # Count of rows written (inserted or updated)
|
|
654
|
-
"""Import nodes honoring merge_strategy (see import_graph).
|
|
703
|
+
"""Import nodes honoring merge_strategy (see import_graph). Carried
|
|
704
|
+
created_at / updated_at are honored like add_nodes (0d50b921, design 8f6f2343)."""
|
|
655
705
|
now = time.time()
|
|
656
706
|
written = 0
|
|
657
|
-
with self.
|
|
707
|
+
with self._writing() as con:
|
|
658
708
|
for node in nodes:
|
|
659
709
|
props_json = json.dumps(node.properties)
|
|
660
710
|
sources_json = json.dumps([s.to_dict() for s in node.sources])
|
|
@@ -662,7 +712,10 @@ class SQLiteGraphCapability(ToolCapability):
|
|
|
662
712
|
if row is None:
|
|
663
713
|
con.execute(
|
|
664
714
|
"INSERT INTO nodes (id, label, properties, sources, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
665
|
-
(node.id, node.label, props_json, sources_json,
|
|
715
|
+
(node.id, node.label, props_json, sources_json,
|
|
716
|
+
node.created_at if node.created_at is not None else now,
|
|
717
|
+
node.updated_at if node.updated_at is not None
|
|
718
|
+
else (node.created_at if node.created_at is not None else now)),
|
|
666
719
|
)
|
|
667
720
|
written += 1
|
|
668
721
|
elif merge_strategy == "skip":
|
|
@@ -670,7 +723,8 @@ class SQLiteGraphCapability(ToolCapability):
|
|
|
670
723
|
elif merge_strategy == "overwrite":
|
|
671
724
|
con.execute(
|
|
672
725
|
"UPDATE nodes SET label = ?, properties = ?, sources = ?, updated_at = ? WHERE id = ?",
|
|
673
|
-
(node.label, props_json, sources_json,
|
|
726
|
+
(node.label, props_json, sources_json,
|
|
727
|
+
node.updated_at if node.updated_at is not None else now, node.id),
|
|
674
728
|
)
|
|
675
729
|
written += 1
|
|
676
730
|
else: # merge
|
|
@@ -686,7 +740,8 @@ class SQLiteGraphCapability(ToolCapability):
|
|
|
686
740
|
seen.add(key)
|
|
687
741
|
con.execute(
|
|
688
742
|
"UPDATE nodes SET label = ?, properties = ?, sources = ?, updated_at = ? WHERE id = ?",
|
|
689
|
-
(node.label, json.dumps(merged_props), json.dumps(existing_sources),
|
|
743
|
+
(node.label, json.dumps(merged_props), json.dumps(existing_sources),
|
|
744
|
+
node.updated_at if node.updated_at is not None else now, node.id),
|
|
690
745
|
)
|
|
691
746
|
written += 1
|
|
692
747
|
return written
|
|
@@ -696,10 +751,11 @@ class SQLiteGraphCapability(ToolCapability):
|
|
|
696
751
|
edges: List[GraphEdge], # Edges to import
|
|
697
752
|
merge_strategy: str # "overwrite" | "skip" | "merge"
|
|
698
753
|
) -> int: # Count of rows written (inserted or updated)
|
|
699
|
-
"""Import edges honoring merge_strategy (see import_graph).
|
|
754
|
+
"""Import edges honoring merge_strategy (see import_graph). Carried
|
|
755
|
+
created_at / updated_at are honored like add_edges (0d50b921, design 8f6f2343)."""
|
|
700
756
|
now = time.time()
|
|
701
757
|
written = 0
|
|
702
|
-
with self.
|
|
758
|
+
with self._writing() as con:
|
|
703
759
|
for edge in edges:
|
|
704
760
|
props_json = json.dumps(edge.properties)
|
|
705
761
|
row = con.execute("SELECT properties FROM edges WHERE id = ?", (edge.id,)).fetchone()
|
|
@@ -707,7 +763,10 @@ class SQLiteGraphCapability(ToolCapability):
|
|
|
707
763
|
try:
|
|
708
764
|
con.execute(
|
|
709
765
|
"INSERT INTO edges (id, source_id, target_id, relation_type, properties, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
710
|
-
(edge.id, edge.source_id, edge.target_id, edge.relation_type, props_json,
|
|
766
|
+
(edge.id, edge.source_id, edge.target_id, edge.relation_type, props_json,
|
|
767
|
+
edge.created_at if edge.created_at is not None else now,
|
|
768
|
+
edge.updated_at if edge.updated_at is not None
|
|
769
|
+
else (edge.created_at if edge.created_at is not None else now)),
|
|
711
770
|
)
|
|
712
771
|
written += 1
|
|
713
772
|
except sqlite3.IntegrityError as err:
|
|
@@ -717,7 +776,8 @@ class SQLiteGraphCapability(ToolCapability):
|
|
|
717
776
|
elif merge_strategy == "overwrite":
|
|
718
777
|
con.execute(
|
|
719
778
|
"UPDATE edges SET source_id = ?, target_id = ?, relation_type = ?, properties = ?, updated_at = ? WHERE id = ?",
|
|
720
|
-
(edge.source_id, edge.target_id, edge.relation_type, props_json,
|
|
779
|
+
(edge.source_id, edge.target_id, edge.relation_type, props_json,
|
|
780
|
+
edge.updated_at if edge.updated_at is not None else now, edge.id),
|
|
721
781
|
)
|
|
722
782
|
written += 1
|
|
723
783
|
else: # merge
|
|
@@ -725,7 +785,8 @@ class SQLiteGraphCapability(ToolCapability):
|
|
|
725
785
|
merged.update(edge.properties)
|
|
726
786
|
con.execute(
|
|
727
787
|
"UPDATE edges SET properties = ?, updated_at = ? WHERE id = ?",
|
|
728
|
-
(json.dumps(merged),
|
|
788
|
+
(json.dumps(merged), edge.updated_at if edge.updated_at is not None else now,
|
|
789
|
+
edge.id),
|
|
729
790
|
)
|
|
730
791
|
written += 1
|
|
731
792
|
return written
|
|
@@ -820,7 +881,7 @@ def query_nodes(
|
|
|
820
881
|
return NodeQueryResult(nodes=[self._row_to_node(r) for r in rows])
|
|
821
882
|
out = []
|
|
822
883
|
for r in rows:
|
|
823
|
-
d =
|
|
884
|
+
d = decode_projected_row(keys, r, NODE_STRUCTURAL_KEYS) # properties keep their JSON types
|
|
824
885
|
if "sources" in d and d["sources"]:
|
|
825
886
|
d["sources"] = json.loads(d["sources"]) # ref dicts (CR-19 shape)
|
|
826
887
|
out.append(d)
|
|
@@ -843,7 +904,7 @@ def query_edges(
|
|
|
843
904
|
con.close()
|
|
844
905
|
if mode == "full":
|
|
845
906
|
return EdgeQueryResult(edges=[self._row_to_edge(r) for r in rows])
|
|
846
|
-
return EdgeQueryResult(rows=[
|
|
907
|
+
return EdgeQueryResult(rows=[decode_projected_row(keys, r, EDGE_STRUCTURAL_KEYS) for r in rows])
|
|
847
908
|
|
|
848
909
|
|
|
849
910
|
def raw_query(
|
|
@@ -881,7 +942,39 @@ def integrity_check(self) -> Dict[str, Any]: # {"ok": bool, "errors": [...], "b
|
|
|
881
942
|
return {"ok": ok, "errors": [] if ok else rows, "backend": "sqlite"}
|
|
882
943
|
|
|
883
944
|
|
|
945
|
+
def record_ingest_sources(
|
|
946
|
+
self,
|
|
947
|
+
sources: Dict[str, str], # source id -> the HEAD commit the ingest read
|
|
948
|
+
) -> int: # Sources recorded
|
|
949
|
+
"""Record the source HEADs this db was ingested from, REPLACING any earlier record
|
|
950
|
+
(DEC a9176261). The rebuild writes it into the fresh db it builds, so the record and the
|
|
951
|
+
rows it describes swap in together; it is provenance about the db, not a graph row, so
|
|
952
|
+
it is neither stamped by the op clock nor seen by the write observer."""
|
|
953
|
+
rows = sorted((str(k), str(v)) for k, v in dict(sources).items())
|
|
954
|
+
with self._writing() as con:
|
|
955
|
+
con.execute("DELETE FROM ingest_sources")
|
|
956
|
+
con.executemany("INSERT INTO ingest_sources (source, head) VALUES (?, ?)", rows)
|
|
957
|
+
return len(rows)
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def ingest_sources(self) -> Dict[str, str]: # source id -> HEAD; {} when the db holds no record
|
|
961
|
+
"""The source HEADs this db was ingested from, on a fresh read-only connection. A db built
|
|
962
|
+
before the record existed has no table: it reads as no record, never as an error."""
|
|
963
|
+
con = sqlite3.connect(f"file:{self._db_path}?mode=ro", uri=True)
|
|
964
|
+
try:
|
|
965
|
+
rows = con.execute("SELECT source, head FROM ingest_sources ORDER BY source").fetchall()
|
|
966
|
+
except sqlite3.OperationalError as e:
|
|
967
|
+
if "no such table" not in str(e):
|
|
968
|
+
raise
|
|
969
|
+
rows = []
|
|
970
|
+
finally:
|
|
971
|
+
con.close()
|
|
972
|
+
return {s: h for s, h in rows}
|
|
973
|
+
|
|
974
|
+
|
|
884
975
|
SQLiteGraphCapability.query_nodes = query_nodes
|
|
885
976
|
SQLiteGraphCapability.query_edges = query_edges
|
|
886
977
|
SQLiteGraphCapability.raw_query = raw_query
|
|
887
978
|
SQLiteGraphCapability.integrity_check = integrity_check
|
|
979
|
+
SQLiteGraphCapability.record_ingest_sources = record_ingest_sources
|
|
980
|
+
SQLiteGraphCapability.ingest_sources = ingest_sources
|
|
@@ -25,7 +25,11 @@ Translation rules:
|
|
|
25
25
|
Projected node rows always carry `id` (+ `label`/`created_at`/`updated_at`
|
|
26
26
|
projectable structurally — the storage stamps, never shadowed by a
|
|
27
27
|
same-named property; `sources` projectable as parsed ref dicts); edge rows
|
|
28
|
-
always carry `id`/`source_id`/`target_id` (+ `relation_type` projectable).
|
|
28
|
+
always carry `id`/`source_id`/`target_id` (+ `relation_type` projectable).
|
|
29
|
+
A projected PROPERTY is selected as its JSON representation (`->`) and decoded
|
|
30
|
+
by the capability, so it keeps its type — a list stays a list, an object an
|
|
31
|
+
object (`json_extract` returned arrays and objects as JSON TEXT: a projected
|
|
32
|
+
`calls` list read as a string, the live code fold's index keyed characters)."""
|
|
29
33
|
|
|
30
34
|
import json
|
|
31
35
|
import re
|
|
@@ -167,16 +171,22 @@ EDGE_FULL_COLUMNS = "e.id, e.source_id, e.target_id, e.relation_type, e.properti
|
|
|
167
171
|
|
|
168
172
|
|
|
169
173
|
def _order_limit_sql(
|
|
170
|
-
query, # NodeQuery or EdgeQuery (order_by / limit / offset fields)
|
|
174
|
+
query, # NodeQuery or EdgeQuery (order_by / limit / offset / after fields)
|
|
171
175
|
props_col: str, # Properties column expression for ORDER BY paths
|
|
176
|
+
id_col: str, # Row id column: the keyset key and the tie-break
|
|
172
177
|
params: List[Any], # Bound-params list (appended in place)
|
|
173
178
|
) -> str: # ORDER BY / LIMIT / OFFSET tail
|
|
174
|
-
"""Compile the shared ordering + paging tail.
|
|
179
|
+
"""Compile the shared ordering + paging tail. A property ordering breaks ties on the
|
|
180
|
+
id, and a bounded or keyset read with no ordering is ordered by the id, so a page is
|
|
181
|
+
never an arbitrary subset (findings a107e2f3 / ae2b196e)."""
|
|
175
182
|
tail = ""
|
|
176
183
|
if query.order_by is not None:
|
|
177
184
|
tail += (f" ORDER BY json_extract({props_col}, "
|
|
178
185
|
f"{_json_path(query.order_by.prop)})"
|
|
179
|
-
+ (" DESC" if query.order_by.descending else " ASC")
|
|
186
|
+
+ (" DESC" if query.order_by.descending else " ASC")
|
|
187
|
+
+ f", {id_col}")
|
|
188
|
+
elif query.limit is not None or query.offset or query.after is not None:
|
|
189
|
+
tail += f" ORDER BY {id_col}"
|
|
180
190
|
if query.limit is not None or query.offset:
|
|
181
191
|
tail += " LIMIT ?"
|
|
182
192
|
params.append(query.limit if query.limit is not None else -1)
|
|
@@ -202,6 +212,9 @@ def translate_node_query(
|
|
|
202
212
|
if q.ids is not None:
|
|
203
213
|
where.append("n.id IN (SELECT value FROM json_each(?))")
|
|
204
214
|
params.append(json.dumps(list(q.ids)))
|
|
215
|
+
if q.after is not None:
|
|
216
|
+
where.append("n.id > ?")
|
|
217
|
+
params.append(q.after)
|
|
205
218
|
if q.label is not None:
|
|
206
219
|
where.append("n.label = ?")
|
|
207
220
|
params.append(q.label)
|
|
@@ -238,13 +251,13 @@ def translate_node_query(
|
|
|
238
251
|
select_parts.append("n.sources")
|
|
239
252
|
keys.append("sources")
|
|
240
253
|
else:
|
|
241
|
-
select_parts.append(f"
|
|
254
|
+
select_parts.append(f"n.properties -> {_json_path(name)}")
|
|
242
255
|
keys.append(name)
|
|
243
|
-
tail = _order_limit_sql(q, "n.properties", params)
|
|
256
|
+
tail = _order_limit_sql(q, "n.properties", "n.id", params)
|
|
244
257
|
return (f"SELECT {', '.join(select_parts)} FROM nodes AS n{where_sql}{tail}",
|
|
245
258
|
params, "rows", keys)
|
|
246
259
|
|
|
247
|
-
tail = _order_limit_sql(q, "n.properties", params)
|
|
260
|
+
tail = _order_limit_sql(q, "n.properties", "n.id", params)
|
|
248
261
|
return (f"SELECT {NODE_FULL_COLUMNS} FROM nodes AS n{where_sql}{tail}",
|
|
249
262
|
params, "full", None)
|
|
250
263
|
|
|
@@ -264,6 +277,9 @@ def translate_edge_query(
|
|
|
264
277
|
if q.ids is not None:
|
|
265
278
|
where.append("e.id IN (SELECT value FROM json_each(?))")
|
|
266
279
|
params.append(json.dumps(list(q.ids)))
|
|
280
|
+
if q.after is not None:
|
|
281
|
+
where.append("e.id > ?")
|
|
282
|
+
params.append(q.after)
|
|
267
283
|
if q.relation_type is not None:
|
|
268
284
|
where.append("e.relation_type = ?")
|
|
269
285
|
params.append(q.relation_type)
|
|
@@ -306,12 +322,30 @@ def translate_edge_query(
|
|
|
306
322
|
select_parts.append("e.relation_type")
|
|
307
323
|
keys.append("relation_type")
|
|
308
324
|
else:
|
|
309
|
-
select_parts.append(f"
|
|
325
|
+
select_parts.append(f"e.properties -> {_json_path(name)}")
|
|
310
326
|
keys.append(name)
|
|
311
|
-
tail = _order_limit_sql(q, "e.properties", params)
|
|
327
|
+
tail = _order_limit_sql(q, "e.properties", "e.id", params)
|
|
312
328
|
return (f"SELECT {', '.join(select_parts)} FROM edges AS e{where_sql}{tail}",
|
|
313
329
|
params, "rows", keys)
|
|
314
330
|
|
|
315
|
-
tail = _order_limit_sql(q, "e.properties", params)
|
|
331
|
+
tail = _order_limit_sql(q, "e.properties", "e.id", params)
|
|
316
332
|
return (f"SELECT {EDGE_FULL_COLUMNS} FROM edges AS e{where_sql}{tail}",
|
|
317
333
|
params, "full", None)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# The structural columns a projection selects as-is; every other projected key is a property,
|
|
337
|
+
# selected as JSON (`->`) and decoded by `decode_projected_row`.
|
|
338
|
+
NODE_STRUCTURAL_KEYS = frozenset({"id", "label", "created_at", "updated_at", "sources"})
|
|
339
|
+
EDGE_STRUCTURAL_KEYS = frozenset({"id", "source_id", "target_id", "relation_type"})
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def decode_projected_row(
|
|
343
|
+
keys: List[str], # The projected keys, in select order
|
|
344
|
+
values: Any, # One cursor row
|
|
345
|
+
structural: frozenset, # The keys selected as plain columns
|
|
346
|
+
) -> dict: # The row: structural columns as-is, each property decoded to its JSON type (absent -> None)
|
|
347
|
+
"""Zip a projected row, decoding every property column from its JSON representation."""
|
|
348
|
+
out = {}
|
|
349
|
+
for k, v in zip(keys, values):
|
|
350
|
+
out[k] = v if k in structural or v is None else json.loads(v)
|
|
351
|
+
return out
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cjm-capability-graph-sqlite
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.30
|
|
4
4
|
Summary: A local, file-backed context-graph storage capability for the cjm-substrate runtime 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 @@ Requires-Python: >=3.12
|
|
|
15
15
|
Description-Content-Type: text/markdown
|
|
16
16
|
License-File: LICENSE
|
|
17
17
|
Requires-Dist: cjm-substrate>=0.0.51
|
|
18
|
-
Requires-Dist: cjm-context-graph-primitives>=0.0.
|
|
18
|
+
Requires-Dist: cjm-context-graph-primitives>=0.0.18
|
|
19
19
|
Dynamic: license-file
|
|
20
20
|
|
|
21
21
|
# cjm-capability-graph-sqlite
|
|
@@ -26,6 +26,7 @@ A local, file-backed context-graph storage capability for the cjm-substrate runt
|
|
|
26
26
|
|
|
27
27
|
## Modules
|
|
28
28
|
|
|
29
|
+
- **`cjm_capability_graph_sqlite.__init__`**
|
|
29
30
|
- **`cjm_capability_graph_sqlite.capability`** — Capability implementation for Context Graph using SQLite.
|
|
30
31
|
- **`cjm_capability_graph_sqlite.query_translation`** — The per-backend translation of the typed query expressions (pass-2 Thread 5; stage 4): NodeQuery/EdgeQuery -> parameterized SQLite SQL over the nodes/edges schema. THIS module is what makes the typed surface portable - the expression is domain- and backend-neutral; every backend tool owns a translation like this one (the ratified stage-4 split: backend owns translation; the adapter stays generic). Pure functions, unit-tested against an in-memory DB with the production schema - no capability runtime needed.
|
|
31
32
|
|
|
@@ -35,13 +36,16 @@ A local, file-backed context-graph storage capability for the cjm-substrate runt
|
|
|
35
36
|
|
|
36
37
|
- `SQLiteGraphCapability` _class_ — Local, file-backed Context Graph implementation using SQLite.
|
|
37
38
|
- `SQLiteGraphCapabilityConfig` _class_ — Configuration for SQLite Graph Capability.
|
|
39
|
+
- `ingest_sources` _function_ — The source HEADs this db was ingested from, on a fresh read-only connection. A db built
|
|
38
40
|
- `integrity_check` _function_ — Backend self-check (stage 4; the G3 corruption find institutionalized):
|
|
39
41
|
- `query_edges` _function_ — Execute a typed edge query (stage 4) — same contract as `query_nodes`.
|
|
40
42
|
- `query_nodes` _function_ — Execute a typed node query (stage 4) — translated to parameterized SQL
|
|
41
43
|
- `raw_query` _function_ — Execute the raw escape — refuses backend mismatches (non-portable by
|
|
44
|
+
- `record_ingest_sources` _function_ — Record the source HEADs this db was ingested from, REPLACING any earlier record
|
|
42
45
|
|
|
43
46
|
### `cjm_capability_graph_sqlite.query_translation`
|
|
44
47
|
|
|
48
|
+
- `decode_projected_row` _function_ — Zip a projected row, decoding every property column from its JSON representation.
|
|
45
49
|
- `translate_edge_query` _function_ — Translate an `EdgeQuery` to parameterized SQLite SQL.
|
|
46
50
|
- `translate_node_query` _function_ — Translate a `NodeQuery` to parameterized SQLite SQL.
|
|
47
51
|
|
|
@@ -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 = ['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-substrate>=0.0.51', 'cjm-context-graph-primitives>=0.0.
|
|
15
|
+
dependencies = ['cjm-substrate>=0.0.51', 'cjm-context-graph-primitives>=0.0.18']
|
|
16
16
|
|
|
17
17
|
[project.urls]
|
|
18
18
|
Repository = "https://github.com/cj-mills/cjm-capability-graph-sqlite"
|
{cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.30}/tests/test_capability.py
RENAMED
|
@@ -3,18 +3,17 @@
|
|
|
3
3
|
Projected from the capability notebook's demo/test cells at the c25780e8 flip:
|
|
4
4
|
the CRUD/round-trip walk, the SG-41 smells (WAL / readonly / guarded query /
|
|
5
5
|
merge_strategy), and the stage-4 typed-surface smoke test."""
|
|
6
|
+
|
|
6
7
|
import sqlite3
|
|
7
8
|
import uuid
|
|
8
9
|
|
|
9
10
|
import pytest
|
|
10
|
-
|
|
11
11
|
from cjm_capability_graph_sqlite.capability import SQLiteGraphCapability
|
|
12
12
|
from cjm_context_graph_primitives.graph import GraphContext, GraphEdge, GraphNode
|
|
13
13
|
from cjm_context_graph_primitives.locators import FileRef
|
|
14
14
|
from cjm_context_graph_primitives.provenance import SourceRef
|
|
15
|
-
from cjm_context_graph_primitives.query import (EdgeQuery, NodeQuery, OrderBy,
|
|
16
|
-
|
|
17
|
-
RelationPredicate)
|
|
15
|
+
from cjm_context_graph_primitives.query import (EdgeQuery, NodeQuery, OrderBy, PropertyPredicate,
|
|
16
|
+
RawQuery, RelationPredicate)
|
|
18
17
|
from cjm_context_graph_primitives.slices import FullContent
|
|
19
18
|
from cjm_substrate.core.errors import CapabilityInputError
|
|
20
19
|
|
|
@@ -140,6 +139,34 @@ def test_sg41_wal_query_guard_merge_readonly(tmp_path):
|
|
|
140
139
|
ro.cleanup()
|
|
141
140
|
|
|
142
141
|
|
|
142
|
+
def test_reads_and_opens_never_write_writes_keep_statistics(tmp_path):
|
|
143
|
+
# Finding ff3ede3d: statistics are written by the paths that change data (PRAGMA
|
|
144
|
+
# optimize at the close of each write), so an open or a read commits nothing -- an
|
|
145
|
+
# observer's data_version moves only when ANOTHER connection commits.
|
|
146
|
+
db = str(tmp_path / "stats.db")
|
|
147
|
+
w = SQLiteGraphCapability()
|
|
148
|
+
w.initialize({"db_path": db})
|
|
149
|
+
indexes = {r[0] for r in sqlite3.connect(db).execute(
|
|
150
|
+
"SELECT name FROM sqlite_master WHERE type = 'index'")}
|
|
151
|
+
assert "idx_nodes_label_id" in indexes and "idx_nodes_label" not in indexes
|
|
152
|
+
w.add_nodes([GraphNode(id=f"n{i:03d}", label="Doc", properties={"i": i}) for i in range(50)])
|
|
153
|
+
stats = {r[0] for r in sqlite3.connect(db).execute(
|
|
154
|
+
"SELECT idx FROM sqlite_stat1 WHERE tbl = 'nodes'")}
|
|
155
|
+
assert "idx_nodes_label_id" in stats
|
|
156
|
+
w.cleanup()
|
|
157
|
+
|
|
158
|
+
observer = sqlite3.connect(db)
|
|
159
|
+
before = observer.execute("PRAGMA data_version").fetchone()[0]
|
|
160
|
+
for readonly in (True, False):
|
|
161
|
+
r = SQLiteGraphCapability()
|
|
162
|
+
r.initialize({"db_path": db, "readonly": readonly})
|
|
163
|
+
assert [n.id for n in r.find_nodes_by_label("Doc", limit=3)] == ["n000", "n001", "n002"]
|
|
164
|
+
assert r.query_nodes(NodeQuery(label="Doc", count=True)).count == 50
|
|
165
|
+
r.cleanup()
|
|
166
|
+
assert observer.execute("PRAGMA data_version").fetchone()[0] == before
|
|
167
|
+
observer.close()
|
|
168
|
+
|
|
169
|
+
|
|
143
170
|
def test_typed_surface_smoke(cap):
|
|
144
171
|
doc = str(uuid.uuid4())
|
|
145
172
|
segs = [str(uuid.uuid4()) for _ in range(3)]
|
|
@@ -221,3 +248,67 @@ def test_add_edges_aggregates_missing_endpoint_warnings(cap, caplog):
|
|
|
221
248
|
msg = fk_lines[0].getMessage()
|
|
222
249
|
assert "25 of 26" in msg and "sample:" in msg
|
|
223
250
|
assert not any("Edge creation error (likely missing node)" in r.getMessage() for r in caplog.records)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_update_edge_reserved_updated_at_sets_column(cap):
|
|
254
|
+
# The op clock (design 8f6f2343): update_edge takes the reserved `updated_at` exactly as
|
|
255
|
+
# update_node does — the COLUMN, never the JSON blob; without it, now()-stamping stands.
|
|
256
|
+
import json
|
|
257
|
+
|
|
258
|
+
a, b = str(uuid.uuid4()), str(uuid.uuid4())
|
|
259
|
+
cap.add_nodes([GraphNode(id=a, label="Person"), GraphNode(id=b, label="Person")])
|
|
260
|
+
eid = str(uuid.uuid4())
|
|
261
|
+
cap.add_edges([GraphEdge(id=eid, source_id=a, target_id=b, relation_type="KNOWS")])
|
|
262
|
+
assert cap.update_edge(eid, {"since": 1999, "updated_at": 1234.5})
|
|
263
|
+
con = sqlite3.connect(cap._db_path)
|
|
264
|
+
props, stamp = con.execute("SELECT properties, updated_at FROM edges WHERE id = ?",
|
|
265
|
+
(eid,)).fetchone()
|
|
266
|
+
con.close()
|
|
267
|
+
assert json.loads(props) == {"since": 1999} and stamp == 1234.5
|
|
268
|
+
assert cap.update_edge(eid, {"since": 2000})
|
|
269
|
+
con = sqlite3.connect(cap._db_path)
|
|
270
|
+
(stamp2,) = con.execute("SELECT updated_at FROM edges WHERE id = ?", (eid,)).fetchone()
|
|
271
|
+
con.close()
|
|
272
|
+
assert stamp2 > 1234.5
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def test_import_honors_carried_times(cap):
|
|
276
|
+
# import_graph keeps carried created_at / updated_at like add_nodes / add_edges.
|
|
277
|
+
a, b = str(uuid.uuid4()), str(uuid.uuid4())
|
|
278
|
+
eid = str(uuid.uuid4())
|
|
279
|
+
cap.import_graph(GraphContext(
|
|
280
|
+
nodes=[GraphNode(id=a, label="P", created_at=10.0, updated_at=20.0),
|
|
281
|
+
GraphNode(id=b, label="P", created_at=30.0)],
|
|
282
|
+
edges=[GraphEdge(id=eid, source_id=a, target_id=b, relation_type="R", created_at=40.0)]))
|
|
283
|
+
con = sqlite3.connect(cap._db_path)
|
|
284
|
+
rows = dict((r[0], r[1:]) for r in con.execute("SELECT id, created_at, updated_at FROM nodes"))
|
|
285
|
+
erow = con.execute("SELECT created_at, updated_at FROM edges WHERE id = ?", (eid,)).fetchone()
|
|
286
|
+
con.close()
|
|
287
|
+
assert rows[a] == (10.0, 20.0) and rows[b] == (30.0, 30.0) and erow == (40.0, 40.0)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_ingest_record_replaces_reads_readonly_and_tolerates_a_legacy_db(cap, tmp_path):
|
|
291
|
+
"""The ingest record (DEC a9176261): a replace, never a merge; beside the graph (no row,
|
|
292
|
+
no op-clock stamp); readable through a read-only open; a db built before the table
|
|
293
|
+
existed reads as no record."""
|
|
294
|
+
assert cap.ingest_sources() == {}
|
|
295
|
+
assert cap.record_ingest_sources({"archive:/site/posts": "a" * 40, "repo:/r/x": "b" * 40}) == 2
|
|
296
|
+
assert cap.record_ingest_sources({"repo:/r/x": "c" * 40}) == 1
|
|
297
|
+
assert cap.ingest_sources() == {"repo:/r/x": "c" * 40}
|
|
298
|
+
assert cap.get_schema()["counts"] == {} # beside the graph, never in it
|
|
299
|
+
|
|
300
|
+
ro = SQLiteGraphCapability()
|
|
301
|
+
ro.initialize({"db_path": cap._db_path, "readonly": True})
|
|
302
|
+
assert ro.ingest_sources() == {"repo:/r/x": "c" * 40}
|
|
303
|
+
ro.cleanup()
|
|
304
|
+
|
|
305
|
+
legacy = str(tmp_path / "legacy.db")
|
|
306
|
+
con = sqlite3.connect(legacy)
|
|
307
|
+
con.execute("CREATE TABLE nodes (id TEXT PRIMARY KEY, label TEXT NOT NULL, properties JSON, "
|
|
308
|
+
"sources JSON, created_at REAL, updated_at REAL)")
|
|
309
|
+
con.commit()
|
|
310
|
+
con.close()
|
|
311
|
+
old = SQLiteGraphCapability()
|
|
312
|
+
old.initialize({"db_path": legacy, "readonly": True})
|
|
313
|
+
assert old.ingest_sources() == {}
|
|
314
|
+
old.cleanup()
|
|
@@ -3,17 +3,19 @@
|
|
|
3
3
|
Projected from the query_translation notebook's behavior-test cell at the
|
|
4
4
|
c25780e8 flip: the translation contract is verified against the PRODUCTION
|
|
5
5
|
schema in an in-memory DB — by what it returns, not by SQL strings."""
|
|
6
|
+
|
|
6
7
|
import json
|
|
7
8
|
import sqlite3
|
|
8
9
|
|
|
9
10
|
import pytest
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
from cjm_capability_graph_sqlite.query_translation import (decode_projected_row,
|
|
12
|
+
EDGE_STRUCTURAL_KEYS,
|
|
13
|
+
NODE_STRUCTURAL_KEYS,
|
|
14
|
+
translate_edge_query,
|
|
12
15
|
translate_node_query)
|
|
13
16
|
from cjm_context_graph_primitives.locators import FileRef
|
|
14
|
-
from cjm_context_graph_primitives.query import (EdgeQuery, NodeQuery, OrderBy,
|
|
15
|
-
|
|
16
|
-
SourcePredicate)
|
|
17
|
+
from cjm_context_graph_primitives.query import (EdgeQuery, NodeQuery, OrderBy, PropertyPredicate,
|
|
18
|
+
RelationPredicate, SourcePredicate)
|
|
17
19
|
from cjm_substrate.core.errors import CapabilityInputError
|
|
18
20
|
|
|
19
21
|
|
|
@@ -71,7 +73,8 @@ def run(con, q):
|
|
|
71
73
|
return cur.fetchone()[0]
|
|
72
74
|
rows = cur.fetchall()
|
|
73
75
|
if mode == "rows":
|
|
74
|
-
|
|
76
|
+
structural = NODE_STRUCTURAL_KEYS if isinstance(q, NodeQuery) else EDGE_STRUCTURAL_KEYS
|
|
77
|
+
return [decode_projected_row(keys, r, structural) for r in rows]
|
|
75
78
|
return rows
|
|
76
79
|
|
|
77
80
|
|
|
@@ -85,6 +88,21 @@ def test_spine_read_ordered_projection(con):
|
|
|
85
88
|
assert json.loads(rows[1]["sources"])[0]["content_hash"] == "sha256:ab"
|
|
86
89
|
|
|
87
90
|
|
|
91
|
+
def test_projected_properties_keep_their_json_types(con):
|
|
92
|
+
# A projected object stays an object and a missing key is None (json_extract used to
|
|
93
|
+
# hand arrays and objects back as JSON TEXT — a projected list read as a string).
|
|
94
|
+
rows = run(con, NodeQuery(label="Correction", project=["payload", "correction_type"]))
|
|
95
|
+
by = {r["id"]: r for r in rows}
|
|
96
|
+
assert by["c1"]["payload"] == {"document_id": "doc-1", "segment_id": "s1"}
|
|
97
|
+
assert by["c2"]["correction_type"] == "text_content"
|
|
98
|
+
rows = run(con, NodeQuery(label="Segment", related=RelationPredicate("PART_OF", node_id="doc-1"),
|
|
99
|
+
order_by=OrderBy(prop="index"), project=["text", "start_time"]))
|
|
100
|
+
assert [r["text"] for r in rows] == ["It's 100% real_deal", "hello world", "", None]
|
|
101
|
+
assert rows[1]["start_time"] == 1.0
|
|
102
|
+
rows = run(con, EdgeQuery(relation_type="REVIEWED", project=["decision"]))
|
|
103
|
+
assert rows == [{"id": "rev", "source_id": "sess-1", "target_id": "s0", "decision": "corrected"}]
|
|
104
|
+
|
|
105
|
+
|
|
88
106
|
def test_structural_timestamp_projection(con):
|
|
89
107
|
# created_at/updated_at project from the storage COLUMNS (the stamps),
|
|
90
108
|
# not json_extract over properties — no Segment carries them as props.
|
|
@@ -163,6 +181,27 @@ def test_ne_order_desc_limit_and_paging(con):
|
|
|
163
181
|
assert [r[0] for r in rows] == ["s1", "s2"]
|
|
164
182
|
|
|
165
183
|
|
|
184
|
+
def test_keyset_pages_and_deterministic_bounded_order(con):
|
|
185
|
+
# The keyset cursor (finding ae2b196e): a page is the rows after the last id seen, in
|
|
186
|
+
# id order, so a pass over the whole answer visits every row exactly once.
|
|
187
|
+
every = sorted(r["id"] for r in run(con, NodeQuery(label="Segment", project=[])))
|
|
188
|
+
seen, after = [], None
|
|
189
|
+
while True:
|
|
190
|
+
rows = run(con, NodeQuery(label="Segment", project=[], limit=2, after=after))
|
|
191
|
+
seen.extend(r["id"] for r in rows)
|
|
192
|
+
if len(rows) < 2:
|
|
193
|
+
break
|
|
194
|
+
after = rows[-1]["id"]
|
|
195
|
+
assert seen == every
|
|
196
|
+
# A bounded read with no ordering is ordered by id, never an arbitrary subset
|
|
197
|
+
# (finding a107e2f3); a property ordering breaks its ties on the id.
|
|
198
|
+
assert [r[0] for r in run(con, NodeQuery(label="Segment", limit=3))] == every[:3]
|
|
199
|
+
tied = run(con, NodeQuery(label="Segment", where=[PropertyPredicate("index", "eq", 0)],
|
|
200
|
+
order_by=OrderBy(prop="index")))
|
|
201
|
+
assert [r[0] for r in tied] == ["other", "s0"]
|
|
202
|
+
assert [r[0] for r in run(con, EdgeQuery(relation_type="NEXT", after="n0"))] == ["n1", "nx"]
|
|
203
|
+
|
|
204
|
+
|
|
166
205
|
def test_in_op_ids_batch_and_top_level_source(con):
|
|
167
206
|
rows = run(con, NodeQuery(label="Segment", where=[PropertyPredicate("index", "in", [0, 2])],
|
|
168
207
|
related=RelationPredicate("PART_OF", node_id="doc-1")))
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.0.28"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|