You already know how to ask a database a question. By the end of this chapter you will ask a graph the same questions, and watch the JOIN keyword quietly disappear into the arrows.
It is 4:55 on a Friday and someone from marketing leans over your desk: "Quick one. Which of our customers in Wisconsin spent the most?" You know the SQL: join purchase to customer on cid, customer to address on location, filter on the state, sum price times quantity, sort. You also know the catch: customers live in Aurora and purchases in Redshift, so "just join them" means an export, a staging table, or a pipeline. By Chapter 9 the graph will answer that question across both in one query. First you need the language the question is written in, and you already know most of it.
SPARQL (pronounced "sparkle"; the letters stand for SPARQL Protocol and RDF Query Language) is the W3C standard query language for RDF graphs: to triples what SQL is to tables. SPARQL 1.1 (2013) added aggregates, subqueries and a write half called SPARQL Update. Every serious graph database, Stardog included, speaks it.
Why not just use SQL? Because SQL assumes tables with a fixed list of columns, and a graph has neither. You could store triples in a three-column table, triples(subject, predicate, object), but then every arrow you follow is another self-join, and SQL has no idea that <urn:…customer:225> is a global name, that "2013-04-18"^^xsd:date is a typed value, or that triples live in named graphs (Chapter 3). SPARQL builds those ideas in and makes "follow this arrow" the basic move.
The "Protocol" part defines how to send a query over HTTP and get JSON or CSV back. Every Stardog database is a SPARQL endpoint, a URL that accepts queries; that is how the agent in Chapter 14 will talk to our graph.
| Term | What it is | Why it exists | Tiny example |
|---|---|---|---|
| SPARQL | The W3C query language for RDF | SQL has no notion of triples, IRIs or graphs | SELECT ?n WHERE { ?c :name ?n } |
| SPARQL endpoint | An HTTP URL that runs SPARQL | So any program can query the graph | your kit-c360 database |
| Triple pattern | A triple with some blanks | It describes the facts you are looking for | ?c :name ?n . |
| Variable | A named blank, starting with ? | It marks what the engine should fill in | ?c, ?n |
| Binding | The value a variable gets for one match | Each set of bindings is one result row | ?n = "Carolyn Inworth" |
| SPARQL Update | The write half of SPARQL | To add, remove and drop triples | INSERT DATA { … } |
In Chapter 2 you learned that every fact is a triple: subject predicate object. Carolyn's name is customer:225 :name "Carolyn Inworth". A triple pattern is the same shape with some positions replaced by variables:
?customer :name ?name .
Read it aloud: "find anything that has a :name; call the thing ?customer and the name ?name." For every triple whose predicate is :name, the engine binds the variables. One match, one row.
Press Play to watch the engine match the pattern, one triple at a time.
A full query wraps patterns in WHERE { … } and lists the variables you want back:
PREFIX : <tag:stardog:api:ecomm:>
SELECT ?customer ?name
WHERE {
?customer :name ?name .
}The PREFIX line is the shorthand from Chapter 3: :name expands to <tag:stardog:api:ecomm:name>. Every query in this book declares its prefixes, so each one runs on its own.
Now put three patterns together and reuse variables:
?customer :name ?name . ?customer :address ?addr . ?addr :city ?city .
Because ?customer appears in the first two patterns, both must match the same customer; because ?addr appears in the last two, the city must belong to that customer's address. That is the whole trick: a variable used in more than one pattern is a join condition. No JOIN, no ON. Here is the same question over the Aurora tables:
SELECT c.full_name, a.city FROM c360.customer c JOIN c360.address a ON a.id = c.location;
The SPARQL never mentions the key columns location and id. In the graph, :address is not a key to match but an arrow pointing straight at the address. You do not join on it, you follow it.
A variable is a placeholder, and a shared variable is a join.
Example: customer:225 :address ?addr . ?addr :city ?city joins on ?addr and returns "Milwaukee".
Named graphs are folders of triples (Chapter 3). A Stardog query with no GRAPH keyword reads only the default graph (unless the database option query.all.graphs is switched on; it is off by default and this book assumes it stays off). Our practice data lives in the folder <urn:book:ch04>, so every query in this chapter names it:
SELECT ?customer ?name
WHERE {
GRAPH <urn:book:ch04> {
?customer :name ?name .
}
}GRAPH <iri> { … } is SPARQL's nearest thing to the c360. in FROM c360.customer. GRAPH ?g { … } searches every folder, with ?g naming the one each match came from. In Chapter 9 this keyword is the heart of federation: one GRAPH block aimed at Aurora, another at Redshift, in one query.
The rest of SPARQL is clauses you already know, spelled differently.
| You want to… | SQL | SPARQL |
|---|---|---|
| Pick output columns | SELECT a, b | SELECT ?a ?b (no commas) |
| Name the source | FROM c360.customer | GRAPH <urn:…> { … } |
| Join | JOIN … ON x.k = y.k | Reuse a variable in two patterns |
| Filter rows | WHERE price > 1000 | FILTER (?price > 1000) inside WHERE |
| Keep rows with no match | LEFT JOIN | OPTIONAL { … } |
| Test for missing | WHERE phone IS NULL | FILTER (!BOUND(?phone)) |
| Remove duplicates | SELECT DISTINCT | SELECT DISTINCT |
| Aggregate | SUM(x) … GROUP BY k | (SUM(?x) AS ?total) … GROUP BY ?k |
| Filter groups | HAVING SUM(x) > 100 | HAVING (SUM(?x) > 100) |
| Sort and page | ORDER BY x DESC LIMIT 10 | ORDER BY DESC(?x) LIMIT 10 |
| Computed column | price * quantity AS spend | BIND(?price * ?qty AS ?spend) |
| Yes/no question | SELECT EXISTS (…) | ASK { … } |
| Insert rows | INSERT INTO … VALUES | INSERT DATA { GRAPH <g> { … } } |
| Delete rows | DELETE FROM … WHERE | DELETE DATA { … } |
| Drop a table | DROP TABLE t | DROP GRAPH <g> |
Two differences deserve a warning. There is no fixed column list: a thing has whatever triples it has, and a query asks only for the ones it names. And there is no NULL: a missing value is simply an absent triple. Section 4.7 shows what that does to LEFT JOIN, and Chapter 5 why it matters.
Row conditions go in a FILTER inside WHERE. Filters compare numbers, strings and dates, combine with &&, || and !, and call functions such as CONTAINS, STRSTARTS, REGEX and YEAR:
?order :purchasePrice ?price ;
:purchaseDate ?date .
FILTER (?price > 1000 && ?date >= "2015-01-01"^^xsd:date)Here Chapter 3's datatypes pay off. ?price > 1000 compares numerically only because the price is an xsd:decimal. Store it as the string "10479.00" and the comparison errors for that row, and the trap is that an error inside a filter does not stop the query: it counts as "not true", and the row silently disappears. (The semicolon is Turtle's "same subject, another predicate": two patterns sharing ?order, so a join.)
OPTIONAL keeps a row even when part of the pattern has no match, like LEFT JOIN:
?c a :Customer ;
:name ?name .
OPTIONAL { ?c :phone ?phone }With no phone triple, the row stays and ?phone is unbound: an empty cell in Studio. Unbound is not a value; ?phone = "" will not find it. Test with FILTER (!BOUND(?phone)).
A missing triple is not a NULL: without OPTIONAL, the whole row disappears.
Example: Marlane has no :phone, so ?c :phone ?p drops her; OPTIONAL { ?c :phone ?p } keeps her.
Aggregates work as in SQL (COUNT, SUM, AVG, MIN, MAX, and GROUP_CONCAT for string_agg), with one spelling rule: in SELECT an aggregate goes in parentheses with a name, (SUM(?price * ?qty) AS ?spend). That formula is the SUM(price * quantity) behind the book's headline number, Carolyn Inworth's $502,529.42. SELECT DISTINCT matters because following arrows multiplies rows: "cities of customers who ordered" gives one row per order without it. And put a LIMIT on exploratory queries; from Chapter 9 on, they reach a Redshift cluster that bills by the second.
Two more query forms: ASK returns only true or false ("does this shape exist anywhere?"), and CONSTRUCT returns new triples built from a template instead of a table.
Writes use SPARQL Update, and you need three statements: INSERT DATA adds literal triples (no variables; think INSERT … VALUES), DELETE DATA removes exactly the triples you list, and DROP GRAPH empties a whole named graph (how every scratch exercise cleans up).
The values below are real c360 rows from Aurora and Redshift; only the selection is ours. Two customers: Carolyn Inworth (cid 225, Milwaukee, Wisconsin) and Marlane Shadwick (cid 88, Brooksville, Florida), the number-two spender overall. Their addresses, two orders each, and the four products those orders point at. The IRIs are exactly the ones the virtual graphs will mint in Chapters 8 and 9. One deliberate edit: we left out Marlane's phone so the OPTIONAL exercise has a gap to find.
| Order (id, shortened) | Customer | Product | Date | Qty | Price | Price × qty |
|---|---|---|---|---|---|---|
| 363f9784… | Carolyn (225) | Wax Paper | 2013-04-18 | 21 | 10479.00 | 220,059.00 |
| 4d7483bc… | Carolyn (225) | Softball Cleats | 2019-02-22 | 10 | 199.70 | 1,997.00 |
| 433d33e7… | Marlane (88) | Tent | 2017-10-03 | 15 | 74.85 | 1,122.75 |
| 3552866c… | Marlane (88) | Lemonade | 2022-12-25 | 25 | 13749.75 | 343,743.75 |
Run each one and compare with the expected result. If yours differs, the difference is the lesson.
"Every customer and their name." ?customer a :Customer means "things of type Customer"; a is short for rdf:type, the star of Chapter 5.
PREFIX : <tag:stardog:api:ecomm:>
SELECT ?customer ?name
WHERE {
GRAPH <urn:book:ch04> {
?customer a :Customer ;
:name ?name .
}
}
ORDER BY ?nameExpected: 2 rows: …customer:225 Carolyn Inworth, then …customer:88 Marlane Shadwick.
"Which orders have a price above 1,000?"
PREFIX : <tag:stardog:api:ecomm:>
SELECT ?order ?date ?price
WHERE {
GRAPH <urn:book:ch04> {
?order a :Order ;
:purchaseDate ?date ;
:purchasePrice ?price .
FILTER (?price > 1000)
}
}
ORDER BY ?dateExpected: 2 rows: wax paper (2013-04-18, 10479.00) and lemonade (2022-12-25, 13749.75). Studio may print 10479.0; same decimal. Now change the filter to FILTER (?price > 1000 && ?date >= "2015-01-01"^^xsd:date) and add PREFIX xsd: <http://www.w3.org/2001/XMLSchema#> at the top: only the lemonade remains.
"For each order: who bought it, where do they live, what did they buy?" Zero join keywords.
PREFIX : <tag:stardog:api:ecomm:>
SELECT ?customerName ?city ?productName ?date
WHERE {
GRAPH <urn:book:ch04> {
?order :purchasedBy ?customer ;
:itemPurchased ?product ;
:purchaseDate ?date .
?customer :name ?customerName ;
:address ?addr .
?addr :city ?city .
?product :name ?productName .
}
}
ORDER BY ?customerName ?date| customerName | city | productName | date |
|---|---|---|---|
| Carolyn Inworth | Milwaukee | Wax Paper | 2013-04-18 |
| Carolyn Inworth | Milwaukee | Softball Cleats | 2019-02-22 |
| Marlane Shadwick | Brooksville | Tent | 2017-10-03 |
| Marlane Shadwick | Brooksville | Lemonade | 2022-12-25 |
Expected: 4 rows. In the finished system this exact shape spans Redshift (orders) and Aurora (customers); the query only cares that the IRIs agree. Bonus: SELECT DISTINCT ?city gives 2 rows instead of 4.
"Every customer, with a phone if we have one."
PREFIX : <tag:stardog:api:ecomm:>
SELECT ?name ?phone
WHERE {
GRAPH <urn:book:ch04> {
?c a :Customer ;
:name ?name .
OPTIONAL { ?c :phone ?phone }
}
}
ORDER BY ?nameExpected: 2 rows; Carolyn with 704-619-5453, Marlane with an empty phone cell. Now delete OPTIONAL { and its closing brace and rerun: 1 row, Carolyn only. That is Figure 4.3 on your screen.
"Orders and total spend per customer, biggest first": the headline question in miniature.
PREFIX : <tag:stardog:api:ecomm:>
SELECT ?name (COUNT(?order) AS ?orders) (SUM(?price * ?qty) AS ?spend)
WHERE {
GRAPH <urn:book:ch04> {
?order :purchasedBy ?customer ;
:purchasePrice ?price ;
:quantity ?qty .
?customer :name ?name .
}
}
GROUP BY ?name
ORDER BY DESC(?spend)| name | orders | spend |
|---|---|---|
| Marlane Shadwick | 2 | 344866.50 |
| Carolyn Inworth | 2 | 222056.00 |
Expected: 2 rows, Marlane first (Studio may show 344866.5 and 222056.0). Marlane beats Carolyn, the overall number one, because we loaded only two orders each. A total is only as complete as the data you queried: keep that thought for Chapter 5.
The W3C query language (and HTTP protocol) for RDF. In a three-column SQL table every hop is another self-join, and SQL knows nothing about IRIs, typed literals or named graphs. SPARQL makes "follow this arrow" the basic operation.
JOIN … ON a.k = b.k?Using the same variable in two or more triple patterns: all must match the same value, an equi-join. You follow the arrow instead of matching key columns.
?c :email ?email . to a working customer report and three customers disappear. Why, and how do you fix it?Every plain pattern is an inner join, so customers with no email triple drop out. Wrap it: OPTIONAL { ?c :email ?email }.
GRAPH keyword returns nothing, though you know the data is there. The likely cause?The triples are in a named graph, and without GRAPH Stardog reads only the default graph (query.all.graphs is off by default). Add GRAPH <urn:book:ch04> { … }, or GRAPH ?g { … } to search every named graph.
FILTER (?price > 1000) silently return nothing?When the prices are plain strings, not typed numbers. Comparing a string with a number is an error, and a filter error counts as false: the rows vanish silently.
GRAPH <iri> picks the folder. FILTER needs correct datatypes, and its errors silently drop rows.<urn:book:ch04>.