The Semantic Layer, From ScratchChapter 8
Chapter 8

Mappings: teaching Stardog to read your tables

How a few lines of mapping turn Aurora's customer rows into triples, and why we build two graphs instead of one.

Hand a new hire a printout of the customer table and ask what it means. They'll get most of it right by squinting at the headers: cid is probably an ID, location looks like a UUID pointing somewhere, ssn is scary. Stardog can't squint. After Chapter 7 it can reach Aurora, but it doesn't know which column is the key, which is a pointer, or which ontology property each feeds. A mapping is the note you'd staple to the printout: "run this SQL; for every row, produce these triples." You'll read one line by line, meet the IRI template that Chapter 9 depends on, and create your first two virtual graphs.

8.1What a mapping is

A mapping has an input, a SQL query, and an output, triple patterns with variables in them. For every row, Stardog fills the variables and emits the triples. For a virtual graph nothing is emitted in advance: the mapping is a recipe on file, and Stardog reads it backwards: when a query asks for ?c :email ?e, it looks up ":email comes from the email column of the customer SQL" and generates SQL to fetch just that.

Here is the smallest useful mapping, for Carolyn's email. It has three parts, each marked by a keyword:

MAPPING
FROM SQL {                                   # 1. input: a SQL query on Aurora
  SELECT cid, email FROM c360.customer
}
TO {                                         # 2. output: the triples to produce
  ?c :email ?email .
}
WHERE {                                      # 3. how to build the names
  BIND(TEMPLATE("urn:stardog:demos:c360:customer:{cid}") AS ?c)
}

For the row cid = 225, email = 'cinworth68@g.co', the FROM SQL part supplies the columns, the WHERE part turns 225 into the name customer:225, and the TO part produces the triple customer:225 :email "cinworth68@g.co". Section 8.3 goes through each part in detail.

So who knows that customers live in Aurora? Not the ontology (it only defines what :Customer means) and not Aurora (it's never heard of the ontology). The mapping does: it names ontology terms in its TO block and tables in its FROM SQL, and the virtual graph ties it to a data source. Chapter 9 draws this as three layers.

8.2Two languages: R2RML and SMS2

R2RML ("RDB to RDF Mapping Language") is the W3C standard for this job, portable between vendors but verbose: it is itself written as Turtle (rr:subjectMap, rr:predicateObjectMap, rr:column…), and a simple table takes twenty lines. Stardog reads it.

SMS2 (Stardog Mapping Syntax 2) is Stardog's own format: it looks like SPARQL's CONSTRUCT (Chapter 4) and also handles CSV and JSON. The docs recommend it, every mapping in this book uses it, and you select it with the virtual-graph option mappings.syntax=SMS2.

8.3The shape of an SMS2 mapping

An SMS2 file has a prologue (prefix declarations, exactly like Turtle and SPARQL) followed by one or more mapping blocks separated by semicolons. Each block has four parts:

MAPPING <urn:c360:aurora:customer> FROM SQL { SELECT cid, email, location … FROM c360.customer } TO { ?iri a :Customer ; :email ?email ; :address ?loc_iri . } WHERE { BIND(TEMPLATE("…customer:{cid}") AS ?iri) BIND(TEMPLATE("…location:{location}") AS ?loc_iri) } MAPPING a name for this block (any IRI) FROM SQL plain SQL in the source's dialect; each output column becomes ?column TO the triples to produce per row (like SPARQL CONSTRUCT) WHERE BIND new variables: IRIs via TEMPLATE, typed values via casts
Figure 8.1: The four parts of an SMS2 mapping block. Several blocks go in one file, separated by semicolons.

A mapping is written FROM SQL → TO → WHERE but runs FROM SQL → WHERE → TO. WHERE only names things (TEMPLATE) and types them (casts); compute everything else in the SQL.

Example: BIND(TEMPLATE("…customer:{cid}") AS ?c) turns cid 225 into customer:225.

8.4The customer mapping, line by line

We will map Aurora's data into two virtual graphs. aurora_c360_safe holds everything except the sensitive values: Carolyn's name, email, phone and address go here. aurora_c360_pii holds only the sensitive ones (PII, personally identifiable information): her SSN 640-83-5765 (fake data) and full card numbers. Keeping them apart lets you later give Marketing the safe graph and HR both (Section 8.7 explains why, Chapter 12 does it).

Here is the first block of aurora_c360_safe.sms, the mapping file for the safe graph (.sms is just the file extension for Stardog Mapping Syntax, like .sql for SQL), in full:

prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#>
prefix xsd: <http://www.w3.org/2001/XMLSchema#>
prefix : <tag:stardog:api:ecomm:>

MAPPING <urn:c360:aurora:customer>
FROM SQL { SELECT cid, first_name, last_name, email, phone, location, full_name FROM c360.customer }
TO {
  ?iri a :Customer ;
    :email ?email ; :firstName ?first_name ; :lastName ?last_name ;
    :phone ?phone ; :address ?loc_iri ; :name ?full_name ; rdfs:label ?full_name .
}
WHERE {
  BIND(TEMPLATE("urn:stardog:demos:c360:customer:{cid}") AS ?iri)
  BIND(TEMPLATE("urn:stardog:demos:c360:location:{location}") AS ?loc_iri)
}
LineWhat it does
prefix rdfs: …
prefix xsd: …
Short names for two standard vocabularies: rdfs:label (the human-readable name every tool displays) and the XML Schema datatypes used in casts (Chapter 3).
prefix : <tag:stardog:api:ecomm:>The c360 ontology's namespace: :Customer expands to <tag:stardog:api:ecomm:Customer>, the class you loaded in Chapter 5. Misspell it and the mapping still runs, but emits terms your ontology has never heard of.
MAPPING <urn:c360:aurora:customer>Names the block. Any unique IRI will do.
FROM SQL { SELECT cid, first_name, … FROM c360.customer }The "table" for this block. It lists columns explicitly and leaves out ssn and picture: this is the safe graph, so the SSN column is never even read. c360.customer resolves because the data source has sql.schemas=c360.
TO { … }The triples to emit per row.
?iri a :Customer ;Every row is a customer (a is rdf:type). The subject ?iri is built in the WHERE part below.
:email ?email ; :firstName ?first_name ; :lastName ?last_name ;Plain columns become datatype properties (Chapter 6) with literal values. Semicolons repeat the subject, as in Turtle.
:phone ?phone ; :address ?loc_iri ;:phone is another literal. :address is an object property: its value is an IRI built from the location column (?loc_iri). This is how a foreign key becomes an arrow to another node.
:name ?full_name ; rdfs:label ?full_name .The full name goes into the ontology's :name and the generic rdfs:label that Studio, Explorer and Voicebox display. The original kit built the label with CONCAT in a BIND, which a SQL source doesn't allow, so we use the full_name column.
WHERE { … }The derived variables. In the full file a ; follows, then the next MAPPING.
BIND(TEMPLATE("urn:stardog:demos:c360:customer:{cid}") AS ?iri)The IRI template. For cid = 225 it produces <urn:stardog:demos:c360:customer:225>, Carolyn's global name (Section 8.5).
BIND(TEMPLATE("urn:stardog:demos:c360:location:{location}") AS ?loc_iri)Same trick for the foreign key: Carolyn's ?loc_iri is <urn:stardog:demos:c360:location:b80ea2ce-2361-408a-b443-721594cbbb35>. The address mapping mints its subjects with the same template from address.id, so the arrow lands on the right node.
c360.customer, one row 225 Carolyn Inworth cinworth68@g.co 704-619-5453 b80ea2ce-2361-… cid full_name email phone location mapping <urn:c360:aurora:customer> TEMPLATE(…customer:{cid}) TEMPLATE(…location:{location}) urn:…:customer:225 a :Customer "cinworth68@g.co" :email "Carolyn Inworth" :name "704-619-5453" :phone urn:…:location:b80ea2ce-… a :Address (from the address mapping) :address "Milwaukee" :city "Wisconsin" :state Both nodes meet because the customer and address mappings use the same location template: the FK became an arrow. 225 full_name email phone location

Press ▶ Play to watch one row pass through the mapping and come out as triples.

Figure 8.2: Carolyn's row through the customer (and address) mappings, some properties omitted. Subjects in red, properties in green, literal values in purple.

8.5IRI templates: the most important line in the file

An IRI is RDF's globally unique name for a thing (Chapter 3). An IRI template is a string with {column} placeholders that turns a row's local key into such a name: cid = 225 means nothing outside the customer table, but urn:stardog:demos:c360:customer:225 means one customer anywhere. Three things matter in practice:

  1. A template is a primary key and a foreign key at once. Used as a subject (?iri in the customer mapping) it identifies the row. Used as an object (?loc_iri) it points at another row. location:{location} in the customer mapping and location:{id} in the address mapping produce the same string for the same UUID: the matching names are the join.
  2. Stardog can read a template backwards. Ask about <urn:stardog:demos:c360:customer:225>. The slow way would be to fetch all 500 customer rows, build a name for each (…customer:1 … …customer:500) and compare every one with yours. Instead, Stardog knows the template is …customer:{cid}, removes the fixed part, is left with 225, and sends Aurora SELECT … FROM c360.customer WHERE cid = 225: one row, using the database's index.
  3. Values are percent-encoded. By default (percent.encode=true) illegal IRI characters such as spaces are escaped: "Acme Sports" becomes …vendor:Acme%20Sports (Chapter 9's vendor mapping).

These are the exact IRIs the original c360 kit used, so anything written against the kit keeps working. And the Redshift mapping will mint the same customer IRI from purchase.cid; Chapter 9 is built on that fact.

The same template means the same node, and that is the join, even across databases.

Example: the customer and address mappings both build location:b80ea2ce…; Aurora and Redshift both build customer:225.

8.6The other Aurora mappings: address, card, rewards

The rest of aurora_c360_safe.sms uses the same four parts; each block adds one idea.

Address: computed values belong in the SQL

MAPPING <urn:c360:aurora:address>
FROM SQL {
  SELECT id, city, state, zip,
         street_number || ' ' || street_name || ' ' || street_suffix AS street_addr,
         street_number || ' ' || street_name || ', ' || state       AS label
  FROM c360.address
}
TO {
  ?iri a :Address ; :city ?city ; :streetAddress ?street_addr ; :zipCode ?zip ; :state ?state ; rdfs:label ?label .
}
WHERE { BIND(TEMPLATE("urn:stardog:demos:c360:location:{id}") AS ?iri) }

The ontology has one :streetAddress; the table has three street columns. Since BIND can't concatenate for SQL sources, the SELECT does it and the alias becomes a variable. The subject template, location:{id}, is where the customer's :address arrow lands.

Card: expose the fact, not the number

MAPPING <urn:c360:aurora:card>
FROM SQL { SELECT id, cid, card_type, card_type || ' ' || right(card_num, 4) AS label FROM c360.credit_card }
TO { ?cc a :CreditCard ; rdfs:label ?label ; :cardType ?card_type ; :cardHolder ?cust_iri . }
WHERE {
  BIND(TEMPLATE("urn:stardog:demos:c360:cards:{id}") AS ?cc)
  BIND(TEMPLATE("urn:stardog:demos:c360:customer:{cid}") AS ?cust_iri)
}

The safe graph knows a card's type and holder, and labels it with the type and last four digits (the usual "safe to display" convention). The full card_num is never exposed as a column, so no triple can carry it.

Rewards: an arrow in the other direction, and a cast

MAPPING <urn:c360:aurora:rewards>
FROM SQL { SELECT id, cid, account_id, create_date FROM c360.rewards_account }
TO {
  ?r a :RewardsAccount ; :openDate ?open_dt ; :accountId ?account_id ; rdfs:label ?account_id .
  ?cust :hasRewards ?r .
}
WHERE {
  BIND(TEMPLATE("urn:stardog:demos:c360:customer:{cid}") AS ?cust)
  BIND(TEMPLATE("urn:stardog:demos:c360:rewards:{id}") AS ?r)
  BIND(xsd:date(?create_date) AS ?open_dt)
}

Two new ideas. The TO block has two subjects: the row describes the account and emits ?cust :hasRewards ?r with the customer as subject. The foreign key lives on the rewards table, but the ontology's arrow points from customer to account (Chapter 6), and a mapping may emit arrows in whichever direction the ontology wants. And xsd:date(?create_date) is a cast: it types the literal as xsd:date, so date comparisons and YEAR() work (Chapter 3).

8.7Safe and PII: why two graphs

Aurora holds two things marketing should never see: SSNs and full card numbers. Rather than hide them later, the build splits Aurora into two virtual graphs over the same data source:

Here is the whole PII mapping. It's short on purpose:

# Virtual graph: aurora_c360_pii — ONLY the sensitive properties. Grant to hr_user, not marketing_user.
prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#>
prefix xsd: <http://www.w3.org/2001/XMLSchema#>
prefix : <tag:stardog:api:ecomm:>

MAPPING <urn:c360:aurora:pii:ssn>
FROM SQL { SELECT cid, ssn FROM c360.customer }
TO { ?iri :ssn ?ssn . }
WHERE { BIND(TEMPLATE("urn:stardog:demos:c360:customer:{cid}") AS ?iri) }
;
MAPPING <urn:c360:aurora:pii:card>
FROM SQL { SELECT id, card_num FROM c360.credit_card }
TO { ?cc :cardNumber ?card_num . }
WHERE { BIND(TEMPLATE("urn:stardog:demos:c360:cards:{id}") AS ?cc) }

The PII graph declares no types, names or addresses; it only adds facts to IRIs the safe graph describes, using the same templates. Read both graphs and you see one customer with an SSN; read only the safe graph and the SSN doesn't exist.

data source aurora_c360 one connection, one password virtual://aurora_c360_safe customer:1 :name "Jolynn Bithany" customer:1 :address location:… cards:… rdfs:label "type + last 4" no :ssn, no :cardNumber anywhere virtual://aurora_c360_pii customer:1 :ssn "609-38-5573" cards:… :cardNumber "…" same IRIs, only the sensitive facts marketing_user: safe only hr_user: safe + PII
Figure 8.3: One data source, two virtual graphs. The shared templates let the PII graph add facts to the same customer nodes. (Customer 1 and the SSN are fake data.)

The payoff comes in Chapter 12: named-graph security grants read access per graph, so marketing_user gets virtual://aurora_c360_safe only and hr_user gets both. (Chapter 12 also compares a property-level alternative, sensitive properties with masking.)

8.8Try it: create the two Aurora virtual graphs (real build step 3)

The graphs you create here stay. Aurora is probably paused, so wake it with any DBeaver query first (or expect the first Stardog query to take 15 to 20 seconds).

Optional, for automation. The same step can be done from the command line, without Studio: one POST /admin/virtual_graphs request per graph. This is what you would put in a setup script. You don't need it for the book; skip it.

# Reference only. Run from the folder holding the .sms files; needs jq.
jq -n --rawfile m aurora_c360_safe.sms \
  '{name:"aurora_c360_safe", data_source:"aurora_c360", db:"kit-c360",
    mappings:$m, options:{"mappings.syntax":"SMS2"}}' \
| curl -s -u "stardog_admin:$STARDOG_PASSWORD" -H 'Content-Type: application/json' \
       -d @- https://<your-endpoint>.stardog.cloud:5820/admin/virtual_graphs

8.9How it works: small mappings in, one SQL query out

You now have separate mapping blocks for customers, addresses and rewards accounts. None of them knows about the others, and none contains a JOIN. So what happens when one query needs all three? Take this question: which Wisconsin customers have a rewards account, with their city and account ID?

PREFIX : <tag:stardog:api:ecomm:>
SELECT ?fullname ?city ?accountId
WHERE {
  GRAPH <virtual://aurora_c360_safe> {
    ?c :name ?fullname ; :address ?a ; :hasRewards ?r .
    ?a :state "Wisconsin" ; :city ?city .
    ?r :accountId ?accountId .
  }
}

It returns 8 rows (Christine Ricardon has 3 accounts, Renaud Brosio and Sephira McKleod 2 each, Corbin Mandel 1). Carolyn is missing because she has no rewards account. The interesting part is what Stardog sent to Aurora. This is its real plan for that query:

SELECT rewards_account.account_id, customer.full_name, address.city
FROM c360.rewards_account
INNER JOIN c360.customer ON rewards_account.cid = customer.cid
INNER JOIN c360.address  ON customer.location   = address.id
                         AND address.state = 'Wisconsin'

One SQL query, with two JOINs Stardog wrote itself. Step by step:

  1. Find the mappings. For each line of the query, Stardog finds the mapping block whose TO produces that property: :name and :address from the customer block, :state and :city from the address block, :accountId from the rewards block.
  2. Notice they share a source. All three blocks read from the same data source, aurora_c360, so one SQL statement can cover them all.
  3. Turn shared variables into JOINs. ?c appears in the customer and rewards lines, and both mappings build it from cid, so Stardog writes rewards_account.cid = customer.cid. ?a links customer and address, both built from the location ID, so it writes customer.location = address.id. It joins on the real key columns, never on the long IRI strings.
  4. Push the filter down. "Wisconsin" becomes address.state = 'Wisconsin' inside the SQL, so Aurora uses its own indexes and returns only 8 rows.
  5. Turn rows back into answers. Each result row fills ?fullname, ?city and ?accountId.

Check yourself

What are the four parts of an SMS2 mapping block, and what does each do?

MAPPING names the block; FROM SQL is the source query whose columns become variables; TO lists the triple patterns emitted per row; WHERE binds new variables with TEMPLATE (IRIs) and casts (typed literals). Blocks are separated by semicolons after a prefix prologue.

How does the location column in customer become an arrow to an address node?

The customer mapping builds ?loc_iri with TEMPLATE("urn:stardog:demos:c360:location:{location}") and emits ?iri :address ?loc_iri. The address mapping mints its subjects with the same template from address.id. Same value, same template, same IRI: no join condition needed.

Why does the address mapping concatenate the street in SQL instead of using CONCAT in a BIND?

For SQL sources, BIND in SMS2 supports only TEMPLATE, datatype casts and strdt. Any other computation has to be done in the SELECT and exposed as an aliased column.

The rewards table has the cid column, yet the mapping emits ?cust :hasRewards ?r with the customer as subject. Why is that allowed?

A mapping can emit triples with any subject it can build. Direction follows the ontology (:hasRewards goes from customer to account), not which table holds the foreign key.

Why split Aurora into a safe graph and a PII graph instead of one graph?

Stardog's named-graph security grants read access per graph, so a sensitivity split at mapping time makes "marketing can't see SSNs" a single grant (Chapter 12). Because both graphs use the same IRI templates, anyone allowed to read both sees one customer node with all properties.

What would happen if the PII mapping used urn:c360:person:{cid} instead of urn:stardog:demos:c360:customer:{cid}?

It would produce SSN triples on different subjects, so step 6's query would return nothing: no IRI would have both a :name and an :ssn. Matching templates are the whole mechanism.

Chapter recap

Paste it to Claude with what you saw (the counts or results), so we can confirm it matches before the next build step relies on it.