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 on NetworkXGraph.

Steps:

  1. Connect to Fuseki and load the Game of Thrones dataset

  2. Generate the YAML schema and the Python entity classes

  3. Initialize propagation properties

  4. Create a Document → Section → Page group with create_group()

  5. 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:

  • _propagate on edges with cascade delete enabled;

  • is_weak on the nodes at the child end of those edges;

  • parent_relation: the edge type linking parent → child;

  • _weak_init_done on 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()