Skip to content

Lesson 4 — Joining the wider world

What you will learn

  • Export a DDI file as RDF that other systems can read
  • Read the two-type trick that makes the export reversible
  • Query an export with SPARQL

This lesson needs the RDF extra: pip install "ddigraph[rdf]".

The problem with your own vocabulary

A graph in your database is useful to you. A graph other people can join to is useful to everyone. That is what RDF is for: a shared way of saying "this thing is a question, and it uses that code list", where question and uses mean the same to every reader.

The catch is that "shared" only works if you use terms other people already use. Invent your own and you have XML with extra steps.

The failure mode looks like this, and it is common enough to be worth recognising:

ddi:USES_CODELIST      # a Neo4j relationship name
ddi:question_text      # a Python attribute name

Both leak an internal naming convention into a format whose entire purpose is to be external — one from the database, one from the source code. And a ddi: prefix pointing at a domain you do not control cannot be looked up by anyone. Output like that joins to nothing.

Three layers

The fix has three parts, and every RDF vocabulary worth using does something like this.

Reuse published terms. The DDI Alliance publishes RDF vocabularies — DISCO for studies, variables and questions; XKOS for classifications. Code lists map onto SKOS, the standard for controlled vocabularies. Where a term exists, use it.

Mint one namespace for the rest. DISCO defines 16 classes; DDI has about 250 concepts. The remainder need terms, and they live under one namespace that resolves to a page describing them.

Emit both types. Explained below — it is the interesting part.

Your first export

import os

import ddigraph

result = ddigraph.export(os.environ["FIXTURE"], "survey.ttl", format="turtle")
print(result.nodes, "nodes ->", result.triples, "triples")

Look at what came out for the question:

<urn:ddi:test.org:q1:1.0> a disco:Question,
        ddigraph:QuestionItem ;
    rdfs:label "Question 1" ;
    dcterms:identifier "urn:ddi:test.org:q1:1.0" ;
    dcterms:publisher "test.org" ;
    disco:questionText "What is your age?" .

Three things to notice.

The subject is the DDI URN from lesson 1 — urn:ddi:test.org:q1:1.0. Not a made-up URL. It is already globally unique, so there is nothing to invent.

The predicates are published terms. disco:questionText, dcterms:publisher, rdfs:label. No USES_CODELIST anywhere.

There are two types. disco:Question and ddigraph:QuestionItem.

Why two types

That last one looks redundant. It is not, and the reason is worth understanding because it is what makes the round trip work.

The mapping to standard classes is many-to-one:

ddigraph label Standard class
Question disco:Question
QuestionItem disco:Question
CodeScheme skos:ConceptScheme
CodeList skos:ConceptScheme
CategoryScheme skos:ConceptScheme

Given only disco:Question, you cannot tell whether it started as a Question or a QuestionItem. The information is gone.

So every node carries both: the standard class for anyone else, and the project class for identity. A consumer doing interoperability reads disco:Question and ignores the other. The ddigraph reader reads ddigraph:QuestionItem and rebuilds the original graph exactly.

That is why this works:

ddigraph export survey.xml --format turtle -o out.ttl
ddigraph load out.ttl        # straight back in, nothing lost

Code lists become SKOS

Controlled vocabularies get special treatment, because SKOS is the best-supported standard in this whole space. A CodeList becomes a skos:ConceptScheme, and each Category a skos:Concept:

import os

import rdflib

import ddigraph

ddigraph.export(os.environ["FIXTURE"], "survey.ttl", format="turtle")

graph = rdflib.Graph().parse("survey.ttl", format="turtle")
rows = graph.query("""
    PREFIX skos: <http://www.w3.org/2004/02/skos/core#>
    SELECT ?concept ?label
    WHERE { ?concept a skos:Concept ; skos:prefLabel ?label }
""")
for concept, label in rows:
    print(concept, "->", label)
urn:ddi:test.org:cat1:1.0 -> Under 18

That is a SPARQL query against your survey metadata, using a vocabulary that thesaurus tools, EuroVoc and the whole SKOS ecosystem already speak.

Direction is not decoration

The graph says CodeList -HAS_CATEGORY-> Category, because that is how the XML nests. SKOS says the opposite: the concept carries skos:inScheme pointing at its scheme, and skos:member belongs to skos:Collection, not skos:ConceptScheme.

So the exporter swaps subject and object for those edges. Emitting the graph direction verbatim would produce SKOS that validators reject — correct-looking triples that say the scheme is inside the concept.

Exercise

Export the Codebook fixture and count how many distinct RDF types it uses. Then count how many of those are project-namespace types rather than published ones.

Solution
import os

import rdflib

import ddigraph
from ddigraph.rdf.vocabulary import DDIGRAPH

ddigraph.export(os.environ["CODEBOOK_FIXTURE"], "cb.ttl", format="turtle")
graph = rdflib.Graph().parse("cb.ttl", format="turtle")

types = {str(t) for t in graph.objects(None, rdflib.RDF.type)}
project = {t for t in types if t.startswith(DDIGRAPH)}

print(len(types), "types,", len(project), "of them project-namespace")

Most will be project-namespace, and that is expected: DISCO covers 16 classes against DDI's ~250. The published terms carry the concepts other people care about — studies, variables, questions, code lists — and the rest are still recorded rather than dropped.

Check yourself

  • Why is the subject IRI a DDI URN rather than a URL under your domain?
  • What breaks if you emit only the standard rdf:type?
  • Why does the exporter reverse the direction of HAS_CATEGORY?

Next: Proving it is right — checking an export against machine-readable rules.