Lesson 6 — Build your own pipeline¶
What you will learn
- Send DDI to a store this package has never heard of
- Handle the two-phase ordering correctly
- Know which parts are shipped and which are examples
What actually ships¶
Know this before writing anything, because it decides how much work is ahead of you.
| Target | Status |
|---|---|
| Neo4j | Shipped: ddigraph load |
| RDF / SPARQL | Shipped: ddigraph export, ddigraph load out.ttl |
| JSON / CSV | Shipped: ddigraph export |
| NetworkX, pandas, Gremlin | Worked examples in demo/, not shipped adapters |
Anything not in the first three you write yourself — and lesson 3 already showed you the whole interface you need.
The pattern¶
Three steps, and only the middle one is subtle.
- Collect nodes, keyed by identity.
- Collect edges, whose endpoints refer to those keys.
- Write both to wherever you like.
The reason to collect before writing is the two-phase ordering from lesson 3: for DDI-L, every node arrives before any edge does, and an edge may point at a node from a much earlier chunk. Writing edges as they arrive would mean looking up nodes you have already thrown away.
import os
from ddigraph import iter_graph
def node_key(node):
"""A stable key from the whole identity, not just the first field."""
return "|".join(f"{k}={v}" for k, v in sorted(node.identity.items()))
nodes, edges = {}, []
for chunk in iter_graph(os.environ["FIXTURE"]):
for node in chunk.nodes:
nodes[node_key(node)] = node
for edge in chunk.relationships:
edges.append((node_key(edge.start), edge.type, node_key(edge.end)))
print(len(nodes), "nodes,", len(edges), "edges")
dangling = [e for e in edges if e[0] not in nodes or e[2] not in nodes]
print("dangling endpoints:", len(dangling))
6 nodes, 5 edges
dangling endpoints: 0
That node_key function is the part worth copying. Using
next(iter(node.identity.values())) — the first identity field — looks
equivalent and is not. Some node types are keyed on several fields
together, and taking only the first silently merges distinct nodes into
one. Nothing errors; you simply end up with fewer nodes than you had, and
their properties mixed together.
A real target¶
NetworkX, in nine more lines:
import os
import networkx as nx
from ddigraph import iter_graph
def node_key(node):
return "|".join(f"{k}={v}" for k, v in sorted(node.identity.items()))
graph = nx.DiGraph()
for chunk in iter_graph(os.environ["FIXTURE"]):
for node in chunk.nodes:
graph.add_node(node_key(node), node_type=node.label, **node.properties)
for edge in chunk.relationships:
graph.add_edge(node_key(edge.start), node_key(edge.end), type=edge.type)
print(graph.number_of_nodes(), "nodes,", graph.number_of_edges(), "edges")
print("acyclic:", nx.is_directed_acyclic_graph(graph))
6 nodes, 5 edges
acyclic: True
Note node_type=, not label=. DDI records carry their own label
property, so **node.properties already supplies one, and passing
label=node.label as well raises TypeError: got multiple values for
keyword argument 'label'. It is an easy line to write and an easy one to
miss.
Because add_node here is keyed by identity, NetworkX merges repeats
naturally, so you can skip the collect step. A store without that property
needs the two-pass version above.
It works on RDF too¶
read_graph yields the same GraphChunk values, so anything you build on
iter_graph reads Turtle, JSON-LD, N-Triples and RDF/XML for free:
import os
import ddigraph
from ddigraph.rdf.reader import read_graph
ddigraph.export(os.environ["FIXTURE"], "survey.ttl", format="turtle")
labels = sorted({node.label for chunk in read_graph("survey.ttl") for node in chunk.nodes})
print(labels)
['Category', 'CodeList', 'Instrument', 'QuestionConstruct', 'QuestionItem', 'Sequence']
Same labels the XML produced, back out of RDF. That is the two-rdf:type
trick from lesson 4 paying off: without the project-namespace type, this
would return Question where the original said QuestionItem.
Streaming, if you need it¶
Both examples above hold the whole graph in memory. For a 65 MB file with tens of thousands of nodes that may be fine, or may not.
If it is not, do what GraphChunkWriter does: write each chunk as it
arrives and let the store resolve endpoints by identity. In Cypher that
is MERGE on the identity, which creates the node if the edge arrives
first and matches it if not. Any store with an upsert keyed on your
identity can do the same.
Exercise¶
Write a pipeline that reports, for each node type, which relationship types it participates in. Run it on all three fixtures.
Solution
import collections
import os
from ddigraph import iter_graph
for name in ("FIXTURE", "CODEBOOK_FIXTURE", "CDI_FIXTURE"):
shapes = collections.defaultdict(set)
for chunk in iter_graph(os.environ[name]):
for edge in chunk.relationships:
shapes[edge.start.label].add(f"-{edge.type}->")
shapes[edge.end.label].add(f"<-{edge.type}-")
print(f"--- {name}")
for label in sorted(shapes)[:4]:
print(f" {label}: {', '.join(sorted(shapes[label]))}")
One loop, three flavors, no branching on which is which. That is the
whole payoff of the graph view: you wrote this against iter_graph and
it works on formats you have never looked at.
Where to go next¶
Next: Grounding a model in the graph — putting this metadata in front of a language model without letting it invent the parts it does not know.
Two other things worth reading:
- The RDF case study takes one code list from DDI all the way to validated linked data — this whole course applied to one realistic problem.
- Custom Adapters covers the async writer interface, for feeding a database rather than building a file.
And if something here was wrong or unclear, open an issue. Every example in these lessons runs in CI, so "this does not work" is a bug worth reporting.