Skip to content

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

FunctionReturnsHandles
sparql(q)SETOF jsonbSELECT, ASK, every UPDATE form
construct(q)SETOF jsonbCONSTRUCT
describe(q)SETOF jsonbDESCRIBE
sparql_parse(q)jsonbthe parsed shape, including unsupported_algebra
sparql_sql(q)textthe translated SQL, for inspection
last_call_stats()jsonbcompleteness 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

text
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 row

parser.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:

sql
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 = -1

Translation by construct

SPARQLSQLNotes
multi-pattern BGPq1 INNER JOIN q2 ON …shared variables become join predicates
FILTER =, !=, sameTerm, INdictionary-id comparisonsound because the dictionary deduplicates on (type, lexical form, datatype, language)
FILTER <, >, <=, >=CASE WHEN datatype_iri_id IN (…numeric XSD types…) THEN lexical_value::numeric ENDa non-numeric operand yields NULL, so the row drops
FILTER REGEX~, or ~* with the i flagagainst lexical_value
FILTER BOUNDIS NOT NULLmeaningful for OPTIONAL variables
OPTIONAL { … }LEFT JOIN LATERAL (SELECT … ) qOPT ON TRUEthe whole group binds or none of it does; nested OPTIONAL recurses
VALUESCROSS JOIN (VALUES (…), (…)) AS vN(…) joined on shared variablesUNDEF is a NULL cell that constrains nothing
UNIONbranch SELECTs combined with UNION ALLeach branch projects NULL for variables it doesn't bind
MINUSWHERE 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 downstreamsee below
aggregates, GROUP BY, HAVINGSQL aggregates over the patternover a UNION: the aggregate runs over a derived table of the branches
GRAPH <iri> / GRAPH ?ggraph_id predicates; a join to _pgrdf_graphs for a variablesee below
DISTINCT / REDUCEDSELECT DISTINCTREDUCED is treated as DISTINCT
ORDER BYa multi-tier sort keysee below
LIMIT / OFFSETLIMIT / OFFSET

Worked example: an atomic OPTIONAL group. The two-triple OPTIONAL binds both variables or neither:

sql
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) becomes CASE (c) WHEN TRUE THEN a WHEN FALSE THEN b END, so an errored condition yields unbound rather than the else branch.
  • ROUND follows XPath (ROUND(-2.5) is -2), so it is emitted as floor(x + 0.5); PostgreSQL's round() 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:

sql
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_graphs at translation time, and every triple in the block gets qN.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 get qN.graph_id = q{anchor}.graph_id, so a multi-triple block cannot stitch triples from different graphs. ?g projects as gS.iri, the IRI string. The join is INNER (only registered graphs bind ?g) and excludes graph 0, because GRAPH ?g ranges over named graphs only.
  • Scopes compose. A GRAPH ?g born inside an OPTIONAL uses a LEFT JOIN, so an unmatched OPTIONAL leaves ?g unbound without dropping the outer row. A MINUS keeps its scope inside its NOT EXISTS subquery. Two blocks binding the same ?g are tied together with an equality on graph_id.
  • No GRAPH. A triple outside any GRAPH clause 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.

OperatorLowering
pan ordinary triple
^pthe same triple with subject and object swapped; nested inverses fold by parity
p1|p2one 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/p2refused: 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).

sql
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. The WHERE pattern 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. USING and USING NAMED are refused.
  • CREATE, CLEAR, DROP (with DEFAULT, NAMED, ALL and SILENT) call add_graph, clear_graph and drop_graph, so the SPARQL and SQL routes share one implementation. ADD, MOVE and COPY are 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:

text
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 of OwnedPreparedStatements (SPI_keepplan keeps them past SPI_finish). Backends are single-threaded, so the hot path takes no lock. The cache is unbounded; plan_cache_local_size in stats() shows its size for the current backend.
  • Counters. plan_cache_hits, plan_cache_misses and plan_cache_inserts live 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

SettingDefaultEffect
pgrdf.path_max_depth64maximum recursive path depth
pgrdf.on_path_truncationwarnwarn, 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), 9399 (UPDATE), 100107 (CONSTRUCT and ORDER BY), 108111 (property paths), 112116 (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.

pgRDF is released under the MIT license. Documentation built with VitePress, served via GitHub Pages.