Skip to content

fix: preserve projection shape in JSON-LD query output#1195

Merged
bplatz merged 2 commits intomainfrom
fix/format-shape
Apr 24, 2026
Merged

fix: preserve projection shape in JSON-LD query output#1195
bplatz merged 2 commits intomainfrom
fix/format-shape

Conversation

@bplatz
Copy link
Copy Markdown
Contributor

@bplatz bplatz commented Apr 24, 2026

  • Output formatter no longer flattens 1-var SELECT results via a vars.len() == 1 heuristic. Shape is now driven by user-declared projection intent captured at parse time.
  • SPARQL results are now spec-correct tabular ([[v1],[v2]]), not the previous flat [v1,v2].
  • JSON-LD select: ["?x"] (array form) now round-trips to array-of-arrays, preserving the user's wrapper; select: "?x" (bare string) remains scalar-flat.
  • selectOne now enforces LIMIT 1 at 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-59 collapsed 1-variable SELECT results to a flat array whenever vars.len() == 1, regardless of input syntax:

selectDistinct: "?type"          → ["x", "y"]   (flat)
selectDistinct: ["?type"]        → ["x", "y"]   (flat — wrong; array wrapper discarded)
selectDistinct: ["?s", "?type"]  → [["a","b"],…] (tuple — correct)

Fix

ProjectionShape { Tuple, Scalar } (new enum in fluree-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 / SelectOne became struct variants carrying { vars, shape }. The formatter's flatten is now gated on shape == Scalar && vars.len() == 1 instead of vars.len() == 1 alone. Flattening happens once at the top level; format_row_array always emits a JsonValue::Array internally.

Behavior contract

Input Output (1 var) Output (≥2 vars)
select: "?x" [v1, v2] n/a
select: ["?x"] [[v1],[v2]] [[a,b],[c,d]]
selectOne: "?x" v1 (LIMIT 1) n/a
selectOne: ["?x"] [v1] (LIMIT 1) [a,b] (LIMIT 1)

selectOne now injects options.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:

  1. Bare-string migration (JSON-LD tests that just wanted values): "select": ["?x"]"select": "?x". Preserves the existing flat assertion; lowest-churn fix. Used by the majority of JSON-LD tests.
  2. Assertion update (SPARQL tests and multi-var tests): json!([v1,v2])json!([[v1],[v2]]). Required for SPARQL — the syntax has no scalar form.

Also removed normalize_rows_array helper from tests/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 to normalize_rows (pure sort, shape-preserving). Remaining helpers (normalize_rows, normalize_flat_results, normalize_sparql_bindings) are pure sort / envelope-extraction — not shape-shifters.

@bplatz bplatz requested review from aaj3f and zonotope April 24, 2026 00:11
Copy link
Copy Markdown
Contributor

@aaj3f aaj3f left a comment

Choose a reason for hiding this comment

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

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

Comment thread fluree-db-api/src/format/jsonld.rs Outdated
Comment on lines +57 to +63
// 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
{
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.

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()

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.

Addressed in 7c9926b

@bplatz bplatz merged commit 174eab3 into main Apr 24, 2026
11 checks passed
@bplatz bplatz deleted the fix/format-shape branch April 24, 2026 16:07
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