diff --git a/.changeset/fix-optional-filter-triples.md b/.changeset/fix-optional-filter-triples.md new file mode 100644 index 0000000..2ad59d1 --- /dev/null +++ b/.changeset/fix-optional-filter-triples.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 7b206d7..c560436 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@_linked/core", - "version": "2.1.0", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@_linked/core", - "version": "2.1.0", + "version": "2.2.0", "license": "MIT", "dependencies": { "next-tick": "^1.1.0", diff --git a/package.json b/package.json index 44ddada..e3f9fab 100644 --- a/package.json +++ b/package.json @@ -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": { 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/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/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) }) }`); 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