diff --git a/.changeset/fix-tojson-where-clause.md b/.changeset/fix-tojson-where-clause.md new file mode 100644 index 0000000..56ed649 --- /dev/null +++ b/.changeset/fix-tojson-where-clause.md @@ -0,0 +1,5 @@ +--- +"@_linked/core": patch +--- + +Fix QueryBuilder.toJSON() to serialize where, orderBy, minus, and preload clauses that were previously silently dropped during JSON round-trips 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/package-lock.json b/package-lock.json index a8ceabf..0c18a1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@_linked/core", - "version": "2.2.3", + "version": "2.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@_linked/core", - "version": "2.2.3", + "version": "2.4.0", "license": "MIT", "dependencies": { "next-tick": "^1.1.0", diff --git a/src/queries/FieldSet.ts b/src/queries/FieldSet.ts index abbd28b..dfae911 100644 --- a/src/queries/FieldSet.ts +++ b/src/queries/FieldSet.ts @@ -357,6 +357,9 @@ export class FieldSet { } if (entry.subSelect) { field.subSelect = entry.subSelect.toJSON(); + } else if (entry.preloadSubSelect) { + // Preloads produce identical IR to subSelect — serialize as subSelect. + field.subSelect = entry.preloadSubSelect.toJSON(); } if (entry.aggregation) { field.aggregation = entry.aggregation; diff --git a/src/queries/QueryBuilder.ts b/src/queries/QueryBuilder.ts index c1adca4..4f22848 100644 --- a/src/queries/QueryBuilder.ts +++ b/src/queries/QueryBuilder.ts @@ -12,14 +12,25 @@ import { evaluateSortCallback, } from './SelectQuery.js'; import type {SortByPath, WherePath} from './SelectQuery.js'; -import type {PropertyPathSegment, RawSelectInput} from './IRDesugar.js'; +import type {PropertyPathSegment, RawMinusEntry, RawSelectInput} from './IRDesugar.js'; import {buildSelectQuery} from './IRPipeline.js'; import {getQueryDispatch} from './queryDispatch.js'; import type {NodeReferenceValue} from './QueryFactory.js'; import {resolveUriOrThrow} from '../utils/NodeReference.js'; -import {FieldSet, FieldSetJSON, FieldSetFieldJSON} from './FieldSet.js'; +import {FieldSet, FieldSetJSON, FieldSetFieldJSON, type FieldSetEntry} from './FieldSet.js'; import {PendingQueryContext} from './QueryContext.js'; import {createProxiedPathBuilder} from './ProxiedPathBuilder.js'; +import { + serializeWherePath, + serializeSortByPath, + serializeRawMinusEntry, + deserializeWherePath, + deserializeSortByPath, + deserializeRawMinusEntry, + type WherePathJSON, + type SortByPathJSON, + type RawMinusEntryJSON, +} from './QueryBuilderSerialization.js'; /** JSON representation of a QueryBuilder. */ export type QueryBuilderJSON = { @@ -31,6 +42,11 @@ export type QueryBuilderJSON = { subjects?: string[]; singleResult?: boolean; orderDirection?: 'ASC' | 'DESC'; + where?: WherePathJSON; + sortBy?: SortByPathJSON; + minusEntries?: RawMinusEntryJSON[]; + nullSubject?: boolean; + pendingContextName?: string; }; /** A preload entry binding a property path to a component's query. */ @@ -63,6 +79,10 @@ interface QueryBuilderInit { minusEntries?: MinusEntry[]; _nullSubject?: boolean; _pendingContextName?: string; + // Pre-evaluated data (restored from JSON; used when callbacks are not available) + _where?: WherePath; + _sortBy?: SortByPath; + _rawMinusEntries?: RawMinusEntry[]; } /** @@ -97,6 +117,10 @@ export class QueryBuilder private readonly _minusEntries?: MinusEntry[]; private readonly _nullSubject?: boolean; private readonly _pendingContextName?: string; + // Pre-evaluated data (restored from JSON; used when callbacks are not available) + private readonly _where?: WherePath; + private readonly _sortBy?: SortByPath; + private readonly _rawMinusEntries?: RawMinusEntry[]; private constructor(init: QueryBuilderInit) { this._shape = init.shape; @@ -115,6 +139,9 @@ export class QueryBuilder this._minusEntries = init.minusEntries; this._nullSubject = init._nullSubject; this._pendingContextName = init._pendingContextName; + this._where = init._where; + this._sortBy = init._sortBy; + this._rawMinusEntries = init._rawMinusEntries; } /** Create a shallow clone with overrides. */ @@ -136,6 +163,9 @@ export class QueryBuilder minusEntries: this._minusEntries, _nullSubject: this._nullSubject, _pendingContextName: this._pendingContextName, + _where: this._where, + _sortBy: this._sortBy, + _rawMinusEntries: this._rawMinusEntries, ...overrides, }); } @@ -327,6 +357,60 @@ export class QueryBuilder return undefined; } + // --------------------------------------------------------------------------- + // Internal helpers + // --------------------------------------------------------------------------- + + /** Return the FieldSet with preload entries merged in (if any). */ + private _fieldsWithPreloads(): FieldSet | undefined { + let fs = this.fields(); + if (this._preloads && this._preloads.length > 0) { + const preloadFn = (p: any) => { + return this._preloads!.map((entry) => p[entry.path].preloadFor(entry.component)); + }; + const preloadFs = FieldSet.for(this._shape, preloadFn); + if (fs) { + fs = FieldSet.createFromEntries(fs.shape, [ + ...(fs.entries as FieldSetEntry[]), + ...(preloadFs.entries as FieldSetEntry[]), + ]); + } else { + fs = preloadFs; + } + } + return fs; + } + + /** Evaluate minus entry callbacks into RawMinusEntry[] (plain data). */ + private _evaluateMinusEntries(): RawMinusEntry[] { + const proxy = createProxiedPathBuilder(this._shape); + return this._minusEntries!.map((entry) => { + if (entry.shapeId) { + return {shapeId: entry.shapeId}; + } + if (entry.whereFn) { + const result = (entry.whereFn as Function)(proxy); + + if (Array.isArray(result)) { + const propertyPaths = result.map((item: any) => { + const segments = FieldSet.collectPropertySegments(item); + return segments.map((seg): PropertyPathSegment => ({propertyShapeId: seg.id})); + }); + return {propertyPaths}; + } + + if (result && typeof result === 'object' && 'property' in result && 'subject' in result) { + const segments = FieldSet.collectPropertySegments(result); + return {propertyPaths: [segments.map((seg): PropertyPathSegment => ({propertyShapeId: seg.id}))]}; + } + + // WHERE-based exclusion + return {where: processWhereClause(entry.whereFn, this._shape)}; + } + return {}; + }); + } + // --------------------------------------------------------------------------- // Serialization // --------------------------------------------------------------------------- @@ -338,8 +422,9 @@ export class QueryBuilder * string[], selectAll, or callback). Callback-based selections are eagerly * evaluated through the proxy to produce a FieldSet. * - * The `where` and `orderBy` callbacks are not serialized (only the direction - * is preserved for orderBy). + * Where, orderBy, and minus clauses are evaluated through the proxy and + * serialized as plain data structures. Preloads are merged into the FieldSet + * as subSelect entries, producing identical IR on deserialization. */ toJSON(): QueryBuilderJSON { const shapeId = this._shape.shape?.id || ''; @@ -347,9 +432,7 @@ export class QueryBuilder shape: shapeId, }; - // Serialize fields — fields() already handles _selectAllLabels, so - // no separate branch is needed (T1: dead else-if removed). - const fs = this.fields(); + const fs = this._fieldsWithPreloads(); if (fs) { json.fields = fs.toJSON().fields; } @@ -373,6 +456,33 @@ export class QueryBuilder json.orderDirection = this._sortDirection; } + if (this._whereFn) { + json.where = serializeWherePath(processWhereClause(this._whereFn, this._shape)); + } else if (this._where) { + json.where = serializeWherePath(this._where); + } + + if (this._sortByFn) { + json.sortBy = serializeSortByPath( + evaluateSortCallback(this._shape, this._sortByFn as unknown as (p: any) => any, this._sortDirection || 'ASC'), + ); + } else if (this._sortBy) { + json.sortBy = serializeSortByPath(this._sortBy); + } + + if (this._minusEntries && this._minusEntries.length > 0) { + json.minusEntries = this._evaluateMinusEntries().map(serializeRawMinusEntry); + } else if (this._rawMinusEntries && this._rawMinusEntries.length > 0) { + json.minusEntries = this._rawMinusEntries.map(serializeRawMinusEntry); + } + + if (this._nullSubject) { + json.nullSubject = true; + } + if (this._pendingContextName) { + json.pendingContextName = this._pendingContextName; + } + return json; } @@ -406,12 +516,43 @@ export class QueryBuilder if (json.singleResult && !json.subject) { builder = builder.one() as QueryBuilder; } - // Restore orderDirection. The sort key callback isn't serializable, - // so we only store the direction. When a sort key is later re-applied - // via .orderBy(), the direction will be available. - if (json.orderDirection) { - // Access private clone() — safe because fromJSON is in the same class. - builder = (builder as any).clone({sortDirection: json.orderDirection}) as QueryBuilder; + + // Restore pre-evaluated data via clone — safe because fromJSON is in the same class. + const overrides: Partial> = {}; + const nodeShape = builder._shape.shape; + + // Restore where clause + if (json.where && nodeShape) { + overrides._where = deserializeWherePath(nodeShape, json.where); + } + + // Restore sort key + direction + if (json.sortBy && nodeShape) { + overrides._sortBy = deserializeSortByPath(nodeShape, json.sortBy); + overrides.sortDirection = json.sortBy.direction; + } else if (json.orderDirection) { + overrides.sortDirection = json.orderDirection; + } + + // Restore minus entries + if (json.minusEntries && json.minusEntries.length > 0 && nodeShape) { + overrides._rawMinusEntries = json.minusEntries.map((e) => + deserializeRawMinusEntry(nodeShape, e), + ); + } + + // Restore nullSubject flag + if (json.nullSubject) { + overrides._nullSubject = true; + } + + // Restore pending context name + if (json.pendingContextName) { + overrides._pendingContextName = json.pendingContextName; + } + + if (Object.keys(overrides).length > 0) { + builder = (builder as any).clone(overrides) as QueryBuilder; } return builder; @@ -430,41 +571,18 @@ export class QueryBuilder return this._buildDirectRawInput(); } - /** - * Build RawSelectInput directly from FieldSet entries. - */ + /** Build RawSelectInput directly from FieldSet entries. */ private _buildDirectRawInput(): RawSelectInput { - let fs = this.fields(); - - // When preloads exist, trace them through the proxy and merge with the FieldSet. - if (this._preloads && this._preloads.length > 0) { - const preloadFn = (p: any) => { - const results: any[] = []; - for (const entry of this._preloads!) { - results.push(p[entry.path].preloadFor(entry.component)); - } - return results; - }; - const preloadFs = FieldSet.for(this._shape, preloadFn); - if (fs) { - fs = FieldSet.createFromEntries(fs.shape, [ - ...(fs.entries as any[]), - ...(preloadFs.entries as any[]), - ]); - } else { - fs = preloadFs; - } - } - + const fs = this._fieldsWithPreloads(); const entries = fs ? fs.entries : []; - // Evaluate where callback let where: WherePath | undefined; if (this._whereFn) { where = processWhereClause(this._whereFn, this._shape); + } else if (this._where) { + where = this._where; } - // Evaluate sort callback let sortBy: SortByPath | undefined; if (this._sortByFn) { sortBy = evaluateSortCallback( @@ -472,6 +590,8 @@ export class QueryBuilder this._sortByFn as unknown as (p: any) => any, this._sortDirection || 'ASC', ); + } else if (this._sortBy) { + sortBy = this._sortBy; } const input: RawSelectInput = { @@ -496,39 +616,10 @@ export class QueryBuilder if (this._subjects && this._subjects.length > 0) { input.subjects = this._subjects; } - - // Process minus entries → convert callbacks to WherePaths or property paths if (this._minusEntries && this._minusEntries.length > 0) { - input.minusEntries = this._minusEntries.map((entry) => { - if (entry.shapeId) { - return {shapeId: entry.shapeId}; - } - if (entry.whereFn) { - // Call the callback through the proxy and inspect the result type - const proxy = createProxiedPathBuilder(this._shape); - const result = (entry.whereFn as Function)(proxy); - - // Array of QBOs → property existence paths - if (Array.isArray(result)) { - const propertyPaths = result.map((item: any) => { - const segments = FieldSet.collectPropertySegments(item); - return segments.map((seg): PropertyPathSegment => ({propertyShapeId: seg.id})); - }); - return {propertyPaths}; - } - - // Single QBO (has .property field) → single property existence path - if (result && typeof result === 'object' && 'property' in result && 'subject' in result) { - const segments = FieldSet.collectPropertySegments(result); - const propertyPaths = [segments.map((seg): PropertyPathSegment => ({propertyShapeId: seg.id}))]; - return {propertyPaths}; - } - - // Evaluation → existing WHERE-based path - return {where: processWhereClause(entry.whereFn, this._shape)}; - } - return {}; - }); + input.minusEntries = this._evaluateMinusEntries(); + } else if (this._rawMinusEntries && this._rawMinusEntries.length > 0) { + input.minusEntries = this._rawMinusEntries; } return input; diff --git a/src/queries/QueryBuilderSerialization.ts b/src/queries/QueryBuilderSerialization.ts new file mode 100644 index 0000000..ac8f228 --- /dev/null +++ b/src/queries/QueryBuilderSerialization.ts @@ -0,0 +1,352 @@ +/** + * Serialization and deserialization helpers for QueryBuilder fields that contain + * live object references (PropertyShape, ExpressionNode, etc.). + * + * These convert between runtime WherePath / SortByPath / RawMinusEntry structures + * and plain JSON-safe representations that can round-trip through JSON.stringify/parse. + */ + +import type {NodeShape} from '../shapes/SHACL.js'; +import {getShapeClass} from '../utils/ShapeClass.js'; +import {walkPropertyPath} from './PropertyPath.js'; +import {ExpressionNode} from '../expressions/ExpressionNode.js'; +// ExistsCondition only exists after the WherePath refactor; access dynamically +// so this file compiles on branches where it doesn't exist yet. +import * as _exprModule from '../expressions/ExpressionNode.js'; +const ExistsConditionCtor: (new (...args: any[]) => any) | undefined = + (_exprModule as any).ExistsCondition; +import type { + WherePath, + QueryPropertyPath, + QueryStep, + PropertyQueryStep, + SizeStep, + QueryArg, + ArgPath, + SortByPath, +} from './SelectQuery.js'; +import type {ShapeReferenceValue, NodeReferenceValue} from './QueryFactory.js'; +import type {IRExpression} from './IntermediateRepresentation.js'; +import type {RawMinusEntry, PropertyPathSegment} from './IRDesugar.js'; + +// ============================================================================= +// JSON types +// ============================================================================= + +export type QueryStepJSON = + | {kind: 'property'; label: string; where?: WherePathJSON} + | {kind: 'size'; count: QueryStepJSON[]; label?: string} + | {kind: 'shapeRef'; id: string; shapeId: string}; + +export type ExistsConditionJSON = { + kind: 'exists'; + pathSegmentIds: string[]; + predicate: {ir: IRExpression; refs?: Record}; + negated: boolean; + chain: {op: 'and' | 'or'; condition: ExistsConditionJSON | {kind: 'expression'; ir: IRExpression; refs?: Record}}[]; +}; + +export type WherePathJSON = + | {kind: 'evaluation'; path: QueryStepJSON[]; method: string; args: QueryArgJSON[]} + | {kind: 'andOr'; firstPath: WherePathJSON; andOr: {and?: WherePathJSON; or?: WherePathJSON}[]} + | {kind: 'expression'; ir: IRExpression; refs?: Record} + | ExistsConditionJSON; + +export type QueryArgJSON = + | {kind: 'nodeRef'; id: string} + | {kind: 'primitive'; value: string | number | boolean} + | {kind: 'date'; value: string} + | {kind: 'argPath'; path: QueryStepJSON[]; subject: {id: string; shapeId: string}} + | {kind: 'where'; where: WherePathJSON}; + +export type SortByPathJSON = { + paths: string[]; + direction: 'ASC' | 'DESC'; +}; + +export type RawMinusEntryJSON = { + shapeId?: string; + where?: WherePathJSON; + propertyPaths?: string[][]; +}; + +// ============================================================================= +// Serialization +// ============================================================================= + +export function serializeQueryPropertyPath(path: QueryPropertyPath): QueryStepJSON[] { + return path.map(serializeQueryStep); +} + +function serializeQueryStep(step: QueryStep): QueryStepJSON { + // PropertyQueryStep — has a .property field that is a PropertyShape + if ('property' in step && (step as PropertyQueryStep).property) { + const pqs = step as PropertyQueryStep; + const json: {kind: 'property'; label: string; where?: WherePathJSON} = { + kind: 'property', + label: pqs.property.label, + }; + if (pqs.where) { + json.where = serializeWherePath(pqs.where); + } + return json; + } + // SizeStep — has a .count field + if ('count' in step) { + const ss = step as SizeStep; + const json: {kind: 'size'; count: QueryStepJSON[]; label?: string} = { + kind: 'size', + count: serializeQueryPropertyPath(ss.count), + }; + if (ss.label) json.label = ss.label; + return json; + } + // ShapeReferenceValue — has .id and .shape + const ref = step as ShapeReferenceValue; + return {kind: 'shapeRef', id: ref.id, shapeId: ref.shape.id}; +} + +export function serializeWherePath(where: WherePath): WherePathJSON { + if ('expressionNode' in where) { + const expr = (where as unknown as {expressionNode: ExpressionNode}).expressionNode; + const json: {kind: 'expression'; ir: IRExpression; refs?: Record} = { + kind: 'expression', + ir: expr.ir, + }; + if (expr._refs.size > 0) { + const refs: Record = {}; + for (const [k, v] of expr._refs) refs[k] = [...v]; + json.refs = refs; + } + return json; + } + if ('firstPath' in where) { + const andOr = where as unknown as {firstPath: WherePath; andOr: {and?: WherePath; or?: WherePath}[]}; + return { + kind: 'andOr', + firstPath: serializeWherePath(andOr.firstPath), + andOr: andOr.andOr.map((token) => { + const t: {and?: WherePathJSON; or?: WherePathJSON} = {}; + if (token.and) t.and = serializeWherePath(token.and); + if (token.or) t.or = serializeWherePath(token.or); + return t; + }), + }; + } + if ('path' in where && 'method' in where && 'args' in where) { + const ev = where as unknown as {path: QueryPropertyPath; method: string; args: QueryArg[]}; + return { + kind: 'evaluation', + path: serializeQueryPropertyPath(ev.path), + method: ev.method, + args: ev.args.map(serializeQueryArg), + }; + } + if ('existsCondition' in where) { + // WhereExistsPath — serialize the ExistsCondition structurally + const ec = (where as unknown as {existsCondition: any}).existsCondition; + return { + kind: 'exists', + pathSegmentIds: [...ec.pathSegmentIds], + predicate: {ir: ec.predicate.ir, refs: serializePropertyRefMap(ec.predicate._refs)}, + negated: ec.negated ?? false, + chain: (ec.chain ?? []).map((c: any) => ({ + op: c.op, + condition: 'pathSegmentIds' in c.condition + ? {kind: 'exists' as const, pathSegmentIds: [...c.condition.pathSegmentIds], predicate: {ir: c.condition.predicate.ir, refs: serializePropertyRefMap(c.condition.predicate._refs)}, negated: c.condition.negated ?? false, chain: []} + : {kind: 'expression' as const, ir: c.condition.ir, refs: serializePropertyRefMap(c.condition._refs)}, + })), + }; + } + throw new Error(`Cannot serialize WherePath: ${JSON.stringify(Object.keys(where))}`); +} + +function serializePropertyRefMap(refs: ReadonlyMap): Record | undefined { + if (!refs || refs.size === 0) return undefined; + const out: Record = {}; + for (const [k, v] of refs) out[k] = [...v]; + return out; +} + +function serializeQueryArg(arg: QueryArg): QueryArgJSON { + if (typeof arg === 'string' || typeof arg === 'number' || typeof arg === 'boolean') { + return {kind: 'primitive', value: arg}; + } + if (arg instanceof Date) { + return {kind: 'date', value: arg.toISOString()}; + } + if (typeof arg === 'object' && arg !== null) { + if ('expressionNode' in arg || 'existsCondition' in arg || 'firstPath' in arg || ('path' in arg && 'method' in arg && 'args' in arg)) { + return {kind: 'where', where: serializeWherePath(arg as WherePath)}; + } + // ArgPath — has 'subject' and 'path' + if ('subject' in arg && 'path' in arg) { + const ap = arg as ArgPath; + return { + kind: 'argPath', + path: serializeQueryPropertyPath(ap.path), + subject: {id: ap.subject.id, shapeId: ap.subject.shape.id}, + }; + } + // NodeReferenceValue — has 'id' + if ('id' in arg) { + return {kind: 'nodeRef', id: (arg as NodeReferenceValue).id}; + } + } + throw new Error(`Cannot serialize query arg: ${JSON.stringify(arg)}`); +} + +export function serializeSortByPath(sort: SortByPath): SortByPathJSON { + return { + paths: sort.paths.map((p) => p.toString()), + direction: sort.direction, + }; +} + +export function serializeRawMinusEntry(entry: RawMinusEntry): RawMinusEntryJSON { + const json: RawMinusEntryJSON = {}; + if (entry.shapeId) json.shapeId = entry.shapeId; + if (entry.where) json.where = serializeWherePath(entry.where); + if (entry.propertyPaths) { + json.propertyPaths = entry.propertyPaths.map((pp) => + pp.map((seg) => seg.propertyShapeId), + ); + } + return json; +} + +// ============================================================================= +// Deserialization +// ============================================================================= + +export function deserializeQueryPropertyPath( + shape: NodeShape, + steps: QueryStepJSON[], +): QueryPropertyPath { + const result: QueryStep[] = []; + let currentShape = shape; + + for (const step of steps) { + if (step.kind === 'property') { + const propertyShape = currentShape.getPropertyShape(step.label); + if (!propertyShape) { + throw new Error( + `Property '${step.label}' not found on shape '${currentShape.label || currentShape.id}'`, + ); + } + const queryStep: PropertyQueryStep = {property: propertyShape}; + if (step.where) { + const nestedShape = propertyShape.valueShape + ? getShapeClass(propertyShape.valueShape)?.shape + : currentShape; + queryStep.where = deserializeWherePath(nestedShape || currentShape, step.where); + } + result.push(queryStep); + + // Advance shape context for subsequent traversals + if (propertyShape.valueShape) { + const cls = getShapeClass(propertyShape.valueShape); + if (cls?.shape) currentShape = cls.shape; + } + } else if (step.kind === 'size') { + result.push({ + count: deserializeQueryPropertyPath(currentShape, step.count), + label: step.label, + } as SizeStep); + } else { + // shapeRef + result.push({id: step.id, shape: {id: step.shapeId}} as ShapeReferenceValue); + } + } + + return result; +} + +export function deserializeWherePath(shape: NodeShape, json: WherePathJSON): WherePath { + if (json.kind === 'expression') { + const refs = new Map(); + if (json.refs) { + for (const [k, v] of Object.entries(json.refs)) refs.set(k, v); + } + return {expressionNode: new ExpressionNode(json.ir, refs)}; + } + if (json.kind === 'exists') { + if (!ExistsConditionCtor) throw new Error('ExistsCondition is not available on this build'); + const predRefs = deserializeRefMap(json.predicate.refs); + const predicate = new ExpressionNode(json.predicate.ir, predRefs); + const chain = json.chain.map((c) => { + if (c.condition.kind === 'exists') { + const innerRefs = deserializeRefMap(c.condition.predicate.refs); + return {op: c.op, condition: new ExistsConditionCtor(c.condition.pathSegmentIds, new ExpressionNode(c.condition.predicate.ir, innerRefs), c.condition.negated)}; + } + const exprRefs = deserializeRefMap(c.condition.refs); + return {op: c.op, condition: new ExpressionNode(c.condition.ir, exprRefs)}; + }); + return {existsCondition: new ExistsConditionCtor(json.pathSegmentIds, predicate, json.negated, chain)} as unknown as WherePath; + } + if (json.kind === 'andOr') { + return { + firstPath: deserializeWherePath(shape, json.firstPath), + andOr: json.andOr.map((token) => { + const t: {and?: WherePath; or?: WherePath} = {}; + if (token.and) t.and = deserializeWherePath(shape, token.and); + if (token.or) t.or = deserializeWherePath(shape, token.or); + return t; + }), + } as unknown as WherePath; + } + // evaluation (legacy) + return { + path: deserializeQueryPropertyPath(shape, json.path), + method: json.method, + args: json.args.map((a) => deserializeQueryArg(shape, a)), + } as unknown as WherePath; +} + +function deserializeRefMap(refs?: Record): Map { + const m = new Map(); + if (refs) { + for (const [k, v] of Object.entries(refs)) m.set(k, v); + } + return m; +} + +function deserializeQueryArg(shape: NodeShape, json: QueryArgJSON): QueryArg { + switch (json.kind) { + case 'primitive': + return json.value; + case 'date': + return new Date(json.value); + case 'nodeRef': + return {id: json.id}; + case 'where': + return deserializeWherePath(shape, json.where); + case 'argPath': + return { + path: deserializeQueryPropertyPath(shape, json.path), + subject: {id: json.subject.id, shape: {id: json.subject.shapeId}}, + }; + } +} + +export function deserializeSortByPath(shape: NodeShape, json: SortByPathJSON): SortByPath { + return { + paths: json.paths.map((p) => walkPropertyPath(shape, p)), + direction: json.direction, + }; +} + +export function deserializeRawMinusEntry( + shape: NodeShape, + json: RawMinusEntryJSON, +): RawMinusEntry { + const entry: RawMinusEntry = {}; + if (json.shapeId) entry.shapeId = json.shapeId; + if (json.where) entry.where = deserializeWherePath(shape, json.where); + if (json.propertyPaths) { + entry.propertyPaths = json.propertyPaths.map((pp) => + pp.map((id): PropertyPathSegment => ({propertyShapeId: id})), + ); + } + return entry; +} 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 d4466e7..5e99765 100644 --- a/src/test-helpers/query-fixtures.ts +++ b/src/test-helpers/query-fixtures.ts @@ -337,6 +337,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/serialization.test.ts b/src/tests/serialization.test.ts index 977710f..bdb2aa2 100644 --- a/src/tests/serialization.test.ts +++ b/src/tests/serialization.test.ts @@ -1,5 +1,5 @@ import {describe, expect, test} from '@jest/globals'; -import {Person, tmpEntityBase} from '../test-helpers/query-fixtures'; +import {Person, Employee, tmpEntityBase} from '../test-helpers/query-fixtures'; import {sanitize} from '../test-helpers/test-utils'; import {FieldSet} from '../queries/FieldSet'; import {QueryBuilder} from '../queries/QueryBuilder'; @@ -234,3 +234,296 @@ describe('QueryBuilder — serialization', () => { expect(restoredJson.orderDirection).toBe('DESC'); }); }); + +// ============================================================================= +// Where clause serialization tests +// ============================================================================= + +describe('QueryBuilder — where clause serialization', () => { + test('toJSON — simple where equals', () => { + const json = QueryBuilder.from(Person) + .select((p) => [p.name]) + .where((p) => p.name.equals('Bob')) + .toJSON(); + + expect(json.where).toBeDefined(); + expect(json.where!.kind).toBe('expression'); + if (json.where!.kind === 'expression') { + expect(json.where!.ir.kind).toBe('binary_expr'); + if (json.where!.ir.kind === 'binary_expr') { + expect(json.where!.ir.operator).toBe('='); + } + } + }); + + test('toJSON — where with nodeRef arg', () => { + const json = QueryBuilder.from(Person) + .select((p) => [p.name]) + .where((p) => p.bestFriend.equals({id: `${tmpEntityBase}p1`})) + .toJSON(); + + expect(json.where).toBeDefined(); + expect(json.where!.kind).toBe('expression'); + if (json.where!.kind === 'expression') { + expect(json.where!.ir.kind).toBe('binary_expr'); + if (json.where!.ir.kind === 'binary_expr') { + expect(json.where!.ir.right.kind).toBe('reference_expr'); + } + } + }); + + test('toJSON — where with AND', () => { + const json = QueryBuilder.from(Person) + .select((p) => [p.name]) + .where((p) => p.name.equals('Bob').and(p.hobby.equals('Chess'))) + .toJSON(); + + expect(json.where).toBeDefined(); + expect(json.where!.kind).toBe('expression'); + if (json.where!.kind === 'expression') { + expect(json.where!.ir.kind).toBe('logical_expr'); + if (json.where!.ir.kind === 'logical_expr') { + expect(json.where!.ir.operator).toBe('and'); + } + } + }); + + test('round-trip — simple where equals produces same IR', () => { + const original = QueryBuilder.from(Person) + .select((p) => [p.name]) + .where((p) => p.name.equals('Bob')); + + const json = original.toJSON(); + const restored = QueryBuilder.fromJSON(json); + + expect(sanitize(restored.build())).toEqual(sanitize(original.build())); + }); + + test('round-trip — where with nodeRef produces same IR', () => { + const original = QueryBuilder.from(Person) + .select((p) => [p.name]) + .where((p) => p.bestFriend.equals({id: `${tmpEntityBase}p1`})); + + const json = original.toJSON(); + const restored = QueryBuilder.fromJSON(json); + + expect(sanitize(restored.build())).toEqual(sanitize(original.build())); + }); + + test('round-trip — where AND produces same IR', () => { + const original = QueryBuilder.from(Person) + .select((p) => [p.name]) + .where((p) => p.name.equals('Bob').and(p.hobby.equals('Chess'))); + + const json = original.toJSON(); + const restored = QueryBuilder.fromJSON(json); + + expect(sanitize(restored.build())).toEqual(sanitize(original.build())); + }); + + test('round-trip — nested where path produces same IR', () => { + const original = QueryBuilder.from(Person) + .select((p) => [p.name]) + .where((p) => p.bestFriend.name.equals('Alice')); + + const json = original.toJSON(); + const restored = QueryBuilder.fromJSON(json); + + expect(sanitize(restored.build())).toEqual(sanitize(original.build())); + }); + + test('round-trip — where + limit + one produces same IR', () => { + const original = QueryBuilder.from(Person) + .select((p) => [p.name]) + .where((p) => p.name.equals('Bob')) + .one(); + + const json = original.toJSON(); + const restored = QueryBuilder.fromJSON(json); + + expect(sanitize(restored.build())).toEqual(sanitize(original.build())); + }); +}); + +// ============================================================================= +// Sort key serialization tests +// ============================================================================= + +describe('QueryBuilder — sort key serialization', () => { + test('toJSON — orderBy includes sort key path', () => { + const json = QueryBuilder.from(Person) + .select(['name']) + .orderBy((p) => p.name, 'DESC') + .toJSON(); + + expect(json.sortBy).toBeDefined(); + expect(json.sortBy!.paths).toContain('name'); + expect(json.sortBy!.direction).toBe('DESC'); + }); + + test('round-trip — orderBy produces same IR', () => { + const original = QueryBuilder.from(Person) + .select(['name', 'hobby']) + .orderBy((p) => p.name, 'ASC'); + + const json = original.toJSON(); + const restored = QueryBuilder.fromJSON(json); + + expect(sanitize(restored.build())).toEqual(sanitize(original.build())); + }); +}); + +// ============================================================================= +// Minus entry serialization tests +// ============================================================================= + +describe('QueryBuilder — minus entry serialization', () => { + test('toJSON — minus with shape type', () => { + const json = QueryBuilder.from(Person) + .select(['name']) + .minus(Employee) + .toJSON(); + + expect(json.minusEntries).toBeDefined(); + expect(json.minusEntries).toHaveLength(1); + expect(json.minusEntries![0].shapeId).toBeDefined(); + }); + + test('toJSON — minus with where callback', () => { + const json = QueryBuilder.from(Person) + .select(['name']) + .minus((p) => p.hobby.equals('Chess')) + .toJSON(); + + expect(json.minusEntries).toBeDefined(); + expect(json.minusEntries).toHaveLength(1); + expect(json.minusEntries![0].where).toBeDefined(); + }); + + test('round-trip — minus with shape type produces same IR', () => { + const original = QueryBuilder.from(Person) + .select(['name']) + .minus(Employee); + + const json = original.toJSON(); + const restored = QueryBuilder.fromJSON(json); + + expect(sanitize(restored.build())).toEqual(sanitize(original.build())); + }); + + test('round-trip — minus with where produces same IR', () => { + const original = QueryBuilder.from(Person) + .select(['name']) + .minus((p) => p.hobby.equals('Chess')); + + const json = original.toJSON(); + const restored = QueryBuilder.fromJSON(json); + + expect(sanitize(restored.build())).toEqual(sanitize(original.build())); + }); +}); + +// ============================================================================= +// nullSubject and pendingContextName serialization tests +// ============================================================================= + +describe('QueryBuilder — nullSubject & pendingContextName serialization', () => { + test('toJSON — nullSubject preserved', () => { + const json = QueryBuilder.from(Person) + .select(['name']) + .for(null) + .toJSON(); + + expect(json.nullSubject).toBe(true); + }); + + test('fromJSON — nullSubject restored', () => { + const json: QueryBuilderJSON = { + shape: personShape.id, + fields: [{path: 'name'}], + nullSubject: true, + singleResult: true, + }; + const restored = QueryBuilder.fromJSON(json); + // The restored builder should have _nullSubject set + expect(restored.toJSON().nullSubject).toBe(true); + }); +}); + +// ============================================================================= +// Combined round-trip: complex queries +// ============================================================================= + +describe('QueryBuilder — complex round-trip', () => { + test('round-trip — where + subject + limit + fields', () => { + const original = QueryBuilder.from(Person) + .select((p) => [p.name, p.hobby]) + .where((p) => p.name.equals('Bob')) + .for({id: `${tmpEntityBase}p1`}); + + const json = original.toJSON(); + const restored = QueryBuilder.fromJSON(json); + + expect(sanitize(restored.build())).toEqual(sanitize(original.build())); + }); + + test('round-trip — JSON string serialization', () => { + const original = QueryBuilder.from(Person) + .select((p) => [p.name]) + .where((p) => p.name.equals('Bob')) + .limit(10); + + const jsonString = JSON.stringify(original.toJSON()); + const parsed = JSON.parse(jsonString); + const restored = QueryBuilder.fromJSON(parsed); + + expect(sanitize(restored.build())).toEqual(sanitize(original.build())); + }); +}); + +// ============================================================================= +// Preload serialization tests +// ============================================================================= + +describe('QueryBuilder — preload serialization', () => { + const componentLike = {query: QueryBuilder.from(Person).select((p: any) => ({name: p.name}))}; + + test('toJSON — preload merges into fields as subSelect', () => { + const json = QueryBuilder.from(Person) + .select((p) => [p.name]) + .preload('bestFriend', componentLike) + .toJSON(); + + // Should have 2 fields: name + bestFriend (with subSelect from preload) + expect(json.fields!.length).toBe(2); + const bestFriendField = json.fields!.find((f) => f.path === 'bestFriend'); + expect(bestFriendField).toBeDefined(); + expect(bestFriendField!.subSelect).toBeDefined(); + expect(bestFriendField!.subSelect!.fields.length).toBeGreaterThan(0); + }); + + test('round-trip — preload produces same IR', () => { + const original = QueryBuilder.from(Person) + .select((p) => [p.name]) + .preload('bestFriend', componentLike); + + const json = original.toJSON(); + const restored = QueryBuilder.fromJSON(json); + + expect(sanitize(restored.build())).toEqual(sanitize(original.build())); + }); + + test('round-trip — preload with FieldSet component', () => { + const componentFs = FieldSet.for(personShape, ['name']); + const componentWithFs = {query: componentFs, fields: componentFs}; + + const original = QueryBuilder.from(Person) + .select((p) => [p.name]) + .preload('bestFriend', componentWithFs); + + const json = original.toJSON(); + const restored = QueryBuilder.fromJSON(json); + + expect(sanitize(restored.build())).toEqual(sanitize(original.build())); + }); +}); 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 efba532..e82b1b8 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") }`); }); @@ -637,9 +644,7 @@ 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 = "Alice")) }`); }); @@ -651,9 +656,7 @@ WHERE { SELECT DISTINCT ?a0 ?a0_name WHERE { ?a0 rdf:type <${P}> . - OPTIONAL { - ?a0 <${P}/name> ?a0_name . - } + ?a0 <${P}/name> ?a0_name . FILTER(!(EXISTS { ?a0 <${P}/friends> ?a1 . ?a1 <${P}/hobby> ?a1_hobby . @@ -669,9 +672,7 @@ 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 != "Alice") }`); }); @@ -683,12 +684,8 @@ WHERE { SELECT DISTINCT ?a0 ?a0_name WHERE { ?a0 rdf:type <${P}> . - OPTIONAL { - ?a0 <${P}/name> ?a0_name . - } - OPTIONAL { - ?a0 <${P}/hobby> ?a0_hobby . - } + ?a0 <${P}/name> ?a0_name . + ?a0 <${P}/hobby> ?a0_hobby . FILTER(!(?a0_name = "Alice" && ?a0_hobby = "Chess")) }`); }); @@ -700,9 +697,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 . @@ -718,12 +713,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 = ) }`); });