From b981878a699be1263f251e704f6a0f9046133656 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rene=CC=81=20Verheij?= Date: Tue, 24 Mar 2026 10:53:09 +0800 Subject: [PATCH 1/2] add rawQuery to SparqlStore and getAllShapeClasses utility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supports the lincd → @_linked/core migration in downstream packages: - rawQuery(sparql, mode?) on SparqlStore for raw SPARQL passthrough - getAllShapeClasses() exposes registered shape class map for lincd-server Co-Authored-By: Claude Opus 4.6 --- src/queries/IRMutation.ts | 2 +- src/queries/MutationQuery.ts | 4 ++-- src/queries/QueryFactory.ts | 2 +- src/queries/SelectQuery.ts | 2 +- src/shapes/Shape.ts | 6 +++--- src/sparql/SparqlStore.ts | 14 ++++++++++++++ src/utils/ShapeClass.ts | 9 ++++++++- 7 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/queries/IRMutation.ts b/src/queries/IRMutation.ts index 0da3e23..3d85cb6 100644 --- a/src/queries/IRMutation.ts +++ b/src/queries/IRMutation.ts @@ -1,4 +1,4 @@ -import {NodeShape} from '../shapes/SHACL.js'; +import type {NodeShape} from '../shapes/SHACL.js'; import { NodeDescriptionValue, NodeReferenceValue, diff --git a/src/queries/MutationQuery.ts b/src/queries/MutationQuery.ts index d939aa0..4e83ed7 100644 --- a/src/queries/MutationQuery.ts +++ b/src/queries/MutationQuery.ts @@ -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'; @@ -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.`, ); diff --git a/src/queries/QueryFactory.ts b/src/queries/QueryFactory.ts index 43c4a55..904da71 100644 --- a/src/queries/QueryFactory.ts +++ b/src/queries/QueryFactory.ts @@ -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'; diff --git a/src/queries/SelectQuery.ts b/src/queries/SelectQuery.ts index 6e69724..3d47e91 100644 --- a/src/queries/SelectQuery.ts +++ b/src/queries/SelectQuery.ts @@ -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'; diff --git a/src/shapes/Shape.ts b/src/shapes/Shape.ts index 6298952..1a3eb91 100644 --- a/src/shapes/Shape.ts +++ b/src/shapes/Shape.ts @@ -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'; diff --git a/src/sparql/SparqlStore.ts b/src/sparql/SparqlStore.ts index be798da..49e3fe2 100644 --- a/src/sparql/SparqlStore.ts +++ b/src/sparql/SparqlStore.ts @@ -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 { + if (mode === 'update') { + return this.executeSparqlUpdate(sparql); + } + return this.executeSparqlSelect(sparql); + } } diff --git a/src/utils/ShapeClass.ts b/src/utils/ShapeClass.ts index 09afa70..d1b9c7a 100644 --- a/src/utils/ShapeClass.ts +++ b/src/utils/ShapeClass.ts @@ -1,5 +1,5 @@ import {Shape, ShapeConstructor} from '../shapes/Shape.js'; -import {NodeShape, PropertyShape} from '../shapes/SHACL.js'; +import type {NodeShape, PropertyShape} from '../shapes/SHACL.js'; import {ICoreIterable} from '../interfaces/ICoreIterable.js'; import {NodeReferenceValue} from './NodeReference.js'; @@ -48,6 +48,13 @@ export function getShapeClass( return nodeShapeToShapeClass.get(id) as unknown as ShapeConstructor | undefined; } +/** + * Returns all registered shape classes (keyed by NodeShape URI). + */ +export function getAllShapeClasses(): Map { + return nodeShapeToShapeClass; +} + /** * Returns all the sub shapes of the given shape * That is all the shapes that extend this shape From 0a3adc1b9c47d9101da6d5c8b09e44531e2e396f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rene=CC=81=20Verheij?= Date: Wed, 25 Mar 2026 10:52:40 +0800 Subject: [PATCH 2/2] fix: make filter property triples OPTIONAL in inline where and scope EXISTS filter properties correctly Inline .where() filters with OR conditions previously failed when entities lacked certain properties because all filter property triples were required in the OPTIONAL block. Now each filter property triple is wrapped in its own nested OPTIONAL, allowing SPARQL's || to short-circuit over unbound variables. EXISTS/NOT EXISTS blocks for .every() and .some() quantifiers incorrectly placed filter property triples in the outer query scope. They are now emitted inside the EXISTS pattern where they belong. Also: tightened test assertions, added whereFriendsNameEqualsChained fixture, fixed AbortSignal.timeout jsdom compatibility in test helpers. Co-Authored-By: Claude Opus 4.6 --- .changeset/fix-optional-filter-triples.md | 21 +++ src/queries/FieldSet.ts | 17 ++- src/queries/IRDesugar.ts | 7 +- src/sparql/irToAlgebra.ts | 67 ++++++--- src/test-helpers/FusekiStore.ts | 7 +- src/test-helpers/fuseki-test-store.ts | 97 +++++++++++- src/test-helpers/query-fixtures.ts | 2 + src/tests/sparql-fuseki.test.ts | 175 +++++++++++++++++++--- src/tests/sparql-select-golden.test.ts | 56 ++++--- 9 files changed, 379 insertions(+), 70 deletions(-) create mode 100644 .changeset/fix-optional-filter-triples.md diff --git a/.changeset/fix-optional-filter-triples.md b/.changeset/fix-optional-filter-triples.md new file mode 100644 index 0000000..45d9be9 --- /dev/null +++ b/.changeset/fix-optional-filter-triples.md @@ -0,0 +1,21 @@ +--- +"@_linked/core": patch +--- + +Fix SPARQL generation for `.where()` filters with OR conditions and `.every()`/`.some()` quantifiers. + +### Inline where with OR filters + +Previously, filter property triples inside inline `.where()` blocks were required, causing OR filters to fail when some entities lacked certain properties. For example, `.where(f => f.name.equals('Jinx').or(f.hobby.equals('Jogging')))` would exclude entities without a `hobby` triple, even if they matched the `name` condition. + +Filter property triples are now wrapped in nested OPTIONALs within the filtered block, so SPARQL's `||` short-circuits correctly over unbound variables. + +### EXISTS/NOT EXISTS scope fix + +Property triples referenced inside `.every()` and `.some()` quantifier filters were incorrectly placed in the outer query scope instead of inside the EXISTS block. This caused `.every()` to return incorrect results (e.g., not excluding entities whose members fail the predicate). + +Filter property triples are now emitted directly inside the EXISTS pattern where they are semantically scoped. + +### Test improvements + +Tightened assertions across multiple integration tests (`whereOr`, `whereAnd`, `whereEvery`, `whereSequences`, `countEquals`, `selectBirthDate`, and others) to verify exact result counts, correct inclusion/exclusion, and proper type coercion. diff --git a/src/queries/FieldSet.ts b/src/queries/FieldSet.ts index 315ce91..090af34 100644 --- a/src/queries/FieldSet.ts +++ b/src/queries/FieldSet.ts @@ -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'; @@ -604,8 +607,18 @@ export class FieldSet { 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; } diff --git a/src/queries/IRDesugar.ts b/src/queries/IRDesugar.ts index b709042..9d87742 100644 --- a/src/queries/IRDesugar.ts +++ b/src/queries/IRDesugar.ts @@ -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', @@ -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; diff --git a/src/sparql/irToAlgebra.ts b/src/sparql/irToAlgebra.ts index 21f03b6..daba6e0 100644 --- a/src/sparql/irToAlgebra.ts +++ b/src/sparql/irToAlgebra.ts @@ -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(); + 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, @@ -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 = { @@ -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); } @@ -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. @@ -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', diff --git a/src/test-helpers/FusekiStore.ts b/src/test-helpers/FusekiStore.ts index b4de3a2..83e2ec1 100644 --- a/src/test-helpers/FusekiStore.ts +++ b/src/test-helpers/FusekiStore.ts @@ -39,11 +39,14 @@ export class FusekiStore extends SparqlStore { } async init(): Promise { - // 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}`); } diff --git a/src/test-helpers/fuseki-test-store.ts b/src/test-helpers/fuseki-test-store.ts index 25a5552..7bfcfa9 100644 --- a/src/test-helpers/fuseki-test-store.ts +++ b/src/test-helpers/fuseki-test-store.ts @@ -2,6 +2,7 @@ * Test helper module for Apache Jena Fuseki integration tests. * * Provides utilities to: + * - Ensure a Fuseki instance is running (auto-starts via Docker if needed) * - Check if a Fuseki instance is available * - Create / delete an in-memory test dataset * - Load N-Triples data @@ -10,11 +11,17 @@ * * Uses native fetch (Node 18+). No external HTTP libraries. */ +import {execSync} from 'node:child_process'; +import {existsSync} from 'node:fs'; +import {resolve} from 'node:path'; const FUSEKI_BASE_URL = process.env.FUSEKI_BASE_URL || 'http://localhost:3030'; const FUSEKI_ADMIN_PASSWORD = process.env.FUSEKI_ADMIN_PASSWORD || 'admin'; const DATASET_NAME = 'nashville-test'; +/** Whether this process started Fuseki (so we know whether to stop it). */ +let startedByUs = false; + const adminAuth = `Basic ${Buffer.from(`admin:${FUSEKI_ADMIN_PASSWORD}`).toString('base64')}`; /** @@ -23,16 +30,104 @@ const adminAuth = `Basic ${Buffer.from(`admin:${FUSEKI_ADMIN_PASSWORD}`).toStrin */ export async function isFusekiAvailable(): Promise { try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 2000); const response = await fetch(FUSEKI_BASE_URL, { method: 'HEAD', - signal: AbortSignal.timeout(2000), + signal: controller.signal, }); + clearTimeout(timer); return response.status === 200; } catch { return false; } } +/** + * Locate the docker-compose.test.yml for Fuseki. + * Works from both source (src/) and compiled (lib/esm/) paths. + */ +function findComposeFile(): string | null { + // __dirname works in both CJS and ts-jest; for ESM builds the lincd + // toolchain injects a __dirname shim. + const candidates = [ + resolve(__dirname, '../tests/docker-compose.test.yml'), + resolve(__dirname, '../../src/tests/docker-compose.test.yml'), + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + return null; +} + +/** + * Ensure a Fuseki instance is running. + * If Fuseki is already reachable, returns immediately. + * Otherwise, starts it via Docker Compose and waits for the healthcheck. + * + * Returns true if Fuseki is available after this call. + * Returns false if Docker is not installed or the compose file can't be found. + */ +export async function ensureFuseki(): Promise { + if (await isFusekiAvailable()) return true; + + const composeFile = findComposeFile(); + if (!composeFile) { + console.warn( + '[ensureFuseki] docker-compose.test.yml not found — cannot auto-start Fuseki', + ); + return false; + } + + try { + execSync('docker compose version', {stdio: 'ignore'}); + } catch { + console.warn('[ensureFuseki] Docker Compose not available — skipping'); + return false; + } + + console.log('[ensureFuseki] Fuseki not running — starting via Docker...'); + try { + execSync(`docker compose -f "${composeFile}" up -d --wait`, { + stdio: 'inherit', + timeout: 60_000, + }); + startedByUs = true; + + // Verify it came up + if (await isFusekiAvailable()) { + console.log('[ensureFuseki] Fuseki is ready'); + return true; + } + console.warn('[ensureFuseki] Fuseki started but not responding'); + return false; + } catch (err) { + console.warn('[ensureFuseki] Failed to start Fuseki:', (err as Error).message); + return false; + } +} + +/** + * Stop the Fuseki container if it was started by `ensureFuseki()`. + * No-op if Fuseki was already running before the tests. + */ +export async function stopFuseki(): Promise { + if (!startedByUs) return; + + const composeFile = findComposeFile(); + if (!composeFile) return; + + try { + execSync(`docker compose -f "${composeFile}" down`, { + stdio: 'inherit', + timeout: 30_000, + }); + } catch { + // Best effort — don't fail the test suite on cleanup + } + startedByUs = false; +} + /** * Create the in-memory test dataset on Fuseki. * Ignores 409 Conflict (dataset already exists). diff --git a/src/test-helpers/query-fixtures.ts b/src/test-helpers/query-fixtures.ts index ca3953e..b8f619d 100644 --- a/src/test-helpers/query-fixtures.ts +++ b/src/test-helpers/query-fixtures.ts @@ -188,6 +188,8 @@ export const queryFactories = { Person.select((p) => p.friends.bestFriend.bestFriend.name), whereFriendsNameEquals: () => Person.select((p) => p.friends.where((f) => f.name.equals('Moa'))), + whereFriendsNameEqualsChained: () => + Person.select((p) => p.friends.where((f) => f.name.equals('Moa')).name), whereBestFriendEquals: () => Person.select().where((p) => p.bestFriend.equals(entity('p3'))), whereHobbyEquals: () => diff --git a/src/tests/sparql-fuseki.test.ts b/src/tests/sparql-fuseki.test.ts index 1eec00f..6967ab3 100644 --- a/src/tests/sparql-fuseki.test.ts +++ b/src/tests/sparql-fuseki.test.ts @@ -218,10 +218,17 @@ describe('Fuseki SELECT — basic', () => { const semmy = findRowById(rows, 'p1'); expect(semmy).toBeDefined(); const bd = semmy!.birthDate; + expect(bd).toBeDefined(); + expect(bd).not.toBeNull(); if (bd instanceof Date) { expect(bd.getFullYear()).toBe(1990); + expect(bd.getMonth()).toBe(0); // January + expect(bd.getDate()).toBe(1); } else { - expect(String(bd)).toContain('1990'); + // Must be a parseable ISO date string starting with 1990-01-01 + const dateStr = String(bd); + expect(dateStr).toMatch(/^1990-01-01/); + expect(new Date(dateStr).getFullYear()).toBe(1990); } const jinx = findRowById(rows, 'p3'); @@ -768,13 +775,59 @@ describe('Fuseki SELECT — inline where', () => { // Only friends matching name='Moa' should appear in the nested array. expect(rows.length).toBe(4); + // p1 has friends [p2(Moa), p3(Jinx)] — only Moa should match the filter const p1 = findRowById(rows, 'p1'); expect(p1).toBeDefined(); - // p1 has friends [p2(Moa), p3(Jinx)] — only Moa matches the filter - const p1Friends = p1!.friends as ResultRow[] | undefined; - if (p1Friends && p1Friends.length > 0) { - const friendIds = p1Friends.map((f) => f.id); - expect(friendIds.some((id) => id.includes('p2'))).toBe(true); + const p1Friends = p1!.friends as ResultRow[]; + expect(Array.isArray(p1Friends)).toBe(true); + expect(p1Friends.length).toBe(1); + expect(p1Friends[0].id).toContain('p2'); // p2 = Moa + // p3 (Jinx) must NOT appear + expect(p1Friends.some((f) => f.id.includes('p3'))).toBe(false); + + // p2 has friends [p3(Jinx), p4(Quinn)] — neither is Moa, so empty + const p2 = findRowById(rows, 'p2'); + expect(p2).toBeDefined(); + const p2Friends = p2!.friends as ResultRow[] | null; + if (p2Friends) { + expect(p2Friends.length).toBe(0); + } + + // p3 and p4 have no friends at all — returns null or empty array + const p3 = findRowById(rows, 'p3'); + expect(p3).toBeDefined(); + const p3Friends = p3!.friends as ResultRow[] | null; + expect(!p3Friends || p3Friends.length === 0).toBe(true); + }); + + test('whereFriendsNameEqualsChained — .where().name property access', async () => { + if (!fusekiAvailable) return; + + // Same filter as whereFriendsNameEquals but chains .name after .where(). + // Query: Person.select((p) => p.friends.where((f) => f.name.equals('Moa')).name) + // Expected: each person's friends filtered to name=Moa, then name extracted. + const result = await runSelectMapped('whereFriendsNameEqualsChained'); + expect(Array.isArray(result)).toBe(true); + const rows = result as ResultRow[]; + + expect(rows.length).toBe(4); + + // p1 has friends [p2(Moa), p3(Jinx)] — filter to Moa, extract name + const p1 = findRowById(rows, 'p1'); + expect(p1).toBeDefined(); + const p1Friends = p1!.friends as ResultRow[]; + expect(Array.isArray(p1Friends)).toBe(true); + expect(p1Friends.length).toBe(1); + expect(p1Friends[0].name).toBe('Moa'); + // Jinx must NOT appear + expect(p1Friends.some((f) => f.name === 'Jinx')).toBe(false); + + // p2 has friends [p3(Jinx), p4(Quinn)] — neither matches, so empty + const p2 = findRowById(rows, 'p2'); + expect(p2).toBeDefined(); + const p2Friends = p2!.friends as ResultRow[] | null; + if (p2Friends) { + expect(p2Friends.length).toBe(0); } }); @@ -785,6 +838,20 @@ describe('Fuseki SELECT — inline where', () => { expect(Array.isArray(result)).toBe(true); const rows = result as ResultRow[]; expect(rows.length).toBe(4); + + // p1 friends [p2(Moa,Jogging), p3(Jinx,none)] — only p2 matches both conditions + const p1 = findRowById(rows, 'p1'); + expect(p1).toBeDefined(); + const p1Friends = p1!.friends as ResultRow[]; + expect(Array.isArray(p1Friends)).toBe(true); + expect(p1Friends.length).toBe(1); + expect(p1Friends[0].id).toContain('p2'); + + // p2 friends [p3(Jinx), p4(Quinn)] — neither is named Moa + const p2 = findRowById(rows, 'p2'); + expect(p2).toBeDefined(); + const p2Friends = p2!.friends as ResultRow[] | null; + expect(!p2Friends || p2Friends.length === 0).toBe(true); }); test('whereOr — friends filtered by name=Jinx OR hobby=Jogging', async () => { @@ -794,34 +861,81 @@ describe('Fuseki SELECT — inline where', () => { expect(Array.isArray(result)).toBe(true); const rows = result as ResultRow[]; expect(rows.length).toBe(4); + + // p1 friends [p2(Moa,Jogging), p3(Jinx,no hobby)] + // p2 matches via hobby=Jogging; p3 matches via name=Jinx + // (hobby triple is OPTIONAL within the filtered block) + const p1 = findRowById(rows, 'p1'); + expect(p1).toBeDefined(); + const p1Friends = p1!.friends as ResultRow[]; + expect(Array.isArray(p1Friends)).toBe(true); + expect(p1Friends.length).toBe(2); + const p1Ids = p1Friends.map((f) => f.id); + expect(p1Ids.some((id) => id.includes('p2'))).toBe(true); // Moa (hobby match) + expect(p1Ids.some((id) => id.includes('p3'))).toBe(true); // Jinx (name match) + + // p2 friends [p3(Jinx), p4(Quinn)] — p3 matches name=Jinx + const p2 = findRowById(rows, 'p2'); + expect(p2).toBeDefined(); + const p2Friends = p2!.friends as ResultRow[]; + expect(Array.isArray(p2Friends)).toBe(true); + expect(p2Friends.length).toBe(1); + expect(p2Friends[0].id).toContain('p3'); }); test('whereAndOrAnd — (name=Jinx || hobby=Jogging) && name=Moa', async () => { if (!fusekiAvailable) return; // Parenthesized as (A || B) && C due to Phase 15. - // Only p2 (Moa, Jogging) satisfies: (Moa≠Jinx || Jogging=Jogging) && Moa=Moa. - // p3 (Jinx) has no hobby so the triple pattern fails. + // Filter: (name=Jinx || hobby=Jogging) && name=Moa const result = await runSelectMapped('whereAndOrAnd'); expect(Array.isArray(result)).toBe(true); const rows = result as ResultRow[]; expect(rows.length).toBe(4); - // p1 has friends [p2, p3] — only p2 matches the filter + // p1 friends: p2 matches (Jinx=Moa?no || Jogging=Jogging?yes) && Moa=Moa → yes + // p3: (Jinx=Jinx?yes || _) && Jinx=Moa?no → fails AND const p1 = findRowById(rows, 'p1'); expect(p1).toBeDefined(); + const p1Friends = p1!.friends as ResultRow[]; + expect(Array.isArray(p1Friends)).toBe(true); + expect(p1Friends.length).toBe(1); + expect(p1Friends[0].id).toContain('p2'); + expect(p1Friends.some((f) => f.id.includes('p3'))).toBe(false); + + // p2 friends: neither p3(Jinx) nor p4(Quinn) is named Moa → empty + const p2 = findRowById(rows, 'p2'); + expect(p2).toBeDefined(); + const p2Friends = p2!.friends as ResultRow[] | null; + expect(!p2Friends || p2Friends.length === 0).toBe(true); }); test('whereAndOrAndNested — name=Jinx || (hobby=Jogging && name=Moa)', async () => { if (!fusekiAvailable) return; - // No parenthesization needed: && already binds tighter. - // Same effective filter as whereAndOrAnd with this test data - // (p3/Jinx has no hobby so can't match any branch that checks hobby). + // Filter: name=Jinx || (hobby=Jogging && name=Moa) const result = await runSelectMapped('whereAndOrAndNested'); expect(Array.isArray(result)).toBe(true); const rows = result as ResultRow[]; expect(rows.length).toBe(4); + + // p1 friends: p2 matches (hobby=Jogging && name=Moa); p3 matches (name=Jinx) + const p1 = findRowById(rows, 'p1'); + expect(p1).toBeDefined(); + const p1Friends = p1!.friends as ResultRow[]; + expect(Array.isArray(p1Friends)).toBe(true); + expect(p1Friends.length).toBe(2); + const p1Ids = p1Friends.map((f) => f.id); + expect(p1Ids.some((id) => id.includes('p2'))).toBe(true); + expect(p1Ids.some((id) => id.includes('p3'))).toBe(true); + + // p2 friends: p3 matches (name=Jinx) + const p2 = findRowById(rows, 'p2'); + expect(p2).toBeDefined(); + const p2Friends = p2!.friends as ResultRow[]; + expect(Array.isArray(p2Friends)).toBe(true); + expect(p2Friends.length).toBe(1); + expect(p2Friends[0].id).toContain('p3'); }); }); @@ -847,9 +961,14 @@ describe('Fuseki SELECT — quantifiers and aggregates', () => { const rows = mapped as ResultRow[]; // whereEvery: all friends must have name=Moa OR name=Jinx // p1 friends: [p2(Moa), p3(Jinx)] → both match → p1 passes - // p2 friends: [p3(Jinx), p4(Quinn)] → Quinn doesn't match → p2 fails + // p2 friends: [p3(Jinx), p4(Quinn)] → Quinn doesn't match → p2 excluded // p3, p4 have no friends → vacuously true - expect(rows.length).toBeGreaterThanOrEqual(1); + expect(rows.length).toBe(3); + + expect(findRowById(rows, 'p1')).toBeDefined(); + expect(findRowById(rows, 'p2')).toBeUndefined(); // Quinn fails the filter + expect(findRowById(rows, 'p3')).toBeDefined(); + expect(findRowById(rows, 'p4')).toBeDefined(); }); test('whereSequences — EXISTS for some() quantifier', async () => { @@ -861,9 +980,19 @@ describe('Fuseki SELECT — quantifiers and aggregates', () => { const mapped = mapSparqlSelectResult(results, ir); expect(Array.isArray(mapped)).toBe(true); const rows = mapped as ResultRow[]; - // whereSequences: friends.some(f => f.name='Jinx') AND name='Semmy' - // p1 has friend p3(Jinx) and name=Semmy → p1 matches - expect(rows.length).toBeGreaterThanOrEqual(1); + // whereSequences: Person.select() with outer where + // friends.some(f => f.name='Jinx') AND name='Semmy' + // p1 has friend p3(Jinx) and name=Semmy → only p1 matches + // select() returns id only (no property projections) + expect(rows.length).toBe(1); + + const p1 = findRowById(rows, 'p1'); + expect(p1).toBeDefined(); + expect(p1!.id).toContain('p1'); + + expect(findRowById(rows, 'p2')).toBeUndefined(); + expect(findRowById(rows, 'p3')).toBeUndefined(); + expect(findRowById(rows, 'p4')).toBeUndefined(); }); test('countEquals — HAVING with aggregate', async () => { @@ -878,7 +1007,17 @@ describe('Fuseki SELECT — quantifiers and aggregates', () => { const rows = mapped as ResultRow[]; // countEquals: friends.size() = 2 // p1 has 2 friends → matches. p2 has 2 friends → matches. - expect(rows.length).toBeGreaterThanOrEqual(1); + // p3, p4 have 0 friends → don't match. + expect(rows.length).toBe(2); + + const p1 = findRowById(rows, 'p1'); + expect(p1).toBeDefined(); + + const p2 = findRowById(rows, 'p2'); + expect(p2).toBeDefined(); + + expect(findRowById(rows, 'p3')).toBeUndefined(); + expect(findRowById(rows, 'p4')).toBeUndefined(); }); }); diff --git a/src/tests/sparql-select-golden.test.ts b/src/tests/sparql-select-golden.test.ts index b774a00..bf47fb1 100644 --- a/src/tests/sparql-select-golden.test.ts +++ b/src/tests/sparql-select-golden.test.ts @@ -381,7 +381,9 @@ WHERE { ?a0 rdf:type <${P}> . OPTIONAL { ?a0 <${P}/friends> ?a1 . - ?a1 <${P}/name> ?a1_name . + OPTIONAL { + ?a1 <${P}/name> ?a1_name . + } FILTER(?a1_name = "Moa") } }`); @@ -410,8 +412,12 @@ WHERE { ?a0 rdf:type <${P}> . OPTIONAL { ?a0 <${P}/friends> ?a1 . - ?a1 <${P}/name> ?a1_name . - ?a1 <${P}/hobby> ?a1_hobby . + OPTIONAL { + ?a1 <${P}/name> ?a1_name . + } + OPTIONAL { + ?a1 <${P}/hobby> ?a1_hobby . + } FILTER(?a1_name = "Moa" && ?a1_hobby = "Jogging") } }`); @@ -426,8 +432,12 @@ WHERE { ?a0 rdf:type <${P}> . OPTIONAL { ?a0 <${P}/friends> ?a1 . - ?a1 <${P}/name> ?a1_name . - ?a1 <${P}/hobby> ?a1_hobby . + OPTIONAL { + ?a1 <${P}/name> ?a1_name . + } + OPTIONAL { + ?a1 <${P}/hobby> ?a1_hobby . + } FILTER(?a1_name = "Jinx" || ?a1_hobby = "Jogging") } }`); @@ -442,8 +452,12 @@ WHERE { ?a0 rdf:type <${P}> . OPTIONAL { ?a0 <${P}/friends> ?a1 . - ?a1 <${P}/name> ?a1_name . - ?a1 <${P}/hobby> ?a1_hobby . + OPTIONAL { + ?a1 <${P}/name> ?a1_name . + } + OPTIONAL { + ?a1 <${P}/hobby> ?a1_hobby . + } FILTER((?a1_name = "Jinx" || ?a1_hobby = "Jogging") && ?a1_name = "Moa") } }`); @@ -458,8 +472,12 @@ WHERE { ?a0 rdf:type <${P}> . OPTIONAL { ?a0 <${P}/friends> ?a1 . - ?a1 <${P}/name> ?a1_name . - ?a1 <${P}/hobby> ?a1_hobby . + OPTIONAL { + ?a1 <${P}/name> ?a1_name . + } + OPTIONAL { + ?a1 <${P}/hobby> ?a1_hobby . + } FILTER(?a1_name = "Jinx" || ?a1_hobby = "Jogging" && ?a1_name = "Moa") } }`); @@ -553,11 +571,9 @@ WHERE { SELECT DISTINCT ?a0 WHERE { ?a0 rdf:type <${P}> . - OPTIONAL { - ?a1 <${P}/name> ?a1_name . - } FILTER(EXISTS { ?a0 <${P}/friends> ?a1 . + ?a1 <${P}/name> ?a1_name . FILTER(?a1_name = "Moa") }) }`); @@ -570,11 +586,9 @@ WHERE { SELECT DISTINCT ?a0 WHERE { ?a0 rdf:type <${P}> . - OPTIONAL { - ?a1 <${P}/name> ?a1_name . - } FILTER(!(EXISTS { ?a0 <${P}/friends> ?a1 . + ?a1 <${P}/name> ?a1_name . FILTER(!(?a1_name = "Moa" || ?a1_name = "Jinx")) })) }`); @@ -587,14 +601,12 @@ WHERE { SELECT DISTINCT ?a0 WHERE { ?a0 rdf:type <${P}> . - OPTIONAL { - ?a1 <${P}/name> ?a1_name . - } OPTIONAL { ?a0 <${P}/name> ?a0_name . } FILTER(EXISTS { ?a0 <${P}/friends> ?a1 . + ?a1 <${P}/name> ?a1_name . FILTER(?a1_name = "Jinx") } && ?a0_name = "Semmy") }`); @@ -627,14 +639,10 @@ WHERE { OPTIONAL { ?a0 <${P}/name> ?a0_name . } - OPTIONAL { - ?a1 <${P}/name> ?a1_name . - } - OPTIONAL { - <${P}/name> ?__ctx__user_1_name . - } FILTER(EXISTS { ?a0 <${P}/friends> ?a1 . + ?a1 <${P}/name> ?a1_name . + <${P}/name> ?__ctx__user_1_name . FILTER(?a1_name = ?__ctx__user_1_name) }) }`);