Query
The query engine lives in src/query/. It answers SPARQL 1.1 by translating each query into one SQL statement over _pgrdf_quads and _pgrdf_dictionary, which PostgreSQL then plans and runs. The user-facing surface, including what is and isn't supported, is documented under SPARQL.
Entry points
| Function | Returns | Handles |
|---|---|---|
sparql(q) | SETOF jsonb | SELECT, ASK, every UPDATE form |
construct(q) | SETOF jsonb | CONSTRUCT |
describe(q) | SETOF jsonb | DESCRIBE |
sparql_parse(q) | jsonb | the parsed shape, including unsupported_algebra |
sparql_sql(q) | text | the translated SQL, for inspection |
last_call_stats() | jsonb | completeness figures for this session's last query |
A DESCRIBE sent to sparql is refused with a pointer to describe, and describe refuses anything that is not a DESCRIBE, so the caller's intent is explicit at the SQL boundary.
Pipeline
SPARQL text
│ spargebra::SparqlParser::parse_query (parse_update if that fails)
▼
SPARQL algebra
│ executor.rs: walk the algebra
▼
internal plan: BGP triples, FILTERs, OPTIONAL / MINUS / UNION blocks,
VALUES, BINDs, a graph scope per pattern, modifiers
│ BIND substitution pass
│ one of four SQL builders: single-branch, UNION, aggregate,
│ aggregate over UNION
▼
SQL text with $N parameters + dictionary ids for the constants
│ plan cache: prepare once per backend per SQL text
▼
SPI execute ──► one JSONB object per rowparser.rs backs sparql_parse and the "can this be translated" checks. executor.rs holds the translator and every execution path. path.rs owns all property-path SQL; the executor only calls into it. guc.rs registers the pgrdf.* settings.
Result rows. SELECT rows are JSONB objects keyed by variable name. Every value is the term's lexical form as a string (numbers too), and an unbound variable is JSON null. ASK returns {"_ask": "true"} or {"_ask": "false"}; UPDATE returns one {"_update": {…}} summary row.
Translating a basic graph pattern
Each triple pattern becomes an alias of _pgrdf_quads (q1, q2, …). The first occurrence of a variable records its anchor (alias, column). Later occurrences become equality predicates against the anchor (q2.subject_id = q1.subject_id), which is how shared variables turn into joins. Constants in any position are resolved to dictionary ids before execution and bound as $N parameters, so user-supplied IRIs and literals never appear in the SQL text.
A constant that is not in the dictionary resolves to an id no row carries, so the pattern matches nothing, which is the correct SPARQL answer. sparql_sql shows the translation with the ids inlined:
SELECT pgrdf.sparql_sql('PREFIX ex: <http://example.com/>
SELECT ?s WHERE { ?s ex:nope ?o }');
-- SELECT (SELECT lexical_value FROM pgrdf._pgrdf_dictionary
-- WHERE id = q1.subject_id) AS "s"
-- FROM pgrdf._pgrdf_quads q1 WHERE q1.predicate_id = -1Translation by construct
| SPARQL | SQL | Notes |
|---|---|---|
| multi-pattern BGP | q1 INNER JOIN q2 ON … | shared variables become join predicates |
FILTER =, !=, sameTerm, IN | dictionary-id comparison | sound because the dictionary deduplicates on (type, lexical form, datatype, language) |
FILTER <, >, <=, >= | CASE WHEN datatype_iri_id IN (…numeric XSD types…) THEN lexical_value::numeric END | a non-numeric operand yields NULL, so the row drops |
FILTER REGEX | ~, or ~* with the i flag | against lexical_value |
FILTER BOUND | IS NOT NULL | meaningful for OPTIONAL variables |
OPTIONAL { … } | LEFT JOIN LATERAL (SELECT … ) qOPT ON TRUE | the whole group binds or none of it does; nested OPTIONAL recurses |
VALUES | CROSS JOIN (VALUES (…), (…)) AS vN(…) joined on shared variables | UNDEF is a NULL cell that constrains nothing |
UNION | branch SELECTs combined with UNION ALL | each branch projects NULL for variables it doesn't bind |
MINUS | WHERE NOT EXISTS (SELECT 1 FROM _pgrdf_quads …) | dropped when the two sides share no variable, as SPARQL requires |
BIND(expr AS ?v) | the expression substituted for ?v downstream | see below |
aggregates, GROUP BY, HAVING | SQL aggregates over the pattern | over a UNION: the aggregate runs over a derived table of the branches |
GRAPH <iri> / GRAPH ?g | graph_id predicates; a join to _pgrdf_graphs for a variable | see below |
DISTINCT / REDUCED | SELECT DISTINCT | REDUCED is treated as DISTINCT |
ORDER BY | a multi-tier sort key | see below |
LIMIT / OFFSET | LIMIT / OFFSET |
Worked example: an atomic OPTIONAL group. The two-triple OPTIONAL binds both variables or neither:
SELECT pgrdf.add_graph('http://example.com/people');
SELECT pgrdf.parse_turtle('
@prefix ex: <http://example.com/> .
ex:alice a ex:Person ; ex:name "Alice" ; ex:age 34 .
ex:bob a ex:Person ; ex:name "Bob" .
ex:carol a ex:Person ; ex:name "Carol" ; ex:age 29 .
', pgrdf.graph_id('http://example.com/people'));
SELECT * FROM pgrdf.sparql('PREFIX ex: <http://example.com/>
SELECT ?s ?n ?ag WHERE {
?s a ex:Person
OPTIONAL { ?s ex:name ?n . ?s ex:age ?ag } } ORDER BY ?s');
-- {"n": "Alice", "s": "http://example.com/alice", "ag": "34"}
-- {"n": null, "s": "http://example.com/bob", "ag": null}
-- {"n": "Carol", "s": "http://example.com/carol", "ag": "29"}Bob has a name but no age, so neither variable binds for him.
Expressions
FILTER, BIND, projection expressions and ORDER BY keys share one expression translator. It covers comparison and boolean operators, arithmetic, the term tests (isIRI, isLiteral, isBlank, BOUND), STR, LANG, DATATYPE, the string functions (STRLEN, UCASE, LCASE, CONTAINS, STRSTARTS, STRENDS, CONCAT, REGEX), IF, the numeric functions ABS and ROUND, and functions in the XPath math: namespace (http://www.w3.org/2005/xpath-functions/math#, for example math:sqrt), which map onto PostgreSQL's float8 functions.
IF(c, a, b)becomesCASE (c) WHEN TRUE THEN a WHEN FALSE THEN b END, so an errored condition yields unbound rather than the else branch.ROUNDfollows XPath (ROUND(-2.5)is-2), so it is emitted asfloor(x + 0.5); PostgreSQL'sround()rounds half away from zero.- A type error inside an expression evaluates to NULL (unbound), never to a SQL error. A domain error in a
math:function (for example the square root of a negative number) likewise yields unbound. - An unknown function is refused with a message naming it.
BIND
BIND(expr AS ?v) is handled by substitution before the structural walk: every later use of ?v, in a FILTER, a triple position or another BIND, is rewritten to expr. FILTER(?sum > 10) after BIND(?x + ?y AS ?sum) therefore becomes FILTER(?x + ?y > 10), and the ordinary translator handles it. A BIND over an unbound variable yields unbound, not an error.
Aggregates
COUNT (including COUNT(*) and DISTINCT), SUM, AVG, MIN, MAX, GROUP_CONCAT and SAMPLE translate to SQL aggregates, with MIN and MAX numeric-aware. Aggregate arguments may be expressions. Over a UNION, each branch projects the dictionary ids of the grouped and aggregated variables into a shared column set, and the aggregate runs over that derived table:
SELECT * FROM pgrdf.sparql('PREFIX ex: <http://example.com/>
SELECT ?c (SUM(?p) AS ?s) WHERE {
{ ?x ex:cat ?c . ?x ex:price ?p FILTER(?c = "books") }
UNION
{ ?x ex:cat ?c . ?x ex:price ?p FILTER(?c = "tools") } }
GROUP BY ?c HAVING(SUM(?p) > 20)');HAVING accepts both the inline form (HAVING(SUM(?p) > 20)) and a reference to a projected alias (HAVING(?s > 20)). The alias form is a pgRDF extension: strict SPARQL evaluates HAVING before the AS binding exists, so the inline form is the portable one.
UNION and fail-closed filters
The UNION builders assemble each branch from that branch's own patterns and filters. A restriction that the algebra walk attached at group level, outside the branches, would otherwise be silently dropped, widening the result. The executor refuses such a query instead, and counts the event in filter_clauses_dropped (visible in stats() and last_call_stats()), which should stay at zero.
For the same reason a VALUES block that binds a variable also used as a GRAPH name is refused (values_graph_guard.rs): the binding is not joined into graph resolution, so the answer would cover every graph. Write explicit GRAPH <iri> groups instead.
ORDER BY
Sort keys follow the SPARQL value-space order. Each key expands into several SQL sort terms: a kind rank (numerics, then xsd:dateTime, then xsd:boolean, then everything else), then a numeric value, a timestamp, a boolean rank, and finally the text with COLLATE "C" (Unicode code-point order, independent of locale). Numbers therefore sort numerically (2 before 10), and the sort never raises: the numeric and date casts are guarded, so a malformed value falls through to the text tier. DESC(), several keys and expression keys (ORDER BY STRLEN(?s)) all compose. With SELECT DISTINCT, the deduplication is wrapped in a derived table so the sort can still reach its keys.
Named graphs
Each triple, OPTIONAL block and MINUS block carries the scope of the innermost enclosing GRAPH clause:
GRAPH <iri>. The IRI is resolved against_pgrdf_graphsat translation time, and every triple in the block getsqN.graph_id = <id>. An unknown IRI resolves to an id no graph has, so the block matches nothing.GRAPH ?g. The block's first triple is the anchor, and the translator joins_pgrdf_graphs gS ON gS.graph_id = q{anchor}.graph_id. The other triples in the block getqN.graph_id = q{anchor}.graph_id, so a multi-triple block cannot stitch triples from different graphs.?gprojects asgS.iri, the IRI string. The join isINNER(only registered graphs bind?g) and excludes graph 0, becauseGRAPH ?granges over named graphs only.- Scopes compose. A
GRAPH ?gborn inside an OPTIONAL uses aLEFT JOIN, so an unmatched OPTIONAL leaves?gunbound without dropping the outer row. A MINUS keeps its scope inside itsNOT EXISTSsubquery. Two blocks binding the same?gare tied together with an equality ongraph_id. - No
GRAPH. A triple outside anyGRAPHclause matches in every graph: an unscoped query runs over the union of all graphs.
FROM and FROM NAMED dataset clauses are not read by the translator; a query that carries them still runs over all graphs. Use GRAPH to scope.
Property paths
spargebra delivers a path as GraphPattern::Path { subject, path, object }. path.rs classifies the operator, and each operator lowers either to an ordinary triple or to a derived relation that exposes the same subject_id / object_id columns a quad alias does. The rest of the translator joins it like any triple, so paths compose with GRAPH scoping, joins, OPTIONAL, UNION, MINUS, CONSTRUCT and UPDATE WHERE clauses.
| Operator | Lowering |
|---|---|
p | an ordinary triple |
^p | the same triple with subject and object swapped; nested inverses fold by parity |
p1|p2 | one scan with predicate_id IN (…); a single step, no recursion |
p+ | WITH RECURSIVE walk with a CYCLE clause and a depth guard |
p* | the + walk UNION the zero-length pairs |
p? | the direct edge UNION the zero-length pairs; no recursion |
p1/p2 | refused: the parser rewrites a sequence into two patterns joined by a blank node, and blank nodes are not supported in query patterns. Write the two triple patterns with a named variable. |
!(p) | refused (negated property sets) |
The recursion and zero-length builders all match predicates as predicate_id IN (…), so (a|b)+, (a|b)*, (a|b)? and ^(a|b) reuse them unchanged. An alternation whose arm is itself a sequence or a recursive path, such as (a/b|c) or (a+|b), is refused.
Cycles. The recursive CTE uses PostgreSQL's CYCLE … SET is_cycle USING path clause, which stops extending a path as soon as a pair repeats on it. A bare UNION cannot deduplicate once the working row carries a depth column for the guard.
Zero-length pairs follow the SPARQL rules. A bound endpoint (<x> p* ?o) contributes (x, x) even when <x> is not in the data; to make that possible the IRI is registered as a term (no quad is added). An unbound endpoint's node set is the distinct subjects and objects of the active scope, restricted to the named graph under GRAPH.
Depth guard. pgrdf.path_max_depth (default 64) bounds the recursive walk. It is read at translation time, so a changed value produces a distinct SQL text and a separate cached plan. A walk cut by the guard is detected, and pgrdf.on_path_truncation decides what happens: warn (the default) returns the partial result with a WARNING, error fails the query with 54000, and count only increments the counter. The truncation counts appear in stats() (cumulative, server-wide) and last_call_stats() (this session's last query).
SET pgrdf.path_max_depth = 3;
SELECT * FROM pgrdf.sparql('PREFIX ex: <http://example.com/>
SELECT ?o WHERE { ex:c1 ex:sub+ ?o }'); -- over a chain c1 → c2 → … → c6
-- WARNING: sparql: property path truncated at pgrdf.path_max_depth=3 — …
-- c2, c3, c4
SELECT pgrdf.last_call_stats();
-- {"filter_clauses_dropped": 0, "path_depth_truncations": 1}Materialised closure shortcut. For p+ or p* over a single predicate from a short list of transitive ones (rdfs:subClassOf, rdfs:subPropertyOf, owl:sameAs), the translator first checks whether inferred rows for that predicate exist in scope. If they do, materialize has already written the closure, so it emits a direct match (plus the zero-length pairs for *) instead of a recursive CTE. The answer is identical; the plan has no CTE Scan. The check is made per query; multi-predicate paths skip it.
SPARQL UPDATE
sparql(q) first tries parse_query. If that fails it tries parse_update and dispatches each operation:
INSERT DATA/DELETE DATA. Ground quads only. Inserts intern the terms and skip quads that already exist. Deletes use a lookup-only dictionary path: a term that is not in the dictionary cannot be in any quad, so that quad is a no-op.INSERT … WHERE,DELETE … WHERE,DELETE … INSERT … WHERE. TheWHEREpattern goes through the same walker as SELECT and projects the dictionary ids of the template's variables. The pattern is evaluated once, then each template quad is instantiated per solution row. In the combined form the delete for a row runs before its insert, both from the same solution set. Template variables must be bound by the pattern.WITH <iri>is rewritten into a graph scope on the pattern and a default graph for the templates.USINGandUSING NAMEDare refused.CREATE,CLEAR,DROP(withDEFAULT,NAMED,ALLandSILENT) calladd_graph,clear_graphanddrop_graph, so the SPARQL and SQL routes share one implementation.ADD,MOVEandCOPYare rewritten by the parser into the forms above.
Every write passes the graph lock check, and the whole update runs in the caller's transaction, so ROLLBACK undoes it. The summary row reports form (INSERT_DATA, DELETE_WHERE, …, or MIXED for several operations), triples_inserted, triples_deleted, graphs_touched and elapsed_ms.
CONSTRUCT and DESCRIBE
Both return one row per triple, with a structured cell per position: {"type": "iri" | "literal" | "bnode", "value": …, "datatype"?: …, "language"?: …}.
CONSTRUCT. The WHERE clause is translated like a SELECT that projects dictionary ids, and each template triple is instantiated per solution. A blank-node label in the template gets a fresh label for each solution, shared by every template triple of that solution. Variables bound to stored blank nodes pass through with their stored label. The shorthand CONSTRUCT WHERE { … } is accepted when the pattern is a plain BGP with no blank nodes. DISTINCT, ORDER BY, GROUP BY and aggregates on CONSTRUCT are refused.
DESCRIBE. The description of a resource R is every triple with R as subject, followed recursively through blank-node objects. A visited set stops blank-node cycles, and triples are deduplicated across the whole result. Constant, variable, mixed and DESCRIBE * forms are supported, and a GRAPH clause in WHERE restricts the closure to that graph.
The plan cache
plan_cache.rs keeps prepared statements per backend:
translate → (sql with $1…$n, params)
│
├─ cache hit ──────────────► execute with params
└─ cache miss → SPI prepare → keep() → insert → execute- Key. The SQL text itself. Constants are parameters, so every query of the same shape produces the same text, whatever IRIs and literals it names.
- Storage. A
thread_local!map ofOwnedPreparedStatements (SPI_keepplankeeps them pastSPI_finish). Backends are single-threaded, so the hot path takes no lock. The cache is unbounded;plan_cache_local_sizeinstats()shows its size for the current backend. - Counters.
plan_cache_hits,plan_cache_missesandplan_cache_insertslive in shared memory and are cumulative across backends. - Invalidation. Because plans are parameterised, a re-created dictionary changes only the parameter values. PostgreSQL's own cached-plan invalidation handles dropped relations.
pgrdf.plan_cache_clear()empties the current backend's cache and returns the number of entries removed.
The bulk loader's quad INSERT uses the same cache.
Refusals
Constructs the translator cannot run are refused, not approximated. Unsupported algebra (for example SERVICE) raises 0A000 with a message naming the construct; some expression and pattern refusals still arrive as XX000 with a descriptive message. sparql_parse reports untranslatable parts in unsupported_algebra without running anything, so a client can check a query in advance. The full list is on SPARQL and Errors and diagnostics.
Settings used by queries
| Setting | Default | Effect |
|---|---|---|
pgrdf.path_max_depth | 64 | maximum recursive path depth |
pgrdf.on_path_truncation | warn | warn, error or count when the depth guard cuts a walk |
Tests
Query behaviour is pinned by the regression files 30-sparql-parse.sql to 44-sparql-ask.sql, 51-plan-cache.sql, 78/79/87 (GRAPH), 93–99 (UPDATE), 100–107 (CONSTRUCT and ORDER BY), 108–111 (property paths), 112–116 (OPTIONAL, VALUES, BIND, aggregates over UNION, DESCRIBE), 73-filter-union-fail-closed.sql and 140-truncation-fail-closed.sql in tests/regression/sql/, and by the W3C-shape harness in tests/w3c-sparql/. See Testing.