Skip to content

feat(sparql): burn-down Wave 1 — validation passes, grammar tightening, authoritative diagnostics, reifier forms (−123 registers)#1453

Open
aaj3f wants to merge 26 commits into
mainfrom
burndown/wave-1
Open

feat(sparql): burn-down Wave 1 — validation passes, grammar tightening, authoritative diagnostics, reifier forms (−123 registers)#1453
aaj3f wants to merge 26 commits into
mainfrom
burndown/wave-1

Conversation

@aaj3f

@aaj3f aaj3f commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Reviewer quick reference

End-user API/contract changes — this is the reject-more wave; read this list carefully. SPARQL inputs that previously "worked" and now error (each spec-mandated, each with a changelog/migration note in its section): (a) GROUP BY (expr) with the raw expression reprojected — the field/#1362 shape — must alias the key (GROUP BY (expr AS ?k)); (b) Fluree's grouped-list projection extension is removed from SPARQL only (JSON-LD keeps it, parity-pinned); (c) BIND-scope, duplicate-alias, bnode-cross-scope, and GROUP-projection violations now reject; (d) stray/doubled dots and bare FILTER terms now reject (V1 is the highest-regression-risk item — users may rely on dot leniency; isolated commits for bisect/revert); (e) parse errors are now authoritative at the API: a query whose parse produced error diagnostics no longer executes a recovered AST; (f) multi-operation UPDATE requests error loudly instead of silently executing only the first operation (interim guard; real support lands in Wave 2); (g) blank nodes in DELETE forms are rejected on both the SPARQL and JSON-LD transact surfaces (_:fdb- stable ids exempt).

Performance verdict: entirely parse/validate-time and update-staging work — zero per-row engine code touched. BSBM measured anyway on the shared parse entry (numbers below): HEAD ≤ base on all three queries.

Production bugs found & fixed along the way: the API diagnostics-swallowing hole — fluree.query executed error-recovered ASTs, silently answering a different question than asked (this is the wave's most important product fix); five "green-by-recovery" W3C tests that only passed because of that hole (their real parser gaps are fixed here, not registered); the #1438 silent-data-loss window closed (loud guard, verified end-to-end).

Decisions actioned in this PR

D-4 (reject-more behavior changes ship as hard errors) — actioned by pr-2 and pr-3.

  • Options considered: (1) hard error + changelog — chosen; (2) warning under a strictness flag — rejected: a flag means the spec-invalid query still silently executes for everyone who doesn't set it, perpetuating wrong results; (3) defer entirely — rejected: these validations gate ~65 W3C tests and the silent-wrong-results class of bugs.
  • Why (1): accepted-but-misinterpreted queries produce wrong answers with no signal — strictly worse than a loud error; the added requirement (discovered during verification) is that error-severity diagnostics must prevent AST production or execution, otherwise the API swallows them and the "hard error" is theater. Migration notes list every newly-rejected shape.

D-10a (fast-track the multi-op guard) — actioned by pr-3.

  • Options considered: (1) loud trailing-token error now, real ;-loop support in Wave 2 — chosen; (2) wait for full multi-op support — rejected: every day in between, standard multi-op SPARQL UPDATE requests silently commit only their first operation (SPARQL UPDATE: multi-operation (;) requests silently execute only the first operation #1438 — silent data loss in production).
  • Why (1): converts silent data loss into an explicit, actionable error with zero risk; the Wave-2 implementation replaces the guard in place.

D-1 (SPARQL 1.2 triple-term strategy: accept-then-defer) — actioned by pr-w2a for the reifier forms.

  • Options considered: (1) accept-then-defer: parse everything the RDF 1.2 grammar allows, lower reifier-model-expressible forms to the existing edge-annotation IR, clean not_implemented for the rest — chosen; (2) reject-on-principle (documented-divergence register) — rejected: keeps 60+ syntax tests red for forms the engine can actually evaluate; (3) desugar triple terms to reifiers — rejected as semantically wrong: the 1.2 grammar itself distinguishes BIND(<<( )>>) (valid, a value) from BIND(<< >>) (invalid, a reifier) — desugaring would green syntax while corrupting eval semantics.
  • Why (1): zero storage/index/perf risk, honest runtime errors for the deferred tail. Known trade-off, stated plainly: a user writing a deferred form gets a runtime not_implemented instead of a parse error.

Wave 1 of the W3C SPARQL burn-down (4 components, ≈−123 register entries)

Stacks on the Wave-0 PR. This wave is the reject-more + validation wave: it makes the parser and API say "no" where the spec requires it. Every component carries the decision-transparency and changelog obligations for the behavior changes it ships (D-4 was adopted as hard-errors-with-changelog; the options considered are in each section and ROADMAP §4).

Components

1. Semantic validation passes (pr-2, −28). V3 blank-node label scope across BGP boundaries, V4 GROUP BY/aggregate projection scope, V5 BIND scope, V6 duplicate/in-scope SELECT aliases — four validation passes that previously did not exist anywhere — plus the SPARQL 1.2 nested-aggregate and duplicated-VALUES-variable checks. The two AST walkers (free-variable collector, contains_aggregate) were written from scratch (the audit's claim that one existed was refuted). V5 lives in the parser by necessity: the single-pattern group simplification makes the legal and illegal BIND-scope shapes byte-identical as ASTs. Behavior changes your review should consciously bless (both pinned by tests + in the migration note): (a) GROUP BY (expr) + reprojection — the field/#1362 shape, literally W3C agg08 — is now rejected; alias the key (GROUP BY (expr AS ?k)). (b) Fluree's SPARQL grouped-list projection extension is literally W3C group06 — removed from the SPARQL surface; JSON-LD keeps it, with parity pins documenting the deliberate divergence.

2. Grammar tightening + the diagnostics seam (pr-3, −15). V1 dot-structure enforcement (same token flow, no backtracking), V2 FILTER-requires-Constraint, and the product-critical seam fix: parse-error diagnostics are now authoritative at the API even when error recovery produced an AST — previously fluree.query silently executed recovered ASTs that answered a different question than the user asked. That fix immediately unmasked five "green-by-recovery" tests, whose real gaps (bare ORDER BY constraints, VALUES row shape, sub-SELECT VALUES lowering) are fixed here rather than registered. Also owns the single trailing-token/EOF assertion: multi-op UPDATE requests stopped silently executing only their first operation and errored loudly instead (D-10a interim guard for #1438; verified end-to-end). Decision transparency: actions D-4 and D-10a; V1 is flagged as the highest-regression-surface change in the wave (users may rely on dot leniency) — isolated commits for easy bisect.

3. UPDATE validation (pr-u1, −19). Class D: GRAPH blocks inside DELETE WHERE now route through the existing Modify graph-scoped-template lowering (both prior rejections removed; triple-only fast path untouched). Class E: blank nodes in DELETE forms rejected across validator + lowering + the JSON-LD transact surface (which can express a bnode-in-delete — behavior change, in the migration note), with the stable _:fdb- ids exempted. Compliance-doc parity table gains the UPDATE/transact row.

4. RDF 1.2 reifier-form parser (pr-w2a, −61). Object-position/nested/standalone << s p o ~ r >>, ~ variants, multi-reifier/multi-block annotation tails grouped into AnnotationUnits per the RDF 1.2 attachment rule, property-path verbs in annotation bodies — all lowered to the existing Pattern::EdgeAnnotation machinery where evaluable, clean not_implemented where deferred. Kept the negative suite green by design (new VERSION short-string check; anon-annotation-in-DELETE validator). Includes the 3 SPARQL12_VERSION entries (VERSION already parsed; the failures were reified-triple bodies). Decision transparency (D-1): accept-then-defer was chosen over reject-on-principle and over desugar-to-reifier (the latter is semantically wrong: the 1.2 grammar distinguishes triple terms from reifiers); trade-off — deferred forms now fail at runtime with not_implemented instead of at parse. JSON-LD parity: byte-equal results between @annotation inserts queried via rdf:reifies and via the new syntax.

Integration notes

  • The wave-1 fixpoint commit prunes 56 tests that green only under the combined defenses (e.g. update-syntax tests covered by PR-2+PR-3+PR-U1 simultaneously; the pr-1∩pr-3 syntax-order-07 and pr-2∩pr-3 anonreifier-02 double-claims resolved mechanically by the gate).
  • tests.rs was rebuilt deterministically from the clean parents after interleaved test-section hunks defeated naive merging; SubSelect gained pr-3's values field across all initializers; pr-2's walkers were migrated to pr-w2a's AnnotationUnit AST.

Verification

Full W3C suite 36/36, 0 failed, 0 stale; fluree-db-sparql 601 unit tests; grp_query_sparql 274, grp_transact 171; fmt + clippy clean.

Issues closed when merged: #1438's interim guard (full fix in Wave 2).


Performance notes (per component)

Validation passes (pr-2): run once per parse over the AST; the two new walkers are simple recursive visitors. No runtime/engine code touched; nothing per-row.

Grammar tightening + API seam (pr-3): V1/V2 preserve the existing token flow (a dot_allowed flag and a post-parse AST-shape predicate — no backtracking added to the happy path); the EOF assertion is a single end-of-parse check; the API seam fix is one branch at query submission. query_hot_bsbm before/after: 538/682/950 µs (HEAD) vs 579/735/1005 µs (base) for q3/q5/q9 — HEAD ≤ base across the board; later same-code reruns showed the box noisy under concurrent sessions, so read this as "no regression," not as an improvement claim. No per-row code touched.

UPDATE validation (pr-u1): update staging only — the query hot path is untouched by construction. GRAPH-in-DELETE-WHERE routes through the existing Modify graph-scoped-template lowering rather than new machinery; the triple-only DELETE WHERE fast path is preserved.

Reifier-form parser (pr-w2a): parse-time + lowering onto the existing Pattern::EdgeAnnotation operators — D-1's Option 1 was chosen precisely because it carries zero storage/index/perf risk. The grown AnnotationTarget IR variant is boxed to keep the pattern-enum width unchanged (enum width is the real hot-path exposure for IR types). Deferred positions cost nothing at runtime (they error at lowering).

Issues closed on merge

None auto-closed by this PR — deliberately. #1438's window is closed here (silent data loss → loud error, verified end-to-end with a two-op request that previously committed only its first operation), but the fix — real multi-operation support — lands in the Wave 2 PR, which carries the Closes #1438 keyword.

aaj3f added 26 commits July 7, 2026 00:16
Per GroupGraphPatternSub, a '.' at group level is legal only as the
single optional separator immediately after a GraphPatternNotTriples;
within a TriplesBlock the '.' between two same-subject blocks is
mandatory. The group loop previously skipped any dot anywhere, so
leading, doubled, and standalone dots — and missing separators between
triple patterns — were silently accepted.

- parse_group_graph_pattern: replace the skip-any-dot branch with a
  dot_allowed flag tracked per grammar position; stray dots now emit an
  error-severity diagnostic (same single-token flow, no backtracking).
- parse_triples_block: when the optional trailing dot is absent and the
  next token starts a term, emit "expected '.' between triple
  patterns".

Greens W3C syn-bad-02/03 (missing dot) and syn-bad-05..14 (stray dots);
removes the 12 register entries (SPARQL10_SYNTAX).

BEHAVIOR CHANGE (D-4, hard error + changelog): queries relying on the
previous dot leniency (e.g. '{ ?s ?p ?o .. }' or '{ . }') are now
rejected. This is the highest-regression-surface change in the wave and
is isolated in this PR for easy bisect/revert.
Filter ::= 'FILTER' Constraint, where Constraint is a bracketted
expression, a built-in call, or a function call. parse_filter_pattern
previously accepted anything parse_expression accepted, so bare terms
(FILTER ?x, FILTER true) and unparenthesized operator expressions
(FILTER ?x > 5) parsed silently.

The check is a post-parse AST-shape predicate (is_constraint_expression)
rather than a token-level keyword enumeration: it stays correct as
builtins are added and preserves the existing token flow. The Filter
node is still produced for tooling; the error-severity diagnostic makes
the parse fail authoritatively at the API seam. Aggregates remain
grammatically acceptable here (rejecting aggregates in FILTER is
semantic validation, PR-2's territory). PR-1's P5b ORDER-BY-constraint
fix admits the same alternatives; the helper documents that coupling.

Greens W3C filter-missing-parens; removes its SPARQL10_SYNTAX register
entry.

BEHAVIOR CHANGE (D-4, hard error + changelog): FILTER ?x and
FILTER ?x > 5 are now rejected — wrap the expression: FILTER(...).
…ery seam

parse_and_validate_sparql accepted any parse that produced an AST, even
when error recovery had emitted error-severity diagnostics — so a query
the parser flagged as malformed still executed against the recovered
(silently rewritten) AST. Every public query entry point funnels through
this helper (fluree.query / query_sparql / dataset + stream + credential
+ connection + explain paths), so the diagnostic-swallowing hole made
any reject-more parser work cosmetic (ROADMAP §1 addendum).

Decision (D-4 shape): reject at the API seam rather than making
parse_sparql withhold the AST on error. This is the minimal change that
makes error diagnostics authoritative while preserving ParseOutput's
recovery contract (AST + diagnostics) for tooling and the W3C harness;
warning-severity diagnostics still never reject. The SPARQL UPDATE path
(tx_builder::parse_and_lower_sparql_update) already enforced this rule;
the two seams now match.

Adds API-level regression tests proving V1/V2-flagged queries return an
error through fluree.query instead of executing (each fails without
this fix).
…alidation

Two AST walkers needed by the V3-V6 validation passes (roadmap §1.1
item 2 — no Expression::variables()/contains_aggregate() helper
existed):

- Expression::walk/contains_aggregate/variables/unaggregated_variables
  in ast/expr.rs — pre-order expression traversal that stops at
  EXISTS/NOT EXISTS pattern boundaries, with an aggregate-argument-
  skipping variant for GROUP BY projection-scope checks.
- GraphPattern::add_in_scope_variables in ast/pattern.rs — the SPARQL
  1.1 §18.2.1 in-scope variable definition (MINUS right side excluded,
  FILTER contributes nothing, sub-SELECT contributes its projection,
  annotation-tail variables included).

Shared by the GROUP BY projection pass, the BIND scope pass, the
duplicate-alias pass, and the SPARQL 1.2 nested-aggregate check.
…atterns

New validate-time pass (validate/bnode_scope.rs, DiagCode V001) enforcing
SPARQL 1.1 §19.6: a blank-node label may not appear in two different
basic graph patterns. Boundary set per the cluster-doc decision: GRAPH /
OPTIONAL / UNION / MINUS / SERVICE / nested { } group / sub-SELECT end a
BGP; FILTER (per §18.2.2.5) — and, conservatively, BIND / VALUES /
property paths — do not. EXISTS patterns are scanned as their own groups.

Consecutive Bgp siblings inside a Group can only arise from an explicitly
braced block (the parser merges plain adjacent triples and simplifies
single-pattern groups), so they are assigned distinct scopes — this is
what distinguishes '{ _:a ?p ?v . { _:a ?q 1 } }' (illegal) from a
FILTER-split single BGP (legal).

Applies to query WHERE patterns only; update operations keep their own
blank-node rules (PR-U territory).

Register: −11 SPARQL10_SYNTAX entries (blabel-cross-{graph,optional,
union}-bad, syn-bad-34..38, syn-bad-{GRAPH,OPT,UNION}-breaks-BGP); the
13 remaining accepts-invalid entries are V1/V2 dot-and-FILTER grammar
tightening, reclassified for PR-3.
New validate-time pass (validate/projection.rs, DiagCodes V002/V003)
enforcing SPARQL 1.1 §11 / §18.2.4: in a grouped query (explicit
GROUP BY, or an aggregate in the projection = implicit single group)
every projected variable must be a group key or appear only inside an
aggregate, and SELECT * is not permitted with GROUP BY. Runs on the
top-level SELECT clause and on every sub-SELECT.

Deliberate leniencies to avoid over-rejection: GROUP BY (?v) counts as
the key ?v; aliases assigned by earlier items in the same SELECT clause
are usable in later projection expressions; HAVING / ORDER BY are not
checked (no conformance test requires it — grammar-note scope only).

Register: −9 (SPARQL11_AGGREGATES agg08-agg12, SPARQL11_GROUPING
group06/group07 → suite fully green, SPARQL11_SYNTAX_QUERY
test_43/test_44). test_45/test_60/61a/62a/65 remain for the V5/V6
passes.
Parse-time check (DiagCode V004) enforcing SPARQL 1.1 §10.1 / grammar
note 12: the variable assigned by BIND(expr AS ?v) must not already be
in scope in the containing group graph pattern up to that point. The
in-scope set accumulates over the preceding siblings via the shared
GraphPattern::add_in_scope_variables walker (§18.2.1 rules: nested
groups and UNION branches propagate out, MINUS right side and FILTER
variables do not; only elements BEFORE the BIND count).

Placement note: this pass lives in parse_group_graph_pattern, not
validate(), by necessity — the parser's single-pattern group
simplification makes '{ ... { BIND(e AS ?v) } }' (legal, fresh scope,
W3C syntax-BINDscope2) and '{ ... BIND(e AS ?v) }' (illegal, W3C
syntax-BINDscope6) produce byte-identical ASTs, so only the parser can
tell them apart. Changing the AST shape would alter the lowering
invariant that decides scope-boundary subqueries (out of scope here).

Per roadmap D-4 the new error prevents AST production in parse_sparql —
recovered-error ASTs would otherwise execute through the API's
parse-then-validate path, which only inspects diagnostics when the AST
is absent.

Register: −3 SPARQL11_SYNTAX_QUERY (test_60, test_61a, test_62a).
Validate-time check (DiagCode V005) enforcing SPARQL 1.1 §19.8 grammar
note 13: the variable assigned in (expr AS ?v) must not be assigned by
an earlier item in the same SELECT clause, nor already be in scope in
the WHERE pattern (§18.2.1 — a sub-SELECT projects its variables into
the outer pattern's scope). Using an earlier alias in a later
expression remains legal; only re-assignment is rejected. Applies to
the top-level SELECT clause and every sub-SELECT (each against its own
pattern, keeping the positive syntax-SELECTscope1/3 shapes green).

Empirically it is the in-scope half of this pass — not V4/V5 — that
greens test_65 (syntax-SELECTscope2): the query has no GROUP BY, no
aggregate, and no BIND; the violation is the outer (1 AS ?X) colliding
with the sub-SELECT's projected ?X.

Register: −2 SPARQL11_SYNTAX_QUERY (test_45 duplicate-alias, test_65
alias-in-scope); the suite's accepts-invalid class is now empty (the 4
remaining entries are rejects-valid parser gaps).
…LUES variables

Two validate-time checks for the SPARQL12_SYNTAX negative tests
(DiagCodes V006/V007), sharing the PR's walkers:

- Nested aggregates: an aggregate call may not appear inside another
  aggregate's argument (COUNT(COUNT(*))). Checked over SELECT
  projections and GROUP BY / HAVING / ORDER BY expressions, top-level
  and sub-SELECT.
- Duplicated VALUES variable: the VALUES variable list must be
  distinct. Implemented in the Values arm of the graph-pattern walk, so
  it covers inline VALUES everywhere (queries, sub-SELECTs, update
  WHERE clauses) plus the post-query VALUES clause, which is now routed
  through the same walk.

Register: −2 SPARQL12_SYNTAX (nested-aggregate-functions,
duplicated-values-variable) → suite fully green.
…masking

Making parse errors authoritative at the API seam exposed four W3C
queries that only worked because the API executed error-recovered ASTs
(bindings#values7, bindings#inline2, sort#dawg-sort-builtin,
sort#dawg-sort-function) plus two whose dropped tails the new
trailing-token assertion now surfaces (syntax-order-06/07). All are
valid SPARQL the parser rejected:

- ORDER BY bare Constraint (OrderCondition ::= ... | ( Constraint | Var )):
  'ORDER BY str(?o)' / 'ORDER BY xsd:integer(?o)' now parse via a
  try-parse-with-restore branch in parse_order_condition. Hunk is
  byte-identical to wave-0 PR-1's P5b fix so the branches merge cleanly.
- VALUES row shape follows the var-LIST shape, not the variable count:
  a parenthesized single-var list ('VALUES (?x) { (:b) }') takes
  parenthesized rows (InlineDataFull), only a bare var takes bare values
  (InlineDataOneVar).
- SubSelect trailing VALUES clause (SubSelect ::= SelectClause
  WhereClause SolutionModifier ValuesClause): parsed into a new
  SubSelect::values field and lowered by joining the VALUES table into
  the subquery's pattern list before projection (spec 18.2.4.3 insertion
  point for modifier-free subqueries; approximate under GROUP BY).

Greens W3C syntax-order-07 (register entry removed); keeps values7,
inline2, dawg-sort-builtin, dawg-sort-function, and syntax-order-06
green once parse errors reject at the API.
…validate time

Validate-time check (DiagCode V008) for the one wave-2 negative:
an annotation tail with no explicit reifier id (a bare '{| ... |}'
block or bare '~') mints an anonymous reifier, which has no
addressable identity to delete — invalid in DELETE DATA ground data
(W3C sparql12 syntax-update-anonreifier-02, which was verified
parseable-but-unvalidated at this branch's base: the request parses
via the trailing-token hole and validate() raised no error).

This mirrors the existing lowering-time rejection in
fluree-db-transact (AnnotationExpansionMode::DeleteData) with the same
message text, so it now also fires on the parse+validate-only paths
(the W3C syntax harness, standalone validation) — surfacing earlier,
before lowering, on the API path.

INSERT DATA is deliberately NOT tightened: anonymous annotation blocks
there are Fluree's committed SPARQL 1.2 edge-annotation transact
surface (pinned by it_query_sparql_annotations.rs) — recorded as a
reviewed divergence in the PR description. The W3C test greens on its
DELETE DATA half.

Register: −1 SPARQL12_SYNTAX_TRIPLE_TERMS_NEGATIVE → suite fully green.
DELETE WHERE { GRAPH <g> { ... } } previously failed twice: the validator
rejected the GRAPH block outright (making W3C syntax-update-1 test_36 fail
as a positive syntax test) and, for callers bypassing validation, lowering
hard-errored with UnsupportedFeature (failing dawg-delete-where-02/04/06).

DELETE WHERE is shorthand for DELETE { P } WHERE { P }, and the Modify path
already supports concrete-IRI GRAPH blocks end to end. Route GRAPH-bearing
DELETE WHERE patterns through that same machinery: the quad pattern becomes
a stored SparqlWhereClause (staging-time lowering via the shared query
engine, which evaluates GRAPH blocks) plus graph-scoped delete templates
via lower_quad_pattern_to_templates. The triple-only fast path is
unchanged, including its blank-node existential-variable rewriting; on the
new GRAPH path non-stable blank nodes are rewritten to reserved
_fluree_bn_* variables shared by WHERE and templates, and stable _:fdb-
ids keep addressing the stored node as constants.

The validator now applies the same template rule as Modify (GRAPH <iri>
allowed, GRAPH ?var still rejected as Phase 1) instead of rejecting every
GRAPH block, and the parity-test table in the compliance doc gains the
UPDATE/transact row (it_transact_update.rs, it_named_graphs.rs).

Register shrink (-5 across both registers): syntax-update-1 test_36 (both
copies), dawg-delete-where-02/04/06.

Surface parity: IR/engine-level fix. New JSON-LD regression test
(graph-scoped delete-where via top-level "graph") plus a SPARQL
DELETE WHERE { GRAPH } integration test in it_named_graphs.rs.
SPARQL 1.1 Update (§19.8 grammar note 8) forbids blank nodes in DELETE
DATA, DELETE WHERE, and the DELETE template of a Modify operation: a blank
node denotes a fresh node and can never match existing data, so the
retraction skolemized a brand-new SID and silently matched nothing. No
validation existed, failing eleven W3C negative syntax tests.

Enforce the rule at three points, each with a clear error naming the rule
and the alternatives (variable bound by WHERE, concrete IRI, stable id):

- validator: new DiagCode::BlankNodeInDelete (F010) for blank nodes in
  DELETE DATA ground data, DELETE WHERE patterns, and Modify DELETE
  templates, including inside GRAPH <iri> blocks. INSERT DATA and INSERT
  templates keep CONSTRUCT-style fresh-mint blank nodes.
- SPARQL lowering: new LowerError::BlankNodeInDelete for DELETE DATA and
  Modify DELETE templates, so the transact builders (which lower without
  running validate()) reject too — mirroring exactly where the annotation
  machinery already rejects blank reifiers (rejects_blank_reifier:
  DeleteData | DeleteTemplate).
- JSON-LD surface: delete templates containing a blank node (explicit
  "_:..." @id or a nested object without @id, which mints one) are
  rejected at parse with the same rationale.

Two deliberate carve-outs preserve documented Fluree extensions: stable
_:fdb- ids stay legal in every DELETE form (constants addressing the
stored node; the validator duplicates the feature-gated prefix constant
with a lowering-feature sync test), and DELETE WHERE blank nodes keep
their existential-variable semantics at the lowering layer while the
strict SPARQL surface (validate()) rejects them per spec.

Register shrink (-14 across both registers): syntax-update-1
test_50/51/52 (both copies), dawg-delete-insert-03/03b/05/06/07/07b/08/09.
test_54 stays (cross-operation bnode scoping, PR-U2).

Surface parity: surface-validation fix; the JSON-LD transact surface CAN
express a bnode-in-delete, so it gains the same rejection plus the
negative test update_delete_blank_node_rejected in it_transact_update.rs.
parse_sparql returned after one parsed operation with no EOF check, so
anything after it was silently discarded. The worst case is a standard
multi-operation SPARQL UPDATE ('INSERT ...; DELETE ...'): the INSERT
committed and every following operation vanished — silent data loss
through graph().transact().sparql_update().commit() (#1438).

The parse entry now asserts EOF after a complete Query/Update:
- any trailing token after a query form is an error-severity diagnostic;
- for updates, one trailing ';' stays legal (Update ::= Prologue
  ( Update1 ( ';' Update )? )? with an empty recursive Update), but
  content after it is rejected with an explicit multi-op error naming
  the limitation (D-10a interim guard; PR-U2 replaces the rejection
  with request-level multi-op support and reuses this single
  entry-point assertion).

With parse errors authoritative at both API seams, the two-op case is
verified end-to-end in it_transact_update: the request errors loudly
and stages nothing; a single op with a trailing ';' still commits.

Register deltas: syntax-update-1#test_54 (both copies) and
triple-terms-negative#syntax-update-anonreifier-02 now pass (loud
rejection is what those negatives demand) — entries removed;
delete-insert#dawg-delete-insert-01b and
triple-terms-positive#update-reifier-08 are genuinely multi-op W3C
tests now failing loudly instead of passing by silent truncation —
registered with comments pointing at PR-U2/#1438.
Five it_query_sparql.rs tests pinned SPARQL shapes that the V4
projection-scope pass now rejects (they are literally the W3C
agg08/group06 negative-syntax shapes):

- issue-#1362 and field-P0 GROUP-BY-expression tests re-projected an
  UNALIASED key expression (SELECT (LCASE(?cur) AS ?k) ... GROUP BY
  (LCASE(?cur)) — agg08's shape). Migrated to the spec-valid aliased
  key (GROUP BY (expr AS ?k) ... SELECT ?k); the unaliased-collapse
  engine path stays covered by sparql_group_by_bare_builtin_not_projected
  (aggregate-only projection), and a new
  sparql_group_by_reprojected_key_expression_rejected pins the rejection
  and documents the migration.
- two tests pinned Fluree's grouped-list extension on the SPARQL surface
  (SELECT ?person ?favNums ... GROUP BY ?person — group06's shape).
  Rewritten to assert the rejection plus a spec-valid aggregate
  equivalent preserving each test's intent (OPTIONAL-null-through-
  grouping via COUNT; multi-key grouping with AVG/MAX). Grouped-list
  projection remains a JSON-LD-surface feature (see it_query_grouping.rs).

Behavior change is deliberate under roadmap decision D-4 (hard error +
changelog); the PR description carries the migration note.
…sses

New fluree-db-api/tests/it_query_grouping.rs (grp_query), per
docs/contributing/sparql-compliance.md § Query Surface Parity:

- SPARQL surface: hard-error assertions through the public API for V3
  (bnode cross-scope), V4 (ungrouped projection, SELECT *+GROUP BY),
  V5 (BIND rescope), V6 (duplicate / in-scope alias), and the SPARQL
  1.2 nested-aggregate and duplicated-VALUES checks — plus still-works
  controls for valid grouped and BIND queries.
- JSON-LD surface divergence pins (deliberately NOT changed by this
  PR; tightening JSON-LD needs its own decision): ungrouped select
  under groupBy stays accepted and projects a per-group LIST (the
  long-standing analytical feature), and bind on an already-bound
  variable stays accepted, empirically behaving as a join/constraint
  on the existing binding (conflicting expression yields zero rows,
  consistent expression keeps them).

V3/V6/nested-aggregate/dup-VALUES are SPARQL-surface-only rules (no
JSON-LD analogue); recorded as such in the test docs.
…notation IR (burn-down PR-W2A)

Parser (bucket A/D of the wave-2 triple-terms cluster):
- reified triples << s p o ~ r? >> in object position, nested, and as
  standalone statements (SPARQL 1.2 ReifiedTriple with an empty
  property list), including in CONSTRUCT WHERE shorthand
- in-triple reifiers (~ id?) with IRI / blank-node / variable ids;
  the reifier-less subject form stays eligible for the legacy
  f:t / f:op history reading (selected at lowering)
- annotation tails as full (reifier | annotationBlock)* sequences,
  grouped into AnnotationUnit per the RDF 1.2 attachment rule
  (multi-reifier / multi-block, interleaved)
- annotation-block bodies as PropertyListPathNotEmpty (path verbs,
  reified-triple objects)
- VERSION specifier narrowed to short quoted strings (long strings
  were only rejected by accident before the standalone-<<>> fix;
  W3C negative version-bad-01/02)

Lowering routes everything the reifier model can evaluate onto the
existing IR: reified-triple terms desugar per spec to their reifier
ref plus a sibling Pattern::AnnotationTarget (r rdf:reifies <<(s p o)>>),
memoized per occurrence so ;-continued patterns share one reifier;
annotation units lower to one Pattern::EdgeAnnotation each over the
shared base edge; block path verbs reuse the property-path lowering
with the reifier as subject. Non-evaluable positions (UPDATE
templates, path subjects, VALUES) defer with clean not_implemented /
UnsupportedFeature errors per burn-down decision D-1
(accept-then-defer) instead of parse errors.

Validation keeps the W3C negative suite intact: anonymous annotation
units in DELETE templates / DELETE WHERE are rejected (F010,
syntax-update-anonreifier-01 — previously rejected only by the
accidental path-in-block parse failure), and INSERT/DELETE DATA
ground checks recurse into reified-triple terms. The
INSERT/DELETE DATA anonymous-annotation validation stays with
sibling burn-down PR-2.

fluree-db-transact: mechanical AnnotationUnit adoption — the UPDATE
expansion emits one f:reifies* bundle per unit, the user-authored
f:reifies firewall walks path verbs' IRI leaves, and reified-triple
template terms reject cleanly instead of panicking.
…ion tests

Per compliance § Query Surface Parity (and the ROADMAP PR-W2A row's
explicit deliverable): insert through the JSON-LD @annotation surface,
query through the NEW SPARQL 1.2 reifier syntax, and assert the
results are identical to the established ?ann rdf:reifies <<( ... )>>
form — proving the new forms reach the same Pattern::EdgeAnnotation /
Pattern::AnnotationTarget machinery and annotation storage:

- standalone reified triple << s p o ~ ?r >> . vs rdf:reifies
- object-position reified triple joining a JSON-LD-written NAMED
  reifier (@annotation @id) cited by another JSON-LD triple
- two annotation units (~ ?a {| .. |} ~ ?b {| .. |}) both binding the
  single reifier one @annotation wrote (row count stays 1, ?a = ?b)
…rms (-61)

- SPARQL12_SYNTAX_TRIPLE_TERMS_POSITIVE: 85 -> 27. All 58 bucket-A/D
  reifier-form entries green; the remaining 27 are buckets B (3
  triple-term builtins) and C (24 bare triple-term values), owned by
  sibling PR-W2BC under decision D-1 — comments re-pointed.
- SPARQL12_VERSION: 3 -> 0. version-01/02/05 failed only on the bare
  << s p o >> patterns in their bodies (ROADMAP §6.1 comment
  correction), which the reifier-form work greened; version-bad-01/02
  stay rejected via the VERSION short-string check.
- SPARQL12_SYNTAX_TRIPLE_TERMS_NEGATIVE: unchanged (1) — the
  INSERT/DELETE DATA annotation validation is sibling PR-2's;
  ownership note added. syntax-update-anonreifier-01 stays rejected
  through the new DELETE-template validator.

Full suite: 36/36 categories green, 0 unexpected passes / stale
entries.
# Conflicts:
#	testsuite-sparql/tests/registers/mod.rs
# Conflicts:
#	fluree-db-sparql/src/parse/query/mod.rs
#	fluree-db-sparql/src/parse/query/term.rs
#	fluree-db-sparql/src/parse/query/tests.rs
#	testsuite-sparql/tests/registers/mod.rs
# Conflicts:
#	fluree-db-sparql/src/diag/mod.rs
#	fluree-db-sparql/src/validate/mod.rs
#	testsuite-sparql/tests/registers/mod.rs
# Conflicts:
#	fluree-db-sparql/src/diag/mod.rs
#	fluree-db-sparql/src/validate/mod.rs
#	fluree-db-transact/src/lower_sparql_update.rs
#	testsuite-sparql/tests/registers/mod.rs
Union-resolved register merges leave supersets; the both-way CI gate then
reports every entry the integrated branch actually greens (56 tests, 61
lines incl. double-registered copies) — cross-PR interactions like the
update-syntax tests greening under PR-2+PR-3+PR-U1's combined defenses
resolve mechanically instead of by hand-tracked set math.
tests.rs rebuilt deterministically from the clean parents (pr-3's single
inserted block appended; pr-w2a's units-API test migrations applied via
their base anchors) after interleaved conflict hunks with shared
boilerplate context defeated naive both-sides resolution; SubSelect test
literals gain the new values field; unused Annotation import dropped.
@aaj3f aaj3f requested review from bplatz and zonotope July 7, 2026 17:45
bnode_scope::check_blank_node_scopes(pattern, &mut self.diagnostics);
}

fn validate_update(&mut self, op: &UpdateOperation) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validate_update — and the UPDATE rules under it (BlankNodeInDelete, AnonymousAnnotationInDelete, UnsupportedGraphInUpdate) — is never reached on the production UPDATE path. fluree-db-api's parse_and_lower_sparql_update only checks parse-level has_errors() and then lowers; it never calls validate(). Enforcement today relies entirely on the lowering-side mirrors (reject_blank_nodes_in_delete_quad_pattern, the GraphName::Var rejection, resolve_reifier's anon/blank-reifier guards), so behavior is currently correct — but this is fragile in two ways: a W3C negative-update test can go green via the harness's own validate() call while production takes a different code path, and any future validator-only UPDATE rule would silently not apply to real updates. Suggest either wiring validate() into the update seam or documenting explicitly that lowering is the authoritative enforcement point for UPDATE.

let span = stream.current_span();
stream.add_diagnostic(Diagnostic::error(
DiagCode::ExpectedToken,
"multi-operation SPARQL UPDATE requests (';'-separated) are not \

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This multi-op guard (the #1438 silent-data-loss fix) pushes an error diagnostic but does not null the AST — unlike V5's BindTargetAlreadyInScope, which suppresses AST production. The in-tree seams (parse_and_lower_sparql_update, parse_and_validate_sparql) all check has_errors(), so it's loud where it matters. But the public lower_sparql_update_ast export has no internal guard: a caller that reads .ast without first checking has_errors() still gets a valid single-op AST and executes only the first operation — re-opening exactly the #1438 window this guard exists to close. Consider nulling the AST here (or guarding inside the lowering entry point) as belt-and-suspenders, given the failure mode is silent data loss.

@bplatz bplatz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple comments inline.

Base automatically changed from burndown/wave-0 to main July 9, 2026 13:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants