The Semantic Layer, From ScratchChapter 9
Chapter 9

Federation: where the magic happens

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.

9.1The slow way (do this once, so you feel it)

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.

9.2Who knows that customers are in Aurora and purchases are in Redshift?

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:

1. Ontology (T-Box): MEANING :Customer, :Order, :purchasedBy, :purchasePrice (decimal), :address, :state … Knows what a customer is. Has no idea Aurora or Redshift exist. Lives in the named graph <urn:stardog:training-c360:1.0:model> (Chapters 5 and 6). 2. Virtual graph definitions, stored in Stardog: WHERE and HOW virtual://aurora_c360_safe data source aurora_c360 (JDBC → Aurora) mapping: :Customer ⇐ c360.customer, :Address ⇐ … virtual://redshift_c360 data source redshift_c360 (JDBC → Redshift) mapping: :Order, :purchasedBy ⇐ c360.purchase … 3a. Aurora: ROWS customer, address, credit_card, rewards_account never heard of Redshift or the ontology 3b. Redshift: ROWS purchase, product, category, vendor cid is just an integer here TO { … } uses ontology terms FROM SQL { … } FROM SQL { … }
Figure 9.1: Meaning, location, rows. Only the middle layer knows that :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.

9.3The Redshift mapping and the line that matters

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:

9.4Where exactly does SPARQL get converted to SQL?

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:

  1. Parse. The SPARQL text becomes an algebra tree: triple patterns, joins, a group, an order, a limit.
  2. Route each triple pattern to a mapping. Which mapping blocks have a matching pattern in their 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).
  3. Group by source. Patterns answerable by the same virtual graph are bundled, because one SQL statement can answer the bundle. Bundled patterns that share a variable built from matching templates (customer :address ↔ address subject) become a SQL JOIN.
  4. Rewrite each bundle into the source's SQL dialect. Stardog's virtual-graph engine is built on Apache Calcite, an open-source SQL parser and query-algebra framework. Stardog uses it to parse your mapping's 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.
  5. Plan the rest. Everything that can run inside one database is packed into one SQL query for that database. Whatever needs results from both databases (the join across them, and the 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.

How Stardog knows which graphs to look in: GRAPH vs virtual transparency

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.

9.5Sometimes Aurora, sometimes Redshift, sometimes both

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):

Aurora only: customers by state

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 5

Every 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).

Redshift only: top products by units sold

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 10

All 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.

Both: top spenders with their state

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 10

Now ?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.

9.6Where the data lands, and how the answer is rolled up

No database ever joins Aurora to Redshift. Stardog does, in memory, for the duration of one query. Press Play to follow it.

You (Studio) Stardog planner Stardog executor (memory) Aurora Redshift 1SPARQL query 2route each pattern to a mapping block 3split: 4 Redshift patterns | 3 Aurora patterns 4rewrite each bundle to SQL (Calcite, pushdown) 5plan: 2 SQL leaves + join 6SQL → purchase: cid, price, quantity scan 2,500 rows 7SQL → customer ⋈ address join 500 × 500 82,500 rows (cid, price, quantity) 9500 rows (cid, name, state) 10cid → customer:{cid} on both sides; hash join on ?c: THE FEDERATION in Stardog's memory, never stored 11SUM(price×qty) per ?c; sort; LIMIT 10 1210 rows: Carolyn Inworth · Wisconsin · 502529.42 … SQL is illustrative. Steps 6 and 7 may run concurrently, or one side's keys may be fed into the other's SQL (a bind join).

Press ▶ Play to watch one query travel from Studio to both databases and back, step by step.

Figure 9.2: One federated query, start to finish. Aurora and Redshift never talk to each other; the join is step 10, inside Stardog.

Step 10 answers "without a JOIN across two engines, how does the data come together?"

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.

1 At rest Aurora disk 500 customers 500 addresses Redshift disk 2,500 purchases 2 On the wire JDBC over TLS 500 rows (cid, name, state) 2,500 rows (cid, price, qty) 3 Stardog memory hash table: 500 × ?c → name, state probe with 2,500 purchase rows = 2,500 joined bindings 4 Rolled up GROUP BY ?c ≤ 500 totals ORDER BY DESC(?spend) LIMIT 10 5 To you HTTPS 10 rows then memory is released Nothing from stages 2 to 4 is saved in kit-c360. Run the query again and it all happens again, live.
Figure 9.3: Where the rows physically are, stage by stage, for the top-spenders query.

The trade-offs, honestly

ConcernWhat it means here
Data volume on the wireAnything 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.
PushdownSingle-source work runs in the database; cross-source joins and anything above them run in Stardog. Query shape decides (next section).
LatencyAt least the slower source's time plus transfer. Our Aurora adds 15 to 20 seconds after a pause.
CostYou pay each source's compute per query (Redshift bills a 60-second minimum per wake-up) and Stardog's memory for the join.
FreshnessThe upside for all of the above: every run reads live data. No pipeline, no copy.

9.7Helping pushdown: put single-source work inside one source

Why the SUM cannot go to Redshift in the 9.5 query

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 purchaseAurora customer + address
cidpriceqtycidnamestate
22510,479.0021225Carolyn InworthWisconsin
225199.7010
………

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 10

The 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.

The proof: how many rows reach Stardog

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.

VersionRedshift sends StardogAurora sends StardogInto 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 subqueryone total per customer (up to 500)500 customers~1,000
C. Also ORDER BY and LIMIT 10 inside the subquery10 totals10 customers20

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).

9.8Seeing the real 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:

9.9Why the template is the join key, and the gotchas to watch for

Everything above depends on two independent mapping files agreeing on a string pattern. That contract breaks quietly:

9.10Try it: create redshift_c360 and run the federated query (real build step 4)

Check yourself

Who knows that customer names are in Aurora and purchases are in Redshift?

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.

Where exactly is SPARQL turned into SQL?

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.

A query has no 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.

Which databases receive SQL for (a) customers by state, (b) top products by units, (c) top spenders with state?

(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.

Where do the rows land, and why can't the 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.

What would you see if Redshift's mapping used 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.

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.