Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,49 @@ once we cut v1.0; pre-1.0 minor bumps may include breaking changes.

## [Unreleased]

## [0.6.32] — 2026-08-16

The truth cut: canonical graph identity (#117), and two silent failure
modes become refusals (#109, #123).

### Added

- **`pgrdf.graph_digest(graph_id) -> text`** — RDFC-1.0 canonical graph
digest: identity of *meaning* that survives reload. Blank nodes are
existential variables whose labels re-mint on every parse, so byte
digests identify one stored copy and nothing more; `graph_digest`
canonically relabels (W3C RDFC-1.0), serializes canonical N-Triples,
and hashes — isomorphic graphs produce **equal** digests, unequal
digests **prove** difference. Algorithm label `rdfc-1.0-sha256`;
values are deliberately incomparable with byte digests or first-degree
structural pins. Asserted triples only (inferred is a check value).
Conformance is proven, not claimed: a vendored ten-case subset of the
W3C rdf-canon suite passes with our digest equal to the sha256 of the
suite's *own* expected canonical documents, byte-for-byte — and the
suite's poison graph (test074, a NegativeEvalTest) hits the resource
budgets and **raises `pgRDF#117`**, which is precisely the conforming
behaviour: refusing under budget is what the spec demands there.
Downstream: adoption pins and fork/severance re-matching upgrade from
evidence-grade to proof-grade the moment a consumer cites this digest.

### Fixed

- **`VALUES` binding a graph variable silently widened to every graph**
(#109) — re-confirmed live before coding: three nonexistent graphs in
the `VALUES` and the query answered for every graph in the store. Now
refuses with a stable `pgRDF#109` error naming the rewrite, on both
assembly paths, and increments `filter_clauses_dropped`. `VALUES` on
plain variables is regression-pinned as still applying.
- **The staged loader hung silently — and uncancellably — inside
transaction blocks** (#123): staged workers commit per phase, which a
caller's transaction can never allow; measured 31 minutes at 0% CPU
with `statement_timeout` never firing. A direct
`load_turtle_staged_run` in a transaction block now raises `pgRDF#123`
with the rewrite before any slot is taken, and `load_turtle`'s
N-Triples auto-dispatch falls back to the standard parser there.
The deeper cancellability gap (the coordinator's wait observes no
interrupts in *any* context) is filed as #125 with the measurement.

## [0.6.31] — 2026-08-16

The loader records the source byte digest (#118). Closes a defect class a
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "pgrdf"
version = "0.6.31"
version = "0.6.32"
# #72: edition 2024 + resolver "3" — pgrx 0.17+ migrated and 0.19
# restated it; staying on 2021 was drift, not breakage. resolver "3"
# is edition 2024's default, declared explicitly because the workspace
Expand Down
4 changes: 2 additions & 2 deletions META.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "pgrdf",
"abstract": "Rust-native PostgreSQL extension for RDF, SPARQL, SHACL and OWL reasoning",
"description": "pgRDF turns one PostgreSQL instance into a complete semantic-web engine — dictionary-encoded hexastore storage, a SPARQL 1.1 query and update engine, a W3C-conformant SHACL Core validator (25/25), and an OWL 2 RL / RDFS reasoner — with no sidecar triple store and no second system to operate. Load Turtle, TriG or N-Quads, then reason over it, validate it, and query it in place, each step a single function call inside one PostgreSQL session. Supports PostgreSQL 14–18; every release is CI-built and signed with SLSA Build Provenance v1.",
"version": "0.6.31",
"version": "0.6.32",
"maintainer": [
"Peter Styk"
],
Expand All @@ -13,7 +13,7 @@
"abstract": "Rust-native PostgreSQL extension for RDF, SPARQL, SHACL and OWL reasoning",
"file": "pgrdf.control",
"docfile": "README.pgxn.md",
"version": "0.6.31"
"version": "0.6.32"
}
},
"prereqs": {
Expand Down
2 changes: 1 addition & 1 deletion compose/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ services:
# stock extensions instead of hiding them.
- ./extensions/lib/pgrdf.so:/usr/lib/postgresql/18/lib/pgrdf.so:ro,z
- ./extensions/share/extension/pgrdf.control:/usr/share/postgresql/18/extension/pgrdf.control:ro,z
- ./extensions/share/extension/pgrdf--0.6.31.sql:/usr/share/postgresql/18/extension/pgrdf--0.6.31.sql:ro,z
- ./extensions/share/extension/pgrdf--0.6.32.sql:/usr/share/postgresql/18/extension/pgrdf--0.6.32.sql:ro,z
# Read-only ontology + regression fixtures, reachable from the
# postgres process at /fixtures (see pgrdf.load_turtle path arg).
- ../fixtures:/fixtures:ro,z
Expand Down
2 changes: 1 addition & 1 deletion pgrdf.control
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
# pgCK's pgck.control carries `requires = 'pgrdf, pgcrypto'`.

comment = 'Rust-native PostgreSQL extension for RDF, SPARQL, SHACL and OWL reasoning'
default_version = '0.6.31'
default_version = '0.6.32'
# Bare name, NOT '$libdir/pgrdf'. PostgreSQL resolves a module_pathname
# containing a separator directly against pkglibdir and never consults
# `dynamic_library_path`; only a bare name is searched along it. With the
Expand Down
8 changes: 8 additions & 0 deletions sql/pgrdf--0.5.1--0.6.31.sql → sql/pgrdf--0.5.1--0.6.32.sql
Original file line number Diff line number Diff line change
Expand Up @@ -231,3 +231,11 @@ ALTER FUNCTION pgrdf.build_id() IMMUTABLE;
-- 0.6.30 -> 0.6.31 delta (#118): loader-recorded source byte digest.
ALTER TABLE pgrdf._pgrdf_graphs ADD COLUMN IF NOT EXISTS source_sha256 TEXT;
ALTER TABLE pgrdf._pgrdf_graphs ADD COLUMN IF NOT EXISTS source_loads INTEGER;

-- 0.6.31 -> 0.6.32 delta (#117): RDFC-1.0 canonical graph digest.
CREATE FUNCTION "graph_digest"(
"graph_id" bigint /* i64 */
) RETURNS TEXT /* String */
STRICT
LANGUAGE c /* Rust */
AS 'MODULE_PATHNAME', 'graph_digest_wrapper';
23 changes: 23 additions & 0 deletions sql/pgrdf--0.6.31--0.6.32.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
-- pgRDF 0.6.31 -> 0.6.32 — the truth cut (#117, #109, #123).
--
-- #117: pgrdf.graph_digest(graph_id) — RDFC-1.0 canonical graph digest.
-- Identity of MEANING that survives reload: canonical blank-node
-- relabelling (W3C RDFC-1.0), canonical N-Triples, sha256, over the
-- graph's ASSERTED triples only. Algorithm label: rdfc-1.0-sha256 —
-- values are NOT comparable with byte digests or first-degree
-- structural pins, by design. Conformance proven against the W3C
-- rdf-canon suite byte-for-byte; adversarial (poison) structures RAISE
-- pgRDF#117 rather than degrade.
--
-- #109 and #123 ship in the .so as behaviour, not DDL: a VALUES binding
-- a graph variable now refuses (pgRDF#109) instead of silently
-- answering over every graph; the staged loader refuses in transaction
-- blocks (pgRDF#123) instead of hanging uncancellably, and load_turtle's
-- auto-dispatch falls back to the standard parser there.

CREATE FUNCTION "graph_digest"(
"graph_id" bigint /* i64 */
) RETURNS TEXT /* String */
STRICT
LANGUAGE c /* Rust */
AS 'MODULE_PATHNAME', 'graph_digest_wrapper';
49 changes: 49 additions & 0 deletions src/query/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2344,6 +2344,7 @@ fn translate(q: &Query) -> ExecPlan {
/// SELECT clause (`SELECT 1`) so the SQL is well-formed before
/// being wrapped in EXISTS().
fn build_ask_probe_sql(ps: &ParsedSelect) -> String {
refuse_values_on_graph_variable(ps, "ASK");
if !ps.union_branches.is_empty() {
refuse_group_constructs_over_union(ps, "ASK");
let branch_sqls: Vec<String> = ps
Expand Down Expand Up @@ -4064,6 +4065,53 @@ fn type_aware_order_terms(lex: &str, ascending: bool) -> Vec<String> {
/// (`filter_clauses_dropped`) shows callers are hitting it. Lifting the
/// refusal for a construct means actually APPLYING it on the union path,
/// with a regression proving the restricted answer.
/// #109: a `VALUES` that binds a variable also used as a `GRAPH` name is
/// not joined into graph resolution — measured (filed on 0.6.27,
/// re-confirmed live on 0.6.31): three nonexistent graphs in the VALUES
/// and the query answered for EVERY graph in the store. The binding
/// contributes nothing and the answer silently widens. Refuse with the
/// rewrite; the real join is #111-adjacent follow-up work. Runs on BOTH
/// assembly paths and unconditionally — the repro has no UNION in it.
fn refuse_values_on_graph_variable(ps: &ParsedSelect, ctx: &str) {
let mut scope_vars: Vec<String> = Vec::new();
let mut collect_triples = |bgp: &[ScopedTriple]| {
for t in bgp {
if let Some(v) = scope_var_name(&t.scope) {
scope_vars.push(v);
}
}
};
collect_triples(&ps.bgp);
for ob in &ps.optionals {
collect_triples(&ob.triples);
}
for b in &ps.union_branches {
collect_triples(&b.bgp);
for ob in &b.optionals {
collect_triples(&ob.triples);
}
}
if scope_vars.is_empty() {
return;
}
let all_blocks = ps
.values
.iter()
.chain(ps.union_branches.iter().flat_map(|b| b.values.iter()))
.chain(ps.optionals.iter().flat_map(|ob| ob.values.iter()));
for vb in all_blocks {
if let Some(hit) = vb.variables.iter().find(|v| scope_vars.contains(v)) {
crate::storage::shmem_cache::note_filter_clause_dropped();
panic!(
"sparql: VALUES binds ?{hit}, which also names a GRAPH scope — the binding \
is not joined into graph resolution on the {ctx} path and the answer would \
silently widen to every graph (pgRDF#109). Enumerate explicit GRAPH <iri> \
groups instead. Refusing instead of returning a wrong answer."
);
}
}
}

fn refuse_group_constructs_over_union(ps: &ParsedSelect, ctx: &str) {
let parked = [
(!ps.filters.is_empty(), "FILTER"),
Expand All @@ -4084,6 +4132,7 @@ fn refuse_group_constructs_over_union(ps: &ParsedSelect, ctx: &str) {
}

fn build_bgp_sql(ps: &ParsedSelect) -> String {
refuse_values_on_graph_variable(ps, "SELECT/CONSTRUCT");
if !ps.union_branches.is_empty() {
refuse_group_constructs_over_union(ps, "SELECT/CONSTRUCT");
if !ps.aggregates.is_empty() {
Expand Down
1 change: 1 addition & 0 deletions src/query/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ pub mod guc;
pub mod parser;
pub mod path;
pub mod plan_cache;
pub mod values_graph_guard;
62 changes: 62 additions & 0 deletions src/query/values_graph_guard.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//! #109 — VALUES bound to a graph variable refuses instead of widening.
//!
//! Measured live on 0.6.31 (2026-08-16, re-confirming the 0.6.27 filing):
//! `VALUES ?g { <three nonexistent graphs> } GRAPH ?g { … }` answered for
//! EVERY shape-bearing graph in the store — the binding contributed
//! nothing, and the caller received a confidently unscoped answer with no
//! signal. Survives the #114 guards (no UNION involved). The read-path
//! member of the reports-not-refuses family.
//!
//! Contract (v0.6.32 recheck): a `VALUES` that binds a variable used as a
//! `GRAPH` name RAISES a stable `pgRDF#109` error naming the rewrite
//! (enumerate the graphs as explicit `GRAPH <iri>` groups, or wait for the
//! join in #111's follow-up). A `VALUES` on a plain (non-graph) variable
//! keeps applying — pinned here, measured working on the bench today.

#[cfg(any(test, feature = "pg_test"))]
#[pgrx::pg_schema]
mod tests {
use pgrx::prelude::*;

fn seed(graph_id: i64) {
Spi::run(&format!("SELECT pgrdf.add_graph({graph_id})")).expect("add_graph failed");
Spi::get_one_with_args::<i64>(
"SELECT pgrdf.parse_turtle($1, $2)",
&[
"@prefix v: <urn:v:> .\nv:a v:p v:x .\nv:b v:p v:y .\n".into(),
graph_id.into(),
],
)
.expect("seed parse failed");
}

/// The repro, exactly as measured: VALUES on the graph variable must
/// refuse — never answer over graphs the binding excluded.
#[pg_test(
error = "sparql: VALUES binds ?g, which also names a GRAPH scope — the binding is not joined into graph resolution on the SELECT/CONSTRUCT path and the answer would silently widen to every graph (pgRDF#109). Enumerate explicit GRAPH <iri> groups instead. Refusing instead of returning a wrong answer."
)]
fn values_on_graph_variable_refuses() {
seed(982101);
Spi::run(
"SELECT * FROM pgrdf.sparql('SELECT ?g ?s WHERE {
VALUES ?g { <urn:g:one> <urn:g:two> }
GRAPH ?g { ?s ?p ?o } }')",
)
.expect("unreachable: the call above must raise");
}

/// Regression pin (passes today, measured on the bench): VALUES on a
/// plain variable keeps applying inside an explicit GRAPH group.
#[pg_test]
fn values_on_plain_variable_still_applies() {
seed(982102);
let n: i64 = Spi::get_one(
"SELECT count(*)::bigint FROM pgrdf.sparql('SELECT ?s WHERE {
VALUES ?s { <urn:v:a> }
GRAPH <urn:pgrdf:graph:982102> { ?s ?p ?o } }')",
)
.expect("query failed")
.expect("count NULL");
assert_eq!(n, 1, "VALUES on a plain variable must keep restricting");
}
}
Loading
Loading