Skip to content
Merged

Dev #38

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-optional-filter-triples.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@_linked/core": patch
---

Fix SPARQL generation for `.where()` filters with OR conditions and `.every()`/`.some()` quantifiers.
Tightened assertions across multiple integration tests.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@_linked/core",
"version": "2.1.0",
"version": "2.2.0",
"license": "MIT",
"description": "Linked.js core query and SHACL shape DSL (copy-then-prune baseline)",
"repository": {
Expand Down
17 changes: 15 additions & 2 deletions src/queries/FieldSet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ export type FieldSetEntry = {
path: PropertyPath;
alias?: string;
scopedFilter?: WherePath;
/** Index into path.segments indicating which segment the scopedFilter applies to.
* Defaults to the last segment if not specified. */
scopedFilterIndex?: number;
/** Nested object selection — the user explicitly selected sub-fields (e.g. `p.friends.select(...)`) */
subSelect?: FieldSet;
aggregation?: 'count';
Expand Down Expand Up @@ -604,8 +607,18 @@ export class FieldSet<R = any, Source = any> {
const entry: FieldSetEntry = {
path: new PropertyPath(rootShape, segments),
};
if (obj.wherePath) {
entry.scopedFilter = obj.wherePath as WherePath;
// Walk from leaf to root to find wherePath on any object in the chain.
// Track which segment it belongs to so desugarEntry attaches the filter correctly.
let current: QueryBuilderObjectLike | undefined = obj;
let leafDistance = 0;
while (current) {
if (current.wherePath) {
entry.scopedFilter = current.wherePath as WherePath;
entry.scopedFilterIndex = segments.length - 1 - leafDistance;
break;
}
current = current.subject;
leafDistance++;
}
return entry;
}
Expand Down
7 changes: 5 additions & 2 deletions src/queries/IRDesugar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,10 @@ const desugarEntry = (entry: FieldSetEntry): DesugaredSelection => {
return {kind: 'selection_path', steps: []};
}

// Build property steps, attaching scopedFilter to the last segment
// Build property steps, attaching scopedFilter to the segment it belongs to.
// scopedFilterIndex indicates which segment the .where() was called on;
// defaults to the last segment for backwards compatibility.
const filterIndex = entry.scopedFilterIndex ?? (segments.length - 1);
const steps: DesugaredStep[] = segments.map((segment, i) => {
const step: DesugaredPropertyStep = {
kind: 'property_step',
Expand All @@ -242,7 +245,7 @@ const desugarEntry = (entry: FieldSetEntry): DesugaredSelection => {
if (segment.path && isComplexPathExpr(segment.path)) {
step.pathExpr = segment.path;
}
if (entry.scopedFilter && i === segments.length - 1) {
if (entry.scopedFilter && i === filterIndex) {
step.where = toWhere(entry.scopedFilter);
}
return step;
Expand Down
2 changes: 1 addition & 1 deletion src/queries/IRMutation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {NodeShape} from '../shapes/SHACL.js';
import type {NodeShape} from '../shapes/SHACL.js';
import {
NodeDescriptionValue,
NodeReferenceValue,
Expand Down
4 changes: 2 additions & 2 deletions src/queries/MutationQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
UpdateNodePropertyValue,
UpdatePartial,
} from './QueryFactory.js';
import {NodeShape, PropertyShape} from '../shapes/SHACL.js';
import type {NodeShape, PropertyShape} from '../shapes/SHACL.js';
import {Shape} from '../shapes/Shape.js';
import {getShapeClass} from '../utils/ShapeClass.js';
import {isExpressionNode, ExpressionNode} from '../expressions/ExpressionNode.js';
Expand Down Expand Up @@ -211,7 +211,7 @@ export class MutationQueryFactory extends QueryFactory {
if (!propShape.valueShape) {
//It's possible to define the shape of the value in the value itself for properties who do not define the shape in their objectProperty
if (value.shape) {
if (!(value.shape.shape instanceof NodeShape)) {
if (!value.shape.shape || typeof (value.shape.shape as NodeShape).getPropertyShapes !== 'function') {
throw new Error(
`The value of property "shape" is invalid and should be a class that extends Shape.`,
);
Expand Down
2 changes: 1 addition & 1 deletion src/queries/QueryFactory.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {NodeShape, PropertyShape} from '../shapes/SHACL.js';
import type {NodeShape, PropertyShape} from '../shapes/SHACL.js';
import {Shape} from '../shapes/Shape.js';
import {ShapeSet} from '../collections/ShapeSet.js';
import {NodeReferenceValue} from '../utils/NodeReference.js';
Expand Down
2 changes: 1 addition & 1 deletion src/queries/SelectQuery.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {Shape, ShapeConstructor} from '../shapes/Shape.js';
import {PropertyShape} from '../shapes/SHACL.js';
import type {PropertyShape} from '../shapes/SHACL.js';
import {ShapeSet} from '../collections/ShapeSet.js';
import {shacl} from '../ontologies/shacl.js';
import {CoreSet} from '../collections/CoreSet.js';
Expand Down
6 changes: 3 additions & 3 deletions src/shapes/Shape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import type {NodeShape, PropertyShape} from './SHACL.js';
import {
import type {
QueryBuildFn,
QueryResponseToResultType,
QueryShape,
SelectAllQueryResponse,
WhereClause,
} from '../queries/SelectQuery.js';
import {NodeReferenceValue, UpdatePartial} from '../queries/QueryFactory.js';
import {NodeId} from '../queries/MutationQuery.js';
import type {NodeReferenceValue, UpdatePartial} from '../queries/QueryFactory.js';
import type {NodeId} from '../queries/MutationQuery.js';
import {QueryBuilder} from '../queries/QueryBuilder.js';
import {CreateBuilder} from '../queries/CreateBuilder.js';
import {UpdateBuilder} from '../queries/UpdateBuilder.js';
Expand Down
14 changes: 14 additions & 0 deletions src/sparql/SparqlStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,18 @@ export abstract class SparqlStore implements IQuadStore {
count: query.ids.length,
};
}

/**
* Execute a raw SPARQL query string directly against the store.
* Defaults to SELECT; pass `'update'` for INSERT/DELETE operations.
*/
async rawQuery(
sparql: string,
mode?: 'query' | 'update',
): Promise<SparqlJsonResults | void> {
if (mode === 'update') {
return this.executeSparqlUpdate(sparql);
}
return this.executeSparqlSelect(sparql);
}
}
67 changes: 46 additions & 21 deletions src/sparql/irToAlgebra.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,8 +283,19 @@ export function selectToAlgebra(
processPattern(pattern, registry, traverseTriples, optionalPropertyTriples, filteredTraverseBlocks);
}

// 3. Process projection expressions, where clause, orderBy expressions
// to discover any additional property_expr references
// 3. Pre-register filter property references BEFORE processing projections.
// This ensures that property triples needed by inline where filters are
// co-located inside the filtered OPTIONAL block, not in separate OPTIONALs.
const filterPropertyTriplesMap = new Map<number, SparqlTriple[]>();
filteredTraverseBlocks.forEach((block, idx) => {
const filterPropertyTriples: SparqlTriple[] = [];
processExpressionForProperties(block.filter, registry, filterPropertyTriples);
filterPropertyTriplesMap.set(idx, filterPropertyTriples);
});

// 4. Process projection expressions, where clause, orderBy expressions
// to discover any additional property_expr references.
// Properties already registered by inline filters (above) will be skipped.
for (const item of query.projection) {
processExpressionForProperties(
item.expression,
Expand All @@ -311,7 +322,7 @@ export function selectToAlgebra(
}
}

// 4. Build the algebra tree
// 5. Build the algebra tree
// - Start with the required BGP (type triple + traverse triples)
// - Wrap each optional property triple in a LeftJoin
const requiredBgp: SparqlBGP = {
Expand All @@ -321,17 +332,21 @@ export function selectToAlgebra(

let algebra: SparqlAlgebraNode = requiredBgp;

// 4b. Build filtered OPTIONAL blocks for inline where traversals.
// Each block contains: traverse triple + filter property triples + FILTER.
// Property triples referenced by the filter are co-located inside the OPTIONAL
// so that the filter can reference them.
for (const block of filteredTraverseBlocks) {
const filterPropertyTriples: SparqlTriple[] = [];
processExpressionForProperties(block.filter, registry, filterPropertyTriples);
// 5b. Build filtered OPTIONAL blocks for inline where traversals.
// Each block contains: traverse triple + OPTIONAL property triples + FILTER.
// Filter property triples are nested as OPTIONALs so that OR filters work
// even when some entities lack certain properties.
for (let i = 0; i < filteredTraverseBlocks.length; i++) {
const block = filteredTraverseBlocks[i];
const filterPropertyTriples = filterPropertyTriplesMap.get(i) || [];
const filterExpr = convertExpression(block.filter, registry, filterPropertyTriples);
const blockTriples: SparqlTriple[] = [block.traverseTriple, ...filterPropertyTriples];
const blockBgp: SparqlBGP = {type: 'bgp', triples: blockTriples};
const filteredBlock: SparqlFilter = {type: 'filter', expression: filterExpr, inner: blockBgp};
// Start with the traverse triple as the required BGP
let blockInner: SparqlAlgebraNode = {type: 'bgp', triples: [block.traverseTriple]};
// Wrap each filter property triple in its own nested OPTIONAL
for (const propTriple of filterPropertyTriples) {
blockInner = wrapOptional(blockInner, {type: 'bgp', triples: [propTriple]});
}
const filteredBlock: SparqlFilter = {type: 'filter', expression: filterExpr, inner: blockInner};
algebra = wrapOptional(algebra, filteredBlock);
}

Expand Down Expand Up @@ -624,11 +639,9 @@ function processExpressionForProperties(
}
break;
case 'exists_expr':
// exists_expr in IR has pattern + filter
// Process the filter for property references
if (expr.filter) {
processExpressionForProperties(expr.filter, registry, optionalPropertyTriples);
}
// exists_expr filter properties belong INSIDE the EXISTS block, not in
// the outer scope. Do NOT register them here — convertExpression's
// exists_expr handler will collect and emit them locally.
break;
case 'context_property_expr': {
// Context entity property — emit a triple with fixed IRI as subject.
Expand Down Expand Up @@ -753,18 +766,30 @@ function convertExpression(
};

case 'exists_expr': {
// Convert exists expression with inner pattern + filter
const innerAlgebra = convertExistsPattern(
// Convert exists expression with inner pattern + filter.
// Filter property triples must live INSIDE the EXISTS block
// (not in the outer scope), so we collect them locally.
let innerAlgebra = convertExistsPattern(
expr.pattern,
registry,
);

if (expr.filter) {
// First, discover and register filter property references,
// collecting their triples into a local array (NOT the outer scope).
const existsPropertyTriples: SparqlTriple[] = [];
processExpressionForProperties(expr.filter, registry, existsPropertyTriples);

// Now convert the filter expression (variables are registered above).
const filterExpr = convertExpression(
expr.filter,
registry,
optionalPropertyTriples,
existsPropertyTriples, // unused — properties already registered
);
// Add filter property triples inside the EXISTS
for (const propTriple of existsPropertyTriples) {
innerAlgebra = joinNodes(innerAlgebra, {type: 'bgp', triples: [propTriple]})!;
}
// Wrap the inner pattern with a filter
const filteredInner: SparqlFilter = {
type: 'filter',
Expand Down
7 changes: 5 additions & 2 deletions src/test-helpers/FusekiStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,14 @@ export class FusekiStore extends SparqlStore {
}

async init(): Promise<void> {
// Check availability
// Check availability (use AbortController for jsdom compatibility)
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2000);
const response = await fetch(this.baseUrl, {
method: 'HEAD',
signal: AbortSignal.timeout(2000),
signal: controller.signal,
});
clearTimeout(timer);
if (response.status !== 200) {
throw new Error(`Fuseki not available at ${this.baseUrl}`);
}
Expand Down
Loading
Loading