Lesson 3 — Nodes, edges, identity¶
What you will learn
- Stream any DDI file as plain nodes and relationships from Python
- Read the three fields every node has, and what each is for
- Understand why one seam serves all three flavors
One shape for three formats¶
Lesson 2 previewed three very different files with one command. That works
because of a single function underneath: iter_graph.
It takes a path, works out the flavor, and yields the same thing for all
three — chunks of nodes and relationships. Everything else in the package
sits on top of it. The previewer, the RDF exporter, the Neo4j writer, the
SHACL validator: all of them consume iter_graph and none of them knows
which flavor it started from.
graph LR
C[Codebook XML] --> V[iter_graph]
L[Lifecycle XML] --> V
D[CDI XML] --> V
R[RDF] --> V
V --> P[preview]
V --> E[export]
V --> N[Neo4j]
V --> Y[your code]
That last arrow is the point of this lesson.
Streaming a file¶
import os
from ddigraph import iter_graph
for chunk in iter_graph(os.environ["FIXTURE"]):
for node in chunk.nodes:
print(node.label, node.identity)
Instrument {'fragment_id': 'urn:ddi:test.org:inst1:1.0'}
Sequence {'fragment_id': 'urn:ddi:test.org:seq1:1.0'}
QuestionConstruct {'fragment_id': 'urn:ddi:test.org:qc1:1.0'}
QuestionItem {'fragment_id': 'urn:ddi:test.org:q1:1.0'}
CodeList {'fragment_id': 'urn:ddi:test.org:cl1:1.0'}
Category {'fragment_id': 'urn:ddi:test.org:cat1:1.0'}
Note the word stream. iter_graph is a generator that yields chunks as
it parses, so memory stays flat whether the file is 6 KB or 65 MB. It never
holds the whole graph.
The three fields of a node¶
Every node has exactly three things.
label — what kind of thing it is. QuestionItem, CodeList. This
becomes the Neo4j label and the RDF class.
identity — the fields that say which one it is. Usually a single
key, but not always, and that matters: some node types are identified by a
combination of fields, and using only the first would merge distinct things
into one.
properties — everything else. Labels, text, agency, version.
import os
from ddigraph import iter_graph
for chunk in iter_graph(os.environ["FIXTURE"]):
for node in chunk.nodes:
if node.label == "QuestionItem":
print("identity ", node.identity)
print("properties", sorted(node.properties))
identity {'fragment_id': 'urn:ddi:test.org:q1:1.0'}
properties ['agency', 'ddi_id', 'fragment_id', 'label', ...]
Identity is a dict, not a string, precisely because it can hold more than one key. Treat it as a whole and you will not be caught out.
Use the whole identity
Building a key from the first identity value is the tempting shortcut, and it fails quietly. Take a node type keyed on three fields: every node that happens to share the first field collapses onto one key, and their properties merge. You get fewer nodes than you started with and no error to tell you so.
DDIGenericIdentifiable in the Codebook fixture is keyed on
(dataset_id, element_tag, identifiable_id). All fourteen of them
share a dataset_id.
Relationships¶
A relationship has a type and two endpoints, and each endpoint is a node:
import os
from ddigraph import iter_graph
for chunk in iter_graph(os.environ["FIXTURE"]):
for edge in chunk.relationships:
print(f"({edge.start.label})-[:{edge.type}]->({edge.end.label})")
(Instrument)-[:HAS_CONSTRUCT]->(Sequence)
(Sequence)-[:HAS_CONSTRUCT]->(QuestionConstruct)
(QuestionConstruct)-[:REFERENCES_QUESTION]->(QuestionItem)
(QuestionItem)-[:USES_CODELIST]->(CodeList)
(CodeList)-[:HAS_CATEGORY]->(Category)
That is the chain from lesson 2, now as objects you can act on. The endpoints carry identity too, which is what lets a consumer match an edge to nodes it saw in an earlier chunk.
Why chunks and not a list¶
iter_graph yields GraphChunk objects rather than one flat list, and the
reason is ordering.
For DDI-L the parser runs in two phases: every node first, then every relationship. It has to. An edge can point at a fragment that appears later in the file, so the parser cannot build the edge until it has seen everything. Chunking makes that visible instead of hiding it behind a list that would have to be fully materialised anyway.
For your code the practical consequence is simple: do not assume a chunk's edges refer to nodes in that same chunk. Collect nodes first, then wire edges. Lesson 6 does exactly that.
Exercise¶
Count the nodes and relationships in the Codebook fixture, grouped by type,
without using ddigraph preview.
Solution
import collections
import os
from ddigraph import iter_graph
nodes = collections.Counter()
edges = collections.Counter()
for chunk in iter_graph(os.environ["CODEBOOK_FIXTURE"]):
nodes.update(node.label for node in chunk.nodes)
edges.update(edge.type for edge in chunk.relationships)
print(sum(nodes.values()), "nodes,", sum(edges.values()), "relationships")
for label, count in nodes.most_common(5):
print(f" {label:24} {count}")
This is, more or less, what ddigraph preview does. The previewer is
about a hundred lines on top of iter_graph, and nothing in it is
privileged — you have the same access it does.
Check yourself¶
- Why is
identitya dict rather than a string? - Why can an edge in one chunk refer to a node from an earlier chunk?
- What would you have to change to make your code work on DDI-CDI instead of DDI-L?
Next: Joining the wider world — turning these nodes into RDF that other systems already understand.