Customers in Aurora, purchases in Redshift, one query. Who knows where the data lives, where SPARQL turns into SQL, and where the two halves finally meet.
Monday, 9:04 a.m. A message from your VP: "Who are our top spenders, and what state are they in? Need it for the 10 o'clock." Easy, you think, and open DBeaver. Then it sinks in. Names and states live in Aurora; purchases live in Redshift; no SQL statement you can type joins across both. You can answer the question, the slow way. By the end of this chapter you'll answer it with one query, and you'll be able to point at the exact place, inside Stardog, where the two databases meet.
Here's the SQL-only route. First Redshift, which knows spend per customer number:
-- Redshift (dev.c360) SELECT cid, SUM(price * quantity) AS spend FROM c360.purchase GROUP BY cid ORDER BY spend DESC LIMIT 3;
Top row: 225, 502529.42. Redshift has no idea who 225 is. So you copy the three cids out of the result grid and paste them into a second query, on Aurora:
-- Aurora (c360.c360). Paste the cids from the Redshift result. SELECT c.cid, c.full_name, a.state FROM c360.customer c JOIN c360.address a ON a.id = c.location WHERE c.cid IN (225 /*, second cid, third cid */);
Then you match the two grids by eye. That last step is a join, and you are the join engine. It works for three rows. It doesn't work for "spend per state across all 500 customers", it can't be handed to an AI agent, and the usual ways out (a pipeline, Redshift federated query, a lake) all move data or bolt the two systems together. The rest of the chapter makes that go away.
Not the ontology, and not either database: the mappings, stored in Stardog as part of each virtual graph's definition. Here are the three layers you've built:
:purchasedBy comes from Redshift and :name from Aurora.Each virtual graph is a name + a data source + a mapping, saved on the Stardog server (GET /admin/virtual_graphs lists them). The data source says which database (a JDBC connection, Chapter 7). The mapping's TO block says which ontology terms this graph can produce, and its FROM SQL says from which table. So when a query asks for a property, Stardog looks through the mappings for a TO block that produces it. You have already seen this work: your query for :name and :hasRewards was answered from the customer and rewards blocks of aurora_c360_safe, because those are the only mappings that produce those properties. Once you create redshift_c360 in this chapter, a query for :purchasedBy will be answered from its purchase block in the same way.
Redshift needs its own virtual graph. The full redshift_c360.sms is in the Try box; here's the purchase block, the one that makes federation possible:
MAPPING <urn:c360:redshift:purchase>
FROM SQL {
SELECT id, pid, "date" AS pdate, "time" AS ptime, quantity, price, cid, card, reward_account,
CAST("date" AS varchar) || ' ' || "time" || ' - $' || CAST(price AS varchar) AS label
FROM c360.purchase
}
TO {
?iri a :Order ; rdfs:label ?label ;
:purchasePrice ?p ; :quantity ?q ; :purchaseTime ?ptime ; :purchaseDate ?dt ;
:purchasedBy ?cust_iri ; :itemPurchased ?product ; :cardUsed ?cc ; :rewardsAccountUsed ?rewardsIri .
}
WHERE {
BIND(TEMPLATE("urn:stardog:demos:c360:purchase:us:pos{id}") AS ?iri)
BIND(TEMPLATE("urn:stardog:demos:c360:customer:{cid}") AS ?cust_iri)
BIND(TEMPLATE("urn:stardog:demos:c360:product:{pid}") AS ?product)
BIND(TEMPLATE("urn:stardog:demos:c360:cards:{card}") AS ?cc)
BIND(TEMPLATE("urn:stardog:demos:c360:rewards:{reward_account}") AS ?rewardsIri)
BIND(xsd:decimal(?price) AS ?p)
BIND(xsd:integer(?quantity) AS ?q)
BIND(xsd:date(?pdate) AS ?dt)
}Chapter 8's tools read all of it: each row is an :Order; four foreign keys become four arrows; the label is concatenated in SQL; three casts pin the datatypes. The quoted "date" and "time" and the xsd:decimal each have a story (Section 9.9). But one line is the whole chapter:
BIND(TEMPLATE("urn:stardog:demos:c360:customer:{cid}") AS ?cust_iri) -- Redshift, purchase block
BIND(TEMPLATE("urn:stardog:demos:c360:customer:{cid}") AS ?iri) -- Aurora, customer block (Chapter 8)Character for character, the same template. Redshift's purchase.cid = 225 and Aurora's customer.cid = 225 both become the IRI <urn:stardog:demos:c360:customer:225>, and in RDF a node is its IRI (Chapter 3). So Aurora's facts about customer 225 and Redshift's orders "purchased by" customer 225 are facts about one and the same node. In Redshift, purchase.cid is a plain integer column: no foreign key can point from Redshift to a table in Aurora. The cross-engine "join" is the shared IRI template instead.
The same file, redshift_c360.sms, has three more blocks after the purchase block: one each for products, categories and vendors. Here they are in full:
MAPPING <urn:c360:redshift:product>
FROM SQL { SELECT id, name, brand, price, dept, description, '$' || CAST(price AS varchar) AS price_label FROM c360.product }
TO {
?iri a :Product ; :inCategory ?cat_iri ; :description ?description ; :msrp ?up ;
:vendor ?vendor_iri ; :name ?name ; rdfs:label ?name .
?up a :UnitPrice ; rdfs:label ?price_label ; :hasCurrency "USD" ; :hasPrice ?pf .
}
WHERE {
BIND(TEMPLATE("urn:stardog:demos:c360:product:{id}") AS ?iri)
BIND(TEMPLATE("urn:stardog:demos:c360:category:{dept}") AS ?cat_iri)
BIND(TEMPLATE("urn:stardog:demos:c360:vendor:{brand}") AS ?vendor_iri)
BIND(TEMPLATE("urn:stardog:demos:c360:unitprice:{id}") AS ?up)
BIND(xsd:float(?price) AS ?pf)
}
;
MAPPING <urn:c360:redshift:category>
FROM SQL { SELECT id, dept_name FROM c360.category }
TO { ?iri a :Category ; :name ?dept_name ; rdfs:label ?dept_name . }
WHERE { BIND(TEMPLATE("urn:stardog:demos:c360:category:{id}") AS ?iri) }
;
MAPPING <urn:c360:redshift:vendor>
FROM SQL { SELECT id, vendor_name, industry, homepage FROM c360.vendor }
TO {
?iri a :Vendor ; :industry ?ind ; :homepage ?homepage ; :name ?vendor_name ; rdfs:label ?vendor_name .
?ind a :Industry ; :name ?industry ; rdfs:label ?industry .
}
WHERE {
BIND(TEMPLATE("urn:stardog:demos:c360:vendor:{vendor_name}") AS ?iri)
BIND(TEMPLATE("urn:stardog:demos:c360:industry:{industry}") AS ?ind)
}They use the same ideas you already know (FROM SQL, TO, TEMPLATE, casts). Three details are new:
brand column holding the vendor's name; the vendor table has a vendor_name column. Both mappings build the vendor's IRI from that name, so they meet on the same node. Product 1, a Ring Light with brand Neewer, gives vendor:Neewer; the vendor row named Neewer gives vendor:Neewer too.%20 (this is called percent-encoding, the same thing a browser does in URLs). The brand Charmin Ultra Soft becomes vendor:Charmin%20Ultra%20Soft in both mappings, so they still match.xsd:float. The Ring Light's list price, 59.99, is only ever shown, never added up, so a float's tiny rounding error can't accumulate. Purchase prices, which are added up, use xsd:decimal (you'll see the cents drift otherwise, in the gotchas below).Inside the Stardog server's query planner, before a byte goes to AWS. Studio just sends text over HTTPS, and Aurora and Redshift only run the SQL that comes out at the end. The planner, in order:
TO? ?o :purchasedBy ?c matches only the purchase block of redshift_c360; ?c :name ?name matches the customer block of aurora_c360_safe (layer 2 of Figure 9.1 at work).:address ↔ address subject) become a SQL JOIN.FROM SQL, combine it with the query's needs, and print SQL in Postgres or Redshift flavor. You have seen Calcite at work twice already. First, the error described in Section 7.5, Object 'customer' not found within 'c360': Calcite works from its own list of tables, and by default that list covers only the database's public schema. Our tables live in a schema named c360 (inside a database that is also named c360, which makes the message confusing), so until the data source setting sql.schemas=c360 adds that schema to the list, Calcite cannot find customer even though the table exists. Second, the query plan in Section 8.9: lines such as LogicalJoin and JdbcTableScan(table=[[c360, c360, customer]]) (database c360, schema c360, table customer) are Calcite's description of the SQL it built.SUM, GROUP BY and ORDER BY over the joined rows) cannot be sent to either one, so Stardog does it itself, on top of the rows that come back. A query plan is drawn as an upside-down tree, and the SQL queries are its leaves, the bottom nodes where data comes in:
ORDER BY spend DESC, LIMIT 10 <- Stardog
|
GROUP BY customer, SUM(price x qty) <- Stardog
|
JOIN on the customer IRI <- Stardog (needs both sides)
+------+------+
SQL to Aurora SQL to Redshift <- the leaves: one query per database
(customer + address) (purchase)Step 4's "do as much as possible in the source" is pushdown (introduced in Section 7.3): moving computation to the data is almost always cheaper than moving data to the computation.
Our queries name their sources with GRAPH <virtual://…> { … }. That's explicit routing: the patterns inside may only match that virtual graph. By default, a query with no GRAPH block reads the local default graph and does not see virtual graphs at all; you'd get zero rows. With the database option virtual.transparency=true, Stardog adds every virtual graph the user may access to the query's dataset, so you can drop the GRAPH blocks and each pattern is matched against whichever graph can answer it. One subtlety from the docs: a GRAPH ?g { … } block still forces all its patterns to match within one graph, so patterns split across Aurora and Redshift return nothing inside a single GRAPH ?g.
We keep explicit GRAPH blocks in this book: they make the routing visible and Chapter 12's security demo easy to reason about, and Chapter 14's agent uses them too.
Routing is per pattern, so the same endpoint sends SQL to one database, the other, or both, depending on which terms your query uses. Three examples from the blog's sample questions (SQL shown is illustrative; Section 9.8 shows how to see the real text):
PREFIX : <tag:stardog:api:ecomm:>
SELECT ?state (COUNT(?c) AS ?n)
WHERE { GRAPH <virtual://aurora_c360_safe> { ?c a :Customer ; :address ?a . ?a :state ?state } }
GROUP BY ?state ORDER BY DESC(?n) LIMIT 5Every pattern routes to Aurora, so the whole thing, join, group, sort and limit, is pushed down as one statement, roughly SELECT a.state, COUNT(*) FROM customer c JOIN address a ON c.location = a.id GROUP BY a.state ORDER BY 2 DESC LIMIT 5. Redshift stays asleep. Five rows cross the wire (Texas 53, California 51, Florida 48, …; you ran this in Chapter 8).
PREFIX : <tag:stardog:api:ecomm:>
SELECT ?product (SUM(?qty) AS ?units)
WHERE { GRAPH <virtual://redshift_c360> { ?o :itemPurchased ?p ; :quantity ?qty . ?p :name ?product } }
GROUP BY ?product ORDER BY DESC(?units) LIMIT 10All patterns route to Redshift. The purchase-to-product hop matches templates (product:{pid} ↔ product:{id}), so it becomes a SQL join, roughly SELECT p.name, SUM(u.quantity) FROM purchase u JOIN product p ON u.pid = p.id GROUP BY p.name ORDER BY 2 DESC LIMIT 10. Aurora stays paused.
PREFIX : <tag:stardog:api:ecomm:>
SELECT ?name ?state (SUM(?price * ?qty) AS ?spend)
WHERE {
GRAPH <virtual://redshift_c360> {
?o a :Order ; :purchasedBy ?c ; :purchasePrice ?price ; :quantity ?qty .
}
GRAPH <virtual://aurora_c360_safe> {
?c :name ?name ; :address ?a .
?a :state ?state .
}
}
GROUP BY ?c ?name ?state
ORDER BY DESC(?spend)
LIMIT 10Now ?c appears in both blocks. Chapter 4 said "a shared variable is a join"; here the shared variable crosses two databases. Stardog sends one SQL statement to each side:
-- Illustrative: to Redshift. The mapping's SQL becomes a subquery; only needed columns are selected.
SELECT "cid", "price", "quantity"
FROM (SELECT id, pid, "date" AS pdate, "time" AS ptime, quantity, price, cid, card, reward_account,
CAST("date" AS varchar) || ' ' || "time" || ' - $' || CAST(price AS varchar) AS label
FROM c360.purchase) AS "t"
WHERE "cid" IS NOT NULL AND "price" IS NOT NULL AND "quantity" IS NOT NULL
-- Illustrative: to Aurora. The join condition location = id was derived from matching templates.
SELECT "t0"."cid", "t0"."full_name", "t1"."state"
FROM (SELECT cid, first_name, last_name, email, phone, location, full_name FROM c360.customer) AS "t0"
JOIN (SELECT id, city, state, zip, ... FROM c360.address) AS "t1" ON "t0"."location" = "t1"."id"And here's the catch. The SUM is grouped by name and state, from Aurora, over prices from Redshift. Neither database has both, so neither can compute it. That brings us to the heart of the chapter.
No database ever joins Aurora to Redshift. Stardog does, in memory, for the duration of one query. Press Play to follow it.
Press ▶ Play to watch one query travel from Studio to both databases and back, step by step.
Step 10 answers "without a JOIN across two engines, how does the data come together?"
kit-c360. (Very large joins may spill to temporary disk; still scratch space, not stored data.)cid goes through the purchase mapping's template and binds ?c to customer:225; each Aurora row's goes through the customer mapping's template to the same IRI. Because the templates are identical, Stardog can compare the underlying values rather than long strings, but logically it joins on the IRI.?c), then stream the 2,500 purchase rows through it. The planner can also choose a bind join: run one side first, then send its keys into the other side's SQL (think WHERE cid IN (225, …)), which wins when one side is tiny, such as "top spenders in Wisconsin". The docs show plans combining virtual graphs under a ServiceJoin operator; the strategy is the optimizer's call, and your plan (Section 9.8) tells you.SUM(?price * ?qty) per customer (on exact xsd:decimal values), sorts, and keeps ten. An aggregate grouped by Aurora's columns over Redshift's numbers can't be pushed to either source.Per database, Stardog sends one SQL query; across databases, Stardog joins the results itself.
Example: the Wisconsin query in Chapter 8 became one 3-table SQL JOIN on Aurora; top spenders needs a query to Aurora, one to Redshift, then a join in Stardog.
| Concern | What it means here |
|---|---|
| Data volume on the wire | Anything that can't be pushed down gets pulled: here one row per order. 2,500 rows is nothing; 2.5 billion would be a problem. |
| Pushdown | Single-source work runs in the database; cross-source joins and anything above them run in Stardog. Query shape decides (next section). |
| Latency | At least the slower source's time plus transfer. Our Aurora adds 15 to 20 seconds after a pause. |
| Cost | You pay each source's compute per query (Redshift bills a 60-second minimum per wake-up) and Stardog's memory for the join. |
| Freshness | The upside for all of the above: every run reads live data. No pipeline, no copy. |
First, where the rows land. When Redshift and Aurora answer their SQL, the rows travel over the network into Stardog's memory. They live there only while the query runs, for the join and the math; when the answer is sent back, the memory is freed. Nothing is copied into Stardog permanently.
Now the SUM. The 9.5 query ends with SUM(?price * ?qty) and GROUP BY ?c ?name ?state. Look at what each database actually holds for Carolyn:
Redshift purchase | Aurora customer + address | ||||
|---|---|---|---|---|---|
| cid | price | qty | cid | name | state |
| 225 | 10,479.00 | 21 | 225 | Carolyn Inworth | Wisconsin |
| 225 | 199.70 | 10 | |||
| … | … | … | |||
The query says "group by name and state", but Redshift has no name or state columns; it has never heard of "Carolyn Inworth". The grouping key only exists after each Redshift row has been joined with its Aurora row. So the steps must be: fetch all 2,500 purchase rows, join them to Aurora inside Stardog, and only then sum. The SUM is stuck in Stardog.
The fix below groups by ?c (the cid) instead, inside a subquery that touches only Redshift. Redshift does have cid, so it can do the sum itself (SELECT cid, SUM(price * quantity) FROM purchase GROUP BY cid, giving 225 → 502,529.42), and Stardog attaches the name and state afterwards, one Aurora row per total.
You can restructure the query so most of the work is single-source: sum per customer inside Redshift first, then join the totals to Aurora:
PREFIX : <tag:stardog:api:ecomm:>
SELECT ?name ?state ?spend
WHERE {
{
SELECT ?c (SUM(?price * ?qty) AS ?spend)
WHERE {
GRAPH <virtual://redshift_c360> {
?o :purchasedBy ?c ; :purchasePrice ?price ; :quantity ?qty .
}
}
GROUP BY ?c
}
GRAPH <virtual://aurora_c360_safe> {
?c :name ?name ; :address ?a .
?a :state ?state .
}
}
ORDER BY DESC(?spend)
LIMIT 10The subquery touches only Redshift, so Stardog can push the whole GROUP BY into it (roughly SELECT cid, SUM(price * quantity) FROM purchase GROUP BY cid), and Redshift returns about one row per customer instead of one per order. Same answer, far fewer rows. Whether your version pushes this aggregate is what the plan will tell you.
All three versions below return the same top spenders (Carolyn Inworth, Wisconsin, $502,529.42 first). What differs is how many rows travel from the databases into Stardog. Redshift always reads all 2,500 purchase rows internally, because it has to add them up; the question is how much of that leaves Redshift.
| Version | Redshift sends Stardog | Aurora sends Stardog | Into Stardog |
|---|---|---|---|
| A. The plain federated query (Figure 9.2) | 2,500 purchase rows (cid, price, quantity) | 500 customers | ~3,000 |
B. This section: GROUP BY inside the subquery | one total per customer (up to 500) | 500 customers | ~1,000 |
C. Also ORDER BY and LIMIT 10 inside the subquery | 10 totals | 10 customers | 20 |
These are the real SQL statements Redshift receives for B and C (from Stardog's query plan):
-- B: the sum is pushed down SELECT cid, SUM(price * quantity) AS spend FROM c360.purchase GROUP BY cid -- C: the sum, the sort and the limit are all pushed down SELECT cid, SUM(price * quantity) AS spend FROM c360.purchase GROUP BY cid ORDER BY SUM(price * quantity) DESC LIMIT 10
Version C only needs one change to the query: move ORDER BY DESC(?spend) LIMIT 10 inside the subquery, after its GROUP BY ?c. The plan then also changes the join: instead of matching against all 500 customers, Stardog takes the 10 customer IDs that came back from Redshift and looks up only those 10 in Aurora (a bind join, shown as ServiceJoin in the plan).
Figure 9.2 shows how Stardog works in general; your endpoint can show what it actually does. In Studio's Workspace, the tree icon next to Run shows a query's plan without running it; its dropdown has Run Profiler, which runs the query and shows the plan with timings. Note that Studio adds its own safety limit of 1,000 rows when you don't write one, so you may see FETCH NEXT 1000 ROWS ONLY in the SQL. The CLI equivalent is stardog query explain. In the plan text, look for:
VirtualGraphSql<virtual://…>: one per SQL statement sent to a source. Query= is the exact SQL; Vars= shows columns becoming variables, e.g. ?c <- TEMPLATE(urn:stardog:demos:c360:customer:{cid}). Both leaves binding ?c with the same template is the IRI trick in the machine's own words.HashJoin(?c) or a ServiceJoin): the variable is the join key.[#N]: the optimizer's row estimate for each node.Everything above depends on two independent mapping files agreeing on a string pattern. That contract breaks quietly:
urn:c360:buyer:{cid} where Aurora builds urn:stardog:demos:c360:customer:{cid}. To us it is obviously the same person; to Stardog they are two different names, so they never map onto each other. The most common federation bug.Aurora customer mapping: urn:stardog:demos:c360:customer:225 Redshift purchase mapping: urn:c360:buyer:225 <- different string, different node
The top-spenders query joins on ?c. Redshift offers …buyer:225, …buyer:88…; Aurora offers …customer:225, …customer:88…. No value appears on both sides, so the query returns 0 rows: valid, no error, just empty. The SQL version of the same bug is one table storing 225 and the other 'C-225': JOIN ON a.cid = b.cid runs fine and returns nothing. If a federated query comes back empty, ask each graph for its ?c values separately and compare.
225 vs 0225 vs C-225 give different IRIs. Normalize in the mapping's SQL.urn:node:{id} for both customers and products would merge unrelated things (the docs' optimization page has an example with actors and movies). That's why every c360 template names its type.redshift_c360 and run the federated query (real build step 4)The virtual graph definitions stored in Stardog: a data source (which database) plus a mapping (which ontology terms come from which tables). Not the ontology, which only defines meaning, and not the databases.
In the Stardog server's query planner: parse, route each triple pattern to the mapping blocks whose TO can produce it, bundle per source, and rewrite each bundle into that source's SQL dialect with Apache Calcite, pushing down as much as it can.
GRAPH blocks and returns zero rows, though the virtual graphs exist. Why?By default, virtual graphs aren't in a query's dataset. Either name them with GRAPH <virtual://…> or enable the database option virtual.transparency, which adds all accessible virtual graphs.
(a) Aurora only, fully pushed down. (b) Redshift only, fully pushed down. (c) Both: one statement each; the join, the SUM, the sort and the limit happen in Stardog.
SUM be pushed to Redshift in the 9.5 query?They stream into Stardog's memory as bindings for the life of the query; nothing is stored. The SUM is grouped by name and state, which only Aurora has, so it must follow the cross-source join. The 9.7 rewrite groups by ?c inside a Redshift-only subquery, which can be pushed.
urn:c360:buyer:{cid}?No error and zero federated rows: no IRI would appear in both graphs, so the join on ?c matches nothing. Templates are the contract.
GRAPH blocks or turn on virtual.transparency."date"/"time"; cast with xsd:integer/xsd:date; money as xsd:decimal (float drifted 2 cents).
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.