Propagation Properties — Full Apache Jena (Fuseki) Demo
The workflow of the Neo4j demo (propagation_demo.ipynb) on Apache Jena Fuseki. JenaGraph stores the graph as RDF 1.2 and has the same API and propagation behaviour as every other backend; what changes is how you query it:
SPARQL strings run on the Fuseki server, against the RDF the graph is stored as. Every write goes through the cvcdocdb API, so SPARQL Update is refused.
Cypher strings and dict filters run on
JenaGraph’s in-memory copy of the graph, as onNetworkXGraph.
Steps:
Connect to Fuseki and load the Game of Thrones dataset
Generate the YAML schema and the Python entity classes
Initialize propagation properties
Create a Document → Section → Page group with
create_group()Query in SPARQL and Cypher, get subdocuments, and explore the graph in the viewer
Prerequisites
Apache Jena Fuseki 6.2.0 or later (RDF 1.2) with an updatable dataset, for example in Docker:
docker run -p 3030:3030 -e FUSEKI_BASE=/tmp/base -w /jena-fuseki --entrypoint sh \
secoresearch/fuseki:6.2.0 -c "mkdir -p /tmp/db /tmp/base && exec java -cp '*:/javalibs/*' \
org.apache.jena.fuseki.main.cmds.FusekiServerCmd --tdb2 --loc /tmp/db --update /ds"
The notebook reads FUSEKI_URL (default http://localhost:3030/ds) and, if the dataset needs them, FUSEKI_USER and FUSEKI_PASSWORD.
The RDF layout
With the default namespace urn:cvcdocdb:, a node is <urn:cvcdocdb:node/ID>, a label:Character for each label, with one prop:name "…" triple per property. A relation is the triple <node/U> rel:MEMBER_OF <node/V>, and its properties hang from an RDF 1.2 reifier of that triple. Step 8 queries both.
Warning: Step 1.5 deletes every triple in the urn:cvcdocdb: namespace (other data in the dataset is left alone).
[ ]:
import importlib.util
# cvcdocdb with the [notebook] extra (anywidget) for the graph viewer at the end.
if (importlib.util.find_spec("cvcdocdb") is None
or importlib.util.find_spec("cvcdocdb.viewer") is None
or importlib.util.find_spec("anywidget") is None):
print("⚠️ Installing cvcdocdb[notebook]...")
%pip install -q --upgrade "cvcdocdb[notebook]"
print("✅ Installation complete. Restart the kernel if an import below fails.")
else:
print("✅ cvcdocdb and anywidget are already installed.")
[ ]:
import json
import os
import tempfile
from pathlib import Path
from cvcdocdb import JenaGraph
from cvcdocdb.exemples import load_got_characters
from cvcdocdb.schema_gen import generate_classes
def section(title):
"""Print a section header."""
print(f"\n{'=' * 60}")
print(f" {title}")
print(f"{'=' * 60}")
def sub_section(title):
"""Print a subsection header."""
print(f"\n--- {title} ---")
# Generated files (schema and entity classes) go to a fresh temporary directory.
output_dir = Path(tempfile.mkdtemp(prefix="cvcdocdb_got_"))
PREFIXES = """
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
PREFIX label: <urn:cvcdocdb:label/>
PREFIX prop: <urn:cvcdocdb:prop/>
PREFIX rel: <urn:cvcdocdb:rel/>
"""
def sparql(query):
"""Run a SPARQL query on the Fuseki server (with the cvcdocdb prefixes)."""
return graph.query(PREFIXES + query)
Step 1: Connect to Fuseki
JenaGraph checks the Fuseki version on connection and refuses servers older than 6.2.0.
[ ]:
url = os.environ.get("FUSEKI_URL", "http://localhost:3030/ds")
graph = JenaGraph(url, os.environ.get("FUSEKI_USER"), os.environ.get("FUSEKI_PASSWORD"))
print(f"Connected to {url} (Fuseki {graph.server_version})")
total = sparql("SELECT (COUNT(DISTINCT ?n) AS ?total) WHERE { ?n a ?label }")[0]["total"]
print(f"Typed resources in the dataset: {total}")
Step 1.5: Clear existing data
clear() deletes everything in the graph’s namespace, in one SPARQL Update.
[ ]:
graph.clear()
print(f"✅ Graph cleared. Nodes remaining: {len(graph.get_node_ids())}")
Step 2: Load the Game of Thrones dataset
Load character and house data from thronesapi.com. Falls back to bundled data if the API is unavailable.
[ ]:
stats = load_got_characters(graph, limit=25)
print("Loaded:", json.dumps(stats, indent=2))
sub_section("Nodes by label (SPARQL)")
rows = sparql("""
SELECT ?label (COUNT(?n) AS ?cnt) WHERE {
?n a ?type .
FILTER(STRSTARTS(STR(?type), STR(label:)))
BIND(STRAFTER(STR(?type), STR(label:)) AS ?label)
} GROUP BY ?label ORDER BY DESC(?cnt)
""")
for row in rows:
print(f" {row['label']}: {row['cnt']}")
sub_section("Relations by type (SPARQL)")
rows = sparql("""
SELECT ?type (COUNT(*) AS ?cnt) WHERE {
?a ?p ?b .
FILTER(STRSTARTS(STR(?p), STR(rel:)))
BIND(STRAFTER(STR(?p), STR(rel:)) AS ?type)
} GROUP BY ?type ORDER BY DESC(?cnt)
""")
for row in rows:
print(f" {row['type']}: {row['cnt']}")
Step 3: Generate the YAML schema
Introspect the graph and generate a YAML schema description: labels, properties, relationships and counts.
[ ]:
yaml_content = graph.schema_yaml(db_name="got")
yaml_path = output_dir / "got_schema.yaml"
yaml_path.write_text(yaml_content)
print(f"Written: {yaml_path} ({len(yaml_content)} bytes)")
print("\nSchema preview:")
for line in yaml_content.split("\n")[:20]:
print(f" {line}")
print(" ...")
Step 4: Generate Python entity classes
Generate Node, WeakNode, Relation and WeakRelation subclasses for each label and relationship type in the schema.
[ ]:
code = generate_classes(yaml_path.read_text())
py_path = output_dir / "got_entities.py"
py_path.write_text(code)
print(f"Written: {py_path} ({len(code)} bytes)")
print("\nGenerated code preview:")
for line in code.split("\n")[:25]:
print(f" {line}")
print(" ...")
Step 5: Initialize propagation properties
init_propagation() scans the graph and initialises the propagation properties:
_propagateon edges with cascade delete enabled;is_weakon the nodes at the child end of those edges;parent_relation: the edge type linking parent → child;_weak_init_doneon the nodes already initialised.
The Game of Thrones data has no WeakNodes, so the counts only change after the group of Step 6.
[ ]:
def show_counts(title):
sub_section(title)
row = sparql("""
SELECT (COUNT(DISTINCT ?n) AS ?total) (COUNT(DISTINCT ?w) AS ?weak) (COUNT(DISTINCT ?d) AS ?done) WHERE {
?n a ?label .
OPTIONAL { ?n prop:is_weak true . BIND(?n AS ?w) }
OPTIONAL { ?n prop:_weak_init_done ?flag . BIND(?n AS ?d) }
}""")[0]
print(f" Total nodes: {row['total']}")
print(f" Nodes with is_weak: {row['weak']}")
print(f" Nodes with _weak_init_done: {row['done']}")
# Edge properties live on the RDF 1.2 reifier of the relation triple.
edges = sparql("SELECT (COUNT(?r) AS ?c) WHERE { ?r rdf:reifies ?t ; prop:_propagate true }")[0]["c"]
print(f" Edges with _propagate: {edges}")
show_counts("Before init_propagation()")
print("\nRunning init_propagation()...", graph.init_propagation())
show_counts("After init_propagation()")
Step 6: Transactional group creation
create_group() creates a strong node with its WeakNodes and WeakRelations in a single transaction: if any step fails, nothing is written. We create a Document → Section → Page hierarchy.
[ ]:
from cvcdocdb.base import Node, WeakNode
doc = Node(
pk={"doc_id": "GOT-DEMO-001"},
main_label="Document",
title="Game of Thrones — Episode Guide",
author="Demo Script",
)
section1 = WeakNode(parent=doc, parent_relation="HAS_SECTION", pk={"section": "intro"},
main_label="Section", title="Introduction", order=1)
section2 = WeakNode(parent=doc, parent_relation="HAS_SECTION", pk={"section": "characters"},
main_label="Section", title="Main Characters", order=2)
page1 = WeakNode(parent=section1, parent_relation="HAS_PAGE", pk={"page": 1}, main_label="Page",
content="Welcome to the Game of Thrones episode guide.", order=1)
page2 = WeakNode(parent=section2, parent_relation="HAS_PAGE", pk={"page": 1}, main_label="Page",
content="Jon Snow, Daenerys Targaryen, Tyrion Lannister...", order=1)
doc_id = graph.create_group(strong_node=doc, weak_nodes=[section1, section2, page1, page2])
print(f"✅ Group created (strong node id: {doc_id}): Document, 2 Sections, 2 Pages")
Step 7: Verify the group
The same question twice: in SPARQL on the server, and in Cypher on the in-memory copy.
[ ]:
sub_section("Sections and Pages (SPARQL)")
rows = sparql("""
SELECT ?label ?text ?weak WHERE {
?doc a label:Document ; prop:doc_id "GOT-DEMO-001" ; (rel:HAS_SECTION|rel:HAS_SECTION/rel:HAS_PAGE) ?n .
?n a ?type ; prop:is_weak ?weak .
OPTIONAL { ?n prop:title ?title } OPTIONAL { ?n prop:content ?content }
BIND(STRAFTER(STR(?type), STR(label:)) AS ?label)
BIND(COALESCE(?title, ?content) AS ?text)
} ORDER BY ?label ?text
""")
for row in rows:
print(f" {row['label']}: {row['text'][:45]!r} is_weak={row['weak']}")
sub_section("Sections and Pages (Cypher, in-memory copy)")
# The in-memory Cypher has no variable-length paths (-[*1..2]->): one pattern per level.
for pattern in ("-[:HAS_SECTION]->(n)", "-[:HAS_SECTION]->()-[:HAS_PAGE]->(n)"):
rows = graph.query(
f"MATCH (d:Document {{doc_id: 'GOT-DEMO-001'}}){pattern} "
"RETURN labels(n)[0] AS label, n.is_weak AS is_weak"
)
for row in rows:
print(f" {row['label']}: is_weak={row['is_weak']}")
Step 8: Sample queries
GROUP_CONCAT gathers each house’s characters. The second query reads the _propagate flag from the reifier of each relation triple, with the RDF 1.2 triple-term syntax <<( s p o )>>.
[ ]:
sub_section("Characters by House (SPARQL)")
rows = sparql("""
SELECT ?house (GROUP_CONCAT(?name; separator=", ") AS ?characters) WHERE {
?c a label:Character ; prop:name ?name ; rel:MEMBER_OF ?h .
?h prop:name ?house .
} GROUP BY ?house ORDER BY ?house
""")
for row in rows:
names = row["characters"].split(", ")
print(f" {row['house']}: {', '.join(names[:5])}" + (f" ... and {len(names) - 5} more" if len(names) > 5 else ""))
sub_section("Edges with _propagate (SPARQL, RDF 1.2)")
rows = sparql("""
SELECT ?from_label ?rel_type ?to_label (COUNT(*) AS ?count) WHERE {
?r rdf:reifies <<( ?a ?p ?b )>> ; prop:_propagate true .
?a <urn:cvcdocdb:meta/mainLabel> ?from_label .
?b <urn:cvcdocdb:meta/mainLabel> ?to_label .
BIND(STRAFTER(STR(?p), STR(rel:)) AS ?rel_type)
} GROUP BY ?from_label ?rel_type ?to_label ORDER BY DESC(?count)
""")
for row in rows:
print(f" {row['from_label']} -[{row['rel_type']}]-> {row['to_label']}: {row['count']}")
Step 9: Get subdocuments
get_subdocuments() returns every node reachable from a strong node through _propagate edges: the document’s sections and pages.
[ ]:
strong_doc = Node(pk={"doc_id": "GOT-DEMO-001"}, main_label="Document")
subdocuments = graph.get_subdocuments(strong_doc)
print(f"Found {len(subdocuments)} subdocuments:")
for sd in subdocuments:
print(f" - {sd['label']}: {sd['pk']}") # a WeakNode's key includes its parents' keys
Step 10: Explore the graph
The graph viewer works on every backend. Click a node to see its properties, double-click it to expand its neighbours (try the Document), and search or filter from the sidebar.
[ ]:
from cvcdocdb.viewer import show
show(graph, load_all=True)
Cleanup
Closing the graph empties its in-memory copy, and the viewer with it: run this once you are done exploring. The data stays in Fuseki.
[ ]:
# graph.close()