Skip to content

accept legal aggregate and property-path forms below the parser - #1629

Merged
aaj3f merged 9 commits into
mainfrom
fix/lowering-over-restrictions
Aug 11, 2026
Merged

accept legal aggregate and property-path forms below the parser#1629
aaj3f merged 9 commits into
mainfrom
fix/lowering-over-restrictions

Conversation

@aaj3f

@aaj3f aaj3f commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Three cases where the parser accepts a legal SPARQL 1.1 form and a lower layer then rejects it with a 400, plus two aggregate-evaluation fixes found along the way.

Property paths with literal objects: lowering forced every path object through Ref::try_from, so ?s ex:ofMaker/ex:name "Acme" — an ordinary, matchable shape — was rejected wholesale. Endpoints now carry a Term and each arm narrows only as far as it must: sequence final hops and alternative branches take the literal directly, a simple inverse onto a literal lowers to a statically-empty pattern (RDF has no literal subjects — zero solutions, not an error), and the transitive arms keep a narrow error naming the sub-case, since their IR endpoints are typed Ref.

Aggregates over a GROUP BY key: SELECT ?k (COUNT(?k) AS ?n) … GROUP BY ?k (and the HAVING/COUNT(DISTINCT ?k) variants) returned 400. The check was guarding a real hazard on the traditional grouping path — the aggregate would have answered with the key term itself — so rather than delete it, each aggregated key is copied to a fresh non-key column before grouping and the aggregate reads the copy. The copy preserves unboundness, so an OPTIONAL-bound key keeps COUNT(?k) at 0 where COUNT(*) counts rows. The traditional path is pinned explicitly via EXPLAIN (the streaming path alone would have passed even with the old hazard).

COUNT(DISTINCT *) is now implemented (count of distinct solutions, §18.5.1.1 — the W3C agg-count-rows-distinct test now passes and its register entry is removed), with distinctness scoped to user-visible variables so the lowerer's synthetic path-join and blank-node variables don't split solutions the spec considers identical.

And GROUP_CONCAT over IRI-valued variables no longer silently drops them (it only extracted strings from literal bindings, so an all-IRI group returned null): IRIs expand through the same namespace table STR() uses, matching Jena. The spec states no coercion for non-literal members, so if we'd rather this be a type error than a concatenation, say so — either beats dropping members silently.

aaj3f added 6 commits August 10, 2026 13:17
SPARQL 1.1 §19.8 reaches a path object through GraphNodePath, which admits
RDFLiteral / NumericLiteral / BooleanLiteral, so `?s ex:ofMaker/ex:name "Acme"`
is an ordinary matchable shape. Lowering forced every path object through
Ref::try_from and rejected all of them with err:db/InvalidQuery.

Carry the endpoint as a Term (plus the annotation surface's datatype
constraint) and narrow per-arm instead of up front. Sequence final hops and
alternative branches place it straight into the TriplePattern object slot.
A simple inverse onto a literal lowers to a statically-empty pattern —
RDF has no literal subjects, so that is zero solutions, not an error.

Transitive arms (+/*/?) and negated property sets keep a narrow error naming
the sub-case: their IR endpoints are typed Ref. Those shapes are legal SPARQL,
so the message says the limitation is ours.

Tested end-to-end, not by the W3C syntax suite: evaluate_positive_syntax_test
never calls lower_sparql, which is why syn-pp-in-collection stayed green the
whole time this was broken.
SPARQL 1.1 §18.5.1.1 counts an aggregate's argument within each group and
does not exclude an expression that also appears in GROUP BY, so
`SELECT ?k (COUNT(?k) AS ?n) … GROUP BY ?k` is legal. It returned HTTP 400,
and so did HAVING (COUNT(?k) …) and COUNT(DISTINCT ?k) over the key — the
reported "add DISTINCT" workaround never worked.

The check was guarding something real: on the traditional grouping path
GroupByOperator writes the scalar key into key columns and AggregateFn::apply
returns a non-Grouped input unchanged, so the aggregate would have answered
with the key term itself. Rather than delete the guard, make the hazard
unreachable — copy each aggregated key into a fresh non-key column before
grouping and point the aggregate at the copy. Expression::Var(k) preserves the
key's unbound-ness, so an OPTIONAL-bound key keeps COUNT(?k) at 0 where
COUNT(*) counts rows. The streaming path reads the copy as an ordinary column,
so one rewrite serves both operators.

variable_deps is computed from the pre-rewrite IR, so it is extended with the
copies; otherwise GroupByOperator's projection trimming drops the column the
aggregate now reads.

The traditional path is pinned explicitly (GROUP_CONCAT forces it, EXPLAIN
asserts which operators ran) — the streaming path alone would have passed even
with the old hazard.
SPARQL 1.1 §18.5.1.1 defines COUNT(DISTINCT *) as the count of distinct
solutions in a group, and the W3C agg-count-rows-distinct test exercises it.
The parser accepted it and lowering returned not_implemented, so it was a 400.
The comment justifying that ("DISTINCT * is not meaningful for COUNT") was
wrong as a matter of spec and is corrected.

Adds a CountDistinctAll aggregate variant. It is the only aggregate that reads
the whole row rather than one column, so both group operators grew whole-row
plumbing: the streaming side keeps a HashSet over the composed, normalized row
(mirroring CountDistinct's per-value state), and the traditional side
reconstructs a group's solutions by zipping the Grouped columns of the
GroupByOperator output row.

Because it reports no input variable, the backward dependency walk cannot see
that it depends on every WHERE column — projection trimming would drop the
column that makes two solutions differ and undercount. compute_variable_deps
therefore disables trimming when it is present, and duplicate_insensitive is
false for the same reason (the dedup it gates also projects away columns).

Removes agg-count-rows-distinct from SPARQL11_AGGREGATES; the register's
stale-entry check enforces that the two stay in step. Aggregates suite is now
45 passed / 0 failed / 1 registered (was 44 / 0 / 2); the remaining entry is
the documented empty-named-graph divergence.

Known narrow divergence: the composed row is the whole pre-grouping batch, so
lowering-internal variables (property-path joins ?__ppN, blank-node variables)
participate in distinctness even though SPARQL projects them out of the
solution. Mainstream shapes are unaffected.
`*` denotes the solution mapping, and SPARQL projects the lowerer's synthetic
variables out of it — property-path join variables (?__ppN) and
non-distinguished blank-node variables (§4.1.4). Composing distinctness over
the raw executor row let those split solutions the spec considers identical:
two ex:a/ex:b routes between the same endpoints counted as 2, not 1. Silent
overcount, no error.

CountDistinctAll now carries the user-visible variable list, produced by the
same filter SELECT * uses (extracted as `user_visible_vars` so the two cannot
drift). Both group operators resolve it against their input schema and read
only those columns; variables from other scopes and SELECT aliases never
appear there and drop out in that intersection. The WHERE clause is lowered
before the solution modifiers, so the registry is complete when the aggregate
lowers.

Regression tests cover a property-path join variable and a blank-node
variable, on both the streaming and traditional grouping paths. Each asserts
COUNT(*) alongside, so the row multiplicity is proven to exist rather than
assumed.
agg_group_concat extracted strings only from Binding::Lit, so the three IRI
representations (Sid / IriMatch / Iri) fell through its catch-all and were
skipped. An all-IRI group concatenated nothing and returned Unbound — rendered
null — and a mixed IRI/literal group silently dropped its IRI members while
returning a plausible-looking string for the rest. Neither raised an error.

Expand IRI bindings to their full IRI string in apply_aggregate, the wrapper
that already pre-decodes values for exactly this aggregate family. The
namespace table comes from ctx.active_snapshot, the same source STR() uses, so
GROUP_CONCAT(?s) and GROUP_CONCAT(STR(?s)) now agree — the explicit STR
workaround is redundant rather than required. Both lanes are covered: after
the graph-view materialization (indexed ledgers, where an encoded ref decodes
to a Sid) and without one (memory ledgers, where bindings arrive decoded).

A Sid whose namespace code is missing from the table still cannot be expanded;
it is left alone and skipped downstream, as before.

Note SPARQL 1.1 §18.5.1.7 does not state a coercion for non-literal group
members. Concatenating the IRI matches Jena and this engine's own STR(); the
alternative reading is a type error. Worth confirming, but either is better
than silently dropping members.
@aaj3f
aaj3f requested review from bplatz and zonotope August 11, 2026 13:32
test_aggregate_on_group_by_key_errors pinned the blanket rejection this
branch removes. Split it: COUNT over a key into a fresh output var now
executes and returns the group's row count, while writing the output onto
the key itself still errors — the key column stays in the schema, so the
alias collides. The error text asserted is the schema-collision message
rather than the removed rejection.

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

CI is green now. Two inline comments.

On the GROUP_CONCAT question in the description: concatenating is the right call over erroring — Jena parity beats a type error for a shape that silently dropped members before.

Checked and clear, so nobody re-derives: never_matches is safe with a constant subject (apply_values never elides, and ValuesOperator with zero rows yields zero rows regardless of schema); and dropping dtc on the plain path surface really does match the hand-written triple, since term.rs:67 also lowers objects without a constraint.


let num_cols = self.in_schema.len();
let mut output_columns: Vec<Vec<Binding>> = Vec::with_capacity(num_cols);
// Namespace table for expanding IRI-valued GROUP_CONCAT members.

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.

ctx.active_snapshot is one snapshot. In a dataset / multi-ledger query, is this the right namespace table for every IRI that reaches the aggregate?

If bindings arrive already re-encoded into a union namespace space, this is fine. If any arrive carrying a non-active ledger's namespace codes, they expand against the wrong table — which is the #1259 failure mode, and would show up as GROUP_CONCAT emitting a wrong IRI rather than dropping one. Neither the new tests nor the existing ones cover GROUP_CONCAT over a multi-ledger dataset, so the answer isn't pinned either way.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fluree-db-query/src/aggregate.rs:228 — is ctx.active_snapshot the right namespace table?

You were right that nothing pinned this either way, and it was worth asking. The short version: for the dataset path it is correct, for a reason worth writing down, and there's now a test that fails if that ever stops being true. But the SERVICE path is a different story — see the bottom of this note.

Why the dataset path is safe. DatasetOperator stamps every cross-ledger binding before it leaves the member. dataset_operator.rs:388 stamp_provenance runs each batch through stamp_binding (:428), which converts Binding::SidBinding::IriMatch carrying the IRI decoded in its own ledger via ctx.decode_sid_in_ledger(sid, ledger_id) — and it errors rather than falling back to Binding::Sid, precisely because multi-ledger equality is defined around IriMatch. It's gated on needs_provenance, set to multi_ledger at :569, and :616-620 disables the binary store for those members so scans yield Binding::Sid rather than an EncodedSid that couldn't be decoded later. So by the time a batch reaches the aggregate, every cross-ledger IRI is an IriMatch, and group_concat_expand_iris reads that canonical iri field directly and never consults active_snapshot for it. The single-ledger case is trivially fine because active_snapshot is that ledger.

And it's pinned now (5f7abcaab). The new test seeds two ledgers under different prefixes — http://alpha.example/ and http://beta.example/ — that get allocated the same namespace code, since each ledger numbers its namespaces independently and in insertion order. That's what makes a wrong-table expansion visible rather than merely possible: expanding beta's SID against alpha's table produces http://alpha.example/b1, well-formed and wrong, which is exactly the #1259 shape you flagged.

I didn't want to ship a green test that was green for the wrong reason, so I mutation-checked it: routing the IriMatch arm through the namespace table instead of its canonical IRI makes it fail with

each subject must expand against its OWN ledger's namespace table,
got "http://alpha.example/a1|http://alpha.example/b1"

so the assertion really is load-bearing.

The part that isn't fine — and I don't think it's this PR's. While chasing the above I probed SERVICE against another ledger, and it does not go through stamp_provenance: service.rs:189-194 builds the inner tree against ctx.with_graph_ref(gref) and buffers the resulting rows as-is, so a Binding::Sid carrying the target ledger's namespace codes flows back out into a context whose active_snapshot is the parent. Against the same two-ledger fixture:

SELECT ?s WHERE { SERVICE <fluree:ledger:svc-beta:main> { ?s ?p "shared" } }

returns http://alpha.example/b1 — the wrong IRI, with no aggregate anywhere in the query. GROUP_CONCAT(?s) over the same pattern inherits it, but the plain SELECT is the proof that this predates the change: the formatter is decoding a foreign SID against the primary snapshot. Two useful contrasts from the same probe — GROUP_CONCAT(STR(?s)) returns http://beta.example/b1 correctly, because eval_str runs per-row while the ctx is still the SERVICE target's; and the GRAPH <svc-beta:main> form is also correct, because that one does go through the dataset operator.

I'd rather not fix that here, and not because of appetite — it's that the obvious fix (stamp the SERVICE operator's returned batches the way dataset members are stamped) turns every SERVICE-returned IRI into an IriMatch, which changes join, DISTINCT, and GROUP BY key identity for every SERVICE query, and stamp_binding hard-errors on EncodedSid while the SERVICE path doesn't disable the binary store. That's a real change with its own test surface, sitting in a different subsystem from anything this PR touches. So my instinct is a follow-up issue with the repro above, and I'm happy to pick it up as the next thing — but if you'd rather it ride along here, say so and I will.

.flat_map(Grouping::aggregates)
.any(|spec| matches!(spec.function, AggregateFn::CountDistinctAll(_)))
{
return None;

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 disables dead-column trimming for the entire query whenever any COUNT(DISTINCT *) appears anywhere in it — including scopes the aggregate never reads, and including the common shape where it sits beside other aggregates over a wide WHERE.

The conservatism is correct; the blast radius is wider than the hazard. Worth either scoping it to the grouping's own input scope, or noting in the comment that it's deliberately query-global so the cost isn't a surprise later.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fluree-db-query/src/execute/dependency.rs:49 — trimming disabled query-global

Agreed, and thank you — the blast radius really was wider than the hazard, and poking at your either/or turned up a third option that I think is better than either.

The thing I'd missed is that this aggregate does have inputs; it carries them. CountDistinctAll was given the user-visible variable list in ee670ad06 (the commit that stopped ?__ppN and blank-node variables from splitting solutions), so the backward walk doesn't need to be told "give up" — it needs to be told what to trace. 6d7d5b7a3 deletes the early return and extends the aggregate step of the walk instead, so CountDistinctAll's variable list is traced exactly the way every other aggregate's single input_var already is:

match &spec.function {
    AggregateFn::CountDistinctAll(vars) => deps.extend(vars.iter().copied()),
    other => deps.extend(other.input_var()),
}

That lands better than either of the options you offered, I think. The pre-grouping stages keep precisely the columns the whole-row read needs and genuinely dead columns stay trimmable; the post-grouping sets are recorded earlier in the walk so they're untouched entirely; and variables the list names from other scopes simply never match a schema and drop out in compute_trimmed_vars. Net effect is that the common shape you named — COUNT(DISTINCT *) sitting beside other aggregates over a wide WHERE — now keeps its trimming everywhere except the columns that are actually load-bearing.

Same anti-vacuity check as above, since a dependency-set widening is the kind of thing that can look right while doing nothing: dropping just that one match arm fails five of the COUNT(DISTINCT *) tests, including sparql_count_distinct_star_survives_projection_trimming, which projects neither WHERE variable and still has to count 2. So the trace is doing real work rather than belt-and-braces.

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

Left some feedback below to look at before merge

aaj3f added 2 commits August 11, 2026 14:03
apply_aggregate expands IRIs with ctx.active_snapshot's namespace table, which
is one ledger's. Nothing pinned whether that is right for a dataset query.

It is, and for a reason worth recording: DatasetOperator stamps every
cross-ledger Binding::Sid into Binding::IriMatch before the batch leaves the
member (stamp_provenance), carrying the IRI already decoded in its OWN ledger
and erroring rather than falling back. The expansion reads that canonical IRI
and never re-expands a foreign SID.

The test makes that machine-enforced. Its two ledgers use different prefixes
that were allocated the same namespace code, so expanding one ledger's SID
against the other's table yields a well-formed but wrong IRI. Confirmed
discriminating by mutation: routing IriMatch through the namespace table
instead of its canonical IRI produces http://alpha.example/b1 and the test
fails.
Disabling projection trimming for the whole query was conservative in the
right direction but far wider than the hazard: it also gave up trimming in
stages the aggregate never reads, including the common shape where it sits
beside other aggregates over a wide WHERE.

The aggregate does have inputs — it carries them. Extend the backward walk's
aggregate step to trace CountDistinctAll's user-visible variable list the way
every other aggregate's single input_var is traced. The pre-grouping stages
then keep exactly the columns the whole-row read needs, genuinely dead columns
stay trimmable, and the post-grouping stages (recorded earlier in the walk)
are untouched. Variables the list names from other scopes never match a schema
and drop out.

The trace is load-bearing, not belt-and-braces: dropping the arm fails five of
the COUNT(DISTINCT *) tests, including the one that asserts a query projecting
neither WHERE variable still counts 2.
@aaj3f

aaj3f commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

Thank you, @bplatz — and thanks especially for chasing down never_matches and the dtc drop and writing the answers into the review. Both were things I'd reasoned about but not proved, and having apply_values never eliding and term.rs:67 also lowering objects without a constraint stated plainly means nobody has to re-derive either one. That's exactly the kind of note that saves a future reader an hour.

Good to have the GROUP_CONCAT call confirmed too. Jena parity over a type error was the reading I leaned toward but it genuinely was a coin-flip on the spec text (§18.5.1.7 doesn't say what coercion applies to a non-literal member), so I'd rather have it be a decision we made than a default I picked. I've left the spec ambiguity noted in the commit message so the release note can say we chose it.

Both inline notes are addressed below. The active_snapshot one turned into the more interesting of the two — the answer for the dataset path is "correct, and now pinned", but chasing it surfaced a real pre-existing hole on the SERVICE path that I think wants its own issue.

Verification at 6d7d5b7a3 — ## Verification at 6d7d5b7a3

Workspace-wide this time rather than the touched crates — that was the lesson from the two follow-up commits it took to get CI green here, and err_expect would have been caught by it. cargo clippy --workspace --all-targets clean on every file this branch touches; cargo fmt --all clean. cargo test -p fluree-db-query (all targets, not just --lib) 1381 + 6 target suites green; -p fluree-db-sparql 625 + 6 doctests; -p fluree-db-cypher green. fluree-db-api: grp_query_sparql 330, grp_query 414 (this is the target that owns the dataset tests), grp_misc 262, it_query_cypher 231, it_query_explain 13. W3C full suite 36/36; sparql11_aggregates 46 total / 45 passed / 0 failed / 1 registered — unchanged, and still the empty-named-graph divergence rather than anything from this branch; sparql11_grouping 6/6.

@aaj3f
aaj3f merged commit a345010 into main Aug 11, 2026
15 checks passed
@aaj3f
aaj3f deleted the fix/lowering-over-restrictions branch August 11, 2026 18:20
@aaj3f

aaj3f commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

Following up on the SERVICE hole I mentioned in the aggregate.rs thread — filed as #1639 and fixed in #1641.

Two things from chasing it that are worth having here, since both bear on what we concluded in this PR.

The scope turned out narrower than I described. Joins across a SERVICE boundary were already correct, in both orders: service.rs seeds the inner tree from the parent row, so a term crosses by substitution and the target ledger re-encodes it — nothing was ever comparing a foreign SID. So the defect is confined to IRIs a SERVICE block newly binds and that then escape to output or aggregation. The plain SELECT is still the proof it predates #1629; GROUP_CONCAT just made it visible.

And the representation was already mixed rather than uniform, which is the part that made this a small fix instead of a scary one. A cross-ledger SERVICE requires a dataset, and any two-ledger dataset already sets needs_provenance — so the parent's own bindings were IriMatch while SERVICE's were raw SIDs. Stamping the SERVICE rows makes the query uniform rather than introducing a second regime, which is why the join/DISTINCT/GROUP BY key-identity worry I raised when I flagged this didn't materialize. I pinned it three ways anyway before touching anything.

Thanks again for pushing on the active_snapshot question — the dataset path really was fine, but it would not have occurred to me to check SERVICE if you hadn't asked whether one snapshot was the right table for every IRI reaching the aggregate.

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