From a8d9ad955579418388649b524b4bb30ce2654d67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rene=CC=81=20Verheij?= Date: Mon, 6 Apr 2026 16:24:39 +0100 Subject: [PATCH] Refine SPARQL lowering for required outer filter bindings --- .changeset/required-filter-bindings.md | 9 + docs/reports/013-required-filter-bindings.md | 306 +++++++++++++++++++ documentation/sparql-algebra.md | 19 +- src/sparql/irToAlgebra.ts | 167 ++++++++-- src/test-helpers/query-fixtures.ts | 3 + src/tests/sparql-algebra.test.ts | 64 ++++ src/tests/sparql-fuseki.test.ts | 14 + src/tests/sparql-select-golden.test.ts | 45 +-- 8 files changed, 583 insertions(+), 44 deletions(-) create mode 100644 .changeset/required-filter-bindings.md create mode 100644 docs/reports/013-required-filter-bindings.md diff --git a/.changeset/required-filter-bindings.md b/.changeset/required-filter-bindings.md new file mode 100644 index 0000000..7d7faf6 --- /dev/null +++ b/.changeset/required-filter-bindings.md @@ -0,0 +1,9 @@ +--- +'@_linked/core': patch +--- + +Refine SPARQL select lowering so top-level null-rejecting filters emit required triples instead of redundant `OPTIONAL` bindings. Queries like `Person.select().where((p) => p.name.equals('Semmy'))` now lower to a required `?a0 ?a0_name` triple, while cases that still need nullable behavior such as `p.name.equals('Jinx').or(p.hobby.equals('Jogging'))` remain optional. + +This change does not add new DSL APIs, but it does change the generated SPARQL shape for some outer `where()` clauses to better match hand-written intent. Inline traversal `.where(...)`, `EXISTS` filters, and aggregate `HAVING` paths keep their previous behavior. + +See `documentation/sparql-algebra.md` for the updated lowering rules and examples. diff --git a/docs/reports/013-required-filter-bindings.md b/docs/reports/013-required-filter-bindings.md new file mode 100644 index 0000000..1d3bae5 --- /dev/null +++ b/docs/reports/013-required-filter-bindings.md @@ -0,0 +1,306 @@ +--- +summary: Refine SPARQL select lowering so top-level null-rejecting filters use required bindings instead of redundant OPTIONAL triples. +packages: [core] +--- + +# Required Filter Bindings + +## Scope + +This report documents the refinement to `@_linked/core` SPARQL select lowering that promotes top-level filter bindings from `OPTIONAL` to required triples when the filter would reject rows without those bindings. + +The change is intentionally narrow: + +- it affects top-level `query.where` lowering in `selectToAlgebra()` +- it does not change inline traversal `.where(...)` lowering +- it does not change `EXISTS` block lowering +- it does not change aggregate HAVING behavior + +## Motivation + +The previous lowering strategy treated every discovered property binding as optional unless it came from a required traversal. That produced SPARQL such as: + +```sparql +SELECT DISTINCT ?a0 +WHERE { + ?a0 rdf:type . + OPTIONAL { ?a0 ?a0_name . } + FILTER(?a0_name = "Semmy") +} +``` + +That query is valid, but it is not how a human would typically write the same intent. Because the `FILTER` already rejects rows where `?a0_name` is unbound, the `OPTIONAL` is redundant. + +The goal of this scope was to improve generated SPARQL so it more closely matches hand-written intent without changing result semantics. + +## Architecture Overview + +The relevant pipeline remains: + +1. DSL builders emit `IRSelectQuery` +2. `selectToAlgebra()` in `src/sparql/irToAlgebra.ts` converts IR into SPARQL algebra +3. algebra serialization produces the final SPARQL string +4. result mapping reconstructs projected objects from bindings + +The refinement sits inside step 2. + +## Final Design + +### Core rule + +Bindings referenced by a top-level `query.where` are partitioned into: + +- required bindings: emitted in the main BGP +- optional bindings: emitted via `OPTIONAL` `LeftJoin`s exactly as before + +Bindings are promoted only when the outer filter is null-rejecting with respect to that binding. + +### Boolean composition rules + +The implementation uses a small recursive analysis over IR expressions: + +- `AND`: required bindings are the union of both sides +- `OR`: required bindings are the intersection of both sides +- `NOT`: forwards the required set of its inner expression +- `binary_expr` and `function_expr`: gather required bindings from their arguments +- `exists_expr`: contributes nothing to the outer required set because it lowers inside its own block +- `aggregate_expr`: contributes nothing because aggregate comparisons continue through HAVING behavior + +This gives the desired result: + +- `p.name.equals("Semmy")` promotes `name` +- `p.name.equals("A").or(p.name.equals("B"))` promotes `name` +- `p.name.equals("A").or(p.hobby.equals("B"))` promotes neither + +### Why this design + +This was chosen over a simpler “selected and filtered” heuristic because the simplification is about row elimination semantics, not projection overlap. The null-rejection approach is small enough to maintain, scales to function-based filters, and avoids over-constraining `OR` expressions. + +## File Responsibilities + +### `src/sparql/irToAlgebra.ts` + +This file owns the lowering change. + +Key additions: + +- `bindingKey()` and `contextAliasKey()` for stable lookup keys +- `mergeKeySets()` and `intersectKeySets()` for boolean composition +- `collectRequiredBindingKeys()` to compute which outer filter bindings are mandatory +- partitioning logic in `processExpressionForProperties()` to route each discovered binding to either `requiredPropertyTriples` or `optionalPropertyTriples` + +The rest of the select-lowering pipeline stays intact: + +- root type triple is always required +- traversals are still required unless they are already modeled as filtered optional traversal blocks +- inline traversal filters still produce nested OPTIONAL blocks +- `EXISTS` and `MINUS` keep their local property-collection behavior + +### `src/test-helpers/query-fixtures.ts` + +Added one focused fixture: + +- `outerWhereDifferentPropsOr` + +This exists specifically to guard the case that must not simplify: + +```ts +Person.select((p) => [p.name, p.hobby]) + .where((p) => p.name.equals('Jinx').or(p.hobby.equals('Jogging'))) +``` + +### `src/tests/sparql-algebra.test.ts` + +Expanded structural assertions for: + +- simple top-level equality +- top-level filter plus projection +- same-property `OR` +- different-property `OR` +- context-property filters +- implicit traversal filters +- function-based filters +- aggregate filter inputs remaining optional + +These tests verify placement in the algebra tree, not just textual output. + +### `src/tests/sparql-select-golden.test.ts` + +Updated goldens where simplification is now intended and added a golden for the non-simplifying different-property `OR` case. + +### `src/tests/sparql-fuseki.test.ts` + +Added integration coverage to prove semantic preservation for the different-property `OR` case. This is the highest-risk case for accidental over-promotion. + +### `documentation/sparql-algebra.md` + +Updated the docs so the public description no longer claims that every discovered property becomes optional. The documentation now explains that top-level null-rejecting filters promote required triples while projection-only and conditional bindings remain optional. + +## Behavior Examples + +### Example 1: simple top-level equality + +DSL: + +```ts +Person.select().where((p) => p.name.equals('Semmy')) +``` + +Current SPARQL: + +```sparql +SELECT DISTINCT ?a0 +WHERE { + ?a0 rdf:type . + ?a0 ?a0_name . + FILTER(?a0_name = "Semmy") +} +``` + +### Example 2: projected property plus filtered property + +DSL: + +```ts +Person.select((p) => p.name).where((p) => p.bestFriend.equals(getQueryContext('user'))) +``` + +Current SPARQL shape: + +- `bestFriend` is required because the filter depends on it +- `name` stays optional because it is projection-only + +### Example 3: same-property OR + +DSL: + +```ts +Person.select((p) => p.name) + .where((p) => p.name.equals('Semmy').or(p.name.equals('Moa'))) +``` + +`name` is required because both branches depend on the same binding. + +### Example 4: different-property OR + +DSL: + +```ts +Person.select((p) => [p.name, p.hobby]) + .where((p) => p.name.equals('Jinx').or(p.hobby.equals('Jogging'))) +``` + +Both bindings stay optional because either branch can match without the other property being present. + +### Example 5: implicit traversal filter + +DSL: + +```ts +Person.select().where((p) => p.friends.name.equals('Moa')) +``` + +The friend traversal remains required, and the filtered `name` binding is now also required because the outer filter cannot pass without it. + +## Public API Surface + +There are no new public exports, classes, or user-facing DSL methods in this scope. + +The behavioral change is in SPARQL generation only: + +- simpler SPARQL for outer null-rejecting filters +- unchanged result mapping and DSL surface + +## Resolved Edge Cases + +- `OR` across different properties does not simplify +- shared-property `OR` does simplify +- function filters such as `strlen(name) > 5` simplify because they are null-rejecting +- context-property comparisons simplify only the filtered binding and leave projection-only bindings optional +- aggregate comparisons such as `friends.size().equals(2)` keep aggregate inputs optional and continue to rely on HAVING semantics +- `EXISTS` internals do not leak required bindings into the outer query + +## Validation Coverage + +Validation was run at two levels. + +### Targeted validation + +Command: + +```bash +npm test -- --runInBand --runTestsByPath src/tests/sparql-algebra.test.ts src/tests/sparql-select-golden.test.ts src/tests/sparql-fuseki.test.ts +``` + +Result: + +- 3 suites passed +- 204 tests passed + +### Full package validation + +Command: + +```bash +npm test -- --runInBand +``` + +Result: + +- 33 suites passed +- 3 suites skipped +- 1033 tests passed +- 114 skipped tests remained skipped + +## Documentation Links + +- `documentation/sparql-algebra.md` +- `src/sparql/irToAlgebra.ts` +- `src/tests/sparql-algebra.test.ts` +- `src/tests/sparql-select-golden.test.ts` +- `src/tests/sparql-fuseki.test.ts` + +## Tradeoffs And Final Decisions + +- Kept the implementation local to select lowering instead of rewriting all property discovery paths. +- Chose semantic null-rejection analysis over a projection-aware heuristic. +- Treated `OR` conservatively via set intersection to preserve correctness. +- Left inline traversal `.where(...)` and `EXISTS` lowering untouched because they already model their own scope and optionality rules. + +## Limitations + +- The required-binding analysis currently treats function-based outer filter usage as null-rejecting by default. That matches the current function set and SPARQL behavior used here, but future additions with explicit null-tolerant semantics may need an exception list. +- This scope does not attempt to further optimize nested `EXISTS`, traversal-local OPTIONAL blocks, or serializer-level formatting beyond required-vs-optional placement. + +## Deferred Work + +Nothing was deferred from this specific scope. + +If future work expands the expression system with null-tolerant functions, add a focused follow-up ideation doc rather than weakening the current rule implicitly. + +## Wrapup Status + +Code readability: + +- reviewed +- one clarifying comment added to `src/sparql/irToAlgebra.ts` + +Dead code: + +- none found in scope + +Documentation: + +- updated and aligned with behavior + +PR readiness: + +- implementation: ready +- tests: ready +- docs: ready +- changeset: pending user-selected bump level +- final commit: pending changeset resolution + +PR reference: + +- no PR created during this scope diff --git a/documentation/sparql-algebra.md b/documentation/sparql-algebra.md index 1ec18db..da05eb5 100644 --- a/documentation/sparql-algebra.md +++ b/documentation/sparql-algebra.md @@ -181,7 +181,7 @@ Converts an `IRSelectQuery` to a `SparqlSelectPlan`. The algorithm: 1. **Root type triple** — `?a0 rdf:type ` becomes the required BGP. 2. **Traverse patterns** — each `IRTraversePattern` becomes a triple: `?from ?to`. Filtered traversals (inline `.where()`) are collected separately. -3. **Property discovery** — walks all projection, where, and orderBy expressions to find `property_expr` references. Each unique property becomes an OPTIONAL triple: `OPTIONAL { ?alias ?var }`. +3. **Property discovery** — walks all projection, where, and orderBy expressions to find `property_expr` references. Projection-only and conditionally-needed bindings stay OPTIONAL, but top-level WHERE bindings that are mandatory for row survival are emitted as required triples in the main BGP. 4. **Filtered OPTIONAL blocks** — inline `.where()` filters produce OPTIONAL blocks containing the traverse triple, filter property triples, and FILTER expression together. 5. **WHERE clause** — `query.where` becomes either a FILTER (for non-aggregate expressions) or HAVING (for expressions containing aggregates like COUNT > N). 6. **Subject targeting** — `query.subjectId` becomes an additional FILTER: `?a0 = `. @@ -206,6 +206,23 @@ SparqlSelectPlan { } ``` +#### Example: outer where promotion + +DSL: `Person.select().where((p) => p.name.equals('Semmy'))` + +Because the outer filter rejects rows when `?a0_name` is unbound, the `name` triple is emitted as required instead of `OPTIONAL`: + +```sparql +SELECT DISTINCT ?a0 +WHERE { + ?a0 rdf:type . + ?a0 ?a0_name . + FILTER(?a0_name = "Semmy") +} +``` + +For `OR` filters, only bindings required by every branch are promoted. For example, `p.name.equals('Jinx').or(p.hobby.equals('Jogging'))` keeps both bindings optional because either branch can satisfy the filter on its own. + Serialized SPARQL: ```sparql PREFIX rdf: diff --git a/src/sparql/irToAlgebra.ts b/src/sparql/irToAlgebra.ts index 94b27fb..0cc2cb0 100644 --- a/src/sparql/irToAlgebra.ts +++ b/src/sparql/irToAlgebra.ts @@ -145,6 +145,39 @@ function joinNodes( return {type: 'join', left, right}; } +function bindingKey(alias: string, property: string): string { + return `${alias}::${property}`; +} + +function contextAliasKey(contextIri: string): string { + return `__ctx__${contextIri}`; +} + +function mergeKeySets(...sets: ReadonlySet[]): Set { + const merged = new Set(); + for (const set of sets) { + for (const key of set) { + merged.add(key); + } + } + return merged; +} + +function intersectKeySets(sets: ReadonlySet[]): Set { + if (sets.length === 0) { + return new Set(); + } + + const [first, ...rest] = sets; + const intersection = new Set(first); + for (const value of intersection) { + if (!rest.every((set) => set.has(value))) { + intersection.delete(value); + } + } + return intersection; +} + // --------------------------------------------------------------------------- // Pattern helpers // --------------------------------------------------------------------------- @@ -184,7 +217,7 @@ class VariableRegistry { private usedVarNames = new Set(); private key(alias: string, property: string): string { - return `${alias}::${property}`; + return bindingKey(alias, property); } has(alias: string, property: string): boolean { @@ -255,6 +288,14 @@ export function selectToAlgebra( ): SparqlSelectPlan { const registry = new VariableRegistry(); + // Promote bindings only when the top-level WHERE would reject rows without + // them. This keeps human-like SPARQL for null-rejecting filters without + // over-constraining OR cases that can still match through other branches. + const requiredPropertyKeys = query.where + ? collectRequiredBindingKeys(query.where) + : new Set(); + + const requiredPropertyTriples: SparqlTriple[] = []; // Track property triples that need to be added as OPTIONAL const optionalPropertyTriples: SparqlTriple[] = []; @@ -307,6 +348,8 @@ export function selectToAlgebra( item.expression, registry, optionalPropertyTriples, + requiredPropertyTriples, + requiredPropertyKeys, ); } @@ -315,6 +358,8 @@ export function selectToAlgebra( query.where, registry, optionalPropertyTriples, + requiredPropertyTriples, + requiredPropertyKeys, ); } @@ -324,6 +369,8 @@ export function selectToAlgebra( orderItem.expression, registry, optionalPropertyTriples, + requiredPropertyTriples, + requiredPropertyKeys, ); } } @@ -333,7 +380,7 @@ export function selectToAlgebra( // - Wrap each optional property triple in a LeftJoin const requiredBgp: SparqlBGP = { type: 'bgp', - triples: [...requiredTriples, ...traverseTriples], + triples: [...requiredTriples, ...traverseTriples, ...requiredPropertyTriples], }; let algebra: SparqlAlgebraNode = requiredBgp; @@ -603,45 +650,84 @@ function processExpressionForProperties( expr: IRExpression, registry: VariableRegistry, optionalPropertyTriples: SparqlTriple[], + requiredPropertyTriples: SparqlTriple[] = [], + requiredPropertyKeys = new Set(), ): void { switch (expr.kind) { case 'property_expr': { if (!registry.has(expr.sourceAlias, expr.property)) { - // Create a new OPTIONAL triple for this property const varName = registry.getOrCreate(expr.sourceAlias, expr.property); const predicate = expr.pathExpr ? {kind: 'path' as const, value: pathExprToSparql(expr.pathExpr), uris: collectPathUris(expr.pathExpr)} : iriTerm(expr.property); - optionalPropertyTriples.push( - tripleOf( - varTerm(expr.sourceAlias), - predicate, - varTerm(varName), - ), + const triple = tripleOf( + varTerm(expr.sourceAlias), + predicate, + varTerm(varName), ); + const triples = requiredPropertyKeys.has(bindingKey(expr.sourceAlias, expr.property)) + ? requiredPropertyTriples + : optionalPropertyTriples; + triples.push(triple); } break; } case 'binary_expr': - processExpressionForProperties(expr.left, registry, optionalPropertyTriples); - processExpressionForProperties(expr.right, registry, optionalPropertyTriples); + processExpressionForProperties( + expr.left, + registry, + optionalPropertyTriples, + requiredPropertyTriples, + requiredPropertyKeys, + ); + processExpressionForProperties( + expr.right, + registry, + optionalPropertyTriples, + requiredPropertyTriples, + requiredPropertyKeys, + ); break; case 'logical_expr': for (const sub of expr.expressions) { - processExpressionForProperties(sub, registry, optionalPropertyTriples); + processExpressionForProperties( + sub, + registry, + optionalPropertyTriples, + requiredPropertyTriples, + requiredPropertyKeys, + ); } break; case 'not_expr': - processExpressionForProperties(expr.expression, registry, optionalPropertyTriples); + processExpressionForProperties( + expr.expression, + registry, + optionalPropertyTriples, + requiredPropertyTriples, + requiredPropertyKeys, + ); break; case 'function_expr': for (const arg of expr.args) { - processExpressionForProperties(arg, registry, optionalPropertyTriples); + processExpressionForProperties( + arg, + registry, + optionalPropertyTriples, + requiredPropertyTriples, + requiredPropertyKeys, + ); } break; case 'aggregate_expr': for (const arg of expr.args) { - processExpressionForProperties(arg, registry, optionalPropertyTriples); + processExpressionForProperties( + arg, + registry, + optionalPropertyTriples, + requiredPropertyTriples, + requiredPropertyKeys, + ); } break; case 'exists_expr': @@ -653,16 +739,18 @@ function processExpressionForProperties( // Context entity property — emit a triple with fixed IRI as subject. // Use raw IRI as registry key to avoid collision between IRIs that // sanitize to the same string (e.g. ctx-1 vs ctx_1). - const ctxKey = `__ctx__${expr.contextIri}`; + const ctxKey = contextAliasKey(expr.contextIri); if (!registry.has(ctxKey, expr.property)) { const varName = registry.getOrCreate(ctxKey, expr.property); - optionalPropertyTriples.push( - tripleOf( - iriTerm(expr.contextIri), - iriTerm(expr.property), - varTerm(varName), - ), + const triple = tripleOf( + iriTerm(expr.contextIri), + iriTerm(expr.property), + varTerm(varName), ); + const triples = requiredPropertyKeys.has(bindingKey(ctxKey, expr.property)) + ? requiredPropertyTriples + : optionalPropertyTriples; + triples.push(triple); } break; } @@ -674,6 +762,41 @@ function processExpressionForProperties( } } +/** + * Compute which bindings are mandatory for a top-level FILTER to keep a row. + * AND makes either side required; OR only keeps bindings required by every branch. + */ +function collectRequiredBindingKeys(expr: IRExpression): Set { + switch (expr.kind) { + case 'property_expr': + return new Set([bindingKey(expr.sourceAlias, expr.property)]); + case 'context_property_expr': + return new Set([bindingKey(contextAliasKey(expr.contextIri), expr.property)]); + case 'binary_expr': + return mergeKeySets( + collectRequiredBindingKeys(expr.left), + collectRequiredBindingKeys(expr.right), + ); + case 'function_expr': + return mergeKeySets(...expr.args.map((arg) => collectRequiredBindingKeys(arg))); + case 'not_expr': + return collectRequiredBindingKeys(expr.expression); + case 'logical_expr': { + const childSets = expr.expressions.map((sub) => collectRequiredBindingKeys(sub)); + if (expr.operator === 'and') { + return mergeKeySets(...childSets); + } + return intersectKeySets(childSets); + } + case 'aggregate_expr': + case 'exists_expr': + case 'literal_expr': + case 'reference_expr': + case 'alias_expr': + return new Set(); + } +} + // --------------------------------------------------------------------------- // Expression conversion // --------------------------------------------------------------------------- diff --git a/src/test-helpers/query-fixtures.ts b/src/test-helpers/query-fixtures.ts index f3432ce..703ca0a 100644 --- a/src/test-helpers/query-fixtures.ts +++ b/src/test-helpers/query-fixtures.ts @@ -314,6 +314,9 @@ export const queryFactories = { Person.select((p) => p.name) .where((p) => p.name.equals('Semmy').or(p.name.equals('Moa'))) .limit(1), + outerWhereDifferentPropsOr: () => + Person.select((p) => [p.name, p.hobby]) + .where((p) => p.name.equals('Jinx').or(p.hobby.equals('Jogging'))), sortByAsc: () => Person.select((p) => p.name).orderBy((p) => p.name), sortByDesc: () => Person.select((p) => p.name).orderBy((p) => p.name, 'DESC'), diff --git a/src/tests/sparql-algebra.test.ts b/src/tests/sparql-algebra.test.ts index 58e6d36..5e671b7 100644 --- a/src/tests/sparql-algebra.test.ts +++ b/src/tests/sparql-algebra.test.ts @@ -406,6 +406,11 @@ describe('selectToAlgebra — where clauses', () => { (f) => f.expression.kind === 'binary_expr', ); expect(binaryFilter).toBeDefined(); + + const allTriples = collectAllTriples(plan.algebra); + const optionalTriples = collectOptionalTriples(plan.algebra); + expect(countTriplesByPredicate(allTriples, `${Person.shape.id}/name`)).toBe(1); + expect(countTriplesByPredicate(optionalTriples, `${Person.shape.id}/name`)).toBe(0); }); test('outerWhere has Filter wrapping the entire pattern', async () => { @@ -425,6 +430,65 @@ describe('selectToAlgebra — where clauses', () => { // Left side should be a variable referencing name on root alias expect(binaryFilter.expression.left.kind).toBe('variable_expr'); } + + const allTriples = collectAllTriples(plan.algebra); + const optionalTriples = collectOptionalTriples(plan.algebra); + expect(countTriplesByPredicate(allTriples, `${Person.shape.id}/name`)).toBe(1); + expect(countTriplesByPredicate(optionalTriples, `${Person.shape.id}/name`)).toBe(0); + expect(countTriplesByPredicate(optionalTriples, `${Person.shape.id}/friends`)).toBe(1); + }); + + test('outerWhereLimit promotes same-property OR to a required triple', async () => { + const plan = await capturePlan(() => queryFactories.outerWhereLimit()); + + const allTriples = collectAllTriples(plan.algebra); + const optionalTriples = collectOptionalTriples(plan.algebra); + expect(countTriplesByPredicate(allTriples, `${Person.shape.id}/name`)).toBe(1); + expect(countTriplesByPredicate(optionalTriples, `${Person.shape.id}/name`)).toBe(0); + }); + + test('outerWhereDifferentPropsOr keeps both properties optional', async () => { + const plan = await capturePlan(() => queryFactories.outerWhereDifferentPropsOr()); + + const optionalTriples = collectOptionalTriples(plan.algebra); + expect(countTriplesByPredicate(optionalTriples, `${Person.shape.id}/name`)).toBe(1); + expect(countTriplesByPredicate(optionalTriples, `${Person.shape.id}/hobby`)).toBe(1); + }); + + test('whereWithContext keeps projection optional but promotes filter binding', async () => { + const plan = await capturePlan(() => queryFactories.whereWithContext()); + + const allTriples = collectAllTriples(plan.algebra); + const optionalTriples = collectOptionalTriples(plan.algebra); + expect(countTriplesByPredicate(allTriples, `${Person.shape.id}/bestFriend`)).toBe(1); + expect(countTriplesByPredicate(optionalTriples, `${Person.shape.id}/bestFriend`)).toBe(0); + expect(countTriplesByPredicate(optionalTriples, `${Person.shape.id}/name`)).toBe(1); + }); + + test('whereSomeImplicit promotes the traversed filter property to required', async () => { + const plan = await capturePlan(() => queryFactories.whereSomeImplicit()); + + const allTriples = collectAllTriples(plan.algebra); + const optionalTriples = collectOptionalTriples(plan.algebra); + expect(countTriplesByPredicate(allTriples, `${Person.shape.id}/friends`)).toBe(1); + expect(countTriplesByPredicate(allTriples, `${Person.shape.id}/name`)).toBe(1); + expect(countTriplesByPredicate(optionalTriples, `${Person.shape.id}/name`)).toBe(0); + }); + + test('whereExprStrlen promotes function-filter property bindings to required', async () => { + const plan = await capturePlan(() => queryFactories.whereExprStrlen()); + + const allTriples = collectAllTriples(plan.algebra); + const optionalTriples = collectOptionalTriples(plan.algebra); + expect(countTriplesByPredicate(allTriples, `${Person.shape.id}/name`)).toBe(1); + expect(countTriplesByPredicate(optionalTriples, `${Person.shape.id}/name`)).toBe(0); + }); + + test('countEquals keeps aggregate inputs optional', async () => { + const plan = await capturePlan(() => queryFactories.countEquals()); + + const optionalTriples = collectOptionalTriples(plan.algebra); + expect(countTriplesByPredicate(optionalTriples, `${Person.shape.id}/friends`)).toBe(1); }); }); diff --git a/src/tests/sparql-fuseki.test.ts b/src/tests/sparql-fuseki.test.ts index cfea2ef..749dd0e 100644 --- a/src/tests/sparql-fuseki.test.ts +++ b/src/tests/sparql-fuseki.test.ts @@ -831,6 +831,20 @@ describe('Fuseki SELECT — outer where (FILTER)', () => { expect(rows.length).toBeLessThanOrEqual(1); }); + test('outerWhereDifferentPropsOr — different-property OR still matches rows with only one side bound', async () => { + if (!fusekiAvailable) return; + + const result = await runSelectMapped('outerWhereDifferentPropsOr'); + expect(Array.isArray(result)).toBe(true); + const rows = result as ResultRow[]; + + // name = Jinx matches p3 even though hobby is missing. + // hobby = Jogging matches p2. + expect(rows.length).toBe(2); + expect(rows.some((row) => row.id.includes('p2'))).toBe(true); + expect(rows.some((row) => row.id.includes('p3'))).toBe(true); + }); + test('whereWithContext — filter bestFriend = context user (p3)', async () => { if (!fusekiAvailable) return; diff --git a/src/tests/sparql-select-golden.test.ts b/src/tests/sparql-select-golden.test.ts index bf47fb1..dccae43 100644 --- a/src/tests/sparql-select-golden.test.ts +++ b/src/tests/sparql-select-golden.test.ts @@ -496,9 +496,7 @@ describe('SPARQL golden — outer where', () => { SELECT DISTINCT ?a0 WHERE { ?a0 rdf:type <${P}> . - OPTIONAL { - ?a0 <${P}/bestFriend> ?a0_bestFriend . - } + ?a0 <${P}/bestFriend> ?a0_bestFriend . FILTER(?a0_bestFriend = ) }`); }); @@ -510,9 +508,7 @@ WHERE { SELECT DISTINCT ?a0 WHERE { ?a0 rdf:type <${P}> . - OPTIONAL { - ?a0 <${P}/name> ?a0_name . - } + ?a0 <${P}/name> ?a0_name . FILTER(?a0_name = "Semmy") }`); }); @@ -524,12 +520,10 @@ WHERE { SELECT DISTINCT ?a0 ?a0_friends WHERE { ?a0 rdf:type <${P}> . + ?a0 <${P}/name> ?a0_name . OPTIONAL { ?a0 <${P}/friends> ?a0_friends . } - OPTIONAL { - ?a0 <${P}/name> ?a0_name . - } FILTER(?a0_name = "Semmy") }`); }); @@ -541,14 +535,29 @@ WHERE { SELECT DISTINCT ?a0 ?a0_name WHERE { ?a0 rdf:type <${P}> . - OPTIONAL { - ?a0 <${P}/name> ?a0_name . - } + ?a0 <${P}/name> ?a0_name . FILTER(?a0_name = "Semmy" || ?a0_name = "Moa") } LIMIT 1`); }); + test('outerWhereDifferentPropsOr', async () => { + const sparql = await goldenSelect(queryFactories.outerWhereDifferentPropsOr); + expect(sparql).toBe( +`PREFIX rdf: +SELECT DISTINCT ?a0 ?a0_name ?a0_hobby +WHERE { + ?a0 rdf:type <${P}> . + OPTIONAL { + ?a0 <${P}/name> ?a0_name . + } + OPTIONAL { + ?a0 <${P}/hobby> ?a0_hobby . + } + FILTER(?a0_name = "Jinx" || ?a0_hobby = "Jogging") +}`); + }); + test('whereSomeImplicit', async () => { const sparql = await goldenSelect(queryFactories.whereSomeImplicit); expect(sparql).toBe( @@ -557,9 +566,7 @@ SELECT DISTINCT ?a0 ?a1 WHERE { ?a0 rdf:type <${P}> . ?a0 <${P}/friends> ?a1 . - OPTIONAL { - ?a1 <${P}/name> ?a1_name . - } + ?a1 <${P}/name> ?a1_name . FILTER(?a1_name = "Moa") }`); }); @@ -601,9 +608,7 @@ WHERE { SELECT DISTINCT ?a0 WHERE { ?a0 rdf:type <${P}> . - OPTIONAL { - ?a0 <${P}/name> ?a0_name . - } + ?a0 <${P}/name> ?a0_name . FILTER(EXISTS { ?a0 <${P}/friends> ?a1 . ?a1 <${P}/name> ?a1_name . @@ -619,12 +624,10 @@ WHERE { SELECT DISTINCT ?a0 ?a0_name WHERE { ?a0 rdf:type <${P}> . + ?a0 <${P}/bestFriend> ?a0_bestFriend . OPTIONAL { ?a0 <${P}/name> ?a0_name . } - OPTIONAL { - ?a0 <${P}/bestFriend> ?a0_bestFriend . - } FILTER(?a0_bestFriend = ) }`); });