knowledge-graph-rdbms 0.1.2__tar.gz → 0.1.3__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.
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/PKG-INFO +2 -2
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/README.md +1 -1
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/kgrdbms/__init__.py +1 -1
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/kgrdbms/cli.py +6 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/kgrdbms/events.py +19 -1
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/kgrdbms/rdf.py +26 -8
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/kgrdbms/service.py +11 -1
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/pyproject.toml +1 -1
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/tests/test_cli.py +22 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/tests/test_events.py +49 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/tests/test_rdf.py +41 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/.claude/skills/kg-compose/SKILL.md +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/.github/workflows/ci.yml +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/.github/workflows/publish.yml +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/.gitignore +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/CLAUDE.md +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/CODE_OF_CONDUCT.md +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/CONTRIBUTING.md +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/LICENSE +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/SECURITY.md +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/assets/crossover.png +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/assets/read_latency.png +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/assets/runtimes.png +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/assets/write_throughput.png +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/bench/README.md +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/bench/benchmark.py +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/bench/charts.py +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/bench/neo4j/README.md +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/bench/neo4j/headtohead.py +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/bench/postgres/README.md +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/bench/postgres/benchmark.py +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/bench/postgres/charts.py +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/bench/runtimes/compare.py +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/bench/runtimes/run_bun.js +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/bench/runtimes/run_node.mjs +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/bench/runtimes/run_python.py +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/kgrdbms/backends/__init__.py +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/kgrdbms/backends/base.py +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/kgrdbms/backends/neo4j.py +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/kgrdbms/backends/postgres.py +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/kgrdbms/backends/sqlite.py +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/kgrdbms/graph.py +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/kgrdbms/invariants.py +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/kgrdbms/mcp_server.py +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/kgrdbms/policy.py +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/kgrdbms/resolver.py +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/tests/test_bulk.py +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/tests/test_graph.py +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/tests/test_mcp_server.py +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/tests/test_policy.py +0 -0
- {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/tests/test_postgres.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: knowledge-graph-rdbms
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: A label property graph on an RDBMS (SQLite): nodes, typed edges, an append-only event log, and an optional MCP server.
|
|
5
5
|
Project-URL: Homepage, https://github.com/cunicopia-dev/knowledge-graph-rdbms
|
|
6
6
|
Project-URL: Repository, https://github.com/cunicopia-dev/knowledge-graph-rdbms
|
|
@@ -34,7 +34,7 @@ Description-Content-Type: text/markdown
|
|
|
34
34
|

|
|
35
35
|

|
|
36
36
|

|
|
37
|
-

|
|
38
38
|

|
|
39
39
|

|
|
40
40
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|

|
|
4
4
|

|
|
5
5
|

|
|
6
|
-

|
|
7
7
|

|
|
8
8
|

|
|
9
9
|
|
|
@@ -21,6 +21,7 @@ from __future__ import annotations
|
|
|
21
21
|
|
|
22
22
|
import argparse
|
|
23
23
|
import json
|
|
24
|
+
import sqlite3
|
|
24
25
|
import sys
|
|
25
26
|
from typing import Any
|
|
26
27
|
|
|
@@ -600,6 +601,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
600
601
|
except (KeyError, ValueError, FileNotFoundError) as e:
|
|
601
602
|
print(f"error: {e}", file=sys.stderr)
|
|
602
603
|
return 1
|
|
604
|
+
except sqlite3.IntegrityError as e:
|
|
605
|
+
# Safety net for any FK/constraint path not pre-checked in service.py
|
|
606
|
+
# (e.g. restoring a deleted node whose edge endpoint is since gone).
|
|
607
|
+
print(f"error: {e}", file=sys.stderr)
|
|
608
|
+
return 1
|
|
603
609
|
finally:
|
|
604
610
|
app.close()
|
|
605
611
|
|
|
@@ -53,6 +53,7 @@ OP_NODE_DEL_PROPERTY = "NODE_DEL_PROPERTY"
|
|
|
53
53
|
OP_EDGE_ADD = "EDGE_ADD"
|
|
54
54
|
OP_EDGE_REMOVE = "EDGE_REMOVE"
|
|
55
55
|
OP_RESTORE = "RESTORE" # re-create a captured node + its edges (used to undo a delete)
|
|
56
|
+
OP_NODE_RESTORE_STATE = "NODE_RESTORE_STATE" # exact-restore a node to a prior snapshot (undo of an upsert)
|
|
56
57
|
OP_BATCH = "BATCH" # add many nodes + edges in one event
|
|
57
58
|
OP_GENESIS = "GENESIS"
|
|
58
59
|
|
|
@@ -226,7 +227,11 @@ class EventLog:
|
|
|
226
227
|
prior = p.get("prior")
|
|
227
228
|
if prior is None:
|
|
228
229
|
return OP_NODE_DELETE, {"node": p["after"], "edges": []}
|
|
229
|
-
|
|
230
|
+
# A plain re-upsert of `prior` would MERGE, so it cannot remove the
|
|
231
|
+
# labels/properties this upsert *added* — leaving a non-inverse. The
|
|
232
|
+
# exact-restore op carries the prior snapshot plus the `added` delta,
|
|
233
|
+
# so it can strip those additions and rebuild `prior` precisely.
|
|
234
|
+
return OP_NODE_RESTORE_STATE, {"node": prior, "added": p["after"]}
|
|
230
235
|
if op == OP_NODE_DELETE:
|
|
231
236
|
return OP_RESTORE, {"node": p["node"], "edges": p.get("edges", [])}
|
|
232
237
|
if op == OP_NODE_SET_LABEL:
|
|
@@ -261,6 +266,19 @@ def apply_event(graph: "Graph", ev: GraphEvent) -> None:
|
|
|
261
266
|
labels=spec.get("labels", []), properties=spec.get("properties", {}))
|
|
262
267
|
for e in p.get("edges", []):
|
|
263
268
|
graph.add_edge(e["from"], e["to"], e["type"], e.get("properties", {}))
|
|
269
|
+
elif op == OP_NODE_RESTORE_STATE:
|
|
270
|
+
# Exact-restore a node to `node`, removing the labels/properties that the
|
|
271
|
+
# reverted upsert added (`added` is that upsert's delta). add_node is a
|
|
272
|
+
# merge, so we must strip the additions first, then rebuild the snapshot.
|
|
273
|
+
target = p["node"]
|
|
274
|
+
added = p.get("added", {})
|
|
275
|
+
nid = target["id"]
|
|
276
|
+
for label in set(added.get("labels", [])) - set(target.get("labels", [])):
|
|
277
|
+
graph.remove_label(nid, label)
|
|
278
|
+
for key in set(added.get("properties", {}).keys()) - set(target.get("properties", {}).keys()):
|
|
279
|
+
graph.del_property(nid, key)
|
|
280
|
+
graph.add_node(nid, target["kind"], target["name"],
|
|
281
|
+
labels=target.get("labels", []), properties=target.get("properties", {}))
|
|
264
282
|
elif op == OP_NODE_SET_LABEL:
|
|
265
283
|
graph.add_label(p["id"], p["label"])
|
|
266
284
|
elif op == OP_NODE_REMOVE_LABEL:
|
|
@@ -29,6 +29,7 @@ import json
|
|
|
29
29
|
import re
|
|
30
30
|
from dataclasses import dataclass, field
|
|
31
31
|
from typing import Any, Iterator
|
|
32
|
+
from urllib.parse import quote, unquote
|
|
32
33
|
|
|
33
34
|
from kgrdbms.backends.base import GraphBackend
|
|
34
35
|
from kgrdbms.graph import Edge, Node
|
|
@@ -78,6 +79,22 @@ XSD = "http://www.w3.org/2001/XMLSchema#"
|
|
|
78
79
|
KG = "https://kg.local/vocab#"
|
|
79
80
|
|
|
80
81
|
|
|
82
|
+
def _enc(segment: str) -> str:
|
|
83
|
+
"""Percent-encode an arbitrary string into a valid IRI path/fragment segment.
|
|
84
|
+
|
|
85
|
+
Node references are slug-safe, but `kind`, edge `type`, and property `key`
|
|
86
|
+
are free-form user text — a space or '%' there would otherwise emit an IRI
|
|
87
|
+
no conformant RDF store will parse. `safe=""` also encodes '/', so a key like
|
|
88
|
+
'a/b' can't masquerade as a path boundary. Inverted by `_dec` on import.
|
|
89
|
+
"""
|
|
90
|
+
return quote(segment, safe="")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _dec(segment: str) -> str:
|
|
94
|
+
"""Invert `_enc` — percent-decode an IRI segment back to its stored value."""
|
|
95
|
+
return unquote(segment)
|
|
96
|
+
|
|
97
|
+
|
|
81
98
|
# ---- IRI context: the CURIE -> IRI expansion table -------------------
|
|
82
99
|
|
|
83
100
|
|
|
@@ -106,13 +123,13 @@ class IriContext:
|
|
|
106
123
|
base = self.prefix_bases.get(prefix, f"{self.default_base}{prefix}/")
|
|
107
124
|
else:
|
|
108
125
|
prefix, ref, base = "", node_id, self.default_base
|
|
109
|
-
return Iri(f"{base}{ref}")
|
|
126
|
+
return Iri(f"{base}{_enc(ref)}")
|
|
110
127
|
|
|
111
128
|
def prop_predicate(self, key: str) -> Iri:
|
|
112
|
-
return Iri(f"{self.prop_base}{key}")
|
|
129
|
+
return Iri(f"{self.prop_base}{_enc(key)}")
|
|
113
130
|
|
|
114
131
|
def edge_predicate(self, edge_type: str) -> Iri:
|
|
115
|
-
return Iri(f"{self.edge_base}{edge_type}")
|
|
132
|
+
return Iri(f"{self.edge_base}{_enc(edge_type)}")
|
|
116
133
|
|
|
117
134
|
|
|
118
135
|
# ---- value -> literal typing -----------------------------------------
|
|
@@ -163,7 +180,7 @@ def node_to_triples(node: Node, ctx: IriContext) -> list[Triple]:
|
|
|
163
180
|
s = ctx.expand_node(node.id)
|
|
164
181
|
triples: list[Triple] = [
|
|
165
182
|
# kind -> rdf:type, pointing at a class IRI under the kg vocab.
|
|
166
|
-
(s, Iri(f"{RDF}type"), Iri(f"{KG}{node.kind}")),
|
|
183
|
+
(s, Iri(f"{RDF}type"), Iri(f"{KG}{_enc(node.kind)}")),
|
|
167
184
|
]
|
|
168
185
|
if node.name:
|
|
169
186
|
triples.append((s, Iri(f"{KG}name"), Literal(node.name)))
|
|
@@ -494,18 +511,19 @@ def contract_iri(iri: str, ctx: IriContext) -> str:
|
|
|
494
511
|
# Explicit prefix bindings win (longest base first to avoid prefix overlap).
|
|
495
512
|
for prefix, base in sorted(ctx.prefix_bases.items(), key=lambda kv: -len(kv[1])):
|
|
496
513
|
if iri.startswith(base):
|
|
497
|
-
return f"{prefix}:{iri[len(base):]}"
|
|
514
|
+
return f"{prefix}:{_dec(iri[len(base):])}"
|
|
498
515
|
if iri.startswith(ctx.default_base):
|
|
499
516
|
rest = iri[len(ctx.default_base):]
|
|
500
517
|
if "/" in rest:
|
|
501
518
|
prefix, ref = rest.split("/", 1)
|
|
502
|
-
return f"{prefix}:{ref}"
|
|
503
|
-
return rest
|
|
519
|
+
return f"{prefix}:{_dec(ref)}"
|
|
520
|
+
return _dec(rest)
|
|
504
521
|
return iri # foreign IRI — keep verbatim
|
|
505
522
|
|
|
506
523
|
|
|
507
524
|
def _local_after(iri: str, base: str) -> str | None:
|
|
508
|
-
|
|
525
|
+
"""Strip `base` and percent-decode the remaining segment (kind/type/key)."""
|
|
526
|
+
return _dec(iri[len(base):]) if iri.startswith(base) else None
|
|
509
527
|
|
|
510
528
|
|
|
511
529
|
def triples_to_graph(triples: list[Triple], ctx: IriContext | None = None) -> tuple[list[dict], list[dict]]:
|
|
@@ -95,6 +95,11 @@ def upsert_node(
|
|
|
95
95
|
|
|
96
96
|
def set_label(graph: Graph, events: EventLog, id: str, label: str, actor: str = "anonymous") -> Node | None:
|
|
97
97
|
guard(graph, _node_ctx(graph, id, "node_set_label"))
|
|
98
|
+
node = graph.node(id)
|
|
99
|
+
if node is None:
|
|
100
|
+
raise ValueError(f"node {id!r} does not exist")
|
|
101
|
+
if label in node.labels:
|
|
102
|
+
return node # already present: a true no-op, so don't log a non-invertible event
|
|
98
103
|
graph.add_label(id, label)
|
|
99
104
|
events.record(actor, OP_NODE_SET_LABEL, {"id": id, "label": label})
|
|
100
105
|
return graph.node(id)
|
|
@@ -107,7 +112,9 @@ def set_property(
|
|
|
107
112
|
ctx.property_key = key
|
|
108
113
|
guard(graph, ctx)
|
|
109
114
|
prior_node = graph.node(id)
|
|
110
|
-
|
|
115
|
+
if prior_node is None:
|
|
116
|
+
raise ValueError(f"node {id!r} does not exist")
|
|
117
|
+
prior_value = prior_node.properties.get(key, _MISSING)
|
|
111
118
|
graph.set_property(id, key, value)
|
|
112
119
|
events.record(actor, OP_NODE_SET_PROPERTY, {"id": id, "key": key, "value": value, "prior": prior_value})
|
|
113
120
|
return graph.node(id)
|
|
@@ -139,6 +146,9 @@ def add_edge(
|
|
|
139
146
|
) -> Edge:
|
|
140
147
|
ctx = MutationContext(operation="edge_add", edge_type=type, from_node_id=from_id, to_node_id=to_id)
|
|
141
148
|
guard(graph, ctx)
|
|
149
|
+
for endpoint, role in ((from_id, "from"), (to_id, "to")):
|
|
150
|
+
if graph.node(endpoint) is None:
|
|
151
|
+
raise ValueError(f"{role} node {endpoint!r} does not exist")
|
|
142
152
|
edge = graph.add_edge(from_node=from_id, to_node=to_id, type=type, properties=properties or {})
|
|
143
153
|
events.record(actor, OP_EDGE_ADD, {"edge": edge_spec(edge)})
|
|
144
154
|
return edge
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "knowledge-graph-rdbms"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.3"
|
|
8
8
|
description = "A label property graph on an RDBMS (SQLite): nodes, typed edges, an append-only event log, and an optional MCP server."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -189,3 +189,25 @@ def test_rdf_export_lossy_reports_dropped(db, capsys):
|
|
|
189
189
|
assert "rel/influences" in captured.out # bare edge present
|
|
190
190
|
assert "prop/since" not in captured.out # property dropped
|
|
191
191
|
assert "dropped" in captured.err # but loudly, not silently
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ---- regression: FK violations exit 1 cleanly, no traceback ---------
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_set_label_missing_node_exits_1(db, capsys):
|
|
198
|
+
assert run(db, "node", "add-label", "ghost:1", "L") == 1
|
|
199
|
+
err = capsys.readouterr().err
|
|
200
|
+
assert "does not exist" in err and "Traceback" not in err
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_set_prop_missing_node_exits_1(db, capsys):
|
|
204
|
+
assert run(db, "node", "set-prop", "ghost:1", "k", "1") == 1
|
|
205
|
+
assert "does not exist" in capsys.readouterr().err
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_edge_add_missing_endpoint_exits_1(db, capsys):
|
|
209
|
+
run(db, "node", "add", "x:1", "--kind", "T")
|
|
210
|
+
capsys.readouterr()
|
|
211
|
+
assert run(db, "edge", "add", "x:1", "y:1", "LINK") == 1
|
|
212
|
+
err = capsys.readouterr().err
|
|
213
|
+
assert "to node 'y:1' does not exist" in err and "Traceback" not in err
|
|
@@ -154,3 +154,52 @@ def test_batch_op_is_replayable(tmp_path):
|
|
|
154
154
|
assert g.node("n:1") is not None
|
|
155
155
|
assert g.out("n:1", "LINK")
|
|
156
156
|
g.close()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---- regression: revert of an upsert is a TRUE inverse --------------
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_revert_upsert_removes_added_labels_and_props(tmp_path):
|
|
163
|
+
"""An upsert that ADDS labels/props must, on revert, restore the node
|
|
164
|
+
exactly to its prior state — not merely overwrite changed values.
|
|
165
|
+
Regression for: compensation merged instead of replacing."""
|
|
166
|
+
from kgrdbms import service
|
|
167
|
+
|
|
168
|
+
g, log = _fresh(tmp_path)
|
|
169
|
+
service.upsert_node(g, log, id="t:1", kind="T", labels=["A"], properties={"color": "red"})
|
|
170
|
+
service.upsert_node(g, log, id="t:1", kind="T", labels=["B"],
|
|
171
|
+
properties={"color": "blue", "size": "big"})
|
|
172
|
+
ev2 = log.tail(1)[0].id
|
|
173
|
+
|
|
174
|
+
service.revert_event(log, ev2)
|
|
175
|
+
n = g.node("t:1")
|
|
176
|
+
assert sorted(n.labels) == ["A"] # added label B removed
|
|
177
|
+
assert n.properties == {"color": "red"} # added prop dropped, value restored
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_replay_after_revert_is_consistent(tmp_path):
|
|
181
|
+
"""The new restore-state op must itself be replayable: rebuilding from the
|
|
182
|
+
log reproduces the post-revert state."""
|
|
183
|
+
from kgrdbms import service
|
|
184
|
+
|
|
185
|
+
g, log = _fresh(tmp_path)
|
|
186
|
+
service.upsert_node(g, log, id="t:1", kind="T", labels=["A"], properties={"color": "red"})
|
|
187
|
+
service.upsert_node(g, log, id="t:1", kind="T", labels=["B"], properties={"color": "blue"})
|
|
188
|
+
service.revert_event(log, log.tail(1)[0].id)
|
|
189
|
+
|
|
190
|
+
service.replay_log(g, log)
|
|
191
|
+
n = g.node("t:1")
|
|
192
|
+
assert sorted(n.labels) == ["A"] and n.properties == {"color": "red"}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_resetting_existing_label_is_noop_not_logged(tmp_path):
|
|
196
|
+
"""Re-adding a label a node already has must not log an event whose revert
|
|
197
|
+
would then remove the pre-existing label."""
|
|
198
|
+
from kgrdbms import service
|
|
199
|
+
|
|
200
|
+
g, log = _fresh(tmp_path)
|
|
201
|
+
service.upsert_node(g, log, id="t:1", kind="T", labels=["A"])
|
|
202
|
+
before = log.count()
|
|
203
|
+
service.set_label(g, log, "t:1", "A") # already present -> no-op
|
|
204
|
+
assert log.count() == before # nothing logged
|
|
205
|
+
assert "A" in g.node("t:1").labels
|
|
@@ -97,6 +97,47 @@ def test_rdf_star_annotates_the_quoted_triple(populated):
|
|
|
97
97
|
assert "<https://kg.local/prop/since>" in nt
|
|
98
98
|
|
|
99
99
|
|
|
100
|
+
def test_special_chars_produce_valid_iris(populated):
|
|
101
|
+
"""Regression: kind / edge-type / property-key with spaces or punctuation
|
|
102
|
+
must percent-encode into valid IRIs, not emit a raw space a store rejects."""
|
|
103
|
+
g = _fresh_graph()
|
|
104
|
+
g.add_node("topic:ml", kind="Knowledge Area", name="ML",
|
|
105
|
+
properties={"first name": "Ada", "rate %": 50, "a/b": 1})
|
|
106
|
+
g.add_node("topic:cs", kind="Knowledge Area", name="CS")
|
|
107
|
+
g.add_edge("topic:ml", "topic:cs", "is part of", properties={"note": "x"})
|
|
108
|
+
|
|
109
|
+
nt = rdf.export(g, "ntriples")
|
|
110
|
+
assert "Knowledge%20Area" in nt
|
|
111
|
+
assert "prop/first%20name" in nt
|
|
112
|
+
assert "rel/is%20part%20of" in nt
|
|
113
|
+
assert "prop/a%2Fb" in nt # '/' encoded so it can't fake a path boundary
|
|
114
|
+
# No IRI reference may contain a raw space (would break any conformant parser).
|
|
115
|
+
import re
|
|
116
|
+
for iri in re.findall(r"<([^<>]+)>", nt):
|
|
117
|
+
assert " " not in iri, f"raw space in IRI: {iri!r}"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_special_chars_round_trip_and_rdflib_accepts(populated):
|
|
121
|
+
rdflib = pytest.importorskip("rdflib")
|
|
122
|
+
g = _fresh_graph()
|
|
123
|
+
g.add_node("topic:ml", kind="Knowledge Area", name="ML",
|
|
124
|
+
properties={"first name": "Ada", "rate %": 50, "a/b": 1})
|
|
125
|
+
g.add_node("topic:cs", kind="Knowledge Area", name="CS")
|
|
126
|
+
g.add_edge("topic:ml", "topic:cs", "is part of", properties={"note": "x"})
|
|
127
|
+
|
|
128
|
+
# A conformant store accepts the export (reification — rdflib is RDF 1.1).
|
|
129
|
+
ctx = rdf.IriContext(edge_strategy="reification")
|
|
130
|
+
rdflib.Graph().parse(data=rdf.export(g, "ntriples", ctx), format="nt")
|
|
131
|
+
|
|
132
|
+
# And the values survive a full star round-trip unchanged.
|
|
133
|
+
dst = _reimport(rdf.export(g, "ntriples"), "ntriples")
|
|
134
|
+
n = dst.node("topic:ml")
|
|
135
|
+
assert n.kind == "Knowledge Area"
|
|
136
|
+
assert n.properties == {"first name": "Ada", "rate %": 50, "a/b": 1}
|
|
137
|
+
e = dst.out("topic:ml")[0][0]
|
|
138
|
+
assert e.type == "is part of" and e.properties == {"note": "x"}
|
|
139
|
+
|
|
140
|
+
|
|
100
141
|
def test_reification_emits_statement_node(populated):
|
|
101
142
|
ctx = rdf.IriContext(edge_strategy="reification")
|
|
102
143
|
triples = rdf.export_graph(populated, ctx)
|
{knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/.claude/skills/kg-compose/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|