fix: preserve projection shape in JSON-LD query output#1195
Conversation
aaj3f
left a comment
There was a problem hiding this comment.
Good catch here. I do wonder, now that we have dist and release automation, if we should start including a CHANGELOG for PRs like this that may affect external code (e.g. solo for one) where that code had established expectations around previous behavior, even if that behavior was wrong. It might make it easier, for example, for the solo devs--especially if using claude--to update the fluree-db-api dependency version with clarity about what may deserve auditing in the solo code to ensure the version update doesn't break app logic around query results etc
| // Scalar shaping: flatten 1-var rows to bare values. Fires only for | ||
| // JSON-LD `select: "?x"` (bare-string form), which is the user's opt-in | ||
| // to scalar output. SPARQL and JSON-LD array-form select use `Tuple` | ||
| // and skip this step — their rows stay tabular. | ||
| if result.output.projection_shape() == Some(ProjectionShape::Scalar) | ||
| && result.output.select_vars_or_empty().len() == 1 | ||
| { |
There was a problem hiding this comment.
So this makes sense to me in context of this PR. We avoid matches on things like "*" because result.output.projection_shape() would be None.
I just wonder if this guard & condition is readable enough that future refactors would sustain it because they understand what it does defensively. I almost wonder if it might be a nice hygiene and regression-proofing decision to replace this with a method like QueryOutput::should_flatten_scalar()
vars.len() == 1heuristic. Shape is now driven by user-declared projection intent captured at parse time.[[v1],[v2]]), not the previous flat[v1,v2].select: ["?x"](array form) now round-trips to array-of-arrays, preserving the user's wrapper;select: "?x"(bare string) remains scalar-flat.selectOnenow enforcesLIMIT 1at parse time (not just format-time break), matching the ASK path.Problem
The JSON-LD output formatter at
fluree-db-api/src/format/jsonld.rs:50-59collapsed 1-variable SELECT results to a flat array whenevervars.len() == 1, regardless of input syntax:Fix
ProjectionShape { Tuple, Scalar }(new enum influree-db-query/src/parse/lower.rs) captures the user's declared intent at parse time:Tuple(default): every row stays an array. Used by SPARQL and JSON-LD array-form select.Scalar: 1-var rows flatten to bare values. JSON-LD bare-string select only — an explicit opt-in.QueryOutput::Select/SelectOnebecame struct variants carrying{ vars, shape }. The formatter's flatten is now gated onshape == Scalar && vars.len() == 1instead ofvars.len() == 1alone. Flattening happens once at the top level;format_row_arrayalways emits aJsonValue::Arrayinternally.Behavior contract
select: "?x"[v1, v2]select: ["?x"][[v1],[v2]][[a,b],[c,d]]selectOne: "?x"v1(LIMIT 1)selectOne: ["?x"][v1](LIMIT 1)[a,b](LIMIT 1)selectOnenow injectsoptions.limit = Some(1)at parse time, so execution stops after one row instead of materializing the full sequence and discarding the rest at format time.Test fallout & migration
256 test assertions updated across ~35 integration test files plus one unit-test module (
shacl_tests). Two migration patterns were used:"select": ["?x"]→"select": "?x". Preserves the existing flat assertion; lowest-churn fix. Used by the majority of JSON-LD tests.json!([v1,v2])→json!([[v1],[v2]]). Required for SPARQL — the syntax has no scalar form.Also removed
normalize_rows_arrayhelper fromtests/support/mod.rs. It accepted both flat and tuple shapes (wrapping scalars into 1-element arrays), which meant a regression back to flattening would have silently passed tuple-expecting tests. All 179 call sites migrated tonormalize_rows(pure sort, shape-preserving). Remaining helpers (normalize_rows,normalize_flat_results,normalize_sparql_bindings) are pure sort / envelope-extraction — not shape-shifters.