From 17f002c1b5325f2d5ab5ab25d330aefb8c4ce972 Mon Sep 17 00:00:00 2001 From: julianrojas87 Date: Mon, 6 Jul 2026 17:45:51 +0200 Subject: [PATCH 01/22] Changes include: - implement capacity to represent a shape as an IQuery - generalize shape parsing among SHACL and ShEx - rely on vocabulary factory to handle ontology terms --- README.md | 56 ++++++ bun.lock | 11 ++ index.ts | 1 + lib/Binding.ts | 10 +- lib/Shape.ts | 312 ++++++++++++++++++++++++++++++ lib/constant.ts | 130 ++++++++----- lib/containment.ts | 20 +- lib/parser-policy.ts | 35 ++++ lib/query.ts | 392 +++++++++++++++++++++++++++++++++++++- lib/shacl.ts | 218 +++++++++------------ lib/shex.ts | 313 ++++++++++++++++++++++-------- package-lock.json | 54 +++++- package.json | 3 +- test/Binding.test.ts | 12 +- test/containment.test.ts | 139 +++++++++++++- test/query.test.ts | 4 +- test/shacl.test.ts | 8 +- test/shape-parser.test.ts | 96 ++++++++++ test/shapeToQuery.test.ts | 151 +++++++++++++++ test/shex.test.ts | 41 +++- 20 files changed, 1718 insertions(+), 288 deletions(-) create mode 100644 lib/parser-policy.ts create mode 100644 test/shape-parser.test.ts create mode 100644 test/shapeToQuery.test.ts diff --git a/README.md b/README.md index 7f0f6e4..e88b1b5 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,62 @@ const shexQuads = /* RDF quads from ShEx definition */; const personShape = await shexShapeFromQuads(shexQuads, "http://example.org/PersonShape"); ``` +### Shape-to-Shape Example + +You can compare a source shape against candidate target shapes directly using +`solveShapeShapeContainment`. Internally, this uses a best-effort shape-to-query +translation and then reuses the existing query-to-shape containment engine. + +```ts +import { + Shape, + ConstraintType, + solveShapeShapeContainment, +} from 'query-shape-detection'; + +const sourceShape = new Shape({ + name: 'https://www.example.ca/source', + positivePredicates: [ + { + name: 'https://www.example.ca/age', + constraint: { + type: ConstraintType.DATATYPE, + value: new Set(['http://www.w3.org/2001/XMLSchema#integer']), + minInclusive: 18, + maxInclusive: 35, + }, + }, + ], + closed: true, +}); + +const targetShape = new Shape({ + name: 'https://www.example.ca/target', + positivePredicates: [ + { + name: 'https://www.example.ca/age', + constraint: { + type: ConstraintType.DATATYPE, + value: new Set(['http://www.w3.org/2001/XMLSchema#integer']), + minInclusive: 10, + maxInclusive: 40, + }, + }, + ], + closed: true, +}); + +const report = solveShapeShapeContainment({ + sourceShape, + targetShapes: [targetShape], +}); + +console.log(report.starPatternsContainment.get(sourceShape.name)); +``` + +For source shapes with nested `sh:node` / shape-link constraints, pass their linked +definitions in `sourceLinkedShapes`. + ## Containment Results The library returns a report where each star pattern is assigned one of the following `ContainmentResult` values: diff --git a/bun.lock b/bun.lock index 240223a..2fcb6f2 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@traqula/algebra-sparql-1-1": "^1.1.6", "@traqula/algebra-transformations-1-1": "^1.1.4", "@traqula/parser-sparql-1-1": "^1.1.6", + "@treecg/types": "^0.4.6", "rdf-data-factory": "^2.0.2", }, "devDependencies": { @@ -126,6 +127,8 @@ "@traqula/rules-sparql-1-1": ["@traqula/rules-sparql-1-1@1.1.4", "", { "dependencies": { "@traqula/chevrotain": "^1.1.0", "@traqula/core": "^1.1.4" } }, "sha512-E/IbmR7PM50yLDHlWP19amsAf2X0+KBb8lYI8lRQH92/rhVCP7QYEhSZksJ75+w6jm/l+OQvmQKolCZaMF7JHA=="], + "@treecg/types": ["@treecg/types@0.4.6", "", { "dependencies": { "@rdfjs/types": "*", "loglevel": "^1.8.1", "loglevel-plugin-prefix": "^0.8.4", "rdf-data-factory": "^1.1.0" } }, "sha512-iL8rBhAX4INvfuHZzJ1RELh1i/fspviylENZTk6D2p/xDazY1pTkd+y/hxKYVWCC0jHNolDWRsH7ozwaFGNQKA=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -322,6 +325,10 @@ "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "loglevel": ["loglevel@1.9.2", "", {}, "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg=="], + + "loglevel-plugin-prefix": ["loglevel-plugin-prefix@0.8.4", "", {}, "sha512-WpG9CcFAOjz/FtNht+QJeGpvVl/cdR6P0z6OcXSkr8wFJOsV2GRj2j10JLfjuA4aYkcKCNIEqRGCyTife9R8/g=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="], @@ -438,6 +445,8 @@ "@rdfjs/types/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], + "@treecg/types/rdf-data-factory": ["rdf-data-factory@1.1.3", "", { "dependencies": { "@rdfjs/types": "^1.0.0" } }, "sha512-ny6CI7m2bq4lfQQmDYvcb2l1F9KtGwz9chipX4oWu2aAtVoXjb7k3d8J1EsgAsEbMXnBipB/iuRen5H2fwRWWQ=="], + "@types/n3/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], "@types/streamify-array/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], @@ -458,6 +467,8 @@ "@rdfjs/types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "@treecg/types/rdf-data-factory/@rdfjs/types": ["@rdfjs/types@1.1.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-wqpOJK1QCbmsGNtyzYnojPU8gRDPid2JO0Q0kMtb4j65xhCK880cnKAfEOwC+dX85VJcCByQx5zOwyyfCjDJsg=="], + "@types/n3/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "@types/streamify-array/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], diff --git a/index.ts b/index.ts index e5fc001..ca3fd6a 100644 --- a/index.ts +++ b/index.ts @@ -6,3 +6,4 @@ export * from './lib/containment'; export * from './lib/Shape'; export * from './lib/Triple'; export * from './lib/Binding'; +export * from './lib/parser-policy'; diff --git a/lib/Binding.ts b/lib/Binding.ts index c403e9d..139dcc5 100644 --- a/lib/Binding.ts +++ b/lib/Binding.ts @@ -1,6 +1,6 @@ import { ConstraintType, IConstraint, IPredicate, IShape, OneOfPathIndexed } from "./Shape"; import { IStarPatternWithDependencies, type ITriple, Triple } from "./Triple"; -import { TYPE_DEFINITION } from "./constant"; +import { RDF } from "./constant"; /** * A binding from a query to a shape @@ -356,20 +356,20 @@ export class Bindings implements IBindings { constraint: IConstraint, triple: ITriple): ConstraintResult { if (constraint.type === ConstraintType.CLASS && - triple.predicate === TYPE_DEFINITION.value && + triple.predicate === RDF.type && !Array.isArray(triple.object) && triple.object.termType === "NamedNode" && constraint.value.has(triple.object.value)) { return ConstraintResult.RESPECT; } else if (constraint.type === ConstraintType.CLASS && - triple.predicate === TYPE_DEFINITION.value && + triple.predicate === RDF.type && !Array.isArray(triple.object) && triple.object.termType === "NamedNode" && !constraint.value.has(triple.object.value)) { return ConstraintResult.NOT_RESPECT; } else if (constraint.type === ConstraintType.CLASS && - triple.predicate === TYPE_DEFINITION.value && + triple.predicate === RDF.type && Array.isArray(triple.object)) { for (const object of triple.object) { if (constraint.value.has(object.value)) { @@ -377,7 +377,7 @@ export class Bindings implements IBindings { } } return ConstraintResult.NOT_RESPECT; - } else if (constraint.type === ConstraintType.CLASS && triple.predicate !== TYPE_DEFINITION.value) { + } else if (constraint.type === ConstraintType.CLASS && triple.predicate !== RDF.type) { return ConstraintResult.NOT_RESPECT; } diff --git a/lib/Shape.ts b/lib/Shape.ts index 1ea1ea7..19cf5d1 100644 --- a/lib/Shape.ts +++ b/lib/Shape.ts @@ -1,3 +1,7 @@ +import type * as RDFJS from '@rdfjs/types'; +import { RDF, SHACL, SHEX } from './constant'; +import type { IShapeParserOptions, ShapeFormat } from './parser-policy'; + /** * A shape interface */ @@ -322,6 +326,314 @@ export class Shape implements IShape { return this.linkedShapeIri; } } + +export interface IRdfListIndex { + firstByNode: Map; + restByNode: Map; +} + +export interface IRdfListWalkResult { + values: string[]; + malformed: boolean; + cycleDetected: boolean; +} + +/** + * Walk an RDF collection linked with rdf:first / rdf:rest. + * + * Returns all list member values found from `head` until rdf:nil. + * If the collection is malformed, traversal stops and marks `malformed`. + */ +export function walkRdfList( + head: string, + index: IRdfListIndex, +): IRdfListWalkResult { + const values: string[] = []; + const visited = new Set(); + + let malformed = false; + let cycleDetected = false; + let current: string | undefined = head; + + while (true) { + if (current === undefined || current === RDF.nil) { + break; + } + + if (visited.has(current)) { + cycleDetected = true; + malformed = true; + break; + } + visited.add(current); + + const value = index.firstByNode.get(current); + const next = index.restByNode.get(current); + if (value === undefined || next === undefined) { + malformed = true; + break; + } + + values.push(value); + current = next; + } + + return { + values, + malformed, + cycleDetected, + }; +} + +export interface IConstraintParts { + shapeConstraint?: string; + classConstraint?: string; + datatypeConstraint?: string; + minInclusive?: number; + maxInclusive?: number; + minExclusive?: number; + maxExclusive?: number; + pattern?: string; + flags?: string; +} + +export interface IPredicateParts { + name: string; + minCount?: number; + maxCount?: number; + constraintParts?: IConstraintParts; +} + +export interface IRawShape { + name: string; + closed?: boolean; + positivePredicates: IPredicateParts[]; + negativePredicates?: string[]; + oneOf?: IRawOneOf[]; +} + +export type IRawOneOfPath = IPredicateParts[]; +export type IRawOneOf = IRawOneOfPath[]; + +/** + * Shared parser-neutral assembly from raw parser data into IShape. + */ +export function buildShapeFromRaw(raw: IRawShape): IShape | ShapeError { + try { + const positivePredicates = raw.positivePredicates.map(buildPredicate); + const oneOf: OneOf[] = (raw.oneOf ?? []).map(currentOneOf => + currentOneOf.map(path => path.map(buildPredicate)), + ); + + return new Shape({ + name: raw.name, + positivePredicates, + negativePredicates: raw.negativePredicates ?? [], + closed: raw.closed ?? false, + oneOf, + }); + } catch (error: unknown) { + return error as ShapeError; + } +} + +export function predicateToParts(predicate: IPredicate): IPredicateParts { + return { + name: predicate.name, + minCount: predicate.cardinality?.min, + maxCount: predicate.cardinality?.max, + constraintParts: constraintToParts(predicate.constraint), + }; +} + +export function buildPredicate(parts: IPredicateParts): IPredicate { + const hasCardinality = parts.minCount !== undefined || parts.maxCount !== undefined; + return { + name: parts.name, + constraint: parts.constraintParts !== undefined ? buildConstraint(parts.constraintParts, parts.name) : undefined, + cardinality: hasCardinality + ? { + min: parts.minCount ?? 1, + max: parts.maxCount ?? 1, + } + : undefined, + }; +} + +export function buildConstraint(parts: IConstraintParts, predicateName?: string): IConstraint | undefined { + if (parts.classConstraint !== undefined) { + return { + value: new Set([parts.classConstraint]), + type: ConstraintType.CLASS, + }; + } + + if (parts.shapeConstraint !== undefined) { + return { + value: new Set([parts.shapeConstraint]), + type: ConstraintType.SHAPE, + }; + } + + if (parts.datatypeConstraint !== undefined) { + const facets: Partial = {}; + if (parts.minInclusive !== undefined) { + facets.minInclusive = parts.minInclusive; + } + if (parts.maxInclusive !== undefined) { + facets.maxInclusive = parts.maxInclusive; + } + if (parts.minExclusive !== undefined) { + facets.minExclusive = parts.minExclusive; + } + if (parts.maxExclusive !== undefined) { + facets.maxExclusive = parts.maxExclusive; + } + if (parts.pattern !== undefined) { + facets.pattern = parts.pattern; + } + if (parts.flags !== undefined) { + facets.flags = parts.flags; + } + + return { + value: new Set([parts.datatypeConstraint]), + type: predicateName === RDF.type + ? ConstraintType.CLASS + : ConstraintType.DATATYPE, + ...facets, + }; + } + + return undefined; +} + +export function isNegativeCardinality(minCount?: number, maxCount?: number): boolean { + return minCount === 0 && maxCount === 0; +} + +function constraintToParts(constraint: IConstraint | undefined): IConstraintParts | undefined { + if (constraint === undefined) { + return undefined; + } + + const firstValue = constraint.value.values().next().value as string | undefined; + const facets: IConstraintParts = { + minInclusive: constraint.minInclusive, + maxInclusive: constraint.maxInclusive, + minExclusive: constraint.minExclusive, + maxExclusive: constraint.maxExclusive, + pattern: constraint.pattern, + flags: constraint.flags, + }; + + if (firstValue === undefined) { + return facets; + } + + if (constraint.type === ConstraintType.SHAPE) { + facets.shapeConstraint = firstValue; + } else if (constraint.type === ConstraintType.CLASS) { + facets.classConstraint = firstValue; + } else if (constraint.type === ConstraintType.DATATYPE) { + facets.datatypeConstraint = firstValue; + } + + return facets; +} + +export interface IParseShapeFromQuadsOptions extends IShapeParserOptions { + format?: ShapeFormat | 'auto'; +} + +/** + * Unified parser entrypoint for SHACL and ShEx shape graphs. + */ +export async function parseShapeFromQuads( + quads: RDFJS.Stream | RDFJS.Quad[], + shapeIri: string, + options?: IParseShapeFromQuadsOptions, +): Promise { + const format = options?.format ?? 'auto'; + + if (Array.isArray(quads)) { + return parseFromArray(quads, shapeIri, format, options); + } + + return new Promise(resolve => { + const buffer: RDFJS.Quad[] = []; + quads.on('data', (quad: RDFJS.Quad) => { + buffer.push(quad); + }); + quads.on('error', (error: any) => { + resolve(error); + }); + quads.on('end', () => { + parseFromArray(buffer, shapeIri, format, options).then(resolve); + }); + }); +} + +async function parseFromArray( + quads: RDFJS.Quad[], + shapeIri: string, + requestedFormat: ShapeFormat | 'auto', + options?: IParseShapeFromQuadsOptions, +): Promise { + const format = requestedFormat === 'auto' + ? detectShapeFormat(quads) + : requestedFormat; + + if (format === undefined) { + return new PoorlyFormatedShapeError('Cannot detect shape format automatically. Please provide options.format.'); + } + + if (format === 'shacl') { + const module = await import('./shacl'); + return module.shaclShapeFromQuads(quads, shapeIri, options); + } + + const module = await import('./shex'); + return module.shexShapeFromQuads(quads, shapeIri, options); +} + +function detectShapeFormat(quads: RDFJS.Quad[]): ShapeFormat | undefined { + let shaclScore = 0; + let shexScore = 0; + + for (const quad of quads) { + if ( + quad.predicate.equals(SHACL.terms.property) + || quad.predicate.equals(SHACL.terms.path) + || quad.predicate.equals(SHACL.terms.or) + || quad.predicate.equals(SHACL.terms.xone) + || quad.predicate.equals(SHACL.terms.node) + || quad.predicate.equals(SHACL.terms.datatype) + ) { + shaclScore++; + } + + if ( + quad.predicate.equals(SHEX.terms.expression) + || quad.predicate.equals(SHEX.terms.expressions) + || quad.predicate.equals(SHEX.terms.predicate) + || quad.predicate.equals(SHEX.terms.valueExpr) + || quad.object.equals(SHEX.terms.Shape) + || quad.object.equals(SHEX.terms.EachOf) + || quad.object.equals(SHEX.terms.OneOf) + ) { + shexScore++; + } + } + + if (shaclScore > shexScore) { + return 'shacl'; + } + if (shexScore > shaclScore) { + return 'shex'; + } + return undefined; +} /** * An error to indicate that there is an inconsistency with the positive and negative properties */ diff --git a/lib/constant.ts b/lib/constant.ts index d1032fd..e6bc6a4 100644 --- a/lib/constant.ts +++ b/lib/constant.ts @@ -1,51 +1,91 @@ -import type { BaseQuad } from '@rdfjs/types'; -import { DataFactory } from 'rdf-data-factory'; +import type { NamedNode } from '@rdfjs/types'; +import { createUriAndTermNamespace } from '@treecg/types'; -export const DF = new DataFactory(); +type TermVocabulary = { + namespace: NamedNode; + custom: (input: string) => NamedNode; +} & { + [K in T]: NamedNode; +}; -export const SHEX_PREDICATE = DF.namedNode('http://www.w3.org/ns/shex#predicate'); -export const SHEX_SHAPE_EXPRESSION = DF.namedNode('http://www.w3.org/ns/shex#shapeExpr'); -export const SHEX_EXPRESSION = DF.namedNode('http://www.w3.org/ns/shex#expression'); -export const SHEX_SHAPE = DF.namedNode('http://www.w3.org/ns/shex#Shape'); -export const SHEX_EXPRESSIONS = DF.namedNode('http://www.w3.org/ns/shex#expressions'); -export const SHEX_CLOSED_SHAPE = DF.namedNode('http://www.w3.org/ns/shex#closed'); -export const SHEX_VALUE_EXPR = DF.namedNode('http://www.w3.org/ns/shex#valueExpr'); -export const SHEX_NODE_KIND = DF.namedNode('http://www.w3.org/ns/shex#nodeKind'); -export const SHEX_MAX = DF.namedNode('http://www.w3.org/ns/shex#max'); -export const SHEX_MIN = DF.namedNode('http://www.w3.org/ns/shex#min'); -export const SHEX_DATA_TYPE = DF.namedNode('http://www.w3.org/ns/shex#datatype'); +type Vocabulary = { + namespace: string; + custom: (input: string) => string; + terms: TermVocabulary; +} & { + [K in T]: string; +}; -export const SHEX_LITERAL = DF.namedNode('http://www.w3.org/ns/shex#literal'); -export const SHEX_IRI = DF.namedNode('http://www.w3.org/ns/shex#iri'); -export const SHEX_BNODE = DF.namedNode('http://www.w3.org/ns/shex#bnode'); -export const SHEX_NON_LITERAL = DF.namedNode('http://www.w3.org/ns/shex#nonliteral'); -export const SHEX_VALUES = DF.namedNode('http://www.w3.org/ns/shex#values'); +function createVocabulary( + baseUri: string, + ...localNames: T[] +): Vocabulary { + return createUriAndTermNamespace(baseUri, ...localNames) as unknown as Vocabulary; +} -export const SHEX_EACH_OF = DF.namedNode("http://www.w3.org/ns/shex#EachOf"); -export const SHEX_ONE_OF = DF.namedNode("http://www.w3.org/ns/shex#OneOf"); +export const SHACL = createVocabulary( + "http://www.w3.org/ns/shacl#", + "property", + "path", + "minCount", + "maxCount", + "minInclusive", + "maxInclusive", + "minExclusive", + "maxExclusive", + "pattern", + "flags", + "closed", + "class", + "datatype", + "node", + "or", + "xone", + "not" +); -export const TYPE_DEFINITION = DF.namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'); -export const IRI_FIRST_RDF_LIST = DF.namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#first'); -export const IRI_REST_RDF_LIST = DF.namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#rest'); -export const IRI_END_RDF_LIST = DF.namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#nil'); -export const XSD_BOOLEAN = DF.namedNode('http://www.w3.org/2001/XMLSchema#boolean'); -export const RDF_TRUE = DF.literal('true', XSD_BOOLEAN); +export const SHEX = createVocabulary( + "http://www.w3.org/ns/shex#", + "predicate", + "shapeExpr", + "expression", + "Shape", + "expressions", + "closed", + "valueExpr", + "nodeKind", + "max", + "min", + "datatype", + "mininclusive", + "maxinclusive", + "minexclusive", + "maxexclusive", + "pattern", + "flags", + "literal", + "iri", + "bnode", + "nonliteral", + "values", + "EachOf", + "OneOf" +); -export const SHACL_SH = 'http://www.w3.org/ns/shacl#'; -export const SHACL_PROPERTY = DF.namedNode(`${SHACL_SH}property`); -export const SHACL_PATH = DF.namedNode(`${SHACL_SH}path`); -export const SHACL_MIN_COUNT = DF.namedNode(`${SHACL_SH}minCount`); -export const SHACL_MAX_COUNT = DF.namedNode(`${SHACL_SH}maxCount`); -export const SHACL_MIN_INCLUSIVE = DF.namedNode(`${SHACL_SH}minInclusive`); -export const SHACL_MAX_INCLUSIVE = DF.namedNode(`${SHACL_SH}maxInclusive`); -export const SHACL_MIN_EXCLUSIVE = DF.namedNode(`${SHACL_SH}minExclusive`); -export const SHACL_MAX_EXCLUSIVE = DF.namedNode(`${SHACL_SH}maxExclusive`); -export const SHACL_PATTERN = DF.namedNode(`${SHACL_SH}pattern`); -export const SHACL_FLAGS = DF.namedNode(`${SHACL_SH}flags`); -export const SHACL_CLOSED = DF.namedNode(`${SHACL_SH}closed`); -export const SHACL_CLASS = DF.namedNode(`${SHACL_SH}class`); -export const SHACL_DATATYPE = DF.namedNode(`${SHACL_SH}datatype`); -export const SHACL_NODE = DF.namedNode(`${SHACL_SH}node`); -export const SHACL_OR = DF.namedNode(`${SHACL_SH}or`); -export const SHACL_XONE = DF.namedNode(`${SHACL_SH}xone`); -export const SHACL_NOT = DF.namedNode(`${SHACL_SH}not`); +export const RDF = createVocabulary( + "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "type", + "first", + "rest", + "nil" +); + +export const XSD = createVocabulary( + "http://www.w3.org/2001/XMLSchema#", + "string", + "boolean", + "decimal", + "integer", + "float", + "double" +); diff --git a/lib/containment.ts b/lib/containment.ts index 969363a..84e2f7b 100644 --- a/lib/containment.ts +++ b/lib/containment.ts @@ -1,5 +1,5 @@ import { Bindings, ContainmentType, IBindings } from './Binding' -import { generateStarPatternUnion, type IQuery } from './query'; +import { generateStarPatternUnion, shapeToQuery, type IQuery } from './query'; import { ConstraintType, IConstraint, IShape } from './Shape'; import type { IStarPatternWithDependencies } from './Triple'; import type { Term } from '@rdfjs/types'; @@ -78,6 +78,24 @@ export function solveShapeQueryContainment({ query, shapes, decidingShapes }: IC } +export interface IShapeContainmentArg { + sourceShape: IShape; + sourceLinkedShapes?: IShape[] | Map; + targetShapes: IShape[]; + decidingShapes?: Set; +} + +/** + * Convenience wrapper to compare a source shape against candidate target shapes. + * + * Internally this translates the source shape into a best-effort query and then + * delegates to the standard query-to-shape containment solver. + */ +export function solveShapeShapeContainment({ sourceShape, sourceLinkedShapes, targetShapes, decidingShapes }: IShapeContainmentArg): IResult { + const query = shapeToQuery(sourceShape, { linkedShapes: sourceLinkedShapes }); + return solveShapeQueryContainment({ query, shapes: targetShapes, decidingShapes }); +} + function updateContainmentStats( classificationStats: Map, starPatternName: StarPatternName, diff --git a/lib/parser-policy.ts b/lib/parser-policy.ts new file mode 100644 index 0000000..b35425e --- /dev/null +++ b/lib/parser-policy.ts @@ -0,0 +1,35 @@ +export type ShapeFormat = 'shacl' | 'shex'; + +export interface ParserDiagnostic { + level: 'warning' | 'error'; + code: string; + message: string; + shapeIri?: string; + nodeId?: string; +} + +export interface ShapeParserPolicy { + /** + * If true, malformed RDF lists are treated as parse errors. + * Defaults to true. + */ + strictRdfLists?: boolean; +} + +export interface IShapeParserOptions { + policy?: ShapeParserPolicy; + diagnostics?: ParserDiagnostic[]; +} + +export function getPolicy(options?: IShapeParserOptions): Required { + return { + strictRdfLists: options?.policy?.strictRdfLists ?? true, + }; +} + +export function addDiagnostic( + options: IShapeParserOptions | undefined, + diagnostic: ParserDiagnostic, +): void { + options?.diagnostics?.push(diagnostic); +} diff --git a/lib/query.ts b/lib/query.ts index e77913f..4bcc88c 100644 --- a/lib/query.ts +++ b/lib/query.ts @@ -1,8 +1,11 @@ import type { Term } from '@rdfjs/types'; import { algebraUtils, Algebra } from '@traqula/algebra-transformations-1-1'; +import { DataFactory } from 'rdf-data-factory'; import { ITripleWithDependencies, Triple, type IStarPatternWithDependencies, ITriple } from './Triple'; -import { ICardinality } from './Shape'; -import { DF } from './constant'; +import { ConstraintType, ICardinality, IPredicate, IShape } from './Shape'; +import { RDF, XSD } from './constant'; + +const DF = new DataFactory(); // Constrained PropertyPathSymbol type for cardinality operations // These are the cardinality path types that contain a nested path with Link (iri property) @@ -35,6 +38,59 @@ export interface IQuery { values?: Map; } +export interface IShapeToQueryOptions { + linkedShapes?: IShape[] | Map; +} + +interface IShapeToQueryBuildContext { + shapeIndex: Map; + starPatterns: Map; + filters: Algebra.Expression[]; + unions: IQuery[][]; + visitedShapes: Set; + variableCounts: Map; +} + +/** + * Best-effort translation from an IShape to an IQuery representation. + * + * Supported mapping: + * - Positive predicates -> triple patterns + * - Datatype + numeric/regex facets -> FILTER expressions + * - SHAPE constraints -> dependent star patterns (when linked shapes are provided) + * - oneOf/xone branches -> UNION branches + * + * Not represented on purpose: + * - closed/open world semantics + * - negative predicates + */ +export function shapeToQuery(shape: IShape, options?: IShapeToQueryOptions): IQuery { + const shapeIndex = normalizeShapeIndex(shape, options?.linkedShapes); + const context: IShapeToQueryBuildContext = { + shapeIndex, + starPatterns: new Map(), + filters: [], + unions: [], + visitedShapes: new Set(), + variableCounts: new Map(), + }; + + buildStarPatternFromShape(shape, context); + + const query: IQuery = { starPatterns: context.starPatterns }; + if (context.filters.length > 0) { + query.filters = context.filters; + } + if (context.unions.length > 0) { + query.union = context.unions; + } + + return query; +} + +/** + * Extract the star pattern with the requested subject from each UNION branch set. + */ export function generateStarPatternUnion(union: IQuery[][], starPatternName: string): IStarPatternWithDependencies[][] { const resp: IStarPatternWithDependencies[][] = []; for (const unionSet of union) { @@ -52,6 +108,267 @@ export function generateStarPatternUnion(union: IQuery[][], starPatternName: str return resp; } +/** + * Build a lookup table for the root shape and any linked shapes that may be referenced. + */ +function normalizeShapeIndex(rootShape: IShape, linkedShapes?: IShape[] | Map): Map { + const index = new Map(); + index.set(rootShape.name, rootShape); + + if (linkedShapes === undefined) { + return index; + } + + if (linkedShapes instanceof Map) { + for (const [name, shape] of linkedShapes) { + index.set(name, shape); + } + return index; + } + + for (const shape of linkedShapes) { + index.set(shape.name, shape); + } + + return index; +} + +/** + * Convert one shape into a root star pattern and recursively materialize reachable linked shapes. + */ +function buildStarPatternFromShape(shape: IShape, context: IShapeToQueryBuildContext): void { + if (context.visitedShapes.has(shape.name)) { + return; + } + context.visitedShapes.add(shape.name); + + const starPattern: IStarPatternWithDependencies = { + starPattern: new Map(), + name: shape.name, + isVariable: true, + }; + + for (const predicateName of shape.positivePredicates) { + const predicate = shape.get(predicateName); + if (predicate === undefined || predicate.negative === true) { + continue; + } + + const tripleWithDependencies = predicateToTripleWithDependencies(shape.name, predicate, context, context.filters); + starPattern.starPattern.set(predicate.name, tripleWithDependencies); + } + + context.starPatterns.set(shape.name, starPattern); + + addOneOfAsUnion(shape, context); +} + +/** + * Translate shape alternatives into UNION branches attached to the generated query. + */ +function addOneOfAsUnion(shape: IShape, context: IShapeToQueryBuildContext): void { + for (const oneOf of shape.oneOf) { + const unionBranches: IQuery[] = []; + for (const branch of oneOf) { + const branchStar: IStarPatternWithDependencies = { + starPattern: new Map(), + name: shape.name, + isVariable: true, + }; + const branchFilters: Algebra.Expression[] = []; + + for (const predicate of branch) { + if (predicate.negative === true) { + continue; + } + const tripleWithDependencies = predicateToTripleWithDependencies(shape.name, predicate, context, branchFilters); + branchStar.starPattern.set(predicate.name, tripleWithDependencies); + } + + const branchQuery: IQuery = { + starPatterns: new Map([[shape.name, branchStar]]), + }; + if (branchFilters.length > 0) { + branchQuery.filters = branchFilters; + } + unionBranches.push(branchQuery); + } + + if (unionBranches.length > 0) { + context.unions.push(unionBranches); + } + } +} + +/** + * Turn one shape predicate into a triple pattern and any dependency/filter information it implies. + */ +function predicateToTripleWithDependencies( + shapeName: string, + predicate: IPredicate, + context: IShapeToQueryBuildContext, + filterCollector: Algebra.Expression[], +): ITripleWithDependencies { + const constraint = predicate.constraint; + let object: Term; + let dependencies: IStarPatternWithDependencies | undefined; + + if (constraint?.type === ConstraintType.SHAPE && constraint.value.size > 0) { + const linkedShapeName = Array.from(constraint.value)[0]; + object = DF.namedNode(linkedShapeName); + + const linkedShape = context.shapeIndex.get(linkedShapeName); + if (linkedShape !== undefined) { + buildStarPatternFromShape(linkedShape, context); + dependencies = context.starPatterns.get(linkedShapeName); + } + } else if ( + constraint?.type === ConstraintType.CLASS + && predicate.name === RDF.type + && constraint.value.size > 0 + ) { + object = DF.namedNode(Array.from(constraint.value)[0]); + } else { + object = DF.variable(nextVariableName(shapeName, predicate.name, context.variableCounts)); + addDatatypeFiltersFromConstraint(constraint, object, filterCollector); + } + + const triple: ITriple = new Triple({ + subject: shapeName, + predicate: predicate.name, + object, + cardinality: predicate.cardinality, + isOptional: predicate.optional, + }); + + return { triple, dependencies }; +} + +/** + * Produce a stable variable name for generated query objects, deduplicated per predicate occurrence. + */ +function nextVariableName(shapeName: string, predicate: string, variableCounts: Map): string { + const base = toVariableBase(`${shapeName}_${predicate}`); + const currentCount = variableCounts.get(base) ?? 0; + variableCounts.set(base, currentCount + 1); + return currentCount === 0 ? base : `${base}_${currentCount}`; +} + +/** + * Normalize arbitrary IRIs/shape names into SPARQL-safe variable base names. + */ +function toVariableBase(raw: string): string { + const normalized = raw.replace(/[^A-Za-z0-9_]/g, '_').replace(/^_+/, ''); + if (normalized.length === 0) { + return 'v'; + } + if (/^[0-9]/.test(normalized)) { + return `v_${normalized}`; + } + return normalized; +} + +/** + * Translate datatype, numeric, and regex shape facets into FILTER expressions on a generated variable. + */ +function addDatatypeFiltersFromConstraint( + constraint: IPredicate['constraint'], + object: Term, + collector: Algebra.Expression[], +): void { + if (constraint?.type !== ConstraintType.DATATYPE || object.termType !== 'Variable') { + return; + } + + const datatypeFilters = Array.from(constraint.value).map((datatypeIri) => + operatorExpression('=', [ + operatorExpression('datatype', [termExpression(object)]), + termExpression(DF.namedNode(datatypeIri)), + ]), + ); + + if (datatypeFilters.length === 1) { + collector.push(datatypeFilters[0]); + } else if (datatypeFilters.length > 1) { + collector.push(reduceOrExpressions(datatypeFilters)); + } + + if (constraint.minInclusive !== undefined) { + collector.push(operatorExpression('>=', [ + termExpression(object), + termExpression(numberLiteral(constraint.minInclusive)), + ])); + } + if (constraint.maxInclusive !== undefined) { + collector.push(operatorExpression('<=', [ + termExpression(object), + termExpression(numberLiteral(constraint.maxInclusive)), + ])); + } + if (constraint.minExclusive !== undefined) { + collector.push(operatorExpression('>', [ + termExpression(object), + termExpression(numberLiteral(constraint.minExclusive)), + ])); + } + if (constraint.maxExclusive !== undefined) { + collector.push(operatorExpression('<', [ + termExpression(object), + termExpression(numberLiteral(constraint.maxExclusive)), + ])); + } + + if (constraint.pattern !== undefined) { + const regexArgs: Algebra.Expression[] = [ + operatorExpression('str', [termExpression(object)]), + termExpression(DF.literal(constraint.pattern)), + ]; + if (constraint.flags !== undefined) { + regexArgs.push(termExpression(DF.literal(constraint.flags))); + } + collector.push(operatorExpression('regex', regexArgs)); + } +} + +/** + * Fold multiple expressions into a left-associated logical OR tree. + */ +function reduceOrExpressions(expressions: Algebra.Expression[]): Algebra.Expression { + let current = expressions[0]; + for (let i = 1; i < expressions.length; i++) { + current = operatorExpression('||', [current, expressions[i]]); + } + return current; +} + +/** + * Build an xsd:decimal literal for numeric facet comparisons. + */ +function numberLiteral(value: number): Term { + return DF.literal(String(value), DF.namedNode(XSD.decimal)); +} + +/** + * Wrap an RDF term in the algebra expression shape expected by filter constructors. + */ +function termExpression(term: Term): Algebra.Expression { + return { + subType: 'term', + term, + } as unknown as Algebra.Expression; +} + +/** + * Construct a generic operator expression node for the algebra filter representation. + */ +function operatorExpression(operator: string, args: Algebra.Expression[]): Algebra.Expression { + return { + subType: 'operator', + operator, + args, + } as unknown as Algebra.Expression; +} + /** * Divide a query into star patterns * @param {Algebra.Operation} algebraQuery - the algebra of a query @@ -70,6 +387,9 @@ export function generateQuery(algebraQuery: Algebra.Operation, optional?: boolea return buildQuery(accumulatedTriples, accumulatedValues, accumulatedUnion, accumulatedFilters); } +/** + * Finalize the accumulated parsing state into the query structure used by containment. + */ function buildQuery( tripleArgs: Map, values: Map, @@ -134,6 +454,9 @@ function buildQuery( // could be made so that when adding a union we make sure to find the dependency // function should be revisited +/** + * Resolve dependencies inside UNION branches back to root star patterns when possible. + */ function addUnionDependencies(query: IQuery): void { if (query.union === undefined) { return; @@ -185,6 +508,9 @@ function searchDependencyInUnion(unions: IQuery[][], currentBranch: number, trip } */ +/** + * Attach a star-pattern dependency to a triple when its object links to another known subject. + */ function addADependencyToStarPattern( tripleWithDependencies: ITripleWithDependencies, innerQuery: Map): void { @@ -201,6 +527,9 @@ function addADependencyToStarPattern( namespace QueryHandler { + /** + * Walk a SPARQL algebra tree and accumulate triples, values, filters, and unions by query branch. + */ export function collectFromAlgebra( rootAlgebra: Algebra.Operation, accumulatedTriples: Map, @@ -242,6 +571,9 @@ namespace QueryHandler { ); } + /** + * Split a LEFT JOIN into required and optional branches and collect both with the right optional flag. + */ function handleLeftJoin(accumulatedTriples: Map, accumulatedValues: Map, accumulatedFilters: Algebra.Expression[], @@ -256,12 +588,18 @@ namespace QueryHandler { } } + /** + * Record a top-level FILTER expression without traversing into its expression subtree. + */ function handleFilter(accumulatedFilters: Algebra.Expression[]): (element: Algebra.Filter) => void { return (element: Algebra.Filter): void => { accumulatedFilters.push(element.expression); } } + /** + * Dispatch property-path parsing to direct, cardinality, negated-set, or alternative handlers. + */ function handlePropertyPath( accumulatedTriples: Map, accumulatedUnion: IQuery[][], @@ -290,6 +628,9 @@ namespace QueryHandler { }; } + /** + * Convert a SPARQL UNION into a list of independently generated query branches. + */ function handleUnion(accumulatedUnion: IQuery[][], optional?: boolean): (element: Algebra.Union) => void { return (element: Algebra.Union): void => { const branches: Algebra.Operation[] = element.input; @@ -301,6 +642,9 @@ namespace QueryHandler { }; } + /** + * Accumulate VALUES bindings by variable name so they can later replace variable objects. + */ function handleValues(accumulatedValues: Map): (element: Algebra.Values) => void { return (element: Algebra.Values): void => { const bindings: Record[] = element.bindings; @@ -317,6 +661,9 @@ namespace QueryHandler { } } + /** + * Convert a basic triple pattern into the internal Triple representation keyed by subject. + */ function handlePattern( accumulatedTriples: Map, optional?: boolean @@ -343,6 +690,9 @@ namespace QueryHandler { }; } + /** + * Translate SPARQL cardinality property paths into triples carrying the corresponding cardinality range. + */ function handleCardinalityPropertyPath( element: CardinalityPath, optional?: boolean @@ -377,6 +727,9 @@ namespace QueryHandler { } } + /** + * Insert the triple generated from a direct/path handler into the subject's accumulated star pattern. + */ function handleDirectPropertyPath(element: Algebra.Path, accumulatedTriples: Map, triple: { triple: ITriple, isVariable: boolean } | undefined): void { @@ -398,6 +751,9 @@ namespace QueryHandler { } } + /** + * Reverse an inverse path so downstream alternative handling can treat it like a forward path. + */ function handleInvPath(element: Algebra.Inv, subject: Term, object: Term, @@ -410,6 +766,9 @@ namespace QueryHandler { } } + /** + * Expand an alternative property path into one query branch per alternative path expression. + */ function handleAltPropertyPath( subject: Term, predicates: Algebra.Operation[], @@ -436,6 +795,9 @@ namespace QueryHandler { return union; } + /** + * Flatten a sequence path into chained triples and nested unions when intermediate segments require them. + */ function handleSeqPath( element: Algebra.Seq, accumulatedValues: Map, @@ -464,6 +826,10 @@ namespace QueryHandler { } } + + /** + * Build a standalone query branch for one sequence path expression. + */ function handleSeqPathQuery( element: Algebra.Seq, accumulatedValues: Map, @@ -479,6 +845,9 @@ namespace QueryHandler { return buildQuery(accumulatedTriples, accumulatedValues, accumulatedUnion); } + /** + * Map algebra cardinality path operators to the internal min/max representation. + */ function getCardinality(nodeType: string): ICardinality | undefined { switch (nodeType) { case Algebra.Types.ZERO_OR_MORE_PATH: @@ -490,6 +859,9 @@ namespace QueryHandler { } } + /** + * Add a simple forward predicate link as a triple to the accumulated subject map. + */ function handleLink(element: Algebra.Link, accumulatedTriples: Map, subject: Term, @@ -512,6 +884,9 @@ namespace QueryHandler { }); } + /** + * Build a standalone query branch for one direct link path. + */ function handleLinkQuery( element: Algebra.Link, accumulatedValues: Map, @@ -525,6 +900,9 @@ namespace QueryHandler { return buildQuery(accumulatedTriples, accumulatedValues, []); } + /** + * Represent a negated property set path as a synthetic triple carrying the excluded predicate IRIs. + */ function handleNegatedPropertyLink( element: Algebra.Nps, accumulatedTriples: Map, @@ -554,6 +932,9 @@ namespace QueryHandler { } + /** + * Build a standalone query branch for one negated property set path. + */ function handleNegatedPropertyQuery( element: Algebra.Nps, accumulatedValues: Map, @@ -566,6 +947,9 @@ namespace QueryHandler { return buildQuery(accumulatedTriples, accumulatedValues, []); } + /** + * Convert a top-level negated property set path into a triple result consumable by direct path insertion. + */ function handleNegatedPropertySet(element: NpsPath, optional?: boolean): { triple: ITriple, isVariable: boolean } | undefined { const subject = element.subject as Term; const object = element.object as Term; @@ -587,7 +971,9 @@ namespace QueryHandler { }; } - // Type guard to check if an Operation is a cardinality property path + /** + * Narrow a generic algebra operation to the subset of path operators that encode cardinality. + */ function isCardinalityPropertyPath(operation: Algebra.Operation): operation is CardinalityPropertyPathSymbol { return operation.type === Algebra.Types.ZERO_OR_MORE_PATH || operation.type === Algebra.Types.ZERO_OR_ONE_PATH || diff --git a/lib/shacl.ts b/lib/shacl.ts index bdefdb5..358d8ec 100644 --- a/lib/shacl.ts +++ b/lib/shacl.ts @@ -1,34 +1,20 @@ import type * as RDF from '@rdfjs/types'; import { DataFactory } from 'rdf-data-factory'; -import type { IConstraint, OneOf, IShape, IPredicate } from './Shape'; -import { Shape, ConstraintType, PoorlyFormatedShapeError } from './Shape'; +import type { OneOf, IShape, IPredicate } from './Shape'; +import { + PoorlyFormatedShapeError, + walkRdfList, + buildShapeFromRaw, + predicateToParts, + buildPredicate as buildSharedPredicate, + isNegativeCardinality +} from './Shape'; import type { ShapeError } from './Shape'; - -import { - SHACL_PROPERTY, - SHACL_PATH, - SHACL_MIN_COUNT, - SHACL_MAX_COUNT, - SHACL_MIN_INCLUSIVE, - SHACL_MAX_INCLUSIVE, - SHACL_MIN_EXCLUSIVE, - SHACL_MAX_EXCLUSIVE, - SHACL_PATTERN, - SHACL_FLAGS, - SHACL_CLOSED, - SHACL_CLASS, - SHACL_DATATYPE, - SHACL_NODE, - SHACL_OR, - SHACL_XONE, - SHACL_NOT, - IRI_FIRST_RDF_LIST, - IRI_REST_RDF_LIST, - IRI_END_RDF_LIST, - RDF_TRUE, -} from './constant'; +import { SHACL, RDF as RDF_VOCAB, XSD } from './constant'; +import { addDiagnostic, getPolicy, type IShapeParserOptions } from './parser-policy'; const DF = new DataFactory(); +const RDF_TRUE = DF.literal('true', DF.namedNode(XSD.boolean)); // ── Internal parsing state ───────────────────────────────────────────────── @@ -99,36 +85,39 @@ function defaultMap(): IMapTripleShacl { export function shaclShapeFromQuads( quads: RDF.Stream | RDF.Quad[], shapeIri: string, + options?: IShapeParserOptions, ): Promise { if (Array.isArray(quads)) { return new Promise(resolve => { - resolve(shapeFromQuadArray(quads, shapeIri)); + resolve(shapeFromQuadArray(quads, shapeIri, options)); }); } - return shapeFromQuadStream(quads, shapeIri); + return shapeFromQuadStream(quads, shapeIri, options); } function shapeFromQuadStream( quadStream: RDF.Stream, shapeIri: string, + options?: IShapeParserOptions, ): Promise { const map = defaultMap(); return new Promise(resolve => { quadStream.on('data', (quad: RDF.Quad) => { parseQuad(quad, map); }); quadStream.on('error', (error: any) => { resolve(error); }); - quadStream.on('end', () => { resolve(buildShape(map, shapeIri)); }); + quadStream.on('end', () => { resolve(buildShape(map, shapeIri, options)); }); }); } function shapeFromQuadArray( quads: RDF.Quad[], shapeIri: string, + options?: IShapeParserOptions, ): IShape | ShapeError { const map = defaultMap(); for (const quad of quads) { parseQuad(quad, map); } - return buildShape(map, shapeIri); + return buildShape(map, shapeIri, options); } // ── Quad parsing ─────────────────────────────────────────────────────────── @@ -138,7 +127,7 @@ function parseQuad(quad: RDF.Quad, map: IMapTripleShacl): void { const o = quad.object.value; // sh:property → register property shape under the node shape - if (quad.predicate.equals(SHACL_PROPERTY)) { + if (quad.predicate.equals(SHACL.terms.property)) { let props = map.shapeProperties.get(s); if (props === undefined) { props = new Set(); @@ -153,97 +142,97 @@ function parseQuad(quad: RDF.Quad, map: IMapTripleShacl): void { } // sh:path → predicate IRI for this property shape - if (quad.predicate.equals(SHACL_PATH)) { + if (quad.predicate.equals(SHACL.terms.path)) { getOrCreatePropData(map, s).path = o; return; } // sh:minCount - if (quad.predicate.equals(SHACL_MIN_COUNT)) { + if (quad.predicate.equals(SHACL.terms.minCount)) { getOrCreatePropData(map, s).minCount = Number(o); return; } // sh:maxCount - if (quad.predicate.equals(SHACL_MAX_COUNT)) { + if (quad.predicate.equals(SHACL.terms.maxCount)) { getOrCreatePropData(map, s).maxCount = Number(o); return; } // sh:minInclusive - if (quad.predicate.equals(SHACL_MIN_INCLUSIVE)) { + if (quad.predicate.equals(SHACL.terms.minInclusive)) { getOrCreatePropData(map, s).minInclusive = Number(o); return; } // sh:maxInclusive - if (quad.predicate.equals(SHACL_MAX_INCLUSIVE)) { + if (quad.predicate.equals(SHACL.terms.maxInclusive)) { getOrCreatePropData(map, s).maxInclusive = Number(o); return; } // sh:minExclusive - if (quad.predicate.equals(SHACL_MIN_EXCLUSIVE)) { + if (quad.predicate.equals(SHACL.terms.minExclusive)) { getOrCreatePropData(map, s).minExclusive = Number(o); return; } // sh:maxExclusive - if (quad.predicate.equals(SHACL_MAX_EXCLUSIVE)) { + if (quad.predicate.equals(SHACL.terms.maxExclusive)) { getOrCreatePropData(map, s).maxExclusive = Number(o); return; } // sh:pattern - if (quad.predicate.equals(SHACL_PATTERN)) { + if (quad.predicate.equals(SHACL.terms.pattern)) { getOrCreatePropData(map, s).pattern = o; return; } // sh:flags - if (quad.predicate.equals(SHACL_FLAGS)) { + if (quad.predicate.equals(SHACL.terms.flags)) { getOrCreatePropData(map, s).flags = o; return; } // sh:closed - if (quad.predicate.equals(SHACL_CLOSED)) { + if (quad.predicate.equals(SHACL.terms.closed)) { map.closedShape.set(s, quad.object.equals(RDF_TRUE)); return; } // sh:class → SHAPE constraint - if (quad.predicate.equals(SHACL_CLASS)) { + if (quad.predicate.equals(SHACL.terms.class)) { getOrCreatePropData(map, s).classConstraint = o; return; } // sh:datatype → TYPE constraint - if (quad.predicate.equals(SHACL_DATATYPE)) { + if (quad.predicate.equals(SHACL.terms.datatype)) { getOrCreatePropData(map, s).datatypeConstraint = o; return; } // sh:node → SHAPE constraint (reference to another shape) - if (quad.predicate.equals(SHACL_NODE)) { + if (quad.predicate.equals(SHACL.terms.node)) { getOrCreatePropData(map, s).nodeConstraint = o; return; } // sh:or → alternatives list head - if (quad.predicate.equals(SHACL_OR)) { + if (quad.predicate.equals(SHACL.terms.or)) { map.orLists.set(s, o); return; } // sh:xone → exclusive-one-of list head (treated same as sh:or for query matching) - if (quad.predicate.equals(SHACL_XONE)) { + if (quad.predicate.equals(SHACL.terms.xone)) { map.xoneLists.set(s, o); return; } // sh:not → negation (blank node property shape that should become a negative predicate) - if (quad.predicate.equals(SHACL_NOT)) { + if (quad.predicate.equals(SHACL.terms.not)) { map.notLinks.set(s, o); // Create entry for the negated shape's blank node const negData = getOrCreatePropData(map, o); @@ -252,11 +241,11 @@ function parseQuad(quad: RDF.Quad, map: IMapTripleShacl): void { } // rdf:first / rdf:rest for RDF lists (used by sh:or / sh:xone) - if (quad.predicate.equals(IRI_FIRST_RDF_LIST)) { + if (quad.predicate.equals(RDF_VOCAB.terms.first)) { map.listFirst.set(s, o); return; } - if (quad.predicate.equals(IRI_REST_RDF_LIST)) { + if (quad.predicate.equals(RDF_VOCAB.terms.rest)) { map.listRest.set(s, o); return; } @@ -279,6 +268,7 @@ function getOrCreatePropData( function buildShape( map: IMapTripleShacl, shapeIri: string, + options?: IShapeParserOptions, ): IShape | ShapeError { const propIds = map.shapeProperties.get(shapeIri); // Collect sh:not negated property shapes from under the target shape @@ -319,7 +309,7 @@ function buildShape( const oneOfs: OneOf[] = []; const orHead = map.orLists.get(shapeIri) ?? map.xoneLists.get(shapeIri); if (orHead !== undefined) { - const branch = resolveOrList(orHead, map); + const branch = resolveOrList(orHead, map, shapeIri, options); if (branch.length > 0) { oneOfs.push(branch); } @@ -327,17 +317,13 @@ function buildShape( const closed = map.closedShape.get(shapeIri) ?? false; - try { - return new Shape({ - name: shapeIri, - positivePredicates, - negativePredicates, - closed, - oneOf: oneOfs, - }); - } catch (error: unknown) { - return error as ShapeError; - } + return buildShapeFromRaw({ + name: shapeIri, + positivePredicates: positivePredicates.map(predicate => predicateToParts(predicate)), + negativePredicates, + closed, + oneOf: oneOfs.map(currentOneOf => currentOneOf.map(path => path.map(predicateToParts))), + }); } /** Collect all property-shape blank nodes reachable via sh:not from a shape IRI. */ @@ -352,66 +338,27 @@ function collectNotProps(map: IMapTripleShacl, shapeIri: string): Set { /** Whether a property shape data entry represents a negative predicate (minCount=0, maxCount=0). */ function isNegatedData(data: IPropertyShapeData): boolean { - return data.minCount === 0 && data.maxCount === 0; + return isNegativeCardinality(data.minCount, data.maxCount); } /** Build an IPredicate from a property shape data entry. */ function buildPredicate(data: IPropertyShapeData): IPredicate { - const constraint = resolveConstraint(data); - const min = data.minCount; - const max = data.maxCount; - const hasCardinality = min !== undefined || max !== undefined; - return { + return buildSharedPredicate({ name: data.path!, - constraint, - cardinality: hasCardinality - ? { min: min ?? 1, max: max ?? 1 } - : undefined, - }; -} - -function resolveConstraint(data: IPropertyShapeData): IConstraint | undefined { - if (data.classConstraint !== undefined) { - // sh:class constrains the object to be an instance/class IRI. - return { - value: new Set([data.classConstraint]), - type: ConstraintType.CLASS, - }; - } - if (data.nodeConstraint !== undefined) { - // sh:node references another shape definition → SHAPE - return { - value: new Set([data.nodeConstraint]), - type: ConstraintType.SHAPE, - }; - } - if (data.datatypeConstraint !== undefined) { - const numericFacets: Partial = {}; - if (data.minInclusive !== undefined) { - numericFacets.minInclusive = data.minInclusive; - } - if (data.maxInclusive !== undefined) { - numericFacets.maxInclusive = data.maxInclusive; - } - if (data.minExclusive !== undefined) { - numericFacets.minExclusive = data.minExclusive; - } - if (data.maxExclusive !== undefined) { - numericFacets.maxExclusive = data.maxExclusive; - } - if (data.pattern !== undefined) { - numericFacets.pattern = data.pattern; - } - if (data.flags !== undefined) { - numericFacets.flags = data.flags; - } - return { - value: new Set([data.datatypeConstraint]), - type: ConstraintType.DATATYPE, - ...numericFacets, - }; - } - return undefined; + minCount: data.minCount, + maxCount: data.maxCount, + constraintParts: { + classConstraint: data.classConstraint, + shapeConstraint: data.nodeConstraint, + datatypeConstraint: data.datatypeConstraint, + minInclusive: data.minInclusive, + maxInclusive: data.maxInclusive, + minExclusive: data.minExclusive, + maxExclusive: data.maxExclusive, + pattern: data.pattern, + flags: data.flags, + }, + }); } /** @@ -421,20 +368,35 @@ function resolveConstraint(data: IPropertyShapeData): IConstraint | undefined { * Each list member is itself a shape-like blank node that should have * sh:property children. We collect those into one OneOfPath per member. */ -function resolveOrList(head: string, map: IMapTripleShacl): OneOf { +function resolveOrList( + head: string, + map: IMapTripleShacl, + shapeIri: string, + options?: IShapeParserOptions, +): OneOf { const result: OneOf = []; - let current: string | undefined = head; - - while (current !== undefined && current !== IRI_END_RDF_LIST.value) { - const memberId = map.listFirst.get(current); - if (memberId !== undefined) { - const branch = resolveOrMember(memberId, map); - if (branch.length > 0) { - result.push(branch); - } + + const policy = getPolicy(options); + const walked = walkRdfList(head, { + firstByNode: map.listFirst, + restByNode: map.listRest, + }); + + if (walked.malformed) { + addDiagnostic(options, { + level: policy.strictRdfLists ? 'error' : 'warning', + code: 'MALFORMED_RDF_LIST', + message: `Malformed RDF list while resolving sh:or/sh:xone for <${shapeIri}>`, + shapeIri, + nodeId: head, + }); + } + + for (const memberId of walked.values) { + const branch = resolveOrMember(memberId, map); + if (branch.length > 0) { + result.push(branch); } - const next = map.listRest.get(current); - current = next === IRI_END_RDF_LIST.value ? undefined : next; } return result; diff --git a/lib/shex.ts b/lib/shex.ts index d5089a7..3ac7070 100644 --- a/lib/shex.ts +++ b/lib/shex.ts @@ -1,29 +1,17 @@ import type * as RDF from '@rdfjs/types'; -import { - SHEX_PREDICATE, - SHEX_EXPRESSION, - TYPE_DEFINITION, - IRI_FIRST_RDF_LIST, - SHEX_EXPRESSIONS, - IRI_REST_RDF_LIST, - SHEX_CLOSED_SHAPE, - RDF_TRUE, - SHEX_SHAPE_EXPRESSION, - SHEX_MAX, - SHEX_MIN, - SHEX_VALUE_EXPR, - SHEX_DATA_TYPE, - SHEX_EACH_OF, - SHEX_ONE_OF, -} from './constant'; +import { DataFactory } from 'rdf-data-factory'; +import { SHEX, RDF as RDF_VOCAB, XSD } from './constant'; import type { - IConstraint, ShapeError, OneOf, IShape, IPredicate } from './Shape'; -import { Shape, ConstraintType, PoorlyFormatedShapeError } from './Shape'; +import { PoorlyFormatedShapeError, walkRdfList, buildShapeFromRaw, predicateToParts, buildConstraint, isNegativeCardinality } from './Shape'; +import { addDiagnostic, getPolicy, type IShapeParserOptions } from './parser-policy'; + +const DF = new DataFactory(); +const RDF_TRUE = DF.literal('true', DF.namedNode(XSD.boolean)); /** * Parse a Shex shape from a set of quads @@ -32,13 +20,17 @@ import { Shape, ConstraintType, PoorlyFormatedShapeError } from './Shape'; * @returns {Promise} The shape * @todo support for `OR` statement */ -export function shexShapeFromQuads(quads: RDF.Stream | RDF.Quad[], shapeIri: string): Promise { +export function shexShapeFromQuads( + quads: RDF.Stream | RDF.Quad[], + shapeIri: string, + options?: IShapeParserOptions, +): Promise { if (Array.isArray(quads)) { return new Promise(resolve => { - resolve(shapeFromQuadArray(quads, shapeIri)); + resolve(shapeFromQuadArray(quads, shapeIri, options)); }); } - return shapeFromQuadStream(quads, shapeIri); + return shapeFromQuadStream(quads, shapeIri, options); } /** @@ -47,7 +39,11 @@ export function shexShapeFromQuads(quads: RDF.Stream | RDF.Quad[], shapeIri: str * @param {string} shapeIri - The iri of the desired shape * @returns {Promise} The shape */ -function shapeFromQuadStream(quadSteam: RDF.Stream, shapeIri: string): Promise { +function shapeFromQuadStream( + quadSteam: RDF.Stream, + shapeIri: string, + options?: IShapeParserOptions, +): Promise { const mapTripleShex: IMapTripleShex = defaultMapTripleShex(); return new Promise(resolve => { @@ -65,6 +61,7 @@ function shapeFromQuadStream(quadSteam: RDF.Stream, shapeIri: string): Promise} The shape */ -function shapeFromQuadArray(quads: RDF.Quad[], shapeIri: string): IShape | ShapeError { +function shapeFromQuadArray( + quads: RDF.Quad[], + shapeIri: string, + options?: IShapeParserOptions, +): IShape | ShapeError { const mapTripleShex: IMapTripleShex = defaultMapTripleShex(); for (const quad of quads) { @@ -90,6 +91,7 @@ function shapeFromQuadArray(quads: RDF.Quad[], shapeIri: string): IShape | Shape const shape = concatShapeInfo( mapTripleShex, shapeIri, + options, ); return shape; } @@ -103,6 +105,7 @@ function shapeFromQuadArray(quads: RDF.Quad[], shapeIri: string): IShape | Shape function concatShapeInfo( mapTripleShex: IMapTripleShex, shapeIri: string, + options?: IShapeParserOptions, ): IShape | ShapeError { const positivePredicates: IPredicate[] = []; const negativePredicates: string[] = []; @@ -112,6 +115,12 @@ function concatShapeInfo( mapIriCardinalityMax: mapTripleShex.mapIriCardinalityMax, mapIriConstraint: mapTripleShex.mapIriConstraint, mapIriDatatype: mapTripleShex.mapIriDatatype, + mapIriMinInclusive: mapTripleShex.mapIriMinInclusive, + mapIriMaxInclusive: mapTripleShex.mapIriMaxInclusive, + mapIriMinExclusive: mapTripleShex.mapIriMinExclusive, + mapIriMaxExclusive: mapTripleShex.mapIriMaxExclusive, + mapIriPattern: mapTripleShex.mapIriPattern, + mapIriFlags: mapTripleShex.mapIriFlags, positivePredicates, negativePredicates, oneOf: new Map(), @@ -132,8 +141,6 @@ function concatShapeInfo( } else { expressions = mapTripleShex.mapLogicLinkIdExpressions.get(expression); } - let current; - let next; // If there is only one expression if (expressions === undefined) { argsFunctionPredicate.current = expression; @@ -141,23 +148,67 @@ function concatShapeInfo( if (!predicateAdded) { return new PoorlyFormatedShapeError('there are no predicates in the shape'); } - } else if (expression !== undefined && mapTripleShex.setIriEachOf.has(expression)) { - current = mapTripleShex.mapPrevCurrentList.get(expressions); - next = mapTripleShex.mapPrevNextList.get(expressions); - } + } else if (expression === undefined) { + const current = mapTripleShex.mapPrevCurrentList.get(expressions); + const next = mapTripleShex.mapPrevNextList.get(expressions); + const error = handleEachOf(current, next, mapTripleShex, argsFunctionPredicate, shapeIri, options); + if (error !== undefined) { + return error; + } + } else if (mapTripleShex.setIriEachOf.has(expression)) { + const policy = getPolicy(options); + const walked = walkRdfList(expressions, { + firstByNode: mapTripleShex.mapPrevCurrentList, + restByNode: mapTripleShex.mapPrevNextList, + }); + if (walked.malformed) { + addDiagnostic(options, { + level: policy.strictRdfLists ? 'error' : 'warning', + code: 'MALFORMED_RDF_LIST', + message: `Malformed RDF list while resolving eachOf for <${shapeIri}>`, + shapeIri, + nodeId: expressions, + }); + if (policy.strictRdfLists) { + return new PoorlyFormatedShapeError('An RDF list is poorly defined'); + } + } - const error = handleEachOf(current, next, mapTripleShex, argsFunctionPredicate); - if (error !== undefined) { - return error; + const current = mapTripleShex.mapPrevCurrentList.get(expressions); + const next = mapTripleShex.mapPrevNextList.get(expressions); + const error = handleEachOf( + current, + next, + mapTripleShex, + argsFunctionPredicate, + shapeIri, + options, + ); + if (error !== undefined) { + return error; + } + } else if (mapTripleShex.setIriOneOf.has(expression)) { + const error = handleOneOf( + expression, + expression, + mapTripleShex, + argsFunctionPredicate, + mapTripleShex.setIriEachOf.has(expression), + shapeIri, + options, + ); + if (error !== undefined) { + return error; + } } + let isClosed; if (shapeExpr === undefined) { isClosed = mapTripleShex.mapShapeExpressionClosedShape.get(shapeIri); } else { isClosed = mapTripleShex.mapShapeExpressionClosedShape.get(shapeExpr); } - try { - let oneOfs: OneOf[] = []; + let oneOfs: OneOf[] = []; for (const currentOneOf of argsFunctionPredicate.oneOf.values()) { const oneOf: OneOf = []; for (const currentPath of currentOneOf) { @@ -167,16 +218,13 @@ function concatShapeInfo( oneOfs.push(oneOf); } oneOfs = deleteIdenticalBranch(oneOfs) - return new Shape({ + return buildShapeFromRaw({ name: shapeIri, - positivePredicates: positivePredicates, + positivePredicates: positivePredicates.map(predicateToParts), negativePredicates, closed: isClosed, - oneOf: oneOfs, + oneOf: oneOfs.map(currentOneOf => currentOneOf.map(path => path.map(predicateToParts))), }); - } catch (error: unknown) { - return error as ShapeError; - } } /** @@ -188,28 +236,41 @@ function concatShapeInfo( function interpretConstraint( constraint: RDF.Term | undefined, mapIriDatatype: Map, + mapIriMinInclusive: Map, + mapIriMaxInclusive: Map, + mapIriMinExclusive: Map, + mapIriMaxExclusive: Map, + mapIriPattern: Map, + mapIriFlags: Map, predicate?: string, -): IConstraint | undefined { +){ if (constraint === undefined) { return undefined; } if (constraint.termType === 'NamedNode') { - return { - value: new Set([constraint.value]), - type: ConstraintType.SHAPE, - }; + return buildConstraint({ shapeConstraint: constraint.value }, predicate); } if (constraint.termType === 'BlankNode') { const dataType = mapIriDatatype.get(constraint.value); if (dataType !== undefined) { - return { - value: new Set([dataType]), - type: predicate === TYPE_DEFINITION.value - ? ConstraintType.CLASS - : ConstraintType.DATATYPE, - }; + const minInclusive = mapIriMinInclusive.get(constraint.value); + const maxInclusive = mapIriMaxInclusive.get(constraint.value); + const minExclusive = mapIriMinExclusive.get(constraint.value); + const maxExclusive = mapIriMaxExclusive.get(constraint.value); + const pattern = mapIriPattern.get(constraint.value); + const flags = mapIriFlags.get(constraint.value); + + return buildConstraint({ + datatypeConstraint: dataType, + minInclusive, + maxInclusive, + minExclusive, + maxExclusive, + pattern, + flags, + }, predicate); } } @@ -228,11 +289,21 @@ function appendPredicates( if (predicate !== undefined) { const min = args.mapIriCardinalityMin.get(args.current); const max = args.mapIriCardinalityMax.get(args.current); - if (min === max && min === 0) { + if (isNegativeCardinality(min, max)) { args.negativePredicates.push(predicate); } else { const constraintIri = args.mapIriConstraint.get(args.current); - const constraint = interpretConstraint(constraintIri, args.mapIriDatatype, predicate); + const constraint = interpretConstraint( + constraintIri, + args.mapIriDatatype, + args.mapIriMinInclusive, + args.mapIriMaxInclusive, + args.mapIriMinExclusive, + args.mapIriMaxExclusive, + args.mapIriPattern, + args.mapIriFlags, + predicate, + ); args.positivePredicates.push({ name: predicate, cardinality: { @@ -252,7 +323,10 @@ function handleOneOf( index: string, mapTripleShex: IMapTripleShex, prevArgsFunctionPredicate: IAppendPredicateArgs, - eachOf: boolean): undefined | PoorlyFormatedShapeError { + eachOf: boolean, + shapeIri: string, + options?: IShapeParserOptions, +): undefined | PoorlyFormatedShapeError { const positivePredicates: IPredicate[] = []; const negativePredicates: string[] = []; const argsFunctionPredicate: IAppendPredicateArgs = { @@ -261,6 +335,12 @@ function handleOneOf( mapIriCardinalityMax: mapTripleShex.mapIriCardinalityMax, mapIriConstraint: mapTripleShex.mapIriConstraint, mapIriDatatype: mapTripleShex.mapIriDatatype, + mapIriMinInclusive: mapTripleShex.mapIriMinInclusive, + mapIriMaxInclusive: mapTripleShex.mapIriMaxInclusive, + mapIriMinExclusive: mapTripleShex.mapIriMinExclusive, + mapIriMaxExclusive: mapTripleShex.mapIriMaxExclusive, + mapIriPattern: mapTripleShex.mapIriPattern, + mapIriFlags: mapTripleShex.mapIriFlags, positivePredicates, negativePredicates, oneOf: new Map() @@ -272,21 +352,48 @@ function handleOneOf( return new PoorlyFormatedShapeError('There are no expressions in a one of'); } + const policy = getPolicy(options); + const walked = walkRdfList(expressions, { + firstByNode: mapTripleShex.mapPrevCurrentList, + restByNode: mapTripleShex.mapPrevNextList, + }); + + if (walked.malformed) { + addDiagnostic(options, { + level: policy.strictRdfLists ? 'error' : 'warning', + code: 'MALFORMED_RDF_LIST', + message: `Malformed RDF list while resolving oneOf for <${shapeIri}>`, + shapeIri, + nodeId: expressions, + }); + if (policy.strictRdfLists) { + return new PoorlyFormatedShapeError('An RDF list is poorly defined'); + } + } + let current = mapTripleShex.mapPrevCurrentList.get(expressions); let next = mapTripleShex.mapPrevNextList.get(expressions); while (current !== undefined) { if (!mapTripleShex.setIriOneOf.has(current) && !mapTripleShex.setIriEachOf.has(current)) { - const error = handleEachOf(current, next, mapTripleShex, argsFunctionPredicate); - // we don't really support validation of format - /* istanbul ignore next */ + const error = handleEachOf(current, next, mapTripleShex, argsFunctionPredicate, shapeIri, options); if (error !== undefined) { return error; } } else { - handleOneOf(current, iri, mapTripleShex, prevArgsFunctionPredicate, mapTripleShex.setIriEachOf.has(current)); + const error = handleOneOf( + current, + iri, + mapTripleShex, + prevArgsFunctionPredicate, + mapTripleShex.setIriEachOf.has(current), + shapeIri, + options, + ); + if (error !== undefined) { + return error; + } } - // we don't really support validation of format - /* istanbul ignore next */ + if (next === undefined) { return new PoorlyFormatedShapeError('An RDF list is poorly defined'); } @@ -311,20 +418,36 @@ function handleOneOf( } } + + return undefined; } function handleEachOf( current: string | undefined, next: string | undefined, mapTripleShex: IMapTripleShex, - argsFunctionPredicate: IAppendPredicateArgs): undefined | PoorlyFormatedShapeError { + argsFunctionPredicate: IAppendPredicateArgs, + _shapeIri: string, + _options?: IShapeParserOptions, +): undefined | PoorlyFormatedShapeError { // Traverse the RDF list while (current !== undefined) { if (!mapTripleShex.setIriOneOf.has(current)) { argsFunctionPredicate.current = current; appendPredicates((argsFunctionPredicate as Required)); } else { - handleOneOf(current, current, mapTripleShex, argsFunctionPredicate, mapTripleShex.setIriEachOf.has(current)); + const error = handleOneOf( + current, + current, + mapTripleShex, + argsFunctionPredicate, + mapTripleShex.setIriEachOf.has(current), + _shapeIri, + _options, + ); + if (error !== undefined) { + return error; + } } if (next === undefined) { @@ -334,6 +457,8 @@ function handleEachOf( current = mapTripleShex.mapPrevCurrentList.get(next); next = mapTripleShex.mapPrevNextList.get(next); } + + return undefined; } function deleteIdenticalBranch(oneOfs: OneOf[]): OneOf[] { @@ -371,43 +496,61 @@ function parseShapeQuads( quad: RDF.Quad, mapTripleShex: IMapTripleShex, ): void { - if (quad.predicate.equals(SHEX_PREDICATE)) { + if (quad.predicate.equals(SHEX.terms.predicate)) { mapTripleShex.mapIdPredicate.set(quad.subject.value, quad.object.value); } - if (quad.predicate.equals(IRI_FIRST_RDF_LIST)) { + if (quad.predicate.equals(RDF_VOCAB.terms.first)) { mapTripleShex.mapPrevCurrentList.set(quad.subject.value, quad.object.value); } - if (quad.predicate.equals(IRI_REST_RDF_LIST)) { + if (quad.predicate.equals(RDF_VOCAB.terms.rest)) { mapTripleShex.mapPrevNextList.set(quad.subject.value, quad.object.value); } - if (quad.predicate.equals(SHEX_EXPRESSIONS)) { + if (quad.predicate.equals(SHEX.terms.expressions)) { mapTripleShex.mapLogicLinkIdExpressions.set(quad.subject.value, quad.object.value); } - if (quad.predicate.equals(SHEX_EXPRESSION)) { + if (quad.predicate.equals(SHEX.terms.expression)) { mapTripleShex.mapShapeExpressionId.set(quad.subject.value, quad.object.value); } - if (quad.predicate.equals(SHEX_CLOSED_SHAPE)) { + if (quad.predicate.equals(SHEX.terms.closed)) { mapTripleShex.mapShapeExpressionClosedShape.set(quad.subject.value, quad.object.equals(RDF_TRUE)); } - if (quad.predicate.equals(SHEX_SHAPE_EXPRESSION)) { + if (quad.predicate.equals(SHEX.terms.shapeExpr)) { mapTripleShex.mapIriShapeExpression.set(quad.subject.value, quad.object.value); } - if (quad.predicate.equals(SHEX_MAX)) { + if (quad.predicate.equals(SHEX.terms.max)) { mapTripleShex.mapIriCardinalityMax.set(quad.subject.value, Number(quad.object.value)); } - if (quad.predicate.equals(SHEX_MIN)) { + if (quad.predicate.equals(SHEX.terms.min)) { mapTripleShex.mapIriCardinalityMin.set(quad.subject.value, Number(quad.object.value)); } - if (quad.predicate.equals(SHEX_VALUE_EXPR)) { + if (quad.predicate.equals(SHEX.terms.valueExpr)) { mapTripleShex.mapIriConstraint.set(quad.subject.value, quad.object); } - if (quad.predicate.equals(SHEX_DATA_TYPE)) { + if (quad.predicate.equals(SHEX.terms.datatype)) { mapTripleShex.mapIriDatatype.set(quad.subject.value, quad.object.value); } - if (quad.object.equals(SHEX_EACH_OF)) { + if (quad.predicate.equals(SHEX.terms.mininclusive)) { + mapTripleShex.mapIriMinInclusive.set(quad.subject.value, Number(quad.object.value)); + } + if (quad.predicate.equals(SHEX.terms.maxinclusive)) { + mapTripleShex.mapIriMaxInclusive.set(quad.subject.value, Number(quad.object.value)); + } + if (quad.predicate.equals(SHEX.terms.minexclusive)) { + mapTripleShex.mapIriMinExclusive.set(quad.subject.value, Number(quad.object.value)); + } + if (quad.predicate.equals(SHEX.terms.maxexclusive)) { + mapTripleShex.mapIriMaxExclusive.set(quad.subject.value, Number(quad.object.value)); + } + if (quad.predicate.equals(SHEX.terms.pattern)) { + mapTripleShex.mapIriPattern.set(quad.subject.value, quad.object.value); + } + if (quad.predicate.equals(SHEX.terms.flags)) { + mapTripleShex.mapIriFlags.set(quad.subject.value, quad.object.value); + } + if (quad.object.equals(SHEX.terms.EachOf)) { mapTripleShex.setIriEachOf.add(quad.subject.value); } - if (quad.object.equals(SHEX_ONE_OF)) { + if (quad.object.equals(SHEX.terms.OneOf)) { mapTripleShex.setIriOneOf.add(quad.subject.value); } } @@ -424,6 +567,12 @@ interface IMapTripleShex { mapIriCardinalityMin: Map; mapIriConstraint: Map; mapIriDatatype: Map; + mapIriMinInclusive: Map; + mapIriMaxInclusive: Map; + mapIriMinExclusive: Map; + mapIriMaxExclusive: Map; + mapIriPattern: Map; + mapIriFlags: Map; setIriEachOf: Set; setIriOneOf: Set; } @@ -434,6 +583,12 @@ interface IAppendPredicateArgs { mapIriCardinalityMax: Map; mapIriConstraint: Map; mapIriDatatype: Map; + mapIriMinInclusive: Map; + mapIriMaxInclusive: Map; + mapIriMinExclusive: Map; + mapIriMaxExclusive: Map; + mapIriPattern: Map; + mapIriFlags: Map; positivePredicates: IPredicate[]; negativePredicates: string[]; oneOf: Map; @@ -453,6 +608,12 @@ function defaultMapTripleShex(): IMapTripleShex { mapIriCardinalityMin: new Map(), mapIriConstraint: new Map(), mapIriDatatype: new Map(), + mapIriMinInclusive: new Map(), + mapIriMaxInclusive: new Map(), + mapIriMinExclusive: new Map(), + mapIriMaxExclusive: new Map(), + mapIriPattern: new Map(), + mapIriFlags: new Map(), setIriEachOf: new Set(), setIriOneOf: new Set() }; diff --git a/package-lock.json b/package-lock.json index 16d7cd8..d2decb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { - "name": "query-shape-detection", + "name": "query-shape-matching", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "query-shape-detection", + "name": "query-shape-matching", "version": "1.0.0", "license": "MIT", "dependencies": { "@traqula/algebra-sparql-1-1": "^1.1.6", "@traqula/algebra-transformations-1-1": "^1.1.4", "@traqula/parser-sparql-1-1": "^1.1.6", + "@treecg/types": "^0.4.6", "rdf-data-factory": "^2.0.2" }, "devDependencies": { @@ -713,6 +714,36 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@treecg/types": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/@treecg/types/-/types-0.4.6.tgz", + "integrity": "sha512-iL8rBhAX4INvfuHZzJ1RELh1i/fspviylENZTk6D2p/xDazY1pTkd+y/hxKYVWCC0jHNolDWRsH7ozwaFGNQKA==", + "license": "UNLICENSE", + "dependencies": { + "@rdfjs/types": "*", + "loglevel": "^1.8.1", + "loglevel-plugin-prefix": "^0.8.4", + "rdf-data-factory": "^1.1.0" + } + }, + "node_modules/@treecg/types/node_modules/@rdfjs/types": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rdfjs/types/-/types-1.1.2.tgz", + "integrity": "sha512-wqpOJK1QCbmsGNtyzYnojPU8gRDPid2JO0Q0kMtb4j65xhCK880cnKAfEOwC+dX85VJcCByQx5zOwyyfCjDJsg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@treecg/types/node_modules/rdf-data-factory": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rdf-data-factory/-/rdf-data-factory-1.1.3.tgz", + "integrity": "sha512-ny6CI7m2bq4lfQQmDYvcb2l1F9KtGwz9chipX4oWu2aAtVoXjb7k3d8J1EsgAsEbMXnBipB/iuRen5H2fwRWWQ==", + "license": "MIT", + "dependencies": { + "@rdfjs/types": "^1.0.0" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -2178,6 +2209,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, + "node_modules/loglevel-plugin-prefix": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/loglevel-plugin-prefix/-/loglevel-plugin-prefix-0.8.4.tgz", + "integrity": "sha512-WpG9CcFAOjz/FtNht+QJeGpvVl/cdR6P0z6OcXSkr8wFJOsV2GRj2j10JLfjuA4aYkcKCNIEqRGCyTife9R8/g==", + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", diff --git a/package.json b/package.json index d6721fa..9af713b 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@traqula/algebra-sparql-1-1": "^1.1.6", "@traqula/algebra-transformations-1-1": "^1.1.4", "@traqula/parser-sparql-1-1": "^1.1.6", + "@treecg/types": "^0.4.6", "rdf-data-factory": "^2.0.2" }, "devDependencies": { @@ -33,11 +34,11 @@ "@types/n3": "^1.26.1", "@types/node": "^26.1.0", "@types/streamify-array": "^1.0.3", + "@vitest/coverage-v8": "^4.1.9", "eslint": "^10.6.0", "globals": "^17.7.0", "n3": "^2.1.1", "streamify-array": "^2.0.0", - "@vitest/coverage-v8": "^4.1.9", "typescript": "^6.0.3", "typescript-eslint": "^8.62.1", "vitest": "^4.1.9" diff --git a/test/Binding.test.ts b/test/Binding.test.ts index 08c1d50..642ab3b 100644 --- a/test/Binding.test.ts +++ b/test/Binding.test.ts @@ -12,7 +12,7 @@ import * as N3 from 'n3'; import { readFileSync } from 'fs'; import { streamifyArray } from 'streamify-array'; import { shexShapeFromQuads } from '../lib/shex'; -import { TYPE_DEFINITION } from '../lib/constant'; +import { RDF as RDF_VOCAB } from '../lib/constant'; const n3Parser = new N3.Parser(); const sparqlParser = new SPARQLParser(); @@ -1728,7 +1728,7 @@ describe('Bindings', () => { const shape: Shape = new Shape({ name: 'foo', positivePredicates: [ { - name: TYPE_DEFINITION.value, + name: RDF_VOCAB.type, constraint: { type: ConstraintType.CLASS, value: new Set(["t0"]) @@ -1740,13 +1740,13 @@ describe('Bindings', () => { const triple: Triple = new Triple({ subject: 'y', - predicate: TYPE_DEFINITION.value, + predicate: RDF_VOCAB.type, object: DF.namedNode('t0') }); const starPattern: IStarPatternWithDependencies = { starPattern: new Map([ [ - TYPE_DEFINITION.value, + RDF_VOCAB.type, { triple, dependencies: undefined @@ -1822,7 +1822,7 @@ describe('Bindings', () => { const shape: Shape = new Shape({ name: 'foo', positivePredicates: [ { - name: TYPE_DEFINITION.value, + name: RDF_VOCAB.type, constraint: { type: ConstraintType.CLASS, value: new Set(["t0"]) @@ -1848,7 +1848,7 @@ describe('Bindings', () => { const triple1: Triple = new Triple({ subject: 'y', - predicate: TYPE_DEFINITION.value, + predicate: RDF_VOCAB.type, object: [DF.namedNode('t0')] }); const triple2: Triple = new Triple({ diff --git a/test/containment.test.ts b/test/containment.test.ts index c5ccd58..f5ac033 100644 --- a/test/containment.test.ts +++ b/test/containment.test.ts @@ -3,11 +3,11 @@ import { toAlgebra } from '@traqula/algebra-sparql-1-1'; import { describe, expect, it, test } from 'vitest'; import { ConstraintType, IShape, Shape } from '../lib/Shape'; import { IStarPatternWithDependencies, Triple } from '../lib/Triple'; -import { ContainmentResult, IContainmentResult, IResult, StarPatternName, solveShapeQueryContainment } from '../lib/containment'; +import { ContainmentResult, IContainmentResult, IResult, StarPatternName, solveShapeQueryContainment, solveShapeShapeContainment } from '../lib/containment'; import { DataFactory } from 'rdf-data-factory'; import { BaseQuad } from '@rdfjs/types'; import { IQuery, generateQuery } from '../lib/query'; -import { TYPE_DEFINITION } from '../lib/constant'; +import { RDF as RDF_VOCAB } from '../lib/constant'; import type * as RDF from '@rdfjs/types'; import * as N3 from 'n3'; import { readFileSync } from 'fs'; @@ -110,7 +110,7 @@ describe('solveShapeQueryContainment', () => { const shapeP7: Shape = new Shape({ name: 'foo7', positivePredicates: [ { - name: TYPE_DEFINITION.value, + name: RDF_VOCAB.type, constraint: { value: new Set(['']), type: ConstraintType.SHAPE @@ -123,7 +123,7 @@ describe('solveShapeQueryContainment', () => { const shapeP8: Shape = new Shape({ name: 'foo8', positivePredicates: [ { - name: TYPE_DEFINITION.value, + name: RDF_VOCAB.type, constraint: { value: new Set(['']), type: ConstraintType.SHAPE @@ -708,7 +708,7 @@ describe('solveShapeQueryContainment', () => { name: 'fooClassConstraint', positivePredicates: [ { - name: TYPE_DEFINITION.value, + name: RDF_VOCAB.type, constraint: { type: ConstraintType.CLASS, value: new Set(['https://www.example.ca/Person']) @@ -1005,6 +1005,129 @@ describe('solveShapeQueryContainment', () => { }); + it('should support shape-to-shape containment through shapeToQuery wrapper', () => { + const sourceShape: IShape = new Shape({ + name: 'https://www.example.ca/source', + positivePredicates: [ + { + name: RDF_VOCAB.type, + constraint: { + type: ConstraintType.CLASS, + value: new Set(['https://www.example.ca/Person']) + } + }, + { + name: 'https://www.example.ca/age', + constraint: { + type: ConstraintType.DATATYPE, + value: new Set(['http://www.w3.org/2001/XMLSchema#integer']), + minInclusive: 18, + maxInclusive: 35 + } + } + ], + closed: true, + }); + + const targetShape: IShape = new Shape({ + name: 'https://www.example.ca/target', + positivePredicates: [ + { + name: RDF_VOCAB.type, + constraint: { + type: ConstraintType.CLASS, + value: new Set(['https://www.example.ca/Person']) + } + }, + { + name: 'https://www.example.ca/age', + constraint: { + type: ConstraintType.DATATYPE, + value: new Set(['http://www.w3.org/2001/XMLSchema#integer']), + minInclusive: 10, + maxInclusive: 40 + } + } + ], + closed: true, + }); + + const result = solveShapeShapeContainment({ + sourceShape, + targetShapes: [targetShape], + }); + + expect(result.starPatternsContainment.get(sourceShape.name)?.result).toBe(ContainmentResult.CONTAINED); + }); + + it('should support shape-to-shape containment with source linked shapes', () => { + const childSource: IShape = new Shape({ + name: 'https://www.example.ca/sourceChild', + positivePredicates: [ + { + name: 'https://www.example.ca/nickname', + constraint: { + type: ConstraintType.DATATYPE, + value: new Set(['http://www.w3.org/2001/XMLSchema#string']), + pattern: '^a' + } + } + ], + closed: true, + }); + + const sourceShape: IShape = new Shape({ + name: 'https://www.example.ca/sourceRoot', + positivePredicates: [ + { + name: 'https://www.example.ca/knows', + constraint: { + type: ConstraintType.SHAPE, + value: new Set([childSource.name]) + } + } + ], + closed: true, + }); + + const childTarget: IShape = new Shape({ + name: childSource.name, + positivePredicates: [ + { + name: 'https://www.example.ca/nickname', + constraint: { + type: ConstraintType.DATATYPE, + value: new Set(['http://www.w3.org/2001/XMLSchema#string']), + pattern: '^a' + } + } + ], + closed: true, + }); + + const targetShape: IShape = new Shape({ + name: 'https://www.example.ca/targetRoot', + positivePredicates: [ + { + name: 'https://www.example.ca/knows', + constraint: { + type: ConstraintType.SHAPE, + value: new Set([childTarget.name]) + } + } + ], + closed: true, + }); + + const result = solveShapeShapeContainment({ + sourceShape, + sourceLinkedShapes: [childSource], + targetShapes: [targetShape, childTarget], + }); + + expect(result.starPatternsContainment.get(sourceShape.name)?.result).toBe(ContainmentResult.CONTAINED); + }); + function generateMatchingQuery(): IQuery { const queryString = ` SELECT * WHERE { @@ -1076,15 +1199,15 @@ describe('solveShapeQueryContainment', () => { ?x ?z . ?x ?w . - ?y <${TYPE_DEFINITION.value}> . + ?y <${RDF_VOCAB.type}> . - ?z <${TYPE_DEFINITION.value}> . + ?z <${RDF_VOCAB.type}> . ?z . ?w ?w1 . ?w ?w1 . - ?zz <${TYPE_DEFINITION.value}> . + ?zz <${RDF_VOCAB.type}> . ?zz . } `; diff --git a/test/query.test.ts b/test/query.test.ts index c653030..c4ec92b 100644 --- a/test/query.test.ts +++ b/test/query.test.ts @@ -3,7 +3,7 @@ import { DataFactory } from 'rdf-data-factory'; import { describe, expect, it, test } from 'vitest'; import { Parser as SPARQLParser } from '@traqula/parser-sparql-1-1'; import { toAlgebra } from '@traqula/algebra-sparql-1-1'; -import { TYPE_DEFINITION } from '../lib/constant'; +import { RDF as RDF_VOCAB } from '../lib/constant'; import { generateQuery, generateStarPatternUnion, IQuery } from '../lib/query'; import { type IStarPatternWithDependencies, Triple } from '../lib/Triple'; @@ -13,7 +13,7 @@ const sparqlParser = new SPARQLParser(); const RDF_STRING = DF.namedNode('http://www.w3.org/2001/XMLSchema#string'); const SNVOC_PREFIX = 'http://localhost:3000/www.ldbc.eu/ldbc_socialnet/1.0/vocabulary/'; const FOAF_PREFIX = 'http://xmlns.com/foaf/0.1/'; -const RDF_TYPE = TYPE_DEFINITION.value; +const RDF_TYPE = RDF_VOCAB.type; describe('query', () => { describe('generateQuery', () => { diff --git a/test/shacl.test.ts b/test/shacl.test.ts index ee8ca6a..e23c58b 100644 --- a/test/shacl.test.ts +++ b/test/shacl.test.ts @@ -4,7 +4,7 @@ import * as N3 from 'n3'; import { DataFactory } from 'rdf-data-factory'; import { describe, expect, it } from 'vitest'; import { streamifyArray } from 'streamify-array'; -import { TYPE_DEFINITION } from '../lib/constant'; +import { RDF as RDF_VOCAB } from '../lib/constant'; import { ConstraintType, type IConstraint, type OneOf, type IShape } from '../lib/Shape'; import { shaclShapeFromQuads } from '../lib/shacl'; @@ -362,7 +362,7 @@ describe.each([ expect(shape).not.toBeInstanceOf(Error); const expectedPredicates = new Set([ - TYPE_DEFINITION.value, + RDF_VOCAB.type, `${LBDCVOC_PREFIX}id`, `${LBDCVOC_PREFIX}creationDate`, `${LBDCVOC_PREFIX}locationIP`, @@ -375,7 +375,7 @@ describe.each([ ]); const mapCardinality = new Map([ - [TYPE_DEFINITION.value, { min: 0, max: 1 }], + [RDF_VOCAB.type, { min: 0, max: 1 }], [`${LBDCVOC_PREFIX}id`, { min: 1, max: 1 }], [`${LBDCVOC_PREFIX}creationDate`, { min: 1, max: 1 }], [`${LBDCVOC_PREFIX}locationIP`, { min: 1, max: 1 }], @@ -388,7 +388,7 @@ describe.each([ ]); const mapConstraint = new Map([ - [TYPE_DEFINITION.value, { type: ConstraintType.CLASS, value: new Set([`${LBDCVOC_PREFIX}Comment`]) }], + [RDF_VOCAB.type, { type: ConstraintType.CLASS, value: new Set([`${LBDCVOC_PREFIX}Comment`]) }], [`${LBDCVOC_PREFIX}id`, { type: ConstraintType.DATATYPE, value: new Set([`${XSD_PREFIX}long`]) }], [`${LBDCVOC_PREFIX}creationDate`, { type: ConstraintType.DATATYPE, value: new Set([`${XSD_PREFIX}dateTime`]) }], [`${LBDCVOC_PREFIX}locationIP`, { type: ConstraintType.DATATYPE, value: new Set([`${XSD_PREFIX}string`]) }], diff --git a/test/shape-parser.test.ts b/test/shape-parser.test.ts new file mode 100644 index 0000000..d806bc9 --- /dev/null +++ b/test/shape-parser.test.ts @@ -0,0 +1,96 @@ +import { readFileSync } from 'fs'; +import type * as RDF from '@rdfjs/types'; +import * as N3 from 'n3'; +import { describe, expect, it } from 'vitest'; +import { parseShapeFromQuads } from '../lib/Shape'; +import type { IShape } from '../lib/Shape'; +import type { ParserDiagnostic } from '../lib/parser-policy'; + +const n3Parser = new N3.Parser(); +const shapeIri = 'http:exemple.ca/foo'; + +describe('parseShapeFromQuads', () => { + it('parses SHACL explicitly', async () => { + const quads = getShape('./test/shape/shacl_shape_one_property.ttl'); + const shape = await parseShapeFromQuads(quads, shapeIri, { format: 'shacl' }); + + expect(shape).not.toBeInstanceOf(Error); + expect((shape as IShape).positivePredicates).toStrictEqual(['http://example.org/state']); + }); + + it('parses ShEx explicitly', async () => { + const quads = getShape('./test/shape/shex_shape_one_property.ttl'); + const shape = await parseShapeFromQuads(quads, shapeIri, { format: 'shex' }); + + expect(shape).not.toBeInstanceOf(Error); + expect((shape as IShape).positivePredicates).toStrictEqual(['http://example.org/state']); + }); + + it('auto-detects SHACL format', async () => { + const quads = getShape('./test/shape/shacl_shape_one_property.ttl'); + const shape = await parseShapeFromQuads(quads, shapeIri, { format: 'auto' }); + + expect(shape).not.toBeInstanceOf(Error); + expect((shape as IShape).positivePredicates).toStrictEqual(['http://example.org/state']); + }); + + it('auto-detects ShEx format', async () => { + const quads = getShape('./test/shape/shex_shape_one_property.ttl'); + const shape = await parseShapeFromQuads(quads, shapeIri, { format: 'auto' }); + + expect(shape).not.toBeInstanceOf(Error); + expect((shape as IShape).positivePredicates).toStrictEqual(['http://example.org/state']); + }); + + it('produces parser parity for equivalent SHACL and ShEx fixtures', async () => { + const shaclQuads = getShape('./test/shape/shacl_shape_multiple_cardinality.ttl'); + const shexQuads = getShape('./test/shape/shex_shape_multiple_cardinality.ttl'); + + const shaclShape = await parseShapeFromQuads(shaclQuads, shapeIri, { format: 'shacl' }); + const shexShape = await parseShapeFromQuads(shexQuads, shapeIri, { format: 'shex' }); + + expect(shaclShape).not.toBeInstanceOf(Error); + expect(shexShape).not.toBeInstanceOf(Error); + + const left = canonical((shaclShape as IShape)); + const right = canonical((shexShape as IShape)); + + expect(left).toStrictEqual(right); + }); + + it('keeps malformed RDF list strict by default for ShEx', async () => { + const quads = getShape('./test/shape/shex_invalid_shape_incomplete_rdf_list.ttl'); + const shape = await parseShapeFromQuads(quads, shapeIri, { format: 'shex' }); + expect(shape).toBeInstanceOf(Error); + }); + + it('collects diagnostics when RDF list policy is non-strict', async () => { + const diagnostics: ParserDiagnostic[] = []; + const quads = getShape('./test/shape/shex_invalid_shape_incomplete_rdf_list.ttl'); + + await parseShapeFromQuads(quads, shapeIri, { + format: 'shex', + diagnostics, + policy: { strictRdfLists: false }, + }); + + expect(diagnostics.some(d => d.code === 'MALFORMED_RDF_LIST')).toBe(true); + }); +}); + +function canonical(shape: IShape): { positive: string[]; negative: string[]; cards: Record } { + const cards: Record = {}; + for (const predicate of shape.positivePredicates) { + cards[predicate] = shape.get(predicate)?.cardinality; + } + + return { + positive: [...shape.positivePredicates].sort(), + negative: [...(shape.negativePredicates ?? [])].sort(), + cards, + }; +} + +function getShape(path: string): RDF.Quad[] { + return n3Parser.parse(readFileSync(path).toString()) as RDF.Quad[]; +} diff --git a/test/shapeToQuery.test.ts b/test/shapeToQuery.test.ts new file mode 100644 index 0000000..1d99576 --- /dev/null +++ b/test/shapeToQuery.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest'; +import { ConstraintType, type IShape, Shape } from '../lib/Shape'; +import { shapeToQuery } from '../lib/query'; +import { solveShapeQueryContainment, ContainmentResult } from '../lib/containment'; +import { RDF } from '../lib/constant'; + +function collectOperators(expression: unknown, output: string[]): void { + if (typeof expression !== 'object' || expression === null) { + return; + } + + const castExpression = expression as { subType?: string; operator?: string; args?: unknown[] }; + if (castExpression.subType === 'operator' && castExpression.operator !== undefined) { + output.push(castExpression.operator); + for (const arg of castExpression.args ?? []) { + collectOperators(arg, output); + } + } +} + +describe('shapeToQuery', () => { + it('should translate constraints, dependencies and oneOf branches', () => { + const childShape: IShape = new Shape({ + name: 'https://www.example.ca/ChildShape', + positivePredicates: [ + { + name: 'https://www.example.ca/nickname', + constraint: { + type: ConstraintType.DATATYPE, + value: new Set(['http://www.w3.org/2001/XMLSchema#string']), + pattern: '^a', + }, + }, + ], + closed: true, + }); + + const rootShape: IShape = new Shape({ + name: 'https://www.example.ca/RootShape', + positivePredicates: [ + { + name: RDF.type, + constraint: { + type: ConstraintType.CLASS, + value: new Set(['https://www.example.ca/Person']), + }, + }, + { + name: 'https://www.example.ca/age', + constraint: { + type: ConstraintType.DATATYPE, + value: new Set(['http://www.w3.org/2001/XMLSchema#integer']), + minInclusive: 18, + maxInclusive: 35, + }, + }, + { + name: 'https://www.example.ca/knows', + constraint: { + type: ConstraintType.SHAPE, + value: new Set([childShape.name]), + }, + }, + ], + oneOf: [ + [ + [{ name: 'https://www.example.ca/email' }], + [{ name: 'https://www.example.ca/phone' }], + ], + ], + closed: true, + }); + + const query = shapeToQuery(rootShape, { linkedShapes: [childShape] }); + + expect(query.starPatterns.has(rootShape.name)).toBe(true); + expect(query.starPatterns.has(childShape.name)).toBe(true); + + const rootStarPattern = query.starPatterns.get(rootShape.name)!; + const typeTriple = rootStarPattern.starPattern.get(RDF.type)!.triple; + expect(Array.isArray(typeTriple.object)).toBe(false); + expect((typeTriple.object as any).termType).toBe('NamedNode'); + expect((typeTriple.object as any).value).toBe('https://www.example.ca/Person'); + + const knowsTriple = rootStarPattern.starPattern.get('https://www.example.ca/knows')!; + expect(knowsTriple.dependencies?.name).toBe(childShape.name); + + const operators: string[] = []; + for (const filter of query.filters ?? []) { + collectOperators(filter, operators); + } + expect(operators).toEqual(expect.arrayContaining(['datatype', '>=', '<='])); + + expect(query.union).toBeDefined(); + expect(query.union?.length).toBe(1); + expect(query.union?.[0].length).toBe(2); + }); + + it('should be usable directly with solveShapeQueryContainment', () => { + const sourceShape: IShape = new Shape({ + name: 'https://www.example.ca/SourceShape', + positivePredicates: [ + { + name: RDF.type, + constraint: { + type: ConstraintType.CLASS, + value: new Set(['https://www.example.ca/Person']), + }, + }, + { + name: 'https://www.example.ca/age', + constraint: { + type: ConstraintType.DATATYPE, + value: new Set(['http://www.w3.org/2001/XMLSchema#integer']), + minInclusive: 18, + maxInclusive: 35, + }, + }, + ], + closed: true, + }); + + const targetShape: IShape = new Shape({ + name: 'https://www.example.ca/TargetShape', + positivePredicates: [ + { + name: RDF.type, + constraint: { + type: ConstraintType.CLASS, + value: new Set(['https://www.example.ca/Person']), + }, + }, + { + name: 'https://www.example.ca/age', + constraint: { + type: ConstraintType.DATATYPE, + value: new Set(['http://www.w3.org/2001/XMLSchema#integer']), + minInclusive: 10, + maxInclusive: 50, + }, + }, + ], + closed: true, + }); + + const query = shapeToQuery(sourceShape); + const result = solveShapeQueryContainment({ query, shapes: [targetShape] }); + + expect(result.starPatternsContainment.get(sourceShape.name)?.result).toBe(ContainmentResult.CONTAINED); + }); +}); diff --git a/test/shex.test.ts b/test/shex.test.ts index 6e39ca2..692d183 100644 --- a/test/shex.test.ts +++ b/test/shex.test.ts @@ -4,7 +4,7 @@ import * as N3 from 'n3'; import { DataFactory } from 'rdf-data-factory'; import { describe, expect, it } from 'vitest'; import { streamifyArray } from 'streamify-array'; -import { SHEX_SHAPE, TYPE_DEFINITION, SHEX_PREDICATE } from '../lib/constant'; +import { RDF as RDF_VOCAB, SHEX } from '../lib/constant'; import { ConstraintType, IConstraint, OneOf, type IShape } from '../lib/Shape'; import { shexShapeFromQuads } from '../lib/shex'; @@ -34,12 +34,12 @@ describe.each([ ), DF.quad( DF.namedNode('a'), - TYPE_DEFINITION, - SHEX_SHAPE, + RDF_VOCAB.terms.type, + SHEX.terms.Shape, ), DF.quad( DF.namedNode('a'), - SHEX_PREDICATE, + SHEX.terms.predicate, DF.blankNode(), ), ] as RDF.Quad[]), @@ -73,7 +73,7 @@ describe.each([ it(`${name}: should handle Comment`, async () => { const shape = await shexShapeFromQuads(shapeSolidBenchComment, "http://example.com#Comment"); const expectedPredicates: string[] = [ - TYPE_DEFINITION.value, + RDF_VOCAB.type, `${LBDCVOC_PREFIX}id`, `${LBDCVOC_PREFIX}creationDate`, `${LBDCVOC_PREFIX}locationIP`, @@ -86,7 +86,7 @@ describe.each([ ]; const mapCardinality = new Map([ - [TYPE_DEFINITION.value, { min: 0, max: 1 }], + [RDF_VOCAB.type, { min: 0, max: 1 }], [`${LBDCVOC_PREFIX}id`, { min: 1, max: 1 }], [`${LBDCVOC_PREFIX}creationDate`, { min: 1, max: 1 }], [`${LBDCVOC_PREFIX}locationIP`, { min: 1, max: 1 }], @@ -99,7 +99,7 @@ describe.each([ ]); const mapConstraint = new Map([ - [TYPE_DEFINITION.value, { type: ConstraintType.CLASS, value: new Set([`${LBDCVOC_PREFIX}Comment`]) }], + [RDF_VOCAB.type, { type: ConstraintType.CLASS, value: new Set([`${LBDCVOC_PREFIX}Comment`]) }], [`${LBDCVOC_PREFIX}id`, { type: ConstraintType.DATATYPE, value: new Set([`${XSD_PREFIX}long`]) }], [`${LBDCVOC_PREFIX}creationDate`, { type: ConstraintType.DATATYPE, value: new Set([`${XSD_PREFIX}dateTime`]) }], [`${LBDCVOC_PREFIX}locationIP`, { type: ConstraintType.DATATYPE, value: new Set([`${XSD_PREFIX}string`]) }], @@ -167,6 +167,33 @@ describe.each([ expect((shape as IShape).name).toBe(shapeIri); }); + it(`${name}: should parse datatype numeric and regex facets into IConstraint`, async () => { + const shapeWithFacets: any = populateFunction([ + DF.quad(DF.namedNode(shapeIri), RDF_VOCAB.terms.type, SHEX.terms.Shape), + DF.quad(DF.namedNode(shapeIri), SHEX.terms.expression, DF.blankNode('expr1')), + DF.quad(DF.blankNode('expr1'), SHEX.terms.predicate, DF.namedNode('http://example.org/age')), + DF.quad(DF.blankNode('expr1'), SHEX.terms.valueExpr, DF.blankNode('constraint1')), + DF.quad(DF.blankNode('constraint1'), SHEX.terms.datatype, DF.namedNode('http://www.w3.org/2001/XMLSchema#integer')), + DF.quad(DF.blankNode('constraint1'), SHEX.terms.mininclusive, DF.literal('18', DF.namedNode('http://www.w3.org/2001/XMLSchema#integer'))), + DF.quad(DF.blankNode('constraint1'), SHEX.terms.maxexclusive, DF.literal('65', DF.namedNode('http://www.w3.org/2001/XMLSchema#integer'))), + DF.quad(DF.blankNode('constraint1'), SHEX.terms.pattern, DF.literal('^foo')), + DF.quad(DF.blankNode('constraint1'), SHEX.terms.flags, DF.literal('i')), + ] as RDF.Quad[]); + + const shape = await shexShapeFromQuads(shapeWithFacets, shapeIri); + expect(shape).not.toBeInstanceOf(Error); + + const predicate = (shape as IShape).get('http://example.org/age'); + expect(predicate?.constraint).toStrictEqual({ + type: ConstraintType.DATATYPE, + value: new Set(['http://www.w3.org/2001/XMLSchema#integer']), + minInclusive: 18, + maxExclusive: 65, + pattern: '^foo', + flags: 'i', + }); + }); + it(`${name}: should returns a closed Shape with multiple properties`, async () => { const shape = await shexShapeFromQuads(closedShapeWithMultipleProperties, shapeIri); const expectedPredicates: string[] = [ From 52c607574b4cde5eecf0cbbe483215f23db0c515 Mon Sep 17 00:00:00 2001 From: julianrojas87 Date: Fri, 28 Aug 2026 12:19:27 +0200 Subject: [PATCH 02/22] fix: Add roadmap and minor docs and dependency fixes --- lib/query.ts | 4 +-- package-lock.json | 22 ++++++------- roadmap.md | 84 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 13 deletions(-) create mode 100644 roadmap.md diff --git a/lib/query.ts b/lib/query.ts index 4bcc88c..c070e87 100644 --- a/lib/query.ts +++ b/lib/query.ts @@ -52,7 +52,7 @@ interface IShapeToQueryBuildContext { } /** - * Best-effort translation from an IShape to an IQuery representation. + * Translation from an IShape to an IQuery representation. * * Supported mapping: * - Positive predicates -> triple patterns @@ -60,7 +60,7 @@ interface IShapeToQueryBuildContext { * - SHAPE constraints -> dependent star patterns (when linked shapes are provided) * - oneOf/xone branches -> UNION branches * - * Not represented on purpose: + * Not supported (yet): * - closed/open world semantics * - negative predicates */ diff --git a/package-lock.json b/package-lock.json index d2decb9..ea6e209 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1305,16 +1305,16 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/buffer": { @@ -2304,9 +2304,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -2441,9 +2441,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -2461,7 +2461,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/roadmap.md b/roadmap.md new file mode 100644 index 0000000..65479f6 --- /dev/null +++ b/roadmap.md @@ -0,0 +1,84 @@ +1. [VALUES](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) / [FILTER IN](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) vs `sh:in` + +- SPARQL side: explicit finite allowed values. +- SHACL side: enumeration constraint. +- This is a very natural implication check: query allowed-set should be compatible with shape allowed-set. + +2. Constant object patterns vs `sh:hasValue` + +- SPARQL: [?s ex:p ex:Foo](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) or [?s ex:p "x"](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html). +- SHACL: property must contain a specific value. +- This is stricter than plain datatype/class matching and often easier to reason about. + +3. [FILTER(?x = ?y)](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) / `sameTerm` vs cross-property equality constraints + +- SHACL has [sh:equals](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html). +- This compares relational constraints between two properties, not just one property in isolation. + +4. [FILTER(?x != ?y)](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) or exclusion sets vs `sh:disjoint` + +- Similar idea, but for incompatibility between two properties. + +5. Numeric comparisons vs `sh:lessThan` / `sh:lessThanOrEquals` + +- These are cross-property numeric/order constraints. +- They matter when the query relates two variables rather than constraining one value against a constant. + +6. [FILTER isIRI / isLiteral / isBlank](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) vs `sh:nodeKind` + +- This is a clean semantic pairing: + - `isIRI` ↔ IRI node kind + - `isLiteral` ↔ literal node kind + - `isBlank` ↔ blank node kind +- This would strengthen class/datatype reasoning substantially. + +7. String functions vs string facets + +- SPARQL: `STRSTARTS`, `STRENDS`, `CONTAINS`, `LCASE/UCASE`, `STRLEN` +- SHACL: `sh:minLength`, `sh:maxLength`, [sh:pattern](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html), `sh:languageIn`, `sh:uniqueLang` +- Some implications are straightforward, others need conservative reasoning. + +8. Language filters vs `sh:languageIn` + +- SPARQL: `lang(?x) = "en"` or `langMatches(...)` +- SHACL: language whitelist. +- This is one of the clearest missing literal-comparison axes. + +9. Property-path structure vs SHACL path structure + +- SPARQL already has sequence, inverse, alternative, cardinality paths in [query.ts](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html). +- SHACL also has richer path forms. +- Comparing just the terminal predicate is weaker than comparing actual path semantics. + +10. `OPTIONAL` vs relaxed cardinality / optionality + +- A query optional triple is not the same as a shape optional property, but there is a real compatibility relation there. +- Especially useful when deciding contained vs merely aligned. + +11. [UNION](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) / branch structure vs [sh:or](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html), [sh:xone](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html), `sh:and` + +- [or](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) is already the obvious pairing. +- [xone](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) and `and` could be reasoned about more precisely than “best-effort branch compatibility”. + +12. Negation constructs vs negative shape constraints + +- SPARQL: [FILTER NOT EXISTS](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html), `MINUS`, negated property sets +- SHACL: [sh:not](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html), forbidden properties, disjointness-style constraints +- This is harder, but it is the real way to represent absence constraints rather than dropping them in [shapeToQuery](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html). + +13. Closed shape semantics vs query predicate completeness + +- SHACL [closed=true](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) says extra predicates are disallowed. +- On the query side, you can ask whether the query’s mentioned predicates are exhaustive or only partial. +- This matters for containment classification, not just local predicate matching. + +14. Conditional existence vs qualified constraints + +- SPARQL can express “if there exists X then ...” via `EXISTS`/`NOT EXISTS`. +- SHACL has `qualifiedValueShape`, `qualifiedMinCount`, `qualifiedMaxCount`. +- This is a strong next step for nested-shape reasoning. + +15. Count-like query structure vs cardinality families + +- Even without full aggregates, repeated triple patterns and path multiplicities can sometimes imply minimum multiplicity. +- SHACL cardinalities could be compared against that more aggressively. \ No newline at end of file From b0af51ce39b1e8a9768beddecb444ea49495c9a7 Mon Sep 17 00:00:00 2001 From: julianrojas87 Date: Mon, 21 Sep 2026 12:08:37 +0200 Subject: [PATCH 03/22] feat!: report an aggregate result and fix classifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a query-level result to the containment report and corrects three cases where the classification did not match the semantics of the input. Aggregate result - `solveShapeQueryContainment` and `solveShapeShapeContainment` now expose `report.result`, the least favorable result among the star patterns. It is `CONTAINED` only when every star pattern is. Explicit negation (sh:not / ShEx negative triple constraints) A predicate declared under `sh:not` was stored in the same predicate map as the positive ones, so a query asking for exactly the forbidden predicate bound against it and reported `CONTAINED` — the opposite of what the shape states. - Negative predicates never yield a match. - A shape that forbids a required predicate is definitive evidence of incompatibility, so it no longer counts as the open shape that softens `REJECTED` into `WEAKLY_REJECTED`. Such a query is now `REJECTED` even when the shape is open. OPTIONAL patterns Full binding was computed differently for open and closed shapes: open shapes required every triple pattern to bind, closed shapes only the non-optional ones. The same query against the same shape therefore returned `ALIGNED` when the shape was open and `CONTAINED` once `sh:closed true` was added — closing a shape made it look more relevant. - One rule for both: every *required* triple pattern must bind, and at least one triple pattern must bind. An unmatched OPTIONAL never blocks containment, since a resource can answer the query without it. MINUS and SERVICE Neither had an algebra handler, so their triple patterns were collected as ordinary required patterns. A MINUS block could lower `CONTAINED` to `UNALIGNED`, and a SERVICE clause could raise `REJECTED` to `UNALIGNED` — the result moved in both directions on patterns the resource is not expected to hold. - Both are discarded during normalization; MINUS keeps its left operand. - Discarded constructs are listed in `IQuery.unsupported` and surfaced on `IResult.unsupported`. Discarding only removes constraints, so the result can over-estimate relevance but never classifies a relevant resource lower. Queries with no triple pattern - An unrestricted triple pattern (`?s ?p ?o`) asks for every triple of the resource. It yields no star pattern, but it is not a discarded construct: the input is `CONTAINED` by any shape and every shape is marked visitable, flagged by `IQuery.matchesAnyTriple`. - An input with no triple pattern at all — an empty `WHERE`, a body of only `VALUES` or `FILTER`, or one left empty after discarding — never asks the resource for data, so relevance is undefined. It previously returned `CONTAINED`, ranking every resource maximally relevant on no evidence, and now throws `EmptyQueryError`. Docs - README: aggregate result documented, examples reorganised around the SPARQL-to-SHACL and shape-to-shape entry points, containment results illustrated with FILTER and constraint cases. - roadmap: record SHACL target support (sh:targetClass, sh:targetNode, sh:targetSubjectsOf, sh:targetObjectsOf) as future work. BREAKING CHANGE: `solveShapeQueryContainment` throws `EmptyQueryError` for an input that normalizes to no triple pattern, where it previously returned `CONTAINED`. Inputs using OPTIONAL, MINUS, SERVICE or `sh:not` may be assigned a different result than before. Implementors of `IBindings` must add `hasNegativeContradiction()`. --- README.md | 172 ++++++++++++++++++++++----------------- lib/Binding.ts | 58 +++++++++++-- lib/containment.ts | 88 ++++++++++++++++++-- lib/query.ts | 87 ++++++++++++++++++-- roadmap.md | 4 +- test/containment.test.ts | 160 ++++++++++++++++++++++++++++++++---- 6 files changed, 457 insertions(+), 112 deletions(-) diff --git a/README.md b/README.md index e88b1b5..ace07b2 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,9 @@ npm install query-shape-detection yarn add query-shape-detection ``` -## Example Code +## Code examples -### Simple SHACL Example +### SPARQL-to-SHACL Example ```ts import { @@ -69,74 +69,88 @@ const report = solveShapeQueryContainment({ shapes: [personShape], }); -console.log(report.starPatternsContainment.get("person")); -``` - -### Simple ShEx Example - -```ts -import { shexShapeFromQuads } from 'query-shape-detection'; +// 4. Read the global result for the complete query. +console.log(report.result); // ContainmentResult.CONTAINED -// Parsing a ShEx shape follows the same pattern -const shexQuads = /* RDF quads from ShEx definition */; -const personShape = await shexShapeFromQuads(shexQuads, "http://example.org/PersonShape"); +// 5. Inspect how the global result was obtained by checking every query star pattern. +for (const [starPatternName, containment] of report.starPatternsContainment) { + console.log(starPatternName); // "person" + console.log(containment.result); // ContainmentResult.CONTAINED + console.log(containment.target); // ["http://example.org/PersonShape"] + console.log(containment.bindings); // Detailed predicate/dependency matches +} ``` +`report.result` is the aggregate result for the complete query. It is +`CONTAINED` only when every star pattern is also `CONTAINED`; otherwise it reports the most restrictive result among the star patterns. + ### Shape-to-Shape Example You can compare a source shape against candidate target shapes directly using -`solveShapeShapeContainment`. Internally, this uses a best-effort shape-to-query -translation and then reuses the existing query-to-shape containment engine. +`solveShapeShapeContainment`. Internally, this uses a shape-to-query translation and then reuses the existing query-to-shape containment engine. ```ts import { - Shape, - ConstraintType, + shaclShapeFromQuads, solveShapeShapeContainment, } from 'query-shape-detection'; +import * as N3 from 'n3'; -const sourceShape = new Shape({ - name: 'https://www.example.ca/source', - positivePredicates: [ - { - name: 'https://www.example.ca/age', - constraint: { - type: ConstraintType.DATATYPE, - value: new Set(['http://www.w3.org/2001/XMLSchema#integer']), - minInclusive: 18, - maxInclusive: 35, - }, - }, - ], - closed: true, -}); +const sourceShapeRaw = ` + @prefix sh: . + @prefix ex: . + @prefix xsd: . -const targetShape = new Shape({ - name: 'https://www.example.ca/target', - positivePredicates: [ - { - name: 'https://www.example.ca/age', - constraint: { - type: ConstraintType.DATATYPE, - value: new Set(['http://www.w3.org/2001/XMLSchema#integer']), - minInclusive: 10, - maxInclusive: 40, - }, - }, - ], - closed: true, -}); + ex:SourceShape a sh:NodeShape ; + sh:closed true ; + sh:property [ + sh:path ex:age ; + sh:datatype xsd:integer ; + sh:minInclusive 18 ; + sh:maxInclusive 35 + ] . +`; + +const targetShapeRaw = ` + @prefix sh: . + @prefix ex: . + @prefix xsd: . + + ex:TargetShape a sh:NodeShape ; + sh:closed true ; + sh:property [ + sh:path ex:age ; + sh:datatype xsd:integer ; + sh:minInclusive 10 ; + sh:maxInclusive 40 + ] . +`; + +const sourceShape = await shaclShapeFromQuads( + new N3.Parser().parse(sourceShapeRaw), + 'https://www.example.org/SourceShape', +); +const targetShape = await shaclShapeFromQuads( + new N3.Parser().parse(targetShapeRaw), + 'https://www.example.org/TargetShape', +); const report = solveShapeShapeContainment({ sourceShape, targetShapes: [targetShape], }); -console.log(report.starPatternsContainment.get(sourceShape.name)); -``` +// Read the global result for the complete source-shape/target-shape comparison. +console.log(report.result); // ContainmentResult.CONTAINED -For source shapes with nested `sh:node` / shape-link constraints, pass their linked -definitions in `sourceLinkedShapes`. +// Inspect the individual result for every star pattern generated from the source shape. +for (const [starPatternName, containment] of report.starPatternsContainment) { + console.log(starPatternName); + console.log(containment.result); // ContainmentResult.CONTAINED + console.log(containment.target); // ["https://www.example.org/TargetShape"] + console.log(containment.bindings); // Detailed predicate/dependency matches +} +``` ## Containment Results @@ -208,9 +222,7 @@ Another `CONTAINED` case combines a FILTER expression with compatible shape cons ``` Containment decisions do not use runtime FILTER truth values directly. -Instead, FILTER expressions are only used when they can be checked against shape constraints. -For example, a numeric comparison like `?age > 18` is compatible with an `xsd:integer` constraint, -but can contradict a non-numeric datatype constraint. +Instead, FILTER expressions are only used when they can be checked against shape constraints. For example, a numeric comparison like `?age > 18` is compatible with an `xsd:integer` constraint, but can contradict a non-numeric datatype constraint. #### 2. `ALIGNED` @@ -312,17 +324,19 @@ Another `UNALINGED` case is when the root star pattern does not match, but a nes sh:property [ sh:path foaf:name ] . ``` -Another `UNALINGED` case with FILTER and constraints: +Another `UNALINGED` case occurs when the root pattern does not match, but an +optional nested star pattern reachable through a linked variable does. * **Query**: ```sparql - PREFIX ex: - PREFIX xsd: + PREFIX ex: + PREFIX foaf: SELECT * WHERE { - ?person ex:age ?age ; - ex:status ?status . - FILTER(?age > 18) + ?person ex:unknownLink ?friend . + OPTIONAL { + ?friend foaf:name ?friendName . + } } ``` @@ -330,17 +344,23 @@ Another `UNALINGED` case with FILTER and constraints: ```turtle @prefix sh: . - @prefix xsd: . + @prefix ex: . - a sh:NodeShape ; + ex:PersonShape a sh:NodeShape ; sh:closed true ; sh:property [ - sh:path ; - sh:datatype xsd:integer + sh:path ex:knows ; + sh:node ex:FriendShape ] . + + ex:FriendShape a sh:NodeShape ; + sh:closed true ; + sh:property [ sh:path foaf:name ] . ``` -On a closed shape, `ex:age` matches but `ex:status` does not, so containment is partial (`UNALINGED`) even though the FILTER is constraint-compatible. +The required root `ex:unknownLink` pattern does not match the shape, but the +optional nested `foaf:name` pattern matches `ex:FriendShape`. The root result is +therefore `UNALINGED` because only a nested star pattern is aligned. #### 4. `WEAKLY_REJECTED` @@ -362,15 +382,18 @@ No triple pattern matches and at least one candidate shape is open. sh:property [ sh:path foaf:name ] . ``` -Another `WEAKLY_REJECTED` case with FILTER and constraints: +Another `WEAKLY_REJECTED` case has only optional predicates, none of which are +defined by an open shape: * **Query**: ```sparql - PREFIX ex: + PREFIX ex: SELECT * WHERE { - ?person ex:age ?age . - FILTER(?age > 18) + OPTIONAL { + ?person ex:email ?email ; + ex:phone ?phone . + } } ``` @@ -378,20 +401,21 @@ Another `WEAKLY_REJECTED` case with FILTER and constraints: ```turtle @prefix sh: . - @prefix xsd: . + @prefix ex: . - a sh:NodeShape ; + ex:PersonShape a sh:NodeShape ; sh:property [ - sh:path ; - sh:datatype xsd:string + sh:path ex:name ] . ``` -The numeric FILTER contradicts the string datatype constraint. Because the shape is open, the result is `WEAKLY_REJECTED`. +Neither optional predicate is defined by the open shape, so no triple pattern +matches. Because the shape is open, the result is `WEAKLY_REJECTED` rather than +`REJECTED`. #### 5. `REJECTED` -The query uses `schema:birthDate`, but the shape only defines `foaf:name`. +The query uses `schema:birthDate`, but the closed shape only defines `foaf:name`. * **Query**: diff --git a/lib/Binding.ts b/lib/Binding.ts index 139dcc5..4c622fa 100644 --- a/lib/Binding.ts +++ b/lib/Binding.ts @@ -25,7 +25,14 @@ export interface IBindings { */ shouldVisitShape: () => boolean; /** - * + * Indicate that the shape explicitly forbids a predicate required by the star pattern + * (SHACL sh:not / ShEx negative triple constraint). This is definitive evidence of + * incompatibility, independently of whether the shape is closed. + * @returns {boolean} whether the shape contradicts a required triple pattern + */ + hasNegativeContradiction: () => boolean; + /** + * * Return the unbounded triples * @returns {ITriple[]} The binded triples */ @@ -85,6 +92,7 @@ export class Bindings implements IBindings { private shapePredicateBind = new Map(); private strict: boolean; private allOptional = true; + private negativeContradiction = false; private typeOfContainment: IContainmentType = { result: ContainmentType.NONE, unContaineStarPattern: [] }; private alreadyTraversed: Map; public readonly starPattern: IStarPatternWithDependencies; @@ -119,7 +127,11 @@ export class Bindings implements IBindings { negatedTriples.push(triple); continue; } - const singlePredicate = shape.get(triple.predicate); + const declaredPredicate = shape.get(triple.predicate); + // A predicate declared under sh:not (ShEx: a negative triple constraint) states that + // conforming data must NOT carry it, so it can never satisfy a triple pattern. + const negatedByShape = declaredPredicate?.negative === true; + const singlePredicate = negatedByShape ? undefined : declaredPredicate; let predicates: IPredicate[] = []; // check if the triple match a disjunction for (const oneOfBinding of this.oneOfs) { @@ -130,9 +142,12 @@ export class Bindings implements IBindings { } if (singlePredicate === undefined && predicates.length === 0) { - // Open shapes can ignore unknown predicates, but closed shapes keep - // tracking unmatched non-optional predicates. - if (this.closedShape && triple.isOptional !== true && !this.strict) { + // A shape that explicitly forbids the predicate contradicts the triple pattern + // whether or not it is closed; an open shape merely stays silent about unknown ones. + if (negatedByShape && triple.isOptional !== true && !this.strict) { + this.negativeContradiction = true; + } + if ((negatedByShape || this.closedShape) && triple.isOptional !== true && !this.strict) { this.unboundTriple.push(triple); } continue; @@ -181,9 +196,7 @@ export class Bindings implements IBindings { } } } - this.fullyBounded = this.getBoundTriple().length === starPattern.starPattern.size - && starPattern.starPattern.size !== 0 - && boundedUnion; + this.fullyBounded = this.isEveryRequiredTripleBound(starPattern) && boundedUnion; } else { let boundedUnion = true; for (const unionBinding of this.unionBindings) { @@ -198,7 +211,7 @@ export class Bindings implements IBindings { } } } - this.fullyBounded = this.unboundTriple.length === 0 && starPattern.starPattern.size !== 0 && boundedUnion; + this.fullyBounded = this.isEveryRequiredTripleBound(starPattern) && boundedUnion; } if (this.fullyBounded) { const cycle = new Set(); @@ -239,6 +252,29 @@ export class Bindings implements IBindings { } } + /** + * A star pattern is fully bound when every *required* triple pattern binds. + * OPTIONAL triple patterns (and shape properties with sh:minCount 0) are not needed to answer + * the query, so they never block containment — this rule is the same for open and closed shapes. + * At least one triple must bind, so a star pattern made only of unmatched optional patterns + * is not considered contained. + */ + private isEveryRequiredTripleBound(starPattern: IStarPatternWithDependencies): boolean { + if (starPattern.starPattern.size === 0) { + return false; + } + let boundCount = 0; + for (const { triple } of starPattern.starPattern.values()) { + const isBound = this.bindings.get(triple.predicate) !== undefined; + if (isBound) { + boundCount++; + } else if (triple.isOptional !== true) { + return false; + } + } + return boundCount > 0; + } + private evaluateConstraint(predicates: IPredicate[], triple: ITriple, linkedShape: Map, @@ -495,6 +531,10 @@ export class Bindings implements IBindings { return this.getBoundTriple().length > 0; } + public hasNegativeContradiction(): boolean { + return this.negativeContradiction; + } + public getNestedContainedStarPatternName(): IDependentStarPattern[] { return this.nestedContainedStarPatternName; } diff --git a/lib/containment.ts b/lib/containment.ts index 84e2f7b..d1d303e 100644 --- a/lib/containment.ts +++ b/lib/containment.ts @@ -1,5 +1,5 @@ import { Bindings, ContainmentType, IBindings } from './Binding' -import { generateStarPatternUnion, shapeToQuery, type IQuery } from './query'; +import { generateStarPatternUnion, shapeToQuery, type IQuery, type UnsupportedConstruct } from './query'; import { ConstraintType, IConstraint, IShape } from './Shape'; import type { IStarPatternWithDependencies } from './Triple'; import type { Term } from '@rdfjs/types'; @@ -12,6 +12,20 @@ import type { Term } from '@rdfjs/types'; * @returns {IResult} result relative to the containement of the query inside of the shape */ export function solveShapeQueryContainment({ query, shapes, decidingShapes }: IContainementArg): IResult { + if (query.starPatterns.size === 0) { + // A query whose only triple patterns are unrestricted (?s ?p ?o) asks for every triple of the + // resource, so every resource is relevant and every shape is worth visiting. That is a real + // answer, not a failure: the query is maximally unselective, which is a property of the query. + if (query.matchesAnyTriple === true) { + return buildMatchesAnyTripleResult(shapes, query.unsupported); + } + // A query with no triple pattern at all (an empty WHERE, VALUES/FILTER only, or a body made + // solely of discarded constructs) never asks the resource for data, so relevance is undefined. + throw new EmptyQueryError( + 'the normalized query has no triple pattern, so no relevance degree can be assigned' + + (query.unsupported !== undefined ? `; discarded constructs: ${query.unsupported.join(', ')}` : ''), + ); + } const bindingResult = new Map>(); const starPatternsContainment = new Map(); const classificationStats = new Map(); @@ -71,11 +85,41 @@ export function solveShapeQueryContainment({ query, shapes, decidingShapes }: IC starPatternsContainment.set(starPatternName, currentResult); } - return { + const report: Omit = { starPatternsContainment, - visitShapeBoundedResource: generateVisitStatus(bindingResult, shapes) + visitShapeBoundedResource: generateVisitStatus(bindingResult, shapes), + ...(query.unsupported !== undefined ? { unsupported: query.unsupported } : {}), }; + Object.defineProperty(report, 'result', { + value: getQueryContainmentResult(starPatternsContainment), + enumerable: false, + writable: false, + }); + + return report as IResult; + +} + +/** + * Report for a query that asks for every triple of a resource: contained by any shape, and every + * shape is visitable. Marking the shapes visitable matters — a CONTAINED result whose visit + * indications were all false would tell a client to fetch nothing for a query that wants everything. + */ +function buildMatchesAnyTripleResult(shapes: IShape[], unsupported?: UnsupportedConstruct[]): IResult { + const report: Omit = { + starPatternsContainment: new Map(), + visitShapeBoundedResource: new Map(shapes.map(shape => [shape.name, true])), + ...(unsupported !== undefined ? { unsupported } : {}), + }; + + Object.defineProperty(report, 'result', { + value: ContainmentResult.CONTAINED, + enumerable: false, + writable: false, + }); + + return report as IResult; } export interface IShapeContainmentArg { @@ -111,13 +155,16 @@ function updateContainmentStats( } const stats = classificationStats.get(starPatternName)!; - if (shape.closed) { + // A shape that explicitly forbids a required predicate (sh:not) proves incompatibility, so it + // must not count as the "open shape" that would soften REJECTED into WEAKLY_REJECTED. + const contradicts = bindings.hasNegativeContradiction(); + if (shape.closed || contradicts) { stats.hasClosedShape = true; } else { stats.hasOpenShape = true; } - if (!filterCompatible) { + if (!filterCompatible || contradicts) { return; } @@ -951,10 +998,29 @@ export type ShapeName = string; * The result of the alignment */ export interface IResult { + // The aggregate containment result for the complete query or source shape. + result: ContainmentResult; // The documents associated with a shape that can be followed visitShapeBoundedResource: Map; // The type of containment of each star patterns with there associated shapes starPatternsContainment: Map; + /** + * Constructs of the input that were discarded because they are outside the supported profile. + * When non-empty, the result was computed over a sub-query of the given query and therefore + * over-estimates relevance. + */ + unsupported?: UnsupportedConstruct[]; +} + +/** + * Raised when the normalized input has no star pattern, so there is no constraint to assess. + * Returning a relevance degree in that case would rank every resource on no evidence at all. + */ +export class EmptyQueryError extends Error { + public constructor(message: string) { + super(message); + this.name = 'EmptyQueryError'; + } } /** @@ -989,3 +1055,15 @@ export enum ContainmentResult { // No triple matches and all candidate shapes are closed. REJECTED, } + +function getQueryContainmentResult( + starPatternsContainment: Map, +): ContainmentResult { + let queryResult = ContainmentResult.CONTAINED; + + for (const { result } of starPatternsContainment.values()) { + queryResult = Math.max(queryResult, result); + } + + return queryResult; +} diff --git a/lib/query.ts b/lib/query.ts index c070e87..1609de6 100644 --- a/lib/query.ts +++ b/lib/query.ts @@ -25,6 +25,9 @@ type NpsPath = Omit & { interface IAccumulatedTriples { triples: Map, isVariable: boolean } +/** Facts about the input discovered while walking the algebra, not carried by the star patterns. */ +interface INormalizationState { matchesAnyTriple: boolean } + /** * A query divided into star patterns */ @@ -36,8 +39,26 @@ export interface IQuery { filters?: Algebra.Expression[]; // VALUES bindings indexed by variable name values?: Map; + /** + * SPARQL constructs that were discarded during normalization because they are outside the + * supported profile. Discarding them can only over-estimate relevance, never discard a + * relevant resource, but it means the result approximates a sub-query of the given query. + */ + unsupported?: UnsupportedConstruct[]; + /** + * True when the query contained an unrestricted triple pattern (a variable predicate, as in + * `?s ?p ?o`). Such a pattern constrains nothing beyond "the resource holds triples", so it is + * not turned into a star pattern, but it is not a discarded construct either: it asks for every + * triple, which any resource can answer. + */ + matchesAnyTriple?: boolean; } +/** + * A SPARQL construct outside the supported profile, discarded during normalization. + */ +export type UnsupportedConstruct = 'MINUS' | 'SERVICE'; + export interface IShapeToQueryOptions { linkedShapes?: IShape[] | Map; } @@ -381,10 +402,12 @@ export function generateQuery(algebraQuery: Algebra.Operation, optional?: boolea const accumulatedValues = new Map(); const accumulatedUnion: IQuery[][] = []; const accumulatedFilters: Algebra.Expression[] = []; + const accumulatedUnsupported = new Set(); + const state: INormalizationState = { matchesAnyTriple: false }; - QueryHandler.collectFromAlgebra(algebraQuery, accumulatedTriples, accumulatedValues, accumulatedUnion, accumulatedFilters, optional); + QueryHandler.collectFromAlgebra(algebraQuery, accumulatedTriples, accumulatedValues, accumulatedUnion, accumulatedFilters, optional, accumulatedUnsupported, state); - return buildQuery(accumulatedTriples, accumulatedValues, accumulatedUnion, accumulatedFilters); + return buildQuery(accumulatedTriples, accumulatedValues, accumulatedUnion, accumulatedFilters, accumulatedUnsupported, state); } /** @@ -395,9 +418,17 @@ function buildQuery( values: Map, accumulatedUnion: IQuery[][], filters: Algebra.Expression[] = [], + unsupported: Set = new Set(), + state: INormalizationState = { matchesAnyTriple: false }, ): IQuery { const innerQuery = new Map(); const resp: IQuery = { starPatterns: innerQuery }; + if (unsupported.size > 0) { + resp.unsupported = Array.from(unsupported); + } + if (state.matchesAnyTriple) { + resp.matchesAnyTriple = true; + } if (accumulatedUnion.length > 0) { resp.union = accumulatedUnion; } @@ -536,14 +567,27 @@ namespace QueryHandler { accumulatedValues: Map, accumulatedUnion: IQuery[][], accumulatedFilters: Algebra.Expression[], - optional?: boolean + optional?: boolean, + unsupported: Set = new Set(), + state: INormalizationState = { matchesAnyTriple: false }, ): void { algebraUtils.visitOperation( rootAlgebra, { [Algebra.Types.PATTERN]: { preVisitor: () => ({ continue: false }), - visitor: handlePattern(accumulatedTriples, optional), + visitor: handlePattern(accumulatedTriples, optional, () => { state.matchesAnyTriple = true; }), + }, + // MINUS removes solutions and SERVICE evaluates elsewhere, so neither describes data the + // candidate resource must hold. Their triple patterns must not be collected as required + // patterns: doing so both inflates and deflates relevance. + [Algebra.Types.MINUS]: { + preVisitor: () => ({ continue: false }), + visitor: handleMinus(accumulatedTriples, accumulatedValues, accumulatedFilters, accumulatedUnion, unsupported, state, optional), + }, + [Algebra.Types.SERVICE]: { + preVisitor: () => ({ continue: false }), + visitor: (): void => { unsupported.add('SERVICE'); }, }, [Algebra.Types.VALUES]: { preVisitor: () => ({ continue: false }), @@ -555,7 +599,7 @@ namespace QueryHandler { }, [Algebra.Types.LEFT_JOIN]: { preVisitor: () => ({ continue: false }), - visitor: handleLeftJoin(accumulatedTriples, accumulatedValues, accumulatedFilters, accumulatedUnion), + visitor: handleLeftJoin(accumulatedTriples, accumulatedValues, accumulatedFilters, accumulatedUnion, unsupported, state), }, [Algebra.Types.PATH]: { preVisitor: () => ({ continue: false }), @@ -571,20 +615,39 @@ namespace QueryHandler { ); } + /** + * Keep the left operand of a MINUS and discard the right one: the removed patterns describe + * solutions to exclude, not data the candidate resource has to hold. + */ + function handleMinus(accumulatedTriples: Map, + accumulatedValues: Map, + accumulatedFilters: Algebra.Expression[], + accumulatedUnion: IQuery[][], + unsupported: Set, + state: INormalizationState, + optional?: boolean): (element: Algebra.Minus) => void { + return (element: Algebra.Minus): void => { + unsupported.add('MINUS'); + collectFromAlgebra(element.input[0], accumulatedTriples, accumulatedValues, accumulatedUnion, accumulatedFilters, optional, unsupported, state); + } + } + /** * Split a LEFT JOIN into required and optional branches and collect both with the right optional flag. */ function handleLeftJoin(accumulatedTriples: Map, accumulatedValues: Map, accumulatedFilters: Algebra.Expression[], - accumulatedUnion: IQuery[][]): (element: Algebra.LeftJoin) => void { + accumulatedUnion: IQuery[][], + unsupported: Set, + state: INormalizationState): (element: Algebra.LeftJoin) => void { return (element: Algebra.LeftJoin): void => { const joinElement = element.input; const requiredElements = joinElement[0]; const optionalElements = joinElement[1]; - collectFromAlgebra(requiredElements, accumulatedTriples, accumulatedValues, accumulatedUnion, accumulatedFilters); - collectFromAlgebra(optionalElements, accumulatedTriples, accumulatedValues, accumulatedUnion, accumulatedFilters, true); + collectFromAlgebra(requiredElements, accumulatedTriples, accumulatedValues, accumulatedUnion, accumulatedFilters, undefined, unsupported, state); + collectFromAlgebra(optionalElements, accumulatedTriples, accumulatedValues, accumulatedUnion, accumulatedFilters, true, unsupported, state); } } @@ -666,12 +729,18 @@ namespace QueryHandler { */ function handlePattern( accumulatedTriples: Map, - optional?: boolean + optional?: boolean, + wildcard?: () => void, ): (element: Algebra.Pattern) => void { return (quad: Algebra.Pattern): void => { const subject = quad.subject as Term; const predicate = quad.predicate as Term; const object = quad.object as Term; + if (predicate.termType !== 'NamedNode') { + // Shapes constrain named predicates, so an unrestricted pattern yields no star pattern. + // It still asks for every triple of the resource, which is recorded separately. + wildcard?.(); + } if (predicate.termType === 'NamedNode') { const startPattern = accumulatedTriples.get(subject.value); const triple: ITriple = new Triple({ diff --git a/roadmap.md b/roadmap.md index 65479f6..53e201a 100644 --- a/roadmap.md +++ b/roadmap.md @@ -81,4 +81,6 @@ 15. Count-like query structure vs cardinality families - Even without full aggregates, repeated triple patterns and path multiplicities can sometimes imply minimum multiplicity. -- SHACL cardinalities could be compared against that more aggressively. \ No newline at end of file +- SHACL cardinalities could be compared against that more aggressively. + +16. Add support for SHACL targets (sh:targetClass, sh:targetNode, sh:targetSubjectsOf, sh:targetObjectsOf) \ No newline at end of file diff --git a/test/containment.test.ts b/test/containment.test.ts index f5ac033..0199527 100644 --- a/test/containment.test.ts +++ b/test/containment.test.ts @@ -3,7 +3,7 @@ import { toAlgebra } from '@traqula/algebra-sparql-1-1'; import { describe, expect, it, test } from 'vitest'; import { ConstraintType, IShape, Shape } from '../lib/Shape'; import { IStarPatternWithDependencies, Triple } from '../lib/Triple'; -import { ContainmentResult, IContainmentResult, IResult, StarPatternName, solveShapeQueryContainment, solveShapeShapeContainment } from '../lib/containment'; +import { ContainmentResult, EmptyQueryError, IContainmentResult, IResult as ContainmentReport, StarPatternName, solveShapeQueryContainment, solveShapeShapeContainment } from '../lib/containment'; import { DataFactory } from 'rdf-data-factory'; import { BaseQuad } from '@rdfjs/types'; import { IQuery, generateQuery } from '../lib/query'; @@ -18,6 +18,7 @@ import { shaclShapeFromQuads } from '../lib/shacl'; const DF = new DataFactory(); const n3Parser = new N3.Parser(); const sparqlParser = new SPARQLParser(); +type IResult = Omit; describe('solveShapeQueryContainment', () => { @@ -137,32 +138,36 @@ describe('solveShapeQueryContainment', () => { * General tests cases * *******************************************************************/ - it('should return an empty result given an empty query and no shape', () => { + it('should throw given an empty query and no shape', () => { const query: IQuery = { starPatterns: new Map() }; const shapes: IShape[] = []; - const expectedResult: IResult = { - - starPatternsContainment: new Map(), - visitShapeBoundedResource: new Map() - }; - expect(solveShapeQueryContainment({ query, shapes })).toStrictEqual(expectedResult); + // An empty query states no constraint, so no relevance degree can be assigned. + // Reporting CONTAINED here would rank every resource maximally relevant on no evidence. + expect(() => solveShapeQueryContainment({ query, shapes })).toThrow(EmptyQueryError); }); - it('should reject every shape given an empty query', () => { + it('should throw given an empty query and candidate shapes', () => { const query: IQuery = { starPatterns: new Map() }; const shapes: IShape[] = [shape, shapeP1, shapeP2, shapeP3, shapeP4, shapeP5]; - const expectedResult: IResult = { - starPatternsContainment: new Map(), - visitShapeBoundedResource: new Map(shapes.map((shape) => [shape.name, false])) + expect(() => solveShapeQueryContainment({ query, shapes })).toThrow(EmptyQueryError); + }); + + it('should report the discarded constructs when a query falls outside the profile', () => { + const query: IQuery = { + starPatterns: new Map([ + ["x", generateZStarPattern()] + ]), + unsupported: ['MINUS'] }; + const shapes: IShape[] = [shape]; - expect(solveShapeQueryContainment({ query, shapes })).toStrictEqual(expectedResult); + expect(solveShapeQueryContainment({ query, shapes }).unsupported).toStrictEqual(['MINUS']); }); it('should return an empty result given no shape', () => { @@ -896,6 +901,7 @@ describe('solveShapeQueryContainment', () => { expect(resp.starPatternsContainment.get("w")?.result).toBe(ContainmentResult.CONTAINED); expect(resp.starPatternsContainment.get("w1")?.result).toBe(ContainmentResult.CONTAINED); expect(resp.starPatternsContainment.get("w2")?.result).toBe(ContainmentResult.CONTAINED); + expect(resp.result).toBe(ContainmentResult.CONTAINED); expect(resp.visitShapeBoundedResource).toStrictEqual(new Map([ [shape.name, true], @@ -933,7 +939,9 @@ describe('solveShapeQueryContainment', () => { ]) }; - expect(solveShapeQueryContainment({ query, shapes })).toStrictEqual(expectedResult); + const result = solveShapeQueryContainment({ query, shapes }); + expect(result).toStrictEqual(expectedResult); + expect(result.result).toBe(ContainmentResult.UNALINGED); }); @@ -1058,6 +1066,7 @@ describe('solveShapeQueryContainment', () => { }); expect(result.starPatternsContainment.get(sourceShape.name)?.result).toBe(ContainmentResult.CONTAINED); + expect(result.result).toBe(ContainmentResult.CONTAINED); }); it('should support shape-to-shape containment with source linked shapes', () => { @@ -1126,6 +1135,7 @@ describe('solveShapeQueryContainment', () => { }); expect(result.starPatternsContainment.get(sourceShape.name)?.result).toBe(ContainmentResult.CONTAINED); + expect(result.result).toBe(ContainmentResult.CONTAINED); }); function generateMatchingQuery(): IQuery { @@ -1869,3 +1879,125 @@ describe('solveShapeQueryContainment', () => { }) }); + +describe('supported profile and explicit negation', () => { + const P = `PREFIX foaf: + PREFIX ex: `; + + async function classify(rawQuery: string, rawShape: string): Promise { + const query = generateQuery(toAlgebra(new SPARQLParser().parse(`${P} ${rawQuery}`))); + const candidate = await shaclShapeFromQuads(new N3.Parser().parse(rawShape), 'http://example.org/S'); + return solveShapeQueryContainment({ query, shapes: [candidate] }).result; + } + + const SHAPE_PREFIXES = `@prefix sh: . + @prefix foaf: . + @prefix ex: .`; + + const openNameShape = `${SHAPE_PREFIXES} + a sh:NodeShape ; + sh:property [ sh:path foaf:name ] .`; + + const closedNameShape = `${SHAPE_PREFIXES} + a sh:NodeShape ; + sh:closed true ; + sh:property [ sh:path foaf:name ] .`; + + describe('OPTIONAL patterns', () => { + const query = 'SELECT * WHERE { ?s foaf:name ?n . OPTIONAL { ?s ex:age ?a } }'; + + it('should not let an unmatched OPTIONAL pattern block containment on an open shape', async () => { + expect(await classify(query, openNameShape)).toBe(ContainmentResult.CONTAINED); + }); + + it('should classify the same query identically on the closed counterpart of the shape', async () => { + expect(await classify(query, closedNameShape)).toBe(ContainmentResult.CONTAINED); + }); + + it('should not consider a star pattern of only unmatched OPTIONAL patterns contained', async () => { + expect(await classify('SELECT * WHERE { OPTIONAL { ?s ex:email ?e } }', openNameShape)) + .toBe(ContainmentResult.WEAKLY_REJECTED); + }); + }); + + describe('discarded constructs', () => { + it('should not let a MINUS block lower the result', async () => { + expect(await classify('SELECT * WHERE { ?s foaf:name ?n . MINUS { ?s ex:age ?a } }', closedNameShape)) + .toBe(ContainmentResult.CONTAINED); + }); + + it('should not let a MINUS block raise the result', async () => { + expect(await classify('SELECT * WHERE { ?s ex:unknown ?x . MINUS { ?s foaf:name ?n } }', closedNameShape)) + .toBe(ContainmentResult.REJECTED); + }); + + it('should not let a SERVICE clause raise the result', async () => { + expect(await classify('SELECT * WHERE { ?s ex:unknown ?x . SERVICE { ?s foaf:name ?n } }', closedNameShape)) + .toBe(ContainmentResult.REJECTED); + }); + + it('should record the discarded constructs on the query', () => { + const query = generateQuery(toAlgebra(new SPARQLParser().parse( + `${P} SELECT * WHERE { ?s foaf:name ?n . MINUS { ?s ex:age ?a } }`))); + expect(query.unsupported).toStrictEqual(['MINUS']); + }); + + it('should throw when every triple pattern came from a discarded construct', () => { + const query = generateQuery(toAlgebra(new SPARQLParser().parse( + `${P} SELECT * WHERE { SERVICE { ?s foaf:name ?n } }`))); + expect(query.starPatterns.size).toBe(0); + expect(query.unsupported).toStrictEqual(['SERVICE']); + expect(() => solveShapeQueryContainment({ query, shapes: [] })).toThrow(EmptyQueryError); + }); + }); + + describe('queries that ask for every triple', () => { + // `?s ?p ?o` is maximally unselective rather than unanswerable: it asks for every triple of + // the resource, so any resource is relevant and the honest result is CONTAINED. + async function report(rawQuery: string) { + const query = generateQuery(toAlgebra(new SPARQLParser().parse(`${P} ${rawQuery}`))); + const candidate = await shaclShapeFromQuads(new N3.Parser().parse(closedNameShape), 'http://example.org/S'); + return { query, result: solveShapeQueryContainment({ query, shapes: [candidate] }) }; + } + + it('should report CONTAINED for an unrestricted triple pattern', async () => { + const { query, result } = await report('SELECT * WHERE { ?s ?p ?o }'); + expect(query.matchesAnyTriple).toBe(true); + expect(query.unsupported).toBeUndefined(); + expect(result.result).toBe(ContainmentResult.CONTAINED); + }); + + it('should report CONTAINED for an unrestricted OPTIONAL triple pattern', async () => { + const { result } = await report('SELECT * WHERE { OPTIONAL { ?s ?p ?o } }'); + expect(result.result).toBe(ContainmentResult.CONTAINED); + }); + + it('should mark every candidate shape visitable, since the query wants all their triples', async () => { + const { result } = await report('SELECT * WHERE { ?s ?p ?o }'); + expect([...result.visitShapeBoundedResource.values()]).toStrictEqual([true]); + }); + + it('should still throw when there is no triple pattern at all', () => { + const query = generateQuery(toAlgebra(new SPARQLParser().parse('SELECT ?x WHERE { VALUES ?x { 1 2 } }'))); + expect(query.matchesAnyTriple).toBeUndefined(); + expect(() => solveShapeQueryContainment({ query, shapes: [] })).toThrow(EmptyQueryError); + }); + }); + + describe('sh:not', () => { + const notShape = `${SHAPE_PREFIXES} + a sh:NodeShape ; + sh:property [ sh:path foaf:name ] ; + sh:not [ sh:path ex:secret ] .`; + + it('should reject a predicate the shape explicitly forbids, even on an open shape', async () => { + expect(await classify('SELECT * WHERE { ?s ex:secret ?x }', notShape)) + .toBe(ContainmentResult.REJECTED); + }); + + it('should still match the positive predicates of the same shape', async () => { + expect(await classify('SELECT * WHERE { ?s foaf:name ?n }', notShape)) + .toBe(ContainmentResult.CONTAINED); + }); + }); +}); From dde109e3eb74e2eae7ddcfef61f35bd28e8609ae Mon Sep 17 00:00:00 2001 From: julianrojas87 Date: Mon, 21 Sep 2026 16:16:22 +0200 Subject: [PATCH 04/22] fix: resolve sh:node targets and classify the patterns they govern - `dependentShapes` was accepted but never passed to `groupShapeBydependencies`, so every `sh:node` pointing outside `shapes` was treated as satisfied and the root triple bound on an unchecked dependency. - An unresolved dependency is no longer always a refutation. A closed referenced shape that does not declare the nested pattern forbids it and still yields `REJECTED`; an open one is merely silent, so the predicate counts as a partial match and the pattern is `UNALINGED` or `ALIGNED`. - A nested star pattern is now classified against the shape that governs it through `sh:node`. Previously a fully satisfiable query could come out `REJECTED`. - Candidate and dependency-only placement of a referenced shape now give the same classification. - Update README --- README.md | 71 +++++++++++++++++-- lib/Binding.ts | 88 ++++++++++++++++++++++- lib/containment.ts | 43 ++++++++++-- test/containment.test.ts | 148 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 338 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index ace07b2..0062541 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ It is implemented as a Node.js library to calculate the containment (subsumption - **Star Pattern Decomposition**: Breaks down complex SPARQL queries into star patterns (groups of triple patterns sharing the same subject). - **Alignment Detection**: Identifies how closely a query matches the constraints defined in a shape. - **Dependency Tracking**: Handles links between shapes, detecting when a star pattern depends on another to be fully bounded. +- **Explicit Negation**: A predicate declared under SHACL `sh:not` (or a ShEx negative triple constraint) never matches. Because such a shape states that the property must be absent, a query needing it is `REJECTED` even when the shape is open. ## How it Works @@ -158,11 +159,35 @@ The library returns a report where each star pattern is assigned one of the foll | Result | Description | | :----------------- | :---------- | -| **`CONTAINED`** | All query star patterns, including nested ones, are matched by the shape. | +| **`CONTAINED`** | Every *required* triple pattern of every star pattern, including nested ones, is matched by the shape. Unmatched `OPTIONAL` patterns do not prevent this. | | **`ALIGNED`** | At least one triple pattern from the root star pattern matches on an open shape. | -| **`UNALINGED`** | Partial root star pattern match on a closed shape; or match on a nested star pattern while having no match on root star pattern. | -| **`WEAKLY_REJECTED`** | None of the triple patterns match on an open shape. | -| **`REJECTED`** | None of the triple patterns match on a closed shape. | +| **`UNALINGED`** | Partial root star pattern match on a closed shape; or match on a nested star pattern while having no match on root star pattern. A predicate the shape declares but whose `sh:node` dependency could not be established counts as such a partial match. | +| **`WEAKLY_REJECTED`** | No triple pattern matches, and at least one candidate shape is open and does not forbid the predicates involved. | +| **`REJECTED`** | No triple pattern matches, and every shape that describes the pattern either is closed or explicitly forbids a required predicate through `sh:not`. Also returned when there is no candidate shape. | + +### Nested dependencies + +A predicate constrained by `sh:node` is only fully bound when the nested star pattern is +bound by the referenced shape. When it is not, the outcome depends on *why*: + +- the referenced shape is **closed** and does not declare what the nested pattern asks + for, so it forbids it. The dependency is refuted, the predicate does not match, and the + root pattern can be `REJECTED`. This is what lets a closed shape graph prune. +- the referenced shape is **open** and merely silent about it. Nothing is refuted: data + conforming to that shape may well carry the predicate. The root predicate still matched + the referring shape, so the root pattern is a partial match — `UNALINGED` on a closed + referring shape, `ALIGNED` on an open one — and never `REJECTED`. + +A shape reached only through `sh:node` also governs the nested star pattern it resolves, +so that pattern is classified against it even when the shape is passed as a +`dependentShapes` entry rather than a candidate. Both placements give the same +classification. This applies regardless of `decidingShapes`: role filtering chooses which +shapes may decide a resource's relevance, not which shape governs a pattern one hop inside +the shape graph. + +`OPTIONAL` triple patterns are never required for containment: a resource can answer +the query without them. The rule is the same for open and closed shapes, so adding +`sh:closed true` to a shape never raises its result. ### Examples of Containment Results @@ -492,15 +517,49 @@ Another `REJECTED` case due to min/max value constraints: The FILTER range (`> 35`) conflicts with the shape range (`18..35`), so the result is `REJECTED`. +## Solver options and report fields + +`solveShapeQueryContainment` accepts, besides `query` and `shapes`: + +| Option | Meaning | +| :----- | :------ | +| `dependentShapes` | Shapes that are not candidates themselves but are referenced by `sh:node` from a candidate. They are needed to resolve those references: an unresolvable `sh:node` cannot be refuted and is treated as satisfied, so omitting them over-estimates relevance. | +| `decidingShapes` | Restricts which shape names may determine the result. Every shape in `shapes` still takes part in binding, so dependencies keep resolving, but only these classify a star pattern. Use it to apply an external eligibility rule, such as the input/output role filtering defined by the discovery specification. | + +The report carries: + +| Field | Meaning | +| :---- | :------ | +| `result` | The aggregate result for the complete query (see above). | +| `starPatternsContainment` | Per star pattern: its result, the matching shape IRIs, and the predicate-level bindings. | +| `visitShapeBoundedResource` | Per shape: whether at least one triple pattern bound to it. Independent of `result`, and usable to decide whether to retrieve the data behind a shape. | +| `unsupported` | Constructs discarded during normalization, if any. | + ## SPARQL Limitations The detection logic is focused on **Triple Patterns** and **Star Patterns**. Currently, the following SPARQL features are not (yet) supported: - **Filter Expressions**: FILTERs are only used to detect contradictions with shape constraints (for example numeric comparisons against non-numeric datatype constraints). Expressions that cannot be safely compared to shape constraints are conservatively ignored for containment decisions. -- **Negative Patterns**: `MINUS` and `FILTER NOT EXISTS` are not used to determine containment. +- **Negative Patterns**: `MINUS` and `FILTER NOT EXISTS` are discarded. Their triple patterns describe solutions to exclude, not data the resource must hold, so collecting them as ordinary patterns would move the result in both directions. - **Complex Property Paths**: While simple paths are supported, complex or recursive property paths are not considered yet. - **Aggregates & Subqueries**: `GROUP BY`, `HAVING`, and subqueries are not processed. -- **Federated Queries**: `SERVICE` clauses are currently ignored. +- **Federated Queries**: `SERVICE` clauses are discarded, since they read data from another endpoint. + +Discarding a construct removes constraints, so the result can over-estimate relevance +but never ranks a relevant resource lower than it would otherwise be. Whatever was +discarded is listed on `query.unsupported` and echoed on `report.unsupported`. + +### Queries with no usable triple pattern + +An unrestricted triple pattern (`?s ?p ?o`) yields no star pattern, because shapes +constrain named predicates. It still asks for every triple of the resource, so such a +query is `CONTAINED` by any shape, every shape is marked visitable, and +`query.matchesAnyTriple` is set. + +A query with no triple pattern at all — an empty `WHERE`, a body of only `VALUES` or +`FILTER`, or one left empty after discarding — never asks the resource for data, so no +relevance degree applies. `solveShapeQueryContainment` throws `EmptyQueryError` rather +than reporting `CONTAINED` for it. ## License diff --git a/lib/Binding.ts b/lib/Binding.ts index 4c622fa..df743e9 100644 --- a/lib/Binding.ts +++ b/lib/Binding.ts @@ -31,6 +31,20 @@ export interface IBindings { * @returns {boolean} whether the shape contradicts a required triple pattern */ hasNegativeContradiction: () => boolean; + /** + * Indicate that an sh:node dependency of a matched predicate was neither established nor + * refuted, because the referenced shape is open and simply does not describe the nested + * pattern. The predicate itself matched this shape, so the star pattern is partially matched. + * @returns {boolean} whether a dependency was left undecided rather than refuted + */ + hasNonRefutedDependency: () => boolean; + /** + * Shapes that nested star patterns were resolved against while checking sh:node constraints. + * A referenced shape governs its nested star pattern even when it is not a candidate shape + * itself, so its open or closed status is evidence for that pattern's classification. + * @returns {IDependencyEvidence[]} one entry per nested star pattern and referenced shape + */ + getDependencyEvidence: () => IDependencyEvidence[]; /** * * Return the unbounded triples @@ -71,6 +85,22 @@ export enum ConstraintResult { NOT_RESPECT } +/** + * A shape a nested star pattern was resolved against while checking an sh:node constraint, and + * what the nested pattern did against it. The referenced shape governs that pattern, so this is + * the evidence its classification has to be based on. + */ +export interface IDependencyEvidence { + starPatternName: string; + shapeName: string; + closed: boolean; + /** The nested pattern bound at least one triple against the referenced shape. */ + hasMatch: boolean; + /** The nested pattern is fully bound by the referenced shape. */ + contained: boolean; + bindings: IBindings; +} + export interface IContainmentType { result: ContainmentType; unContaineStarPattern?: IStarPatternWithDependencies[]; @@ -93,6 +123,10 @@ export class Bindings implements IBindings { private strict: boolean; private allOptional = true; private negativeContradiction = false; + // Predicates whose sh:node dependency was not established, but not refuted either. + private nonRefutedDependency = new Set(); + // Per nested star pattern, the shapes it was resolved against while checking sh:node. + private dependencyEvidence = new Map(); private typeOfContainment: IContainmentType = { result: ContainmentType.NONE, unContaineStarPattern: [] }; private alreadyTraversed: Map; public readonly starPattern: IStarPatternWithDependencies; @@ -366,6 +400,12 @@ export class Bindings implements IBindings { return ConstraintResult.RESPECT; } const nestedBinding = new Bindings(currentLinkedShape, dependencies, linkedShape, [], this.strict); + this.recordDependencyEvidence(dependencies.name, currentLinkedShape, nestedBinding); + for (const [starPatternName, evidence] of nestedBinding.dependencyEvidence) { + for (const entry of evidence) { + this.addDependencyEvidence(starPatternName, entry); + } + } if (nestedBinding.isFullyBounded()) { this.bindings.set(triple.predicate, triple); this.nestedContainedStarPatternNameShapesContained = new Map( @@ -380,9 +420,18 @@ export class Bindings implements IBindings { dependentShape.push(currentLinkedShape.name); } return ConstraintResult.RESPECT; - } else { - return ConstraintResult.NOT_RESPECT; } + // The dependency was not established. Distinguish a referenced shape that *refutes* the + // nested pattern — it leaves unbound triples, or forbids a predicate — from one that is + // merely silent about it because it is open. Only the former is evidence of + // incompatibility; the latter leaves the predicate of this triple matched by the + // current shape, which the classification records as a partial match. + const refutes = nestedBinding.getUnboundedTriple().length > 0 + || nestedBinding.hasNegativeContradiction(); + if (!refutes) { + this.nonRefutedDependency.add(triple.predicate); + } + return ConstraintResult.NOT_RESPECT; } return ConstraintResult.INAPPLICABLE; @@ -535,6 +584,41 @@ export class Bindings implements IBindings { return this.negativeContradiction; } + public hasNonRefutedDependency(): boolean { + return this.nonRefutedDependency.size > 0; + } + + public getDependencyEvidence(): IDependencyEvidence[] { + const resp: IDependencyEvidence[] = []; + for (const evidence of this.dependencyEvidence.values()) { + resp.push(...evidence); + } + return resp; + } + + private recordDependencyEvidence(starPatternName: string, shape: IShape, nested: Bindings): void { + this.addDependencyEvidence(starPatternName, { + starPatternName, + shapeName: shape.name, + closed: shape.closed, + hasMatch: nested.getBoundTriple().length > 0 || nested.hasNonRefutedDependency(), + contained: nested.isFullyBounded() + && nested.containmentType().result === ContainmentType.FULL, + bindings: nested, + }); + } + + private addDependencyEvidence(starPatternName: string, entry: IDependencyEvidence): void { + const existing = this.dependencyEvidence.get(starPatternName); + if (existing === undefined) { + this.dependencyEvidence.set(starPatternName, [entry]); + return; + } + if (!existing.some(candidate => candidate.shapeName === entry.shapeName)) { + existing.push(entry); + } + } + public getNestedContainedStarPatternName(): IDependentStarPattern[] { return this.nestedContainedStarPatternName; } diff --git a/lib/containment.ts b/lib/containment.ts index d1d303e..a31117c 100644 --- a/lib/containment.ts +++ b/lib/containment.ts @@ -11,7 +11,7 @@ import type { Term } from '@rdfjs/types'; * @param param {IContainementArg} - the shape and the query to evaluate * @returns {IResult} result relative to the containement of the query inside of the shape */ -export function solveShapeQueryContainment({ query, shapes, decidingShapes }: IContainementArg): IResult { +export function solveShapeQueryContainment({ query, shapes, dependentShapes, decidingShapes }: IContainementArg): IResult { if (query.starPatterns.size === 0) { // A query whose only triple patterns are unrestricted (?s ?p ?o) asks for every triple of the // resource, so every resource is relevant and every shape is worth visiting. That is a real @@ -30,7 +30,7 @@ export function solveShapeQueryContainment({ query, shapes, decidingShapes }: IC const starPatternsContainment = new Map(); const classificationStats = new Map(); - const groupedShapes = groupShapeBydependencies(shapes); + const groupedShapes = groupShapeBydependencies(shapes, dependentShapes); // Initialize per-star-pattern classification state. for (const [starPatternsName] of query.starPatterns) { @@ -58,6 +58,39 @@ export function solveShapeQueryContainment({ query, shapes, decidingShapes }: IC } } + // A shape reached only as an sh:node target is not a candidate, but it is the shape that governs + // the nested star pattern it resolves. Classify that pattern against it, so a nested pattern is + // neither rejected on the strength of shapes that never described it, nor left rejected while + // the shape that does describe it binds the pattern completely. This is deliberately independent + // of `decidingShapes`: role filtering selects which shapes may decide a resource's relevance, not + // which shape governs a pattern one hop inside the shape graph. + for (const starPatternBindings of bindingResult.values()) { + for (const { result } of starPatternBindings.values()) { + for (const evidence of result.getDependencyEvidence()) { + const stats = classificationStats.get(evidence.starPatternName); + if (stats === undefined) { + continue; + } + if (evidence.closed) { + stats.hasClosedShape = true; + } else { + stats.hasOpenShape = true; + } + if (evidence.contained) { + stats.containedTargets.add(evidence.shapeName); + } + if (evidence.hasMatch || evidence.contained) { + stats.bindings.set(evidence.shapeName, evidence.bindings); + if (evidence.closed) { + stats.rootTargetsClosed.add(evidence.shapeName); + } else { + stats.rootTargetsOpen.add(evidence.shapeName); + } + } + } + } + } + // Collapse collected stats into the final containment label per star pattern. for (const [starPatternName, starPattern] of query.starPatterns) { const stats = classificationStats.get(starPatternName)!; @@ -168,8 +201,10 @@ function updateContainmentStats( return; } - // Track root-level alignment independently from full containment. - const hasRootMatch = bindings.getBoundTriple().length > 0; + // Track root-level alignment independently from full containment. A predicate whose sh:node + // dependency was neither established nor refuted still matched this shape, so it counts here: + // otherwise a root pattern the shape does declare would be reported as no match at all. + const hasRootMatch = bindings.getBoundTriple().length > 0 || bindings.hasNonRefutedDependency(); if (hasRootMatch) { stats.bindings.set(shape.name, bindings); if (shape.closed) { diff --git a/test/containment.test.ts b/test/containment.test.ts index 0199527..326b1f6 100644 --- a/test/containment.test.ts +++ b/test/containment.test.ts @@ -2001,3 +2001,151 @@ describe('supported profile and explicit negation', () => { }); }); }); + +describe('dependency-only shapes', () => { + // `dependentShapes` carries shapes that are not candidates themselves but are referenced by + // sh:node from a candidate. They must be resolvable, otherwise the constraint is treated as + // satisfied (Bindings.handleShapeConstraint returns RESPECT for an unknown linked shape) and + // the root star pattern binds on a dependency that was never checked. + const shapesTtl = ` + @prefix sh: . + @prefix foaf: . + @prefix ex: . + + ex:A a sh:NodeShape ; sh:closed true ; + sh:property [ sh:path ex:knows ; sh:node ex:B ] . + + ex:B a sh:NodeShape ; sh:closed true ; + sh:property [ sh:path foaf:name ] .`; + + // Same shapes with ex:A open. Resolving sh:node only asks whether the nested star pattern is + // fully bound by ex:B, so ex:B's own openness never changes the outcome; ex:A's does, because + // it decides between the two rejection degrees once the root triple fails to bind. + const openRootShapesTtl = shapesTtl.replace('ex:A a sh:NodeShape ; sh:closed true ;', 'ex:A a sh:NodeShape ;'); + + const PREFIXES = `PREFIX foaf: PREFIX ex: `; + + async function rootResultFor(rawQuery: string, useDependentShapes: boolean, ttl: string = shapesTtl): Promise { + const query = generateQuery(toAlgebra(new SPARQLParser().parse(`${PREFIXES} ${rawQuery}`))); + const quads = new N3.Parser().parse(ttl); + const candidate = await shaclShapeFromQuads(quads, 'http://example.org/A'); + const linked = await shaclShapeFromQuads(quads, 'http://example.org/B'); + const report = solveShapeQueryContainment({ + query, + shapes: [candidate], + ...(useDependentShapes ? { dependentShapes: [linked] } : {}), + }); + return report.starPatternsContainment.get('p')!.result; + } + + const nonFitting = 'SELECT * WHERE { ?p ex:knows ?f . ?f ex:somethingElse ?x }'; + const fitting = 'SELECT * WHERE { ?p ex:knows ?f . ?f foaf:name ?n }'; + + it('should check the dependency when the referenced shape is supplied', async () => { + expect(await rootResultFor(nonFitting, true)).toBe(ContainmentResult.REJECTED); + }); + + it('should bind the root when the nested pattern fits the referenced shape', async () => { + expect(await rootResultFor(fitting, true)).toBe(ContainmentResult.CONTAINED); + }); + + it('should weakly reject rather than reject when the referring shape is open', async () => { + expect(await rootResultFor(nonFitting, true, openRootShapesTtl)).toBe(ContainmentResult.WEAKLY_REJECTED); + }); + + it('should bind the root on a fitting nested pattern whatever the referring shape is', async () => { + expect(await rootResultFor(fitting, true, openRootShapesTtl)).toBe(ContainmentResult.CONTAINED); + }); + + it('should over-estimate when the referenced shape is unavailable', async () => { + // Documents the remaining approximation: with no way to resolve ex:B the sh:node + // constraint cannot be refuted, so the root binds even though the nested pattern does not fit. + expect(await rootResultFor(nonFitting, false)).toBe(ContainmentResult.CONTAINED); + }); +}); + +describe('nested dependencies and partial matches', () => { + // ex:PersonShape is closed and declares foaf:knows, constrained by sh:node ex:FriendShape. + // Whether the nested pattern is refuted depends on ex:FriendShape: closed, it forbids anything + // it does not declare; open, it is merely silent. + const shapesTtl = (friendClosed: boolean) => ` + @prefix sh: . + @prefix foaf: . + @prefix ex: . + + ex:PersonShape a sh:NodeShape ; sh:closed true ; + sh:property [ sh:path foaf:knows ; sh:node ex:FriendShape ] . + + ex:FriendShape a sh:NodeShape ; ${friendClosed ? 'sh:closed true ;' : ''} + sh:property [ sh:path foaf:name ] .`; + + const PREFIXES = `PREFIX foaf: PREFIX ex: `; + // foaf:mbox is not declared by ex:FriendShape; foaf:name is. + const NOT_DECLARED = 'SELECT * WHERE { ?p foaf:knows ?f . ?f foaf:mbox ?m }'; + const DECLARED = 'SELECT * WHERE { ?p foaf:knows ?f . ?f foaf:name ?n }'; + + async function classify(rawQuery: string, friendClosed: boolean, asDependency: boolean) { + const query = generateQuery(toAlgebra(new SPARQLParser().parse(`${PREFIXES} ${rawQuery}`))); + const quads = new N3.Parser().parse(shapesTtl(friendClosed)); + const person = await shaclShapeFromQuads(quads, 'http://example.org/PersonShape'); + const friend = await shaclShapeFromQuads(quads, 'http://example.org/FriendShape'); + const report = asDependency + ? solveShapeQueryContainment({ query, shapes: [person], dependentShapes: [friend] }) + : solveShapeQueryContainment({ query, shapes: [person, friend] }); + return { + root: report.starPatternsContainment.get('p')!.result, + nested: report.starPatternsContainment.get('f')!.result, + aggregate: report.result, + }; + } + + describe('a dependency that is undecided rather than refuted', () => { + it('should leave the root partially matched on the closed referring shape', async () => { + // foaf:knows is declared by the closed ex:PersonShape, so the root pattern did match it. + // Only the nested pattern is unresolved, and open ex:FriendShape does not forbid it. + for (const asDependency of [true, false]) { + const { root } = await classify(NOT_DECLARED, false, asDependency); + expect(root).toBe(ContainmentResult.UNALINGED); + } + }); + + it('should not make the resource omittable', async () => { + for (const asDependency of [true, false]) { + const { aggregate } = await classify(NOT_DECLARED, false, asDependency); + expect(aggregate).toBe(ContainmentResult.WEAKLY_REJECTED); + expect(aggregate).not.toBe(ContainmentResult.REJECTED); + } + }); + }); + + describe('a dependency that is genuinely refuted', () => { + it('should stay rejected, so a closed shape graph can still prune', async () => { + for (const asDependency of [true, false]) { + const { root, aggregate } = await classify(NOT_DECLARED, true, asDependency); + expect(root).toBe(ContainmentResult.REJECTED); + expect(aggregate).toBe(ContainmentResult.REJECTED); + } + }); + }); + + describe('a nested pattern governed by a dependency-only shape', () => { + it('should be classified against the shape that governs it, not only the candidates', async () => { + // Without this the nested pattern is judged against ex:PersonShape alone, which never + // describes it, and a fully satisfiable query comes out REJECTED. + for (const friendClosed of [false, true]) { + const { nested, aggregate } = await classify(DECLARED, friendClosed, true); + expect(nested).toBe(ContainmentResult.CONTAINED); + expect(aggregate).toBe(ContainmentResult.CONTAINED); + } + }); + + it('should classify identically however the referenced shape is passed', async () => { + for (const query of [NOT_DECLARED, DECLARED]) { + for (const friendClosed of [false, true]) { + expect(await classify(query, friendClosed, true)) + .toStrictEqual(await classify(query, friendClosed, false)); + } + } + }); + }); +}); From eeed75c4e23f8943b3136aa445807bd6e311be32 Mon Sep 17 00:00:00 2001 From: julianrojas87 Date: Tue, 6 Oct 2026 15:27:20 +0200 Subject: [PATCH 05/22] fix!: treat shapes outside decidingShapes as dependencies only - Shapes outside `decidingShapes` are now passed on as `dependentShapes`: they still resolve `sh:node` references from the candidates, but their own `sh:node` references no longer count as evidence. Previously that evidence was still collected, so excluded shapes alone could raise a result, e.g. from `REJECTED` to `UNALINGED`. - Excluded shapes no longer get an entry in `visitShapeBoundedResource`. - Update README; add regression tests. --- README.md | 7 +- lib/containment.ts | 36 ++++++---- test/containment.test.ts | 150 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 168 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 0062541..a09e909 100644 --- a/README.md +++ b/README.md @@ -182,8 +182,9 @@ A shape reached only through `sh:node` also governs the nested star pattern it r so that pattern is classified against it even when the shape is passed as a `dependentShapes` entry rather than a candidate. Both placements give the same classification. This applies regardless of `decidingShapes`: role filtering chooses which -shapes may decide a resource's relevance, not which shape governs a pattern one hop inside -the shape graph. +shapes may decide a resource's relevance, not which shape governs a pattern inside the +shape graph. Only references reached from a candidate count, though: a shape excluded by +`decidingShapes` lends no evidence through `sh:node` references of its own. `OPTIONAL` triple patterns are never required for containment: a resource can answer the query without them. The rule is the same for open and closed shapes, so adding @@ -524,7 +525,7 @@ The FILTER range (`> 35`) conflicts with the shape range (`18..35`), so the resu | Option | Meaning | | :----- | :------ | | `dependentShapes` | Shapes that are not candidates themselves but are referenced by `sh:node` from a candidate. They are needed to resolve those references: an unresolvable `sh:node` cannot be refuted and is treated as satisfied, so omitting them over-estimates relevance. | -| `decidingShapes` | Restricts which shape names may determine the result. Every shape in `shapes` still takes part in binding, so dependencies keep resolving, but only these classify a star pattern. Use it to apply an external eligibility rule, such as the input/output role filtering defined by the discovery specification. | +| `decidingShapes` | Restricts which shapes in `shapes` are candidates. The others are treated as `dependentShapes`: they still resolve `sh:node` references from the candidates, but never classify a star pattern themselves, lend evidence through `sh:node` references of their own, or receive a visit indication. Use it to apply an external eligibility rule, such as the input/output role filtering defined by the discovery specification. | The report carries: diff --git a/lib/containment.ts b/lib/containment.ts index a31117c..81aed62 100644 --- a/lib/containment.ts +++ b/lib/containment.ts @@ -12,6 +12,17 @@ import type { Term } from '@rdfjs/types'; * @returns {IResult} result relative to the containement of the query inside of the shape */ export function solveShapeQueryContainment({ query, shapes, dependentShapes, decidingShapes }: IContainementArg): IResult { + if (decidingShapes !== undefined) { + // Role filtering selects the candidates; it does not hide shapes from sh:node resolution. A shape + // outside `decidingShapes` is therefore only a dependency: it still resolves the references of the + // candidates, but neither decides a star pattern, nor lends evidence through references of its own, + // nor receives a visit indication. + return solveShapeQueryContainment({ + query, + shapes: shapes.filter(shape => decidingShapes.has(shape.name)), + dependentShapes: (dependentShapes ?? []).concat(shapes.filter(shape => !decidingShapes.has(shape.name))), + }); + } if (query.starPatterns.size === 0) { // A query whose only triple patterns are unrestricted (?s ?p ?o) asks for every triple of the // resource, so every resource is relevant and every shape is worth visiting. That is a real @@ -54,16 +65,17 @@ export function solveShapeQueryContainment({ query, shapes, dependentShapes, dec const bindings = new Bindings(shape, starPattern, dependencies, starPatternUnion); const filterCompatibility = evaluateFiltersForShape(query.filters, starPattern, shape); bindingResultofShape.set(starPatternName, { result: bindings, shape }); - updateContainmentStats(classificationStats, starPatternName, shape, bindings, groupedShapes, decidingShapes, filterCompatibility !== FilterTruth.FALSE); + updateContainmentStats(classificationStats, starPatternName, shape, bindings, groupedShapes, filterCompatibility !== FilterTruth.FALSE); } } // A shape reached only as an sh:node target is not a candidate, but it is the shape that governs // the nested star pattern it resolves. Classify that pattern against it, so a nested pattern is // neither rejected on the strength of shapes that never described it, nor left rejected while - // the shape that does describe it binds the pattern completely. This is deliberately independent - // of `decidingShapes`: role filtering selects which shapes may decide a resource's relevance, not - // which shape governs a pattern one hop inside the shape graph. + // the shape that does describe it binds the pattern completely. Only candidates have bindings here, + // so the evidence covers exactly the references reached from a candidate: a shape excluded by + // `decidingShapes` still governs the patterns a candidate resolves through it, but its own + // references lend no evidence. for (const starPatternBindings of bindingResult.values()) { for (const { result } of starPatternBindings.values()) { for (const evidence of result.getDependencyEvidence()) { @@ -179,14 +191,8 @@ function updateContainmentStats( shape: IShape, bindings: IBindings, groupedShapes: IShapeWithDependencies[], - decidingShapes?: Set, filterCompatible = true, ): void { - // Skip shapes that are not part of the current decision scope. - if (decidingShapes !== undefined && !decidingShapes.has(shape.name)) { - return; - } - const stats = classificationStats.get(starPatternName)!; // A shape that explicitly forbids a required predicate (sh:not) proves incompatibility, so it // must not count as the "open shape" that would soften REJECTED into WEAKLY_REJECTED. @@ -221,10 +227,10 @@ function updateContainmentStats( } // Preserve previous behavior where a disjunction can be considered fully covered - // when unresolved alternatives are contained by another deciding shape. + // when unresolved alternatives are contained by another candidate shape. if (bindings.isFullyBounded() && bindings.containmentType().result === ContainmentType.PARTIAL) { const unContaineStarPattern = bindings.containmentType().unContaineStarPattern!; - const hasDisjuncContainment = findDisjunctContainment(unContaineStarPattern, groupedShapes, shape, decidingShapes); + const hasDisjuncContainment = findDisjunctContainment(unContaineStarPattern, groupedShapes, shape); if (hasDisjuncContainment) { stats.bindings.set(shape.name, bindings); stats.containedTargets.add(shape.name); @@ -232,12 +238,12 @@ function updateContainmentStats( } } -function findDisjunctContainment(starPatterns: IStarPatternWithDependencies[], groupedShapes: IShapeWithDependencies[], shapeExcluded: IShape, decidingShapes?: Set): boolean { +function findDisjunctContainment(starPatterns: IStarPatternWithDependencies[], groupedShapes: IShapeWithDependencies[], shapeExcluded: IShape): boolean { // Search whether unresolved disjunctive branches can be absorbed by another shape. let haveContainment = false; for (const starPattern of starPatterns) { for (const { shape, dependencies } of groupedShapes) { - if (shape.name !== shapeExcluded.name && (decidingShapes === undefined || decidingShapes.has(shape.name))) { + if (shape.name !== shapeExcluded.name) { const bindings = new Bindings(shape, starPattern, dependencies); haveContainment = haveContainment || bindings.isFullyBounded(); } @@ -1022,7 +1028,7 @@ export interface IContainementArg { query: IQuery; shapes: IShape[]; dependentShapes?: IShape[]; - // shapes to consider when making a decision + // names of the shapes in `shapes` that are candidates; the others only resolve sh:node references decidingShapes?: Set; } diff --git a/test/containment.test.ts b/test/containment.test.ts index 326b1f6..8a649d6 100644 --- a/test/containment.test.ts +++ b/test/containment.test.ts @@ -6,7 +6,7 @@ import { IStarPatternWithDependencies, Triple } from '../lib/Triple'; import { ContainmentResult, EmptyQueryError, IContainmentResult, IResult as ContainmentReport, StarPatternName, solveShapeQueryContainment, solveShapeShapeContainment } from '../lib/containment'; import { DataFactory } from 'rdf-data-factory'; import { BaseQuad } from '@rdfjs/types'; -import { IQuery, generateQuery } from '../lib/query'; +import { IQuery, generateQuery, shapeToQuery } from '../lib/query'; import { RDF as RDF_VOCAB } from '../lib/constant'; import type * as RDF from '@rdfjs/types'; import * as N3 from 'n3'; @@ -20,6 +20,14 @@ const n3Parser = new N3.Parser(); const sparqlParser = new SPARQLParser(); type IResult = Omit; +async function parseShaclShape(quads: RDF.Quad[], name: string): Promise { + const parsed = await shaclShapeFromQuads(quads, name); + if (parsed instanceof Error) { + throw parsed; + } + return parsed; +} + describe('solveShapeQueryContainment', () => { describe('virtual use case', () => { @@ -1886,7 +1894,7 @@ describe('supported profile and explicit negation', () => { async function classify(rawQuery: string, rawShape: string): Promise { const query = generateQuery(toAlgebra(new SPARQLParser().parse(`${P} ${rawQuery}`))); - const candidate = await shaclShapeFromQuads(new N3.Parser().parse(rawShape), 'http://example.org/S'); + const candidate = await parseShaclShape(new N3.Parser().parse(rawShape), 'http://example.org/S'); return solveShapeQueryContainment({ query, shapes: [candidate] }).result; } @@ -1956,7 +1964,7 @@ describe('supported profile and explicit negation', () => { // the resource, so any resource is relevant and the honest result is CONTAINED. async function report(rawQuery: string) { const query = generateQuery(toAlgebra(new SPARQLParser().parse(`${P} ${rawQuery}`))); - const candidate = await shaclShapeFromQuads(new N3.Parser().parse(closedNameShape), 'http://example.org/S'); + const candidate = await parseShaclShape(new N3.Parser().parse(closedNameShape), 'http://example.org/S'); return { query, result: solveShapeQueryContainment({ query, shapes: [candidate] }) }; } @@ -2028,8 +2036,8 @@ describe('dependency-only shapes', () => { async function rootResultFor(rawQuery: string, useDependentShapes: boolean, ttl: string = shapesTtl): Promise { const query = generateQuery(toAlgebra(new SPARQLParser().parse(`${PREFIXES} ${rawQuery}`))); const quads = new N3.Parser().parse(ttl); - const candidate = await shaclShapeFromQuads(quads, 'http://example.org/A'); - const linked = await shaclShapeFromQuads(quads, 'http://example.org/B'); + const candidate = await parseShaclShape(quads, 'http://example.org/A'); + const linked = await parseShaclShape(quads, 'http://example.org/B'); const report = solveShapeQueryContainment({ query, shapes: [candidate], @@ -2087,8 +2095,8 @@ describe('nested dependencies and partial matches', () => { async function classify(rawQuery: string, friendClosed: boolean, asDependency: boolean) { const query = generateQuery(toAlgebra(new SPARQLParser().parse(`${PREFIXES} ${rawQuery}`))); const quads = new N3.Parser().parse(shapesTtl(friendClosed)); - const person = await shaclShapeFromQuads(quads, 'http://example.org/PersonShape'); - const friend = await shaclShapeFromQuads(quads, 'http://example.org/FriendShape'); + const person = await parseShaclShape(quads, 'http://example.org/PersonShape'); + const friend = await parseShaclShape(quads, 'http://example.org/FriendShape'); const report = asDependency ? solveShapeQueryContainment({ query, shapes: [person], dependentShapes: [friend] }) : solveShapeQueryContainment({ query, shapes: [person, friend] }); @@ -2149,3 +2157,131 @@ describe('nested dependencies and partial matches', () => { }); }); }); + +describe('role filtering with decidingShapes', () => { + // The service relates ex:Response with role disc:OutputShape, and ex:Request and ex:Address with + // role disc:InputShape. The user's shapes have role disc:InputShape, so only ex:Response may + // decide; the client applies that through decidingShapes. ex:Request and ex:Address are excluded, + // but remain part of the shape graph that ex:Response refers to. + const serviceTtl = ` + @prefix sh: . + @prefix ex: . + + ex:Response a sh:NodeShape ; sh:closed true ; + sh:property [ sh:path ex:request ; sh:node ex:Request ] . + + ex:Request a sh:NodeShape ; sh:closed true ; + sh:property [ sh:path ex:query ] ; + sh:property [ sh:path ex:address ; sh:node ex:Address ] . + + ex:Address a sh:NodeShape ; sh:closed true ; + sh:property [ sh:path ex:street ] .`; + + const userTtl = ` + @prefix sh: . + @prefix ex: . + @prefix user: . + + # Reachable from ex:Response, directly and through ex:Request. + user:EchoedQuery a sh:NodeShape ; + sh:property [ sh:path ex:request ; sh:node user:QueryRequest ] . + user:QueryRequest a sh:NodeShape ; + sh:property [ sh:path ex:query ] . + + user:EchoedAddress a sh:NodeShape ; + sh:property [ sh:path ex:request ; sh:node user:AddressRequest ] . + user:AddressRequest a sh:NodeShape ; + sh:property [ sh:path ex:address ; sh:node user:StreetAddress ] . + + # Described only by the excluded input shapes. + user:Addressed a sh:NodeShape ; + sh:property [ sh:path ex:address ; sh:node user:StreetAddress ] . + user:StreetAddress a sh:NodeShape ; + sh:property [ sh:path ex:street ] .`; + + const RESPONSE = 'http://example.org/Response'; + const SERVICE_SHAPES = [RESPONSE, 'http://example.org/Request', 'http://example.org/Address']; + const USER = 'http://example.org/user/'; + const USER_SHAPES = ['EchoedQuery', 'QueryRequest', 'EchoedAddress', 'AddressRequest', 'Addressed', 'StreetAddress'] + .map(name => `${USER}${name}`); + const ECHOED_QUERY = `${USER}EchoedQuery`; + const ECHOED_ADDRESS = `${USER}EchoedAddress`; + const ADDRESSED = `${USER}Addressed`; + + async function parseShapes(ttl: string, names: string[]): Promise { + const quads = new N3.Parser().parse(ttl); + const shapes: IShape[] = []; + for (const name of names) { + shapes.push(await parseShaclShape(quads, name)); + } + return shapes; + } + + async function solve(userShape: string, placement: 'deciding' | 'unfiltered' | 'dependent' | 'dropped'): Promise { + const [response, request, address] = await parseShapes(serviceTtl, SERVICE_SHAPES); + const userShapes = await parseShapes(userTtl, USER_SHAPES); + const sourceShape = userShapes.find(shape => shape.name === userShape)!; + switch (placement) { + case 'deciding': + return solveShapeShapeContainment({ sourceShape, sourceLinkedShapes: userShapes, targetShapes: [response, request, address], decidingShapes: new Set([RESPONSE]) }); + case 'unfiltered': + return solveShapeShapeContainment({ sourceShape, sourceLinkedShapes: userShapes, targetShapes: [response, request, address] }); + case 'dependent': + // solveShapeShapeContainment takes no dependentShapes, so translate the shape as it does. + return solveShapeQueryContainment({ query: shapeToQuery(sourceShape, { linkedShapes: userShapes }), shapes: [response], dependentShapes: [request, address] }); + case 'dropped': + return solveShapeShapeContainment({ sourceShape, sourceLinkedShapes: userShapes, targetShapes: [response] }); + } + } + + function summarize(report: ContainmentReport): { aggregate: ContainmentResult; starPatterns: Map; visit: Map } { + return { + aggregate: report.result, + starPatterns: new Map(Array.from(report.starPatternsContainment, ([name, { result }]) => [name, result])), + visit: report.visitShapeBoundedResource, + }; + } + + it('should resolve references to excluded shapes, directly and transitively', async () => { + for (const userShape of [ECHOED_QUERY, ECHOED_ADDRESS]) { + expect((await solve(userShape, 'deciding')).result).toBe(ContainmentResult.CONTAINED); + } + }); + + it('should lose that match when the excluded shapes are dropped instead', async () => { + // The unresolvable reference leaves the nested pattern to ex:Response alone, which is closed + // and never describes it. + expect((await solve(ECHOED_QUERY, 'dropped')).result).toBe(ContainmentResult.REJECTED); + }); + + it('should not let an excluded shape lend evidence through its own references', async () => { + // ex:Request binds the root pattern and resolves the nested one against ex:Address, but neither + // may decide. Counting that evidence would lift the resource to UNALINGED on the strength of + // its input shapes alone. + const report = await solve(ADDRESSED, 'deciding'); + expect(report.result).toBe(ContainmentResult.REJECTED); + expect(report.starPatternsContainment.get(`${USER}StreetAddress`)!.result).toBe(ContainmentResult.REJECTED); + }); + + it('should only give visit indications to the deciding shapes', async () => { + const report = await solve(ADDRESSED, 'deciding'); + expect(report.visitShapeBoundedResource).toStrictEqual(new Map([[RESPONSE, false]])); + }); + + it('should classify exactly as passing the excluded shapes as dependentShapes', async () => { + for (const userShape of [ECHOED_QUERY, ECHOED_ADDRESS, ADDRESSED]) { + expect(summarize(await solve(userShape, 'deciding'))) + .toStrictEqual(summarize(await solve(userShape, 'dependent'))); + } + }); + + it('should contain the same request when no role applies', async () => { + // A role only qualifies an input shape. Without one, or for a SPARQL query, every service shape + // is a candidate: ex:Request covers the root pattern and ex:Address the nested one. + expect((await solve(ADDRESSED, 'unfiltered')).result).toBe(ContainmentResult.CONTAINED); + const query = generateQuery(toAlgebra(new SPARQLParser().parse( + 'PREFIX ex: SELECT * WHERE { ?q ex:address ?a . ?a ex:street ?s }'))); + const shapes = await parseShapes(serviceTtl, SERVICE_SHAPES); + expect(solveShapeQueryContainment({ query, shapes }).result).toBe(ContainmentResult.CONTAINED); + }); +}); From 4c151106409cb27c98f653aa0d0ff83b3e963a38 Mon Sep 17 00:00:00 2001 From: julianrojas87 Date: Fri, 9 Oct 2026 13:28:12 +0200 Subject: [PATCH 06/22] fix!: follow SHACL semantics for absent cardinality bounds - An absent `sh:minCount` is now 0, as in SHACL. A source-shape property constraint without it becomes an optional triple pattern and no longer has to be matched for `CONTAINED`. Previously it was required. - An absent `sh:maxCount` is now unbounded (-1) instead of 1. Cardinality ranges are not compared during binding, so no result changes. SHACL predicates now always carry a `cardinality`. - `sh:maxCount 0` on its own now yields a negative predicate, like `sh:not`. It previously needed an explicit `sh:minCount 0`, and otherwise became a required positive predicate. - ShEx is unchanged: a triple constraint without a cardinality still occurs exactly once. - Update README; add parser and containment tests. Co-Authored-By: Claude Opus 5.5 --- README.md | 9 ++++-- lib/shacl.ts | 11 +++++-- test/containment.test.ts | 31 +++++++++++++++++++ test/shacl.test.ts | 25 +++++++++++++++ .../shape/shacl_shape_default_cardinality.ttl | 20 ++++++++++++ 5 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 test/shape/shacl_shape_default_cardinality.ttl diff --git a/README.md b/README.md index a09e909..9921d7c 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ It is implemented as a Node.js library to calculate the containment (subsumption - **Star Pattern Decomposition**: Breaks down complex SPARQL queries into star patterns (groups of triple patterns sharing the same subject). - **Alignment Detection**: Identifies how closely a query matches the constraints defined in a shape. - **Dependency Tracking**: Handles links between shapes, detecting when a star pattern depends on another to be fully bounded. -- **Explicit Negation**: A predicate declared under SHACL `sh:not` (or a ShEx negative triple constraint) never matches. Because such a shape states that the property must be absent, a query needing it is `REJECTED` even when the shape is open. +- **Explicit Negation**: A predicate declared under SHACL `sh:not` or limited by `sh:maxCount 0` (or a ShEx negative triple constraint) never matches. Because such a shape states that the property must be absent, a query needing it is `REJECTED` even when the shape is open. ## How it Works @@ -163,7 +163,7 @@ The library returns a report where each star pattern is assigned one of the foll | **`ALIGNED`** | At least one triple pattern from the root star pattern matches on an open shape. | | **`UNALINGED`** | Partial root star pattern match on a closed shape; or match on a nested star pattern while having no match on root star pattern. A predicate the shape declares but whose `sh:node` dependency could not be established counts as such a partial match. | | **`WEAKLY_REJECTED`** | No triple pattern matches, and at least one candidate shape is open and does not forbid the predicates involved. | -| **`REJECTED`** | No triple pattern matches, and every shape that describes the pattern either is closed or explicitly forbids a required predicate through `sh:not`. Also returned when there is no candidate shape. | +| **`REJECTED`** | No triple pattern matches, and every shape that describes the pattern either is closed or explicitly forbids a required predicate through `sh:not` or `sh:maxCount 0`. Also returned when there is no candidate shape. | ### Nested dependencies @@ -190,6 +190,11 @@ shape graph. Only references reached from a candidate count, though: a shape exc the query without them. The rule is the same for open and closed shapes, so adding `sh:closed true` to a shape never raises its result. +A source shape follows the cardinality semantics of its language. In SHACL, an absent +`sh:minCount` is 0 and an absent `sh:maxCount` is unbounded, so a property constraint +without `sh:minCount` becomes an optional triple pattern. In ShEx, a triple constraint +without a cardinality occurs exactly once, so it becomes a required one. + ### Examples of Containment Results #### 1. `CONTAINED` diff --git a/lib/shacl.ts b/lib/shacl.ts index 358d8ec..aca6052 100644 --- a/lib/shacl.ts +++ b/lib/shacl.ts @@ -336,17 +336,22 @@ function collectNotProps(map: IMapTripleShacl, shapeIri: string): Set { return result; } +// SHACL bounds a cardinality only when it is declared: an absent sh:minCount is 0 and an absent +// sh:maxCount is unbounded (-1 in ICardinality). +const SHACL_DEFAULT_MIN_COUNT = 0; +const SHACL_DEFAULT_MAX_COUNT = -1; + /** Whether a property shape data entry represents a negative predicate (minCount=0, maxCount=0). */ function isNegatedData(data: IPropertyShapeData): boolean { - return isNegativeCardinality(data.minCount, data.maxCount); + return isNegativeCardinality(data.minCount ?? SHACL_DEFAULT_MIN_COUNT, data.maxCount); } /** Build an IPredicate from a property shape data entry. */ function buildPredicate(data: IPropertyShapeData): IPredicate { return buildSharedPredicate({ name: data.path!, - minCount: data.minCount, - maxCount: data.maxCount, + minCount: data.minCount ?? SHACL_DEFAULT_MIN_COUNT, + maxCount: data.maxCount ?? SHACL_DEFAULT_MAX_COUNT, constraintParts: { classConstraint: data.classConstraint, shapeConstraint: data.nodeConstraint, diff --git a/test/containment.test.ts b/test/containment.test.ts index 8a649d6..84030a4 100644 --- a/test/containment.test.ts +++ b/test/containment.test.ts @@ -2285,3 +2285,34 @@ describe('role filtering with decidingShapes', () => { expect(solveShapeQueryContainment({ query, shapes }).result).toBe(ContainmentResult.CONTAINED); }); }); + +describe('SHACL cardinality defaults in a source shape', () => { + // As in SHACL, a property constraint without sh:minCount sets no lower bound: its triple pattern is + // optional and never prevents containment. ex:Target is open and only declares ex:age. + const shapesTtl = (nameCardinality: string): string => ` + @prefix sh: . + @prefix ex: . + + ex:Source a sh:NodeShape ; + sh:property [ sh:path ex:name ${nameCardinality} ] ; + sh:property [ sh:path ex:age ; sh:minCount 1 ] . + + ex:Target a sh:NodeShape ; + sh:property [ sh:path ex:age ] .`; + + async function solve(nameCardinality: string): Promise { + const quads = new N3.Parser().parse(shapesTtl(nameCardinality)); + const source = await parseShaclShape(quads, 'http://example.org/Source'); + const target = await parseShaclShape(quads, 'http://example.org/Target'); + return solveShapeShapeContainment({ sourceShape: source, targetShapes: [target] }).result; + } + + it('should treat a property constraint without sh:minCount as optional', async () => { + expect(await solve('')).toBe(ContainmentResult.CONTAINED); + expect(await solve('; sh:maxCount 1')).toBe(ContainmentResult.CONTAINED); + }); + + it('should keep a property constraint with a positive sh:minCount required', async () => { + expect(await solve('; sh:minCount 1')).toBe(ContainmentResult.ALIGNED); + }); +}); diff --git a/test/shacl.test.ts b/test/shacl.test.ts index e23c58b..0ab98c9 100644 --- a/test/shacl.test.ts +++ b/test/shacl.test.ts @@ -31,6 +31,7 @@ describe.each([ const closedShapeMultipleProperties = populateFunction('./test/shape/shacl_shape_closed_multiple_properties.ttl'); const twoShapes = populateFunction('./test/shape/shacl_two_shapes.ttl'); const shapeMultipleCardinality = populateFunction('./test/shape/shacl_shape_multiple_cardinality.ttl'); + const shapeDefaultCardinality = populateFunction('./test/shape/shacl_shape_default_cardinality.ttl'); const shapeWithConstraints = populateFunction('./test/shape/shacl_shape_with_constraints.ttl'); const shapeWithOrStatement = populateFunction('./test/shape/shacl_shape_or_statement.ttl'); const shapeWithXoneStatement = populateFunction('./test/shape/shacl_shape_xone_statement.ttl'); @@ -172,6 +173,30 @@ describe.each([ } }); + it(`${name}: should apply the SHACL defaults for an absent sh:minCount or sh:maxCount`, async () => { + const shape = await shaclShapeFromQuads(shapeDefaultCardinality, shapeIri); + expect(shape).not.toBeInstanceOf(Error); + + expect(new Set((shape as IShape).positivePredicates)).toStrictEqual(new Set([ + `${FOAF_PREFIX}prop1`, + `${FOAF_PREFIX}prop2`, + `${FOAF_PREFIX}prop3`, + ])); + // sh:maxCount 0 forbids the property, since the absent sh:minCount is 0 + expect((shape as IShape).negativePredicates).toStrictEqual([`${FOAF_PREFIX}prop4`]); + + // An absent sh:minCount is 0, so the property is optional; an absent sh:maxCount is unbounded (-1) + const expected = new Map([ + [`${FOAF_PREFIX}prop1`, { cardinality: { min: 0, max: -1 }, optional: true }], + [`${FOAF_PREFIX}prop2`, { cardinality: { min: 1, max: -1 }, optional: false }], + [`${FOAF_PREFIX}prop3`, { cardinality: { min: 0, max: 2 }, optional: true }], + ]); + for (const [pred, { cardinality, optional }] of expected) { + expect((shape as IShape).get(pred)?.cardinality).toStrictEqual(cardinality); + expect((shape as IShape).get(pred)?.optional).toBe(optional); + } + }); + // ── Constraints ───────────────────────────────────────────────────────── it(`${name}: should handle sh:node (SHAPE) and sh:datatype (DATATYPE) constraints`, async () => { diff --git a/test/shape/shacl_shape_default_cardinality.ttl b/test/shape/shacl_shape_default_cardinality.ttl new file mode 100644 index 0000000..d707a67 --- /dev/null +++ b/test/shape/shacl_shape_default_cardinality.ttl @@ -0,0 +1,20 @@ +@prefix sh: . +@prefix foaf: . + + + a sh:NodeShape ; + sh:property [ + sh:path foaf:prop1 ; + ] ; + sh:property [ + sh:path foaf:prop2 ; + sh:minCount 1 ; + ] ; + sh:property [ + sh:path foaf:prop3 ; + sh:maxCount 2 ; + ] ; + sh:property [ + sh:path foaf:prop4 ; + sh:maxCount 0 ; + ] . From adb37a15db0142a13196e5899205538824d02d11 Mon Sep 17 00:00:00 2001 From: julianrojas87 Date: Fri, 9 Oct 2026 13:32:41 +0200 Subject: [PATCH 07/22] add: CI badge and on-demand dispatch --- .github/workflows/ci.yml | 1 + README.md | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e678610..653f65b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: - main pull_request: + workflow_dispatch: jobs: build-and-test: diff --git a/README.md b/README.md index 9921d7c..814af9e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # query-shape-matching-algorithm +[![CI](https://github.com/DiSHACLed/query-shape-matching-algorithm/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/DiSHACLed/query-shape-matching-algorithm/actions/workflows/ci.yml) [![npm version](https://badge.fury.io/js/query-shape-matching.svg)](https://www.npmjs.com/package/query-shape-matching) Reference implementation for the query/shape matching algorithm described in the discovery specification. From 9dc8dc19db8f00a4d56c68903c4c8576b328cbcf Mon Sep 17 00:00:00 2001 From: julianrojas87 Date: Fri, 9 Oct 2026 14:01:00 +0200 Subject: [PATCH 08/22] feat!: support negated triple patterns; fix unsound regex contradiction - FILTER NOT EXISTS, FILTER(!EXISTS) and MINUS over a single triple pattern (bound subject, fresh object) become excludedPredicates of the star pattern; a shape requiring the predicate (minCount >= 1) is a negative contradiction, so the pattern is REJECTED even on open shapes - translate sh:not of an input shape the same way - report other EXISTS/NOT EXISTS (kept as undecidable) and constructs discarded inside UNION branches in `unsupported` - count a contradicting shape reached through sh:node as closed - decide sh:pattern/regex contradictions only for flagless literal patterns that no string matches both (^foo vs bar was REJECTED) Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- lib/Binding.ts | 21 +++- lib/Triple.ts | 5 + lib/containment.ts | 62 +++++++++--- lib/query.ts | 206 ++++++++++++++++++++++++++++++++++----- test/containment.test.ts | 186 ++++++++++++++++++++++++++++++++++- 6 files changed, 436 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 814af9e..ef44158 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ It is implemented as a Node.js library to calculate the containment (subsumption - **Alignment Detection**: Identifies how closely a query matches the constraints defined in a shape. - **Dependency Tracking**: Handles links between shapes, detecting when a star pattern depends on another to be fully bounded. - **Explicit Negation**: A predicate declared under SHACL `sh:not` or limited by `sh:maxCount 0` (or a ShEx negative triple constraint) never matches. Because such a shape states that the property must be absent, a query needing it is `REJECTED` even when the shape is open. +- **Negated Triple Patterns**: A `FILTER NOT EXISTS` or `MINUS` over a single triple pattern `?s p ?o` (with `?s` bound by a required triple pattern and `?o` fresh) states that `?s` must not carry `p`. A shape that requires `p` (`sh:minCount` of at least 1) contradicts it, so the star pattern is `REJECTED` even when the shape is open. A `sh:not` of an input shape is translated the same way. ## How it Works @@ -547,7 +548,8 @@ The report carries: The detection logic is focused on **Triple Patterns** and **Star Patterns**. Currently, the following SPARQL features are not (yet) supported: - **Filter Expressions**: FILTERs are only used to detect contradictions with shape constraints (for example numeric comparisons against non-numeric datatype constraints). Expressions that cannot be safely compared to shape constraints are conservatively ignored for containment decisions. -- **Negative Patterns**: `MINUS` and `FILTER NOT EXISTS` are discarded. Their triple patterns describe solutions to exclude, not data the resource must hold, so collecting them as ordinary patterns would move the result in both directions. +- **Negative Patterns**: `MINUS` and `FILTER NOT EXISTS` are only supported over a single triple pattern, as negated triple patterns (see above). Any other form is discarded: its triple patterns describe solutions to exclude, not data the resource must hold, so collecting them as ordinary patterns would move the result in both directions. +- **Existence Tests**: Any other `EXISTS` or `NOT EXISTS` stays in its `FILTER` but is undecidable, so it never causes a rejection; the rest of the expression is still evaluated. It is reported as discarded. - **Complex Property Paths**: While simple paths are supported, complex or recursive property paths are not considered yet. - **Aggregates & Subqueries**: `GROUP BY`, `HAVING`, and subqueries are not processed. - **Federated Queries**: `SERVICE` clauses are discarded, since they read data from another endpoint. diff --git a/lib/Binding.ts b/lib/Binding.ts index df743e9..8a21b94 100644 --- a/lib/Binding.ts +++ b/lib/Binding.ts @@ -26,9 +26,10 @@ export interface IBindings { shouldVisitShape: () => boolean; /** * Indicate that the shape explicitly forbids a predicate required by the star pattern - * (SHACL sh:not / ShEx negative triple constraint). This is definitive evidence of - * incompatibility, independently of whether the shape is closed. - * @returns {boolean} whether the shape contradicts a required triple pattern + * (SHACL sh:not / ShEx negative triple constraint), or requires (minimum cardinality of at + * least one) a predicate the star pattern excludes (a negated triple pattern). This is + * definitive evidence of incompatibility, independently of whether the shape is closed. + * @returns {boolean} whether the shape contradicts the star pattern */ hasNegativeContradiction: () => boolean; /** @@ -194,6 +195,16 @@ export class Bindings implements IBindings { + } + // A predicate the star pattern excludes but the shape requires in its body is carried by + // every conforming node, so no data conforming to the shape answers the star pattern. + if (!this.strict) { + for (const excludedPredicate of starPattern.excludedPredicates ?? []) { + const declaredPredicate = shape.get(excludedPredicate); + if (declaredPredicate?.negative !== true && (declaredPredicate?.cardinality?.min ?? 0) >= 1) { + this.negativeContradiction = true; + } + } } // negative triple in a strict containment mean that the we can take any values // see paper https://link.springer.com/chapter/10.1007/978-3-319-25007-6_1 @@ -230,7 +241,7 @@ export class Bindings implements IBindings { } } } - this.fullyBounded = this.isEveryRequiredTripleBound(starPattern) && boundedUnion; + this.fullyBounded = this.isEveryRequiredTripleBound(starPattern) && boundedUnion && !this.negativeContradiction; } else { let boundedUnion = true; for (const unionBinding of this.unionBindings) { @@ -245,7 +256,7 @@ export class Bindings implements IBindings { } } } - this.fullyBounded = this.isEveryRequiredTripleBound(starPattern) && boundedUnion; + this.fullyBounded = this.isEveryRequiredTripleBound(starPattern) && boundedUnion && !this.negativeContradiction; } if (this.fullyBounded) { const cycle = new Set(); diff --git a/lib/Triple.ts b/lib/Triple.ts index 28de581..8724f99 100644 --- a/lib/Triple.ts +++ b/lib/Triple.ts @@ -9,6 +9,11 @@ export interface IStarPatternWithDependencies { starPattern: Map; name: string; isVariable: boolean; + /** + * Predicates the subject must not carry: negated triple patterns from a `FILTER NOT EXISTS` or + * `MINUS` over a single triple pattern, or negative predicates of an input shape. + */ + excludedPredicates?: Set; } export interface ITripleWithDependencies { diff --git a/lib/containment.ts b/lib/containment.ts index 81aed62..7ea340a 100644 --- a/lib/containment.ts +++ b/lib/containment.ts @@ -83,11 +83,17 @@ export function solveShapeQueryContainment({ query, shapes, dependentShapes, dec if (stats === undefined) { continue; } - if (evidence.closed) { + // As for a candidate shape, a contradiction is evidence of incompatibility: the referenced + // shape counts as closed and contributes no match. + const contradicts = evidence.bindings.hasNegativeContradiction(); + if (evidence.closed || contradicts) { stats.hasClosedShape = true; } else { stats.hasOpenShape = true; } + if (contradicts) { + continue; + } if (evidence.contained) { stats.containedTargets.add(evidence.shapeName); } @@ -605,8 +611,8 @@ function evaluateRegexCompatibility(args: unknown[], context: IFilterEvalContext return FilterTruth.TRUE; } - // FALSE when prefix/literal heuristics identify an obvious incompatibility. - if (hasRegexPrefixIncompatibility(variableConstraint.pattern, regexPattern)) { + // FALSE only when no string can match both literal patterns. + if (hasRegexPrefixIncompatibility(variableConstraint.pattern, variableConstraint.flags, regexPattern, regexFlags)) { return FilterTruth.FALSE; } @@ -676,31 +682,55 @@ function buildRegex(pattern: string, flags?: string): RegExp | undefined { } } -function hasRegexPrefixIncompatibility(shapePattern: string, filterPattern: string): boolean { +function hasRegexPrefixIncompatibility( + shapePattern: string, + shapeFlags: string | undefined, + filterPattern: string, + filterFlags: string, +): boolean { + // Flags change what a literal pattern matches (`i` folds case, `x` drops whitespace, `q` makes `^` and `$` literal, + // `m` anchors at every line), so a contradiction is only decided between patterns without flags. + if ((shapeFlags ?? '') !== '' || filterFlags !== '') { + return false; + } + const shapePrefix = extractAnchoredLiteralPrefix(shapePattern); const filterPrefix = extractAnchoredLiteralPrefix(filterPattern); - // Different anchored prefixes cannot both hold (e.g., ^foo vs ^bar). - if (shapePrefix !== undefined && filterPrefix !== undefined && shapePrefix.prefix !== filterPrefix.prefix) { - return true; + if (shapePrefix !== undefined && filterPrefix !== undefined) { + return !areAnchoredLiteralsCompatible(shapePrefix, filterPrefix); } + // A string can start with one literal and go on to contain another, so a prefix (^foo) contradicts no unanchored + // literal (bar), and neither do two unanchored literals. Only an exact literal (^foo$) excludes what it lacks. const shapeContains = extractContainsLiteral(shapePattern); const filterContains = extractContainsLiteral(filterPattern); - if (shapeContains !== undefined && filterContains !== undefined && shapeContains !== filterContains) { - return false; - } - - // Mixed anchored-prefix/plain-literal incompatibility checks. - if (shapePrefix !== undefined && filterContains !== undefined && !shapePrefix.prefix.includes(filterContains) && !filterContains.includes(shapePrefix.prefix)) { - return true; + if (shapePrefix?.anchoredEnd === true && filterContains !== undefined) { + return !shapePrefix.prefix.includes(filterContains); } - if (filterPrefix !== undefined && shapeContains !== undefined && !filterPrefix.prefix.includes(shapeContains) && !shapeContains.includes(filterPrefix.prefix)) { - return true; + if (filterPrefix?.anchoredEnd === true && shapeContains !== undefined) { + return !filterPrefix.prefix.includes(shapeContains); } return false; } +/** + * Whether some string matches both start-anchored literal patterns: ^foo and ^fo are compatible, ^foo and ^bar are not, + * and an end-anchored ^foo$ only admits prefixes of "foo". + */ +function areAnchoredLiteralsCompatible(first: IAnchoredLiteralPrefix, second: IAnchoredLiteralPrefix): boolean { + if (first.anchoredEnd && second.anchoredEnd) { + return first.prefix === second.prefix; + } + if (first.anchoredEnd) { + return first.prefix.startsWith(second.prefix); + } + if (second.anchoredEnd) { + return second.prefix.startsWith(first.prefix); + } + return first.prefix.startsWith(second.prefix) || second.prefix.startsWith(first.prefix); +} + function extractAnchoredLiteralPrefix(pattern: string): IAnchoredLiteralPrefix | undefined { const match = pattern.match(/^\^([A-Za-z0-9 _-]+)(\$)?$/); if (match === null) { diff --git a/lib/query.ts b/lib/query.ts index 1609de6..510c7aa 100644 --- a/lib/query.ts +++ b/lib/query.ts @@ -25,8 +25,11 @@ type NpsPath = Omit & { interface IAccumulatedTriples { triples: Map, isVariable: boolean } +/** A negated triple pattern, waiting to be attached to the star pattern of its subject. */ +interface INegatedPattern { subject: string, predicate: string, construct: 'MINUS' | 'NOT EXISTS' } + /** Facts about the input discovered while walking the algebra, not carried by the star patterns. */ -interface INormalizationState { matchesAnyTriple: boolean } +interface INormalizationState { matchesAnyTriple: boolean, negatedPatterns: INegatedPattern[] } /** * A query divided into star patterns @@ -55,9 +58,12 @@ export interface IQuery { } /** - * A SPARQL construct outside the supported profile, discarded during normalization. + * A SPARQL construct outside the supported profile, discarded during normalization. A `MINUS` or + * `NOT EXISTS` over a single triple pattern is supported as a negated triple pattern and is only + * reported here when it is not of that form. An `EXISTS` or `NOT EXISTS` expression that is + * discarded stays in its FILTER, where it is undecidable. */ -export type UnsupportedConstruct = 'MINUS' | 'SERVICE'; +export type UnsupportedConstruct = 'MINUS' | 'SERVICE' | 'EXISTS' | 'NOT EXISTS'; export interface IShapeToQueryOptions { linkedShapes?: IShape[] | Map; @@ -77,13 +83,13 @@ interface IShapeToQueryBuildContext { * * Supported mapping: * - Positive predicates -> triple patterns + * - Negative predicates -> negated triple patterns (excluded predicates of the star pattern) * - Datatype + numeric/regex facets -> FILTER expressions * - SHAPE constraints -> dependent star patterns (when linked shapes are provided) * - oneOf/xone branches -> UNION branches * * Not supported (yet): * - closed/open world semantics - * - negative predicates */ export function shapeToQuery(shape: IShape, options?: IShapeToQueryOptions): IQuery { const shapeIndex = normalizeShapeIndex(shape, options?.linkedShapes); @@ -178,6 +184,10 @@ function buildStarPatternFromShape(shape: IShape, context: IShapeToQueryBuildCon const tripleWithDependencies = predicateToTripleWithDependencies(shape.name, predicate, context, context.filters); starPattern.starPattern.set(predicate.name, tripleWithDependencies); } + const negativePredicates = shape.negativePredicates ?? []; + if (negativePredicates.length > 0) { + starPattern.excludedPredicates = new Set(negativePredicates); + } context.starPatterns.set(shape.name, starPattern); @@ -403,7 +413,7 @@ export function generateQuery(algebraQuery: Algebra.Operation, optional?: boolea const accumulatedUnion: IQuery[][] = []; const accumulatedFilters: Algebra.Expression[] = []; const accumulatedUnsupported = new Set(); - const state: INormalizationState = { matchesAnyTriple: false }; + const state: INormalizationState = { matchesAnyTriple: false, negatedPatterns: [] }; QueryHandler.collectFromAlgebra(algebraQuery, accumulatedTriples, accumulatedValues, accumulatedUnion, accumulatedFilters, optional, accumulatedUnsupported, state); @@ -419,13 +429,10 @@ function buildQuery( accumulatedUnion: IQuery[][], filters: Algebra.Expression[] = [], unsupported: Set = new Set(), - state: INormalizationState = { matchesAnyTriple: false }, + state: INormalizationState = { matchesAnyTriple: false, negatedPatterns: [] }, ): IQuery { const innerQuery = new Map(); const resp: IQuery = { starPatterns: innerQuery }; - if (unsupported.size > 0) { - resp.unsupported = Array.from(unsupported); - } if (state.matchesAnyTriple) { resp.matchesAnyTriple = true; } @@ -479,6 +486,23 @@ function buildQuery( addADependencyToStarPattern(tripleWithDependencies, innerQuery); } } + + // A negated triple pattern constrains the star pattern of its subject. Without a required triple + // pattern there, the subject need not be bound at all, so the negation is discarded instead. + for (const { subject, predicate, construct } of state.negatedPatterns) { + const starPattern = innerQuery.get(subject); + const hasRequiredTriple = starPattern !== undefined && + Array.from(starPattern.starPattern.values()).some(({ triple }) => triple.isOptional !== true); + if (!hasRequiredTriple) { + unsupported.add(construct); + continue; + } + starPattern.excludedPredicates ??= new Set(); + starPattern.excludedPredicates.add(predicate); + } + if (unsupported.size > 0) { + resp.unsupported = Array.from(unsupported); + } addUnionDependencies(resp) return resp; } @@ -569,7 +593,7 @@ namespace QueryHandler { accumulatedFilters: Algebra.Expression[], optional?: boolean, unsupported: Set = new Set(), - state: INormalizationState = { matchesAnyTriple: false }, + state: INormalizationState = { matchesAnyTriple: false, negatedPatterns: [] }, ): void { algebraUtils.visitOperation( rootAlgebra, @@ -580,7 +604,8 @@ namespace QueryHandler { }, // MINUS removes solutions and SERVICE evaluates elsewhere, so neither describes data the // candidate resource must hold. Their triple patterns must not be collected as required - // patterns: doing so both inflates and deflates relevance. + // patterns: doing so both inflates and deflates relevance. A MINUS over a single triple + // pattern is kept as a negated triple pattern instead. [Algebra.Types.MINUS]: { preVisitor: () => ({ continue: false }), visitor: handleMinus(accumulatedTriples, accumulatedValues, accumulatedFilters, accumulatedUnion, unsupported, state, optional), @@ -595,7 +620,7 @@ namespace QueryHandler { }, [Algebra.Types.UNION]: { preVisitor: () => ({ continue: false }), - visitor: handleUnion(accumulatedUnion, optional), + visitor: handleUnion(accumulatedUnion, unsupported, optional), }, [Algebra.Types.LEFT_JOIN]: { preVisitor: () => ({ continue: false }), @@ -606,9 +631,9 @@ namespace QueryHandler { visitor: handlePropertyPath(accumulatedTriples, accumulatedUnion, accumulatedValues, optional), }, [Algebra.Types.FILTER]: { - visitor: handleFilter(accumulatedFilters), - // Ignore the expression subtree for now (e.g. FILTER NOT EXISTS) to avoid collecting - // inner patterns that belong to the filter condition, not the query body. + visitor: handleFilter(accumulatedFilters, unsupported, state, optional), + // Ignore the expression subtree (e.g. FILTER NOT EXISTS) to avoid collecting inner + // patterns that belong to the filter condition, not the query body. preVisitor: () => ({ ignoreKeys: new Set(['expression']) }), }, }, @@ -616,8 +641,9 @@ namespace QueryHandler { } /** - * Keep the left operand of a MINUS and discard the right one: the removed patterns describe - * solutions to exclude, not data the candidate resource has to hold. + * Keep the left operand of a MINUS. The right one describes solutions to exclude, not data the + * candidate resource has to hold: it becomes a negated triple pattern when it is a single triple + * pattern of the supported form, and is discarded otherwise. */ function handleMinus(accumulatedTriples: Map, accumulatedValues: Map, @@ -627,8 +653,14 @@ namespace QueryHandler { state: INormalizationState, optional?: boolean): (element: Algebra.Minus) => void { return (element: Algebra.Minus): void => { - unsupported.add('MINUS'); - collectFromAlgebra(element.input[0], accumulatedTriples, accumulatedValues, accumulatedUnion, accumulatedFilters, optional, unsupported, state); + const [left, right] = element.input; + const negatedPattern = optional === true ? undefined : toNegatedPattern(right, left); + if (negatedPattern !== undefined) { + state.negatedPatterns.push({ ...negatedPattern, construct: 'MINUS' }); + } else { + unsupported.add('MINUS'); + } + collectFromAlgebra(left, accumulatedTriples, accumulatedValues, accumulatedUnion, accumulatedFilters, optional, unsupported, state); } } @@ -646,20 +678,143 @@ namespace QueryHandler { const requiredElements = joinElement[0]; const optionalElements = joinElement[1]; + // A filter of the OPTIONAL group only decides whether the optional part binds; it is not + // evaluated, but an EXISTS or NOT EXISTS inside it is still reported as discarded. + if (element.expression !== undefined) { + reportExistence(element.expression, unsupported); + } collectFromAlgebra(requiredElements, accumulatedTriples, accumulatedValues, accumulatedUnion, accumulatedFilters, undefined, unsupported, state); collectFromAlgebra(optionalElements, accumulatedTriples, accumulatedValues, accumulatedUnion, accumulatedFilters, true, unsupported, state); } } /** - * Record a top-level FILTER expression without traversing into its expression subtree. + * Record a top-level FILTER expression without traversing into its expression subtree. A + * `NOT EXISTS` over a single triple pattern of the supported form becomes a negated triple + * pattern; any other `EXISTS` or `NOT EXISTS` stays in the expression, where it is undecidable, + * and is reported as discarded. */ - function handleFilter(accumulatedFilters: Algebra.Expression[]): (element: Algebra.Filter) => void { + function handleFilter(accumulatedFilters: Algebra.Expression[], + unsupported: Set, + state: INormalizationState, + optional?: boolean): (element: Algebra.Filter) => void { return (element: Algebra.Filter): void => { + const negatedInput = getNegatedExistenceInput(element.expression); + const negatedPattern = negatedInput === undefined || optional === true ? + undefined : + toNegatedPattern(negatedInput, element.input); + if (negatedPattern !== undefined) { + state.negatedPatterns.push({ ...negatedPattern, construct: 'NOT EXISTS' }); + return; + } + reportExistence(element.expression, unsupported); accumulatedFilters.push(element.expression); } } + /** + * Return the graph pattern of a `NOT EXISTS` (or `!EXISTS`) expression, or undefined when the + * expression is anything else. + */ + function getNegatedExistenceInput(expression: Algebra.Expression): Algebra.Operation | undefined { + if (expression.subType === Algebra.ExpressionTypes.EXISTENCE) { + return expression.not ? expression.input : undefined; + } + if (expression.subType === Algebra.ExpressionTypes.OPERATOR && expression.operator === '!' && + expression.args.length === 1 && expression.args[0].subType === Algebra.ExpressionTypes.EXISTENCE && + !expression.args[0].not) { + return expression.args[0].input; + } + return undefined; + } + + /** + * Read a negated graph pattern as a negated triple pattern `?x p ?o`. The pattern must be a single + * triple pattern with a variable subject and an IRI predicate; its subject must be bound by a + * required triple pattern of `scope`, the group the negation applies to (the filtered group of a + * FILTER, the left operand of a MINUS); and its object must be a blank node or a variable that + * does not occur in `scope`. The negation then removes exactly the solutions whose subject + * carries `p`, so `MINUS` and `NOT EXISTS` coincide. Any other form returns undefined. + */ + function toNegatedPattern(negated: Algebra.Operation, scope: Algebra.Operation): + Omit | undefined { + if (negated.type !== Algebra.Types.BGP || negated.patterns.length !== 1) { + return undefined; + } + const { subject, predicate, object, graph } = negated.patterns[0]; + if (subject.termType !== 'Variable' || predicate.termType !== 'NamedNode' || + graph.termType !== 'DefaultGraph') { + return undefined; + } + const freshObject = object.termType === 'BlankNode' || + (object.termType === 'Variable' && !occursIn(object.value, scope)); + if (!freshObject || !getRequiredSubjects(scope).has(subject.value)) { + return undefined; + } + return { subject: subject.value, predicate: predicate.value }; + } + + /** + * Subjects of the triple patterns with an IRI predicate that every solution of `operation` binds. + * The approximation is conservative: operations it does not know contribute no subject. + */ + function getRequiredSubjects(operation: Algebra.Operation): Set { + switch (operation.type) { + case Algebra.Types.BGP: + return new Set(operation.patterns + .filter(pattern => pattern.subject.termType === 'Variable' && pattern.predicate.termType === 'NamedNode') + .map(pattern => pattern.subject.value)); + case Algebra.Types.JOIN: + return new Set(operation.input.flatMap(input => Array.from(getRequiredSubjects(input)))); + case Algebra.Types.LEFT_JOIN: + case Algebra.Types.MINUS: + return getRequiredSubjects(operation.input[0]); + case Algebra.Types.FILTER: + case Algebra.Types.EXTEND: + case Algebra.Types.DISTINCT: + case Algebra.Types.REDUCED: + case Algebra.Types.ORDER_BY: + case Algebra.Types.GRAPH: + return getRequiredSubjects(operation.input); + case Algebra.Types.UNION: { + const [first, ...rest] = operation.input.map(getRequiredSubjects); + return new Set(Array.from(first ?? []).filter(subject => rest.every(branch => branch.has(subject)))); + } + default: + return new Set(); + } + } + + /** + * Whether the variable occurs anywhere in the operation, including its expressions. + */ + function occursIn(variable: string, operation: unknown): boolean { + if (typeof operation !== 'object' || operation === null) { + return false; + } + const node = operation as { termType?: unknown, value?: unknown }; + if (node.termType === 'Variable' && node.value === variable) { + return true; + } + return Object.values(operation).some(child => occursIn(variable, child)); + } + + /** + * Report every `EXISTS` and `NOT EXISTS` of an expression as a discarded construct. + */ + function reportExistence(expression: Algebra.Expression, unsupported: Set, negated = false): void { + if (expression.subType === Algebra.ExpressionTypes.EXISTENCE) { + unsupported.add(expression.not !== negated ? 'NOT EXISTS' : 'EXISTS'); + return; + } + if (expression.subType === Algebra.ExpressionTypes.OPERATOR || expression.subType === Algebra.ExpressionTypes.NAMED) { + const negatesArgument = expression.subType === Algebra.ExpressionTypes.OPERATOR && expression.operator === '!'; + for (const argument of expression.args) { + reportExistence(argument, unsupported, negatesArgument); + } + } + } + /** * Dispatch property-path parsing to direct, cardinality, negated-set, or alternative handlers. */ @@ -694,12 +849,17 @@ namespace QueryHandler { /** * Convert a SPARQL UNION into a list of independently generated query branches. */ - function handleUnion(accumulatedUnion: IQuery[][], optional?: boolean): (element: Algebra.Union) => void { + function handleUnion(accumulatedUnion: IQuery[][], unsupported: Set, optional?: boolean): (element: Algebra.Union) => void { return (element: Algebra.Union): void => { const branches: Algebra.Operation[] = element.input; const currentUnion: IQuery[] = []; for (const branch of branches) { - currentUnion.push(generateQuery(branch, optional)) + const branchQuery = generateQuery(branch, optional); + // A construct discarded inside a branch is discarded from the query as a whole. + for (const construct of branchQuery.unsupported ?? []) { + unsupported.add(construct); + } + currentUnion.push(branchQuery); } accumulatedUnion.push(currentUnion); }; diff --git a/test/containment.test.ts b/test/containment.test.ts index 84030a4..27d7227 100644 --- a/test/containment.test.ts +++ b/test/containment.test.ts @@ -447,6 +447,38 @@ describe('solveShapeQueryContainment', () => { expect(solveShapeQueryContainment({ query, shapes: [shape] })).toStrictEqual(expectedResult); }); + it.each([ + ['^fo', '^foo', '', ContainmentResult.CONTAINED], + ['^foo', 'bar', '', ContainmentResult.CONTAINED], + ['^foo', '^FOO', 'i', ContainmentResult.CONTAINED], + ['^foo$', 'oo', '', ContainmentResult.CONTAINED], + ['^foo$', 'bar', '', ContainmentResult.REJECTED], + ['^foo$', '^foobar', '', ContainmentResult.REJECTED], + ])('should reject sh:pattern "%s" against FILTER regex "%s" (flags "%s") only when no string matches both', async (shapePattern, filterPattern, flags, expected) => { + const shacl = ` + PREFIX ex: + PREFIX sh: + PREFIX xsd: + + ex:patternShape a sh:NodeShape ; + sh:closed true ; + sh:property [ + sh:path ex:nickname ; + sh:datatype xsd:string ; + sh:pattern "${shapePattern}" + ] . + `; + const shape = await shaclShapeFromQuads(n3Parser.parse(shacl), 'https://www.example.ca/patternShape') as IShape; + const query = generateQuery(toAlgebra(sparqlParser.parse(` + PREFIX ex: + SELECT * WHERE { + ?s ex:nickname ?nickname . + FILTER(regex(str(?nickname), "${filterPattern}", "${flags}")) + }`))); + + expect(solveShapeQueryContainment({ query, shapes: [shape] }).result).toBe(expected); + }); + it('should support FILTER datatype(?v) comparison with VALUES', () => { const queryString = ` PREFIX ex: @@ -1930,12 +1962,12 @@ describe('supported profile and explicit negation', () => { describe('discarded constructs', () => { it('should not let a MINUS block lower the result', async () => { - expect(await classify('SELECT * WHERE { ?s foaf:name ?n . MINUS { ?s ex:age ?a } }', closedNameShape)) + expect(await classify('SELECT * WHERE { ?s foaf:name ?n . MINUS { ?s ex:age ?a . ?s ex:email ?e } }', closedNameShape)) .toBe(ContainmentResult.CONTAINED); }); it('should not let a MINUS block raise the result', async () => { - expect(await classify('SELECT * WHERE { ?s ex:unknown ?x . MINUS { ?s foaf:name ?n } }', closedNameShape)) + expect(await classify('SELECT * WHERE { ?s ex:unknown ?x . MINUS { ?s foaf:name ?n . ?s ex:email ?e } }', closedNameShape)) .toBe(ContainmentResult.REJECTED); }); @@ -1946,10 +1978,35 @@ describe('supported profile and explicit negation', () => { it('should record the discarded constructs on the query', () => { const query = generateQuery(toAlgebra(new SPARQLParser().parse( - `${P} SELECT * WHERE { ?s foaf:name ?n . MINUS { ?s ex:age ?a } }`))); + `${P} SELECT * WHERE { ?s foaf:name ?n . MINUS { ?s ex:age ?a . ?s ex:email ?e } }`))); expect(query.unsupported).toStrictEqual(['MINUS']); }); + it('should record a construct discarded inside a UNION branch on the query', () => { + const query = generateQuery(toAlgebra(new SPARQLParser().parse( + `${P} SELECT * WHERE { { ?s foaf:name ?n . SERVICE { ?s ex:age ?a } } UNION { ?s ex:email ?e } }`))); + expect(query.unsupported).toStrictEqual(['SERVICE']); + }); + + it.each([ + ['FILTER EXISTS { ?s ex:age ?a }', ['EXISTS']], + ['FILTER(?n = "a" || NOT EXISTS { ?s ex:age ?a })', ['NOT EXISTS']], + ['FILTER(!EXISTS { ?s ex:age ?a . ?s ex:email ?e })', ['NOT EXISTS']], + ['OPTIONAL { ?s ex:email ?e FILTER NOT EXISTS { ?s ex:age ?a } }', ['NOT EXISTS']], + ])('should record a discarded existence test in %s', (pattern, unsupported) => { + const query = generateQuery(toAlgebra(new SPARQLParser().parse( + `${P} SELECT * WHERE { ?s foaf:name ?n . ${pattern} }`))); + expect(query.unsupported).toStrictEqual(unsupported); + }); + + it('should keep the decidable part of a FILTER that contains a discarded existence test', async () => { + const shape = `${SHAPE_PREFIXES} @prefix xsd: . + a sh:NodeShape ; + sh:property [ sh:path ex:age ; sh:datatype xsd:integer ; sh:minInclusive 0 ] .`; + expect(await classify('SELECT * WHERE { ?s ex:age ?a . FILTER(?a < 0 && NOT EXISTS { ?s ex:x ?y . ?s ex:z ?w }) }', shape)) + .toBe(ContainmentResult.WEAKLY_REJECTED); + }); + it('should throw when every triple pattern came from a discarded construct', () => { const query = generateQuery(toAlgebra(new SPARQLParser().parse( `${P} SELECT * WHERE { SERVICE { ?s foaf:name ?n } }`))); @@ -1959,6 +2016,129 @@ describe('supported profile and explicit negation', () => { }); }); + describe('negated triple patterns', () => { + const requiresPwShape = `${SHAPE_PREFIXES} + a sh:NodeShape ; + sh:property [ sh:path foaf:name ], [ sh:path ex:pw ; sh:minCount 1 ] .`; + const optionalPwShape = `${SHAPE_PREFIXES} + a sh:NodeShape ; + sh:property [ sh:path foaf:name ], [ sh:path ex:pw ] .`; + const forbidsPwShape = `${SHAPE_PREFIXES} + a sh:NodeShape ; + sh:property [ sh:path foaf:name ] ; + sh:not [ sh:path ex:pw ; sh:minCount 1 ] .`; + + function normalize(pattern: string): IQuery { + return generateQuery(toAlgebra(new SPARQLParser().parse( + `${P} SELECT * WHERE { ?s foaf:name ?n . ${pattern} }`))); + } + + describe.each([ + 'FILTER NOT EXISTS { ?s ex:pw ?p }', + 'FILTER(!EXISTS { ?s ex:pw ?p })', + 'MINUS { ?s ex:pw ?p }', + 'MINUS { ?s ex:pw [] }', + ])('%s', (pattern) => { + const query = `SELECT * WHERE { ?s foaf:name ?n . ${pattern} }`; + + it('should become an excluded predicate of the star pattern of its subject', () => { + const normalized = normalize(pattern); + expect(normalized.starPatterns.get('s')!.excludedPredicates).toStrictEqual(new Set(['http://example.org/pw'])); + expect(normalized.unsupported).toBeUndefined(); + expect(normalized.filters).toBeUndefined(); + }); + + it('should be rejected by a shape that requires the predicate, although the shape is open', async () => { + expect(await classify(query, requiresPwShape)).toBe(ContainmentResult.REJECTED); + }); + + it.each([ + ['declares the predicate optional', optionalPwShape], + ['forbids the predicate', forbidsPwShape], + ['is closed without declaring the predicate', closedNameShape], + ])('should not prevent containment by a shape that %s', async (_, shape) => { + expect(await classify(query, shape)).toBe(ContainmentResult.CONTAINED); + }); + }); + + it.each([ + ['an object bound by the group', 'FILTER NOT EXISTS { ?s ex:pw ?n }', 'NOT EXISTS'], + ['a constant object', 'FILTER NOT EXISTS { ?s ex:pw ex:secret }', 'NOT EXISTS'], + ['a subject the group does not bind', 'FILTER NOT EXISTS { ?x ex:pw ?p }', 'NOT EXISTS'], + ['more than one triple pattern', 'FILTER NOT EXISTS { ?s ex:pw ?p . ?p ex:kind ?k }', 'NOT EXISTS'], + ['no variable shared with the left operand', 'MINUS { ?x ex:pw ?p }', 'MINUS'], + ['an OPTIONAL group as its scope', 'OPTIONAL { ?s ex:email ?e MINUS { ?s ex:pw ?p } }', 'MINUS'], + ])('should discard a negation with %s', async (_, pattern, construct) => { + const normalized = normalize(pattern); + expect(normalized.unsupported).toStrictEqual([construct]); + expect(normalized.starPatterns.get('s')!.excludedPredicates).toBeUndefined(); + expect(await classify(`SELECT * WHERE { ?s foaf:name ?n . ${pattern} }`, requiresPwShape)) + .toBe(ContainmentResult.CONTAINED); + }); + + it('should discard a negation whose subject is bound only by an OPTIONAL pattern', () => { + const query = generateQuery(toAlgebra(new SPARQLParser().parse( + `${P} SELECT * WHERE { ?y foaf:name ?n . OPTIONAL { ?s ex:email ?e } FILTER NOT EXISTS { ?s ex:pw ?p } }`))); + expect(query.unsupported).toStrictEqual(['NOT EXISTS']); + expect(query.starPatterns.get('s')!.excludedPredicates).toBeUndefined(); + }); + + describe('on a nested star pattern', () => { + const shapes = `${SHAPE_PREFIXES} + a sh:NodeShape ; + sh:property [ sh:path ex:address ; sh:node ] . + a sh:NodeShape ; + sh:property [ sh:path ex:city ], [ sh:path ex:zip ; sh:minCount 1 ] .`; + + async function report(rawQuery: string): Promise { + const query = generateQuery(toAlgebra(new SPARQLParser().parse(`${P} ${rawQuery}`))); + const quads = new N3.Parser().parse(shapes); + const person = await parseShaclShape(quads, 'http://example.org/PersonShape'); + const address = await parseShaclShape(quads, 'http://example.org/AddressShape'); + return solveShapeQueryContainment({ query, shapes: [person], dependentShapes: [address] }); + } + + it('should be contained without the negation', async () => { + expect((await report('SELECT * WHERE { ?p ex:address ?a . ?a ex:city ?c }')).result) + .toBe(ContainmentResult.CONTAINED); + }); + + it('should refute the sh:node dependency when the referenced shape requires the predicate', async () => { + const result = await report('SELECT * WHERE { ?p ex:address ?a . ?a ex:city ?c . FILTER NOT EXISTS { ?a ex:zip ?z } }'); + expect(result.starPatternsContainment.get('p')!.result).toBe(ContainmentResult.WEAKLY_REJECTED); + expect(result.starPatternsContainment.get('a')!.result).toBe(ContainmentResult.WEAKLY_REJECTED); + expect(result.result).toBe(ContainmentResult.WEAKLY_REJECTED); + }); + }); + + describe('from a negative predicate of an input shape', () => { + const input = `${SHAPE_PREFIXES} + a sh:NodeShape ; + sh:property [ sh:path foaf:name ; sh:minCount 1 ] ; + sh:not [ sh:path ex:pw ; sh:minCount 1 ] .`; + + async function compare(rawTarget: string): Promise { + const sourceShape = await parseShaclShape(new N3.Parser().parse(input), 'http://example.org/In'); + const target = await parseShaclShape(new N3.Parser().parse(rawTarget), 'http://example.org/S'); + return solveShapeShapeContainment({ sourceShape, targetShapes: [target] }).result; + } + + it('should become an excluded predicate of the translated star pattern', async () => { + const sourceShape = await parseShaclShape(new N3.Parser().parse(input), 'http://example.org/In'); + expect(shapeToQuery(sourceShape).starPatterns.get('http://example.org/In')!.excludedPredicates) + .toStrictEqual(new Set(['http://example.org/pw'])); + }); + + it('should be rejected by a target shape that requires the predicate', async () => { + expect(await compare(requiresPwShape)).toBe(ContainmentResult.REJECTED); + }); + + it('should be contained by a target shape that does not require the predicate', async () => { + expect(await compare(optionalPwShape)).toBe(ContainmentResult.CONTAINED); + }); + }); + }); + describe('queries that ask for every triple', () => { // `?s ?p ?o` is maximally unselective rather than unanswerable: it asks for every triple of // the resource, so any resource is relevant and the honest result is CONTAINED. From 949ce103527f61c8c117f9e172b2c2f14dd90992 Mon Sep 17 00:00:00 2001 From: julianrojas87 Date: Fri, 9 Oct 2026 16:00:22 +0200 Subject: [PATCH 09/22] feat!: support SHACL targets - Parse sh:targetNode, sh:targetClass, implicit class targets (a shape that is also an rdfs:Class), sh:targetSubjectsOf and sh:targetObjectsOf into `IShape.targets`, and sh:ignoredProperties into `IShape.ignoredProperties`. A shape that only declares targets is no longer a parse error. - Source shapes: shapeToQuery translates the targets of the given shape, not those of shapes reached through sh:node, into patterns about its focus node: rdf:type for a class target, the predicate for a subjects-of target, `subjectValues` for node targets, and an incoming star pattern for an objects-of target. Several class and subjects-of targets become UNION branches; other combinations are ignored and reported as `TARGETS`. - Candidate shapes: targets imply constraints (rdf:type with a target class, matched by IRI; the predicate of a subjects-of target) that match like declared ones and that a closed shape allows, as it allows its sh:ignoredProperties. They are only required when every focus node carries them. A shape with node targets only cannot contain a star pattern about another constant subject. Targets are ignored through sh:node. - A shape with targets only constrains its focus nodes, so its closedness and negative constraints count only for star patterns whose matching nodes are all in a target (`describesStarPattern`). Otherwise it counts as open, and only shapes that describe a pattern can make it REJECTED. Shapes without targets behave as before. - A star pattern whose triple patterns are all in UNION branches can now be contained, and star patterns without constraints are pruned from a translated shape. - Blank-node subjects of a query are variables (`isVariable: true`). - Add lib/target.ts and test/target.test.ts; update README. Co-Authored-By: Claude Opus 5.5 --- README.md | 53 ++++++++- index.ts | 1 + lib/Binding.ts | 39 +++++-- lib/Shape.ts | 75 ++++++++++++- lib/Triple.ts | 6 ++ lib/constant.ts | 13 ++- lib/containment.ts | 28 ++++- lib/query.ts | 146 +++++++++++++++++++++++-- lib/shacl.ts | 122 ++++++++++++++++++++- lib/target.ts | 161 ++++++++++++++++++++++++++++ test/query.test.ts | 20 ++-- test/target.test.ts | 254 ++++++++++++++++++++++++++++++++++++++++++++ 12 files changed, 877 insertions(+), 41 deletions(-) create mode 100644 lib/target.ts create mode 100644 test/target.test.ts diff --git a/README.md b/README.md index ef44158..61d477f 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ It is implemented as a Node.js library to calculate the containment (subsumption - **Dependency Tracking**: Handles links between shapes, detecting when a star pattern depends on another to be fully bounded. - **Explicit Negation**: A predicate declared under SHACL `sh:not` or limited by `sh:maxCount 0` (or a ShEx negative triple constraint) never matches. Because such a shape states that the property must be absent, a query needing it is `REJECTED` even when the shape is open. - **Negated Triple Patterns**: A `FILTER NOT EXISTS` or `MINUS` over a single triple pattern `?s p ?o` (with `?s` bound by a required triple pattern and `?o` fresh) states that `?s` must not carry `p`. A shape that requires `p` (`sh:minCount` of at least 1) contradicts it, so the star pattern is `REJECTED` even when the shape is open. A `sh:not` of an input shape is translated the same way. +- **SHACL Targets**: `sh:targetNode`, `sh:targetClass` (including implicit class targets), `sh:targetSubjectsOf` and `sh:targetObjectsOf` say which nodes a shape describes. The targets of a source shape become patterns about its focus node; the targets of a candidate shape are matched, and limit which star patterns its closedness can reject. See [Targets](#targets). ## How it Works @@ -164,8 +165,8 @@ The library returns a report where each star pattern is assigned one of the foll | **`CONTAINED`** | Every *required* triple pattern of every star pattern, including nested ones, is matched by the shape. Unmatched `OPTIONAL` patterns do not prevent this. | | **`ALIGNED`** | At least one triple pattern from the root star pattern matches on an open shape. | | **`UNALINGED`** | Partial root star pattern match on a closed shape; or match on a nested star pattern while having no match on root star pattern. A predicate the shape declares but whose `sh:node` dependency could not be established counts as such a partial match. | -| **`WEAKLY_REJECTED`** | No triple pattern matches, and at least one candidate shape is open and does not forbid the predicates involved. | -| **`REJECTED`** | No triple pattern matches, and every shape that describes the pattern either is closed or explicitly forbids a required predicate through `sh:not` or `sh:maxCount 0`. Also returned when there is no candidate shape. | +| **`WEAKLY_REJECTED`** | No triple pattern matches, and at least one candidate shape that describes the pattern is open and does not forbid the predicates involved, or no candidate shape describes it (see [Targets](#targets)). | +| **`REJECTED`** | No triple pattern matches, and every shape that describes the pattern (at least one) either is closed or explicitly forbids a required predicate through `sh:not` or `sh:maxCount 0`. Also returned when there is no candidate shape. | ### Nested dependencies @@ -197,6 +198,52 @@ A source shape follows the cardinality semantics of its language. In SHACL, an a without `sh:minCount` becomes an optional triple pattern. In ShEx, a triple constraint without a cardinality occurs exactly once, so it becomes a required one. +### Targets + +The SHACL targets of a shape (`IShape.targets`: `nodes`, `classes`, `subjectsOf`, `objectsOf`) +say which nodes it describes when it is used on its own. A shape that is also an +`rdfs:Class` (or an instance of a class declared a subclass of `rdfs:Class` in the shapes +graph) targets its own instances. SHACL ignores targets when a shape is reached through +`sh:node`, and so does this library, on both sides. + +**Source shapes.** `shapeToQuery` translates the targets of the given shape into patterns +about its focus node: + +- a class target requires `rdf:type` with the class as value, and a subjects-of target + requires its predicate; +- node targets restrict the subject of the focus star pattern to the target nodes + (`IStarPatternWithDependencies.subjectValues`); +- an objects-of target `p` adds a star pattern `?s p ` that depends on the focus + star pattern; +- several class and subjects-of targets become the branches of a UNION, as the branches + of an `sh:or` do, so a resource contains the shape only when every alternative is + covered. + +Node and objects-of targets combined with other targets, or several objects-of targets, +cannot be represented: they are ignored and reported as `TARGETS` in `unsupported`, which +can only over-estimate relevance. A shape without constraints whose targets are node +targets asks for any triple about those nodes, and sets `matchesAnyTriple`. + +**Candidate shapes.** A target implies constraints that the shape does not declare: a +class target implies `rdf:type` with a target class as value (matched by IRI, without +subclass reasoning), and a subjects-of target implies its predicate. They match like +declared constraints, and a closed shape does not forbid them. Since the focus nodes are +the union of the nodes of the targets, an implied constraint is only required — for +negated triple patterns — when every focus node carries it: when all the targets are +class targets, or the only target is one subjects-of target. A closed shape also allows +its `sh:ignoredProperties`. A shape whose only targets are node targets describes those +nodes and nothing else, so it cannot contain a star pattern about another constant +subject. + +A shape without targets describes every star pattern, as before. A shape with targets +only constrains the nodes of its targets, so its closedness and negative constraints +count only for a star pattern whose matching nodes are all in a target: its subject is a +node target, it requires `rdf:type` with target classes only, it requires the predicate +of a subjects-of target, or it is reached through a required triple pattern whose +predicate is that of an objects-of target. For any other star pattern the shape neither +supports nor softens a rejection, and its matches count as on an open shape. A closed +shape targeting persons therefore does not reject a query about places. + ### Examples of Containment Results #### 1. `CONTAINED` @@ -541,7 +588,7 @@ The report carries: | `result` | The aggregate result for the complete query (see above). | | `starPatternsContainment` | Per star pattern: its result, the matching shape IRIs, and the predicate-level bindings. | | `visitShapeBoundedResource` | Per shape: whether at least one triple pattern bound to it. Independent of `result`, and usable to decide whether to retrieve the data behind a shape. | -| `unsupported` | Constructs discarded during normalization, if any. | +| `unsupported` | Constructs discarded during normalization, if any, including the targets of a source shape that could not be translated (`TARGETS`). | ## SPARQL Limitations diff --git a/index.ts b/index.ts index ca3fd6a..11b4741 100644 --- a/index.ts +++ b/index.ts @@ -6,4 +6,5 @@ export * from './lib/containment'; export * from './lib/Shape'; export * from './lib/Triple'; export * from './lib/Binding'; +export * from './lib/target'; export * from './lib/parser-policy'; diff --git a/lib/Binding.ts b/lib/Binding.ts index 8a21b94..c7afab6 100644 --- a/lib/Binding.ts +++ b/lib/Binding.ts @@ -1,6 +1,7 @@ import { ConstraintType, IConstraint, IPredicate, IShape, OneOfPathIndexed } from "./Shape"; import { IStarPatternWithDependencies, type ITriple, Triple } from "./Triple"; import { RDF } from "./constant"; +import { impliedPredicateNames, impliedPredicates, isSubjectOutsideNodeTargets } from "./target"; /** * A binding from a query to a shape @@ -130,13 +131,16 @@ export class Bindings implements IBindings { private dependencyEvidence = new Map(); private typeOfContainment: IContainmentType = { result: ContainmentType.NONE, unContaineStarPattern: [] }; private alreadyTraversed: Map; + // Whether the shape is used on its own, so that its targets apply; false when it is reached through sh:node + private readonly useTargets: boolean; public readonly starPattern: IStarPatternWithDependencies; - public constructor(shape: IShape, starPattern: IStarPatternWithDependencies, linkedShape: Map, unionStarPattern?: IStarPatternWithDependencies[][], strict?: boolean, alreadyTraversed?: Map) { + public constructor(shape: IShape, starPattern: IStarPatternWithDependencies, linkedShape: Map, unionStarPattern?: IStarPatternWithDependencies[][], strict?: boolean, alreadyTraversed?: Map, useTargets?: boolean) { this.starPattern = starPattern; this.strict = strict ?? false; this.closedShape = shape.closed; this.alreadyTraversed = alreadyTraversed ?? new Map(); + this.useTargets = useTargets ?? true; for (const { triple } of starPattern.starPattern.values()) { this.bindings.set(triple.predicate, undefined); this.allOptional = this.allOptional && triple.isOptional === true; @@ -144,6 +148,11 @@ export class Bindings implements IBindings { for (const predicate of shape.getAll()) { this.shapePredicateBind.set(predicate.name, false); } + for (const predicate of impliedPredicateNames(shape, this.useTargets)) { + if (!this.shapePredicateBind.has(predicate)) { + this.shapePredicateBind.set(predicate, false); + } + } this.oneOfs = shape.oneOfIndexed.map((oneOfs: OneOfPathIndexed[]) => new OneOfBinding(oneOfs)); this.calculateBinding(shape, starPattern, linkedShape, unionStarPattern ?? []); @@ -154,7 +163,7 @@ export class Bindings implements IBindings { private calculateBinding(shape: IShape, starPattern: IStarPatternWithDependencies, linkedShape: Map, unionStarPattern: IStarPatternWithDependencies[][]): void { for (const union of unionStarPattern) { - this.unionBindings.push(new UnionBinding(shape, union, linkedShape)); + this.unionBindings.push(new UnionBinding(shape, union, linkedShape, this.useTargets)); } const negatedTriples: ITriple[] = []; for (const { triple, dependencies } of starPattern.starPattern.values()) { @@ -175,6 +184,10 @@ export class Bindings implements IBindings { predicates = predicates.concat(predicatesOneOf); } } + // constraints the shape implies through its targets or sh:ignoredProperties + if (!negatedByShape) { + predicates = predicates.concat(impliedPredicates(shape, triple.predicate, this.useTargets)); + } if (singlePredicate === undefined && predicates.length === 0) { // A shape that explicitly forbids the predicate contradicts the triple pattern @@ -201,11 +214,15 @@ export class Bindings implements IBindings { if (!this.strict) { for (const excludedPredicate of starPattern.excludedPredicates ?? []) { const declaredPredicate = shape.get(excludedPredicate); - if (declaredPredicate?.negative !== true && (declaredPredicate?.cardinality?.min ?? 0) >= 1) { + const required = [declaredPredicate, ...impliedPredicates(shape, excludedPredicate, this.useTargets)] + .some(predicate => predicate !== undefined && predicate.negative !== true && (predicate.cardinality?.min ?? 0) >= 1); + if (declaredPredicate?.negative !== true && required) { this.negativeContradiction = true; } } } + // A shape about given nodes only cannot contain a star pattern about another constant subject. + const subjectOutsideTargets = this.useTargets && isSubjectOutsideNodeTargets(shape, starPattern); // negative triple in a strict containment mean that the we can take any values // see paper https://link.springer.com/chapter/10.1007/978-3-319-25007-6_1 if (!this.strict) { @@ -241,7 +258,8 @@ export class Bindings implements IBindings { } } } - this.fullyBounded = this.isEveryRequiredTripleBound(starPattern) && boundedUnion && !this.negativeContradiction; + this.fullyBounded = this.isEveryRequiredTripleBound(starPattern) && boundedUnion && !this.negativeContradiction + && !subjectOutsideTargets; } else { let boundedUnion = true; for (const unionBinding of this.unionBindings) { @@ -256,7 +274,8 @@ export class Bindings implements IBindings { } } } - this.fullyBounded = this.isEveryRequiredTripleBound(starPattern) && boundedUnion && !this.negativeContradiction; + this.fullyBounded = this.isEveryRequiredTripleBound(starPattern) && boundedUnion && !this.negativeContradiction + && !subjectOutsideTargets; } if (this.fullyBounded) { const cycle = new Set(); @@ -306,7 +325,8 @@ export class Bindings implements IBindings { */ private isEveryRequiredTripleBound(starPattern: IStarPatternWithDependencies): boolean { if (starPattern.starPattern.size === 0) { - return false; + // All the triple patterns of the star pattern are in UNION branches, which are checked separately + return this.unionBindings.length > 0; } let boundCount = 0; for (const { triple } of starPattern.starPattern.values()) { @@ -410,7 +430,8 @@ export class Bindings implements IBindings { if (currentLinkedShape === undefined) { return ConstraintResult.RESPECT; } - const nestedBinding = new Bindings(currentLinkedShape, dependencies, linkedShape, [], this.strict); + // SHACL ignores the targets of a shape reached through sh:node + const nestedBinding = new Bindings(currentLinkedShape, dependencies, linkedShape, [], this.strict, undefined, false); this.recordDependencyEvidence(dependencies.name, currentLinkedShape, nestedBinding); for (const [starPatternName, evidence] of nestedBinding.dependencyEvidence) { for (const entry of evidence) { @@ -666,12 +687,12 @@ export class UnionBinding { public readonly shape: IShape; public readonly linkedShape: Map; - public constructor(shape: IShape, union: IStarPatternWithDependencies[], linkedShape: Map) { + public constructor(shape: IShape, union: IStarPatternWithDependencies[], linkedShape: Map, useTargets?: boolean) { this.shape = shape; this.linkedShape = linkedShape; this.bindings = []; for (const starPattern of union) { - this.bindings.push(new Bindings(shape, starPattern, linkedShape)); + this.bindings.push(new Bindings(shape, starPattern, linkedShape, undefined, undefined, undefined, useTargets)); } this.hasOneContained = this.determineHasOneAtLeastContainment(); this.areAllContained = this.determineAllContained(); diff --git a/lib/Shape.ts b/lib/Shape.ts index 19cf5d1..9462036 100644 --- a/lib/Shape.ts +++ b/lib/Shape.ts @@ -107,6 +107,22 @@ export const enum ConstraintType { // Is bound by a literal datatype IRI DATATYPE, } +/** + * The SHACL targets of a shape: the nodes it describes when it is used on its own. SHACL ignores + * them when the shape is reached through `sh:node`. A shape with no target at all is treated as + * describing any node. + */ +export interface IShapeTargets { + // sh:targetNode values (a literal is kept in its JSON-quoted form) + nodes: string[]; + // sh:targetClass values, and the shape itself when it is also an rdfs:Class (implicit class target) + classes: string[]; + // sh:targetSubjectsOf values + subjectsOf: string[]; + // sh:targetObjectsOf values + objectsOf: string[]; +} + /** * A simple Shape object */ @@ -116,6 +132,10 @@ export interface IShapeObj { positivePredicates: string[]; negativePredicates?: string[]; oneOf?: OneOf[]; + // only present when the shape declares at least one target + targets?: IShapeTargets; + // sh:ignoredProperties of a closed shape; only present when there is at least one + ignoredProperties?: string[]; } /** @@ -128,6 +148,28 @@ export interface IShapeArgs { linkedShapeIri?: string[] closed?: boolean; oneOf?: OneOf[]; + targets?: Partial; + ignoredProperties?: string[]; +} + +/** + * The targets of a shape, with an empty list for every kind it does not declare. + */ +export function targetsOf(shape: IShapeObj): IShapeTargets { + return { + nodes: shape.targets?.nodes ?? [], + classes: shape.targets?.classes ?? [], + subjectsOf: shape.targets?.subjectsOf ?? [], + objectsOf: shape.targets?.objectsOf ?? [], + }; +} + +/** + * Whether a shape declares at least one target. + */ +export function hasTargets(shape: IShapeObj): boolean { + const { nodes, classes, subjectsOf, objectsOf } = targetsOf(shape); + return nodes.length + classes.length + subjectsOf.length + objectsOf.length > 0; } export type OneOf = OneOfPath[]; @@ -165,15 +207,24 @@ export class Shape implements IShape { private readonly predicates = new Map(); public readonly oneOf: OneOf[]; public readonly oneOfIndexed: OneOfIndexed[] = []; + public readonly targets: IShapeTargets; + public readonly ignoredProperties: string[]; /** * * @param {IShapeArgs} args - The argument to build a shape */ - public constructor({ name, positivePredicates, negativePredicates, closed, oneOf }: IShapeArgs) { + public constructor({ name, positivePredicates, negativePredicates, closed, oneOf, targets, ignoredProperties }: IShapeArgs) { this.name = name; this.closed = closed ?? false; this.oneOf = oneOf ? oneOf : []; + this.targets = { + nodes: [...targets?.nodes ?? []], + classes: [...targets?.classes ?? []], + subjectsOf: [...targets?.subjectsOf ?? []], + objectsOf: [...targets?.objectsOf ?? []], + }; + this.ignoredProperties = [...ignoredProperties ?? []]; const linkedShapeIri = new Set(); for (const oneOf of this.oneOf) { const currentOneOf: OneOfIndexed = []; @@ -251,6 +302,11 @@ export class Shape implements IShape { Object.freeze(this.closed); Object.freeze(this.oneOf); Object.freeze(this.oneOfIndexed); + for (const values of Object.values(this.targets)) { + Object.freeze(values); + } + Object.freeze(this.targets); + Object.freeze(this.ignoredProperties); Object.freeze(this); } @@ -270,13 +326,20 @@ export class Shape implements IShape { } public toObject(): IShapeObj { - return { + const obj: IShapeObj = { name: this.name, closed: this.closed, positivePredicates: this.positivePredicates, negativePredicates: this.negativePredicates, oneOf: this.oneOf }; + if (hasTargets(this)) { + obj.targets = this.targets; + } + if (this.ignoredProperties.length > 0) { + obj.ignoredProperties = this.ignoredProperties; + } + return obj; } public toJson(): IShapeJson{ @@ -410,6 +473,8 @@ export interface IRawShape { positivePredicates: IPredicateParts[]; negativePredicates?: string[]; oneOf?: IRawOneOf[]; + targets?: Partial; + ignoredProperties?: string[]; } export type IRawOneOfPath = IPredicateParts[]; @@ -431,6 +496,8 @@ export function buildShapeFromRaw(raw: IRawShape): IShape | ShapeError { negativePredicates: raw.negativePredicates ?? [], closed: raw.closed ?? false, oneOf, + targets: raw.targets, + ignoredProperties: raw.ignoredProperties, }); } catch (error: unknown) { return error as ShapeError; @@ -609,6 +676,10 @@ function detectShapeFormat(quads: RDFJS.Quad[]): ShapeFormat | undefined { || quad.predicate.equals(SHACL.terms.xone) || quad.predicate.equals(SHACL.terms.node) || quad.predicate.equals(SHACL.terms.datatype) + || quad.predicate.equals(SHACL.terms.targetNode) + || quad.predicate.equals(SHACL.terms.targetClass) + || quad.predicate.equals(SHACL.terms.targetSubjectsOf) + || quad.predicate.equals(SHACL.terms.targetObjectsOf) ) { shaclScore++; } diff --git a/lib/Triple.ts b/lib/Triple.ts index 8724f99..58b6885 100644 --- a/lib/Triple.ts +++ b/lib/Triple.ts @@ -14,6 +14,12 @@ export interface IStarPatternWithDependencies { * `MINUS` over a single triple pattern, or negative predicates of an input shape. */ excludedPredicates?: Set; + /** + * The constants the subject is one of, when the star pattern is about given nodes only: the node + * targets (sh:targetNode) of an input shape. A star pattern whose subject is a constant in the + * query does not need it; its name is that constant. + */ + subjectValues?: Set; } export interface ITripleWithDependencies { diff --git a/lib/constant.ts b/lib/constant.ts index e6bc6a4..41ebd9c 100644 --- a/lib/constant.ts +++ b/lib/constant.ts @@ -41,7 +41,12 @@ export const SHACL = createVocabulary( "node", "or", "xone", - "not" + "not", + "targetNode", + "targetClass", + "targetSubjectsOf", + "targetObjectsOf", + "ignoredProperties" ); export const SHEX = createVocabulary( @@ -80,6 +85,12 @@ export const RDF = createVocabulary( "nil" ); +export const RDFS = createVocabulary( + "http://www.w3.org/2000/01/rdf-schema#", + "Class", + "subClassOf" +); + export const XSD = createVocabulary( "http://www.w3.org/2001/XMLSchema#", "string", diff --git a/lib/containment.ts b/lib/containment.ts index 7ea340a..039f934 100644 --- a/lib/containment.ts +++ b/lib/containment.ts @@ -2,6 +2,7 @@ import { Bindings, ContainmentType, IBindings } from './Binding' import { generateStarPatternUnion, shapeToQuery, type IQuery, type UnsupportedConstruct } from './query'; import { ConstraintType, IConstraint, IShape } from './Shape'; import type { IStarPatternWithDependencies } from './Triple'; +import { describesStarPattern, incomingRequiredPredicates } from './target'; import type { Term } from '@rdfjs/types'; /** @@ -49,6 +50,7 @@ export function solveShapeQueryContainment({ query, shapes, dependentShapes, dec classificationStats.set(starPatternsName, { hasOpenShape: false, hasClosedShape: false, + hasUndescribedShape: false, rootTargetsOpen: new Set(), rootTargetsClosed: new Set(), containedTargets: new Set(), @@ -57,6 +59,7 @@ export function solveShapeQueryContainment({ query, shapes, dependentShapes, dec } // Evaluate each star pattern against each candidate shape. + const incoming = incomingRequiredPredicates(query.starPatterns); for (const { shape, dependencies } of groupedShapes) { bindingResult.set(shape.name, new Map()); const bindingResultofShape = bindingResult.get(shape.name)!; @@ -65,7 +68,8 @@ export function solveShapeQueryContainment({ query, shapes, dependentShapes, dec const bindings = new Bindings(shape, starPattern, dependencies, starPatternUnion); const filterCompatibility = evaluateFiltersForShape(query.filters, starPattern, shape); bindingResultofShape.set(starPatternName, { result: bindings, shape }); - updateContainmentStats(classificationStats, starPatternName, shape, bindings, groupedShapes, filterCompatibility !== FilterTruth.FALSE); + const describes = describesStarPattern(shape, starPattern, incoming); + updateContainmentStats(classificationStats, starPatternName, shape, bindings, groupedShapes, filterCompatibility !== FilterTruth.FALSE, describes); } } @@ -128,7 +132,10 @@ export function solveShapeQueryContainment({ query, shapes, dependentShapes, dec const bindings = rootTargetsClosed.length > 0 ? new Map(stats.bindings) : new Map(); currentResult = { result: ContainmentResult.UNALINGED, target: unalignedTargets, bindings }; } else { - const rejectedResult = stats.hasOpenShape ? ContainmentResult.WEAKLY_REJECTED : ContainmentResult.REJECTED; + // Rejection needs a shape that describes the star pattern and excludes it. Without any shape + // describing it, while the resource has shapes, nothing is known about the pattern. + const weaklyRejected = stats.hasOpenShape || (!stats.hasClosedShape && stats.hasUndescribedShape); + const rejectedResult = weaklyRejected ? ContainmentResult.WEAKLY_REJECTED : ContainmentResult.REJECTED; currentResult = { result: rejectedResult, bindings: new Map() }; } } @@ -198,12 +205,19 @@ function updateContainmentStats( bindings: IBindings, groupedShapes: IShapeWithDependencies[], filterCompatible = true, + describes = true, ): void { const stats = classificationStats.get(starPatternName)!; + // A shape with targets only constrains the nodes of its targets. When the nodes matching the star + // pattern need not be among them, its closedness and negative constraints say nothing about those + // nodes: it neither supports nor softens a rejection, and its matches count as on an open shape. + const closed = describes && shape.closed; // A shape that explicitly forbids a required predicate (sh:not) proves incompatibility, so it // must not count as the "open shape" that would soften REJECTED into WEAKLY_REJECTED. - const contradicts = bindings.hasNegativeContradiction(); - if (shape.closed || contradicts) { + const contradicts = describes && bindings.hasNegativeContradiction(); + if (!describes) { + stats.hasUndescribedShape = true; + } else if (closed || contradicts) { stats.hasClosedShape = true; } else { stats.hasOpenShape = true; @@ -219,7 +233,7 @@ function updateContainmentStats( const hasRootMatch = bindings.getBoundTriple().length > 0 || bindings.hasNonRefutedDependency(); if (hasRootMatch) { stats.bindings.set(shape.name, bindings); - if (shape.closed) { + if (closed) { stats.rootTargetsClosed.add(shape.name); } else { stats.rootTargetsOpen.add(shape.name); @@ -1036,8 +1050,12 @@ interface IBindingStatus { } interface IContainmentStats { + // an open shape describes the star pattern hasOpenShape: boolean; + // a closed or contradicting shape describes the star pattern hasClosedShape: boolean; + // a shape with targets does not describe the star pattern + hasUndescribedShape: boolean; rootTargetsOpen: Set; rootTargetsClosed: Set; containedTargets: Set; diff --git a/lib/query.ts b/lib/query.ts index 510c7aa..7913e63 100644 --- a/lib/query.ts +++ b/lib/query.ts @@ -2,7 +2,7 @@ import type { Term } from '@rdfjs/types'; import { algebraUtils, Algebra } from '@traqula/algebra-transformations-1-1'; import { DataFactory } from 'rdf-data-factory'; import { ITripleWithDependencies, Triple, type IStarPatternWithDependencies, ITriple } from './Triple'; -import { ConstraintType, ICardinality, IPredicate, IShape } from './Shape'; +import { ConstraintType, ICardinality, IPredicate, IShape, targetsOf } from './Shape'; import { RDF, XSD } from './constant'; const DF = new DataFactory(); @@ -62,8 +62,11 @@ export interface IQuery { * `NOT EXISTS` over a single triple pattern is supported as a negated triple pattern and is only * reported here when it is not of that form. An `EXISTS` or `NOT EXISTS` expression that is * discarded stays in its FILTER, where it is undecidable. + * + * `TARGETS` reports the targets of an input shape that could not be translated: node or objects-of + * targets combined with other targets, or several objects-of targets. They are ignored. */ -export type UnsupportedConstruct = 'MINUS' | 'SERVICE' | 'EXISTS' | 'NOT EXISTS'; +export type UnsupportedConstruct = 'MINUS' | 'SERVICE' | 'EXISTS' | 'NOT EXISTS' | 'TARGETS'; export interface IShapeToQueryOptions { linkedShapes?: IShape[] | Map; @@ -76,6 +79,7 @@ interface IShapeToQueryBuildContext { unions: IQuery[][]; visitedShapes: Set; variableCounts: Map; + unsupported: Set; } /** @@ -87,6 +91,7 @@ interface IShapeToQueryBuildContext { * - Datatype + numeric/regex facets -> FILTER expressions * - SHAPE constraints -> dependent star patterns (when linked shapes are provided) * - oneOf/xone branches -> UNION branches + * - Targets of the given shape -> patterns about its focus node (see {@link addTargetsOfInputShape}) * * Not supported (yet): * - closed/open world semantics @@ -100,9 +105,12 @@ export function shapeToQuery(shape: IShape, options?: IShapeToQueryOptions): IQu unions: [], visitedShapes: new Set(), variableCounts: new Map(), + unsupported: new Set(), }; buildStarPatternFromShape(shape, context); + addTargetsOfInputShape(shape, context); + const prunedSubjectRestriction = pruneEmptyStarPatterns(context); const query: IQuery = { starPatterns: context.starPatterns }; if (context.filters.length > 0) { @@ -111,10 +119,126 @@ export function shapeToQuery(shape: IShape, options?: IShapeToQueryOptions): IQu if (context.unions.length > 0) { query.union = context.unions; } + if (context.unsupported.size > 0) { + query.unsupported = Array.from(context.unsupported); + } + // A shape about given nodes that constrains none of their properties asks for any triple about them + if (context.starPatterns.size === 0 && prunedSubjectRestriction) { + query.matchesAnyTriple = true; + } return query; } +/** + * Translate the targets of the input shape into patterns about its focus node. Only the input + * shape's own targets are translated: SHACL ignores the targets of the shapes it reaches through + * sh:node. + * + * - A class target requires `rdf:type` with the class as value. + * - A subjects-of target requires the predicate. + * - Several class and subjects-of targets are alternatives, because the focus nodes of a shape are + * the union of the nodes of its targets: each target becomes a branch of a UNION, as the branches + * of an sh:or do. + * - Node targets restrict the subject of the focus star pattern to the target nodes. + * - An objects-of target adds a star pattern whose triple pattern has the focus node as object. + * + * Node and objects-of targets are not representable as alternatives of other targets. When they are + * combined with other targets, or when several objects-of targets are declared, the targets are + * ignored and reported, which can only over-estimate relevance. + */ +function addTargetsOfInputShape(shape: IShape, context: IShapeToQueryBuildContext): void { + const { nodes, classes, subjectsOf, objectsOf } = targetsOf(shape); + const kinds = [ nodes, classes, subjectsOf, objectsOf ].filter(values => values.length > 0).length; + const focus = context.starPatterns.get(shape.name); + if (kinds === 0 || focus === undefined) { + return; + } + + if (nodes.length > 0 || objectsOf.length > 0) { + if (kinds > 1 || objectsOf.length > 1) { + context.unsupported.add('TARGETS'); + return; + } + if (nodes.length > 0) { + focus.subjectValues = new Set(nodes); + return; + } + const incomingName = `${shape.name}#targetObjectsOf`; + const triple = new Triple({ subject: incomingName, predicate: objectsOf[0], object: DF.namedNode(shape.name) }); + context.starPatterns.set(incomingName, { + starPattern: new Map([[ objectsOf[0], { triple, dependencies: focus } ]]), + name: incomingName, + isVariable: true, + }); + return; + } + + const alternatives: ITripleWithDependencies[] = []; + for (const value of classes) { + alternatives.push({ triple: new Triple({ subject: shape.name, predicate: RDF.type, object: DF.namedNode(value) }) }); + } + for (const predicate of subjectsOf) { + // A declared property constraint on the predicate keeps its object and dependency, but is now required + const declared = focus.starPattern.get(predicate); + const object = declared?.triple.object ?? DF.variable(nextVariableName(shape.name, predicate, context.variableCounts)); + alternatives.push({ triple: new Triple({ subject: shape.name, predicate, object }), dependencies: declared?.dependencies }); + } + + if (alternatives.length === 1) { + focus.starPattern.set(alternatives[0].triple.predicate, alternatives[0]); + return; + } + context.unions.push(alternatives.map(alternative => ({ + starPatterns: new Map([[ shape.name, { + starPattern: new Map([[ alternative.triple.predicate, alternative ]]), + name: shape.name, + isVariable: true, + } ]]), + }))); +} + +/** + * Remove the star patterns that constrain nothing: no triple pattern, no excluded predicate and no + * UNION branch. They come from shapes without positive constraints, such as a shape that only + * declares targets, and would otherwise count as unmatched. A triple pattern that pointed to one of + * them is kept, and matches on its predicate alone. + * @returns whether a removed star pattern restricted its subject to given nodes + */ +function pruneEmptyStarPatterns(context: IShapeToQueryBuildContext): boolean { + const inUnion = new Set(); + for (const union of context.unions) { + for (const branch of union) { + for (const name of branch.starPatterns.keys()) { + inUnion.add(name); + } + } + } + const removed = new Set(); + let prunedSubjectRestriction = false; + for (const [ name, starPattern ] of context.starPatterns) { + if (starPattern.starPattern.size === 0 && (starPattern.excludedPredicates?.size ?? 0) === 0 && !inUnion.has(name)) { + removed.add(name); + prunedSubjectRestriction = prunedSubjectRestriction || starPattern.subjectValues !== undefined; + } + } + for (const name of removed) { + context.starPatterns.delete(name); + } + const remaining = [ + ...context.starPatterns.values(), + ...context.unions.flatMap(union => union.flatMap(branch => [ ...branch.starPatterns.values() ])), + ]; + for (const starPattern of remaining) { + for (const tripleWithDependencies of starPattern.starPattern.values()) { + if (tripleWithDependencies.dependencies !== undefined && removed.has(tripleWithDependencies.dependencies.name)) { + tripleWithDependencies.dependencies = undefined; + } + } + } + return prunedSubjectRestriction; +} + /** * Extract the star pattern with the requested subject from each UNION branch set. */ @@ -285,6 +409,14 @@ function nextVariableName(shapeName: string, predicate: string, variableCounts: return currentCount === 0 ? base : `${base}_${currentCount}`; } +/** + * Whether a subject is a variable of the pattern. A blank node in a SPARQL pattern behaves as a + * variable that is not projected. + */ +function isVariableTerm(term: Term): boolean { + return term.termType === 'Variable' || term.termType === 'BlankNode'; +} + /** * Normalize arbitrary IRIs/shape names into SPARQL-safe variable base names. */ @@ -911,7 +1043,7 @@ namespace QueryHandler { }); if (startPattern === undefined) { accumulatedTriples.set(subject.value, - { triples: new Map([[triple.toString(), triple]]), isVariable: subject.termType === "Variable" }); + { triples: new Map([[triple.toString(), triple]]), isVariable: isVariableTerm(subject) }); } else { startPattern.triples.set(triple.toString(), triple); } @@ -951,7 +1083,7 @@ namespace QueryHandler { cardinality, isOptional: optional }), - isVariable: subject.termType === "Variable" + isVariable: isVariableTerm(subject) }; } } @@ -1109,7 +1241,7 @@ namespace QueryHandler { accumulatedTriples.set(subject.value, { triples: new Map([[triple.toString(), triple]]), - isVariable: subject.termType === "Variable" + isVariable: isVariableTerm(subject) }); } @@ -1156,7 +1288,7 @@ namespace QueryHandler { accumulatedTriples.set(subject.value, { triples: new Map([[triple.toString(), triple]]), - isVariable: subject.termType === "Variable" + isVariable: isVariableTerm(subject) }); } @@ -1196,7 +1328,7 @@ namespace QueryHandler { negatedSet: negatedSet, isOptional: optional }), - isVariable: subject.termType === "Variable" + isVariable: isVariableTerm(subject) }; } diff --git a/lib/shacl.ts b/lib/shacl.ts index aca6052..46562c6 100644 --- a/lib/shacl.ts +++ b/lib/shacl.ts @@ -9,8 +9,8 @@ import { buildPredicate as buildSharedPredicate, isNegativeCardinality } from './Shape'; -import type { ShapeError } from './Shape'; -import { SHACL, RDF as RDF_VOCAB, XSD } from './constant'; +import type { ShapeError, IShapeTargets } from './Shape'; +import { SHACL, RDF as RDF_VOCAB, RDFS, XSD } from './constant'; import { addDiagnostic, getPolicy, type IShapeParserOptions } from './parser-policy'; const DF = new DataFactory(); @@ -58,8 +58,18 @@ interface IMapTripleShacl { listFirst: Map; /** RDF list rest: node → next node */ listRest: Map; + /** shape IRI → declared targets (sh:targetNode, sh:targetClass, sh:targetSubjectsOf, sh:targetObjectsOf) */ + targets: Map; + /** shape IRI → list head (from sh:ignoredProperties) */ + ignoredLists: Map; + /** node → rdf:type values, to find implicit class targets */ + types: Map>; + /** class → rdfs:subClassOf values, to find the subclasses of rdfs:Class */ + superClasses: Map>; } +type IShapeTargetSets = { [K in keyof IShapeTargets]: Set }; + function defaultMap(): IMapTripleShacl { return { shapeProperties: new Map(), @@ -70,6 +80,10 @@ function defaultMap(): IMapTripleShacl { notLinks: new Map(), listFirst: new Map(), listRest: new Map(), + targets: new Map(), + ignoredLists: new Map(), + types: new Map(), + superClasses: new Map(), }; } @@ -240,6 +254,42 @@ function parseQuad(quad: RDF.Quad, map: IMapTripleShacl): void { return; } + // Targets: the nodes the shape describes when it is used on its own + if (quad.predicate.equals(SHACL.terms.targetNode)) { + // A literal can be a node target too; keep it distinct from an IRI with the same text + const node = quad.object.termType === 'Literal' ? JSON.stringify(o) : o; + getOrCreateTargets(map, s).nodes.add(node); + return; + } + if (quad.predicate.equals(SHACL.terms.targetClass)) { + getOrCreateTargets(map, s).classes.add(o); + return; + } + if (quad.predicate.equals(SHACL.terms.targetSubjectsOf)) { + getOrCreateTargets(map, s).subjectsOf.add(o); + return; + } + if (quad.predicate.equals(SHACL.terms.targetObjectsOf)) { + getOrCreateTargets(map, s).objectsOf.add(o); + return; + } + + // sh:ignoredProperties → list of properties a closed shape allows without declaring them + if (quad.predicate.equals(SHACL.terms.ignoredProperties)) { + map.ignoredLists.set(s, o); + return; + } + + // rdf:type and rdfs:subClassOf, to recognize a shape that is also a class (implicit class target) + if (quad.predicate.equals(RDF_VOCAB.terms.type)) { + addToSetMap(map.types, s, o); + return; + } + if (quad.predicate.equals(RDFS.terms.subClassOf)) { + addToSetMap(map.superClasses, s, o); + return; + } + // rdf:first / rdf:rest for RDF lists (used by sh:or / sh:xone) if (quad.predicate.equals(RDF_VOCAB.terms.first)) { map.listFirst.set(s, o); @@ -251,6 +301,24 @@ function parseQuad(quad: RDF.Quad, map: IMapTripleShacl): void { } } +function getOrCreateTargets(map: IMapTripleShacl, shapeIri: string): IShapeTargetSets { + let targets = map.targets.get(shapeIri); + if (targets === undefined) { + targets = { nodes: new Set(), classes: new Set(), subjectsOf: new Set(), objectsOf: new Set() }; + map.targets.set(shapeIri, targets); + } + return targets; +} + +function addToSetMap(setMap: Map>, key: string, value: string): void { + let values = setMap.get(key); + if (values === undefined) { + values = new Set(); + setMap.set(key, values); + } + values.add(value); +} + function getOrCreatePropData( map: IMapTripleShacl, id: string, @@ -275,10 +343,14 @@ function buildShape( const negatedPropIds = collectNotProps(map, shapeIri); const hasOrList = map.orLists.has(shapeIri) || map.xoneLists.has(shapeIri); + const targets = collectTargets(map, shapeIri); + const hasTarget = Object.values(targets).some(values => values.length > 0); - if ((propIds === undefined || propIds.size === 0) && !hasOrList && negatedPropIds.size === 0) { + // A shape that only declares targets still describes nodes: they exist and are of the target + // kind, even though the shape constrains none of their properties. + if ((propIds === undefined || propIds.size === 0) && !hasOrList && negatedPropIds.size === 0 && !hasTarget) { return new PoorlyFormatedShapeError( - `No property shapes, sh:or, or sh:not found for shape <${shapeIri}>`, + `No property shapes, sh:or, sh:not, or target found for shape <${shapeIri}>`, ); } @@ -316,6 +388,10 @@ function buildShape( } const closed = map.closedShape.get(shapeIri) ?? false; + const ignoredHead = map.ignoredLists.get(shapeIri); + const ignoredProperties = ignoredHead === undefined + ? [] + : walkRdfList(ignoredHead, { firstByNode: map.listFirst, restByNode: map.listRest }).values; return buildShapeFromRaw({ name: shapeIri, @@ -323,9 +399,47 @@ function buildShape( negativePredicates, closed, oneOf: oneOfs.map(currentOneOf => currentOneOf.map(path => path.map(predicateToParts))), + targets, + ignoredProperties, }); } +/** + * The targets declared for a shape. A shape that is also an rdfs:Class in the shapes graph targets + * its own instances (an implicit class target); as for any SHACL instance, the rdf:type may be a + * class declared, through rdfs:subClassOf, to be a subclass of rdfs:Class. + */ +function collectTargets(map: IMapTripleShacl, shapeIri: string): IShapeTargets { + const declared = map.targets.get(shapeIri); + const classes = new Set(declared?.classes ?? []); + const metaClasses = subClassesOf(map, RDFS.Class); + if (Array.from(map.types.get(shapeIri) ?? []).some(type => metaClasses.has(type))) { + classes.add(shapeIri); + } + return { + nodes: Array.from(declared?.nodes ?? []), + classes: Array.from(classes), + subjectsOf: Array.from(declared?.subjectsOf ?? []), + objectsOf: Array.from(declared?.objectsOf ?? []), + }; +} + +/** A class and every class declared, directly or transitively, to be a subclass of it. */ +function subClassesOf(map: IMapTripleShacl, superClass: string): Set { + const result = new Set([superClass]); + let added = true; + while (added) { + added = false; + for (const [subClass, superClasses] of map.superClasses) { + if (!result.has(subClass) && Array.from(superClasses).some(value => result.has(value))) { + result.add(subClass); + added = true; + } + } + } + return result; +} + /** Collect all property-shape blank nodes reachable via sh:not from a shape IRI. */ function collectNotProps(map: IMapTripleShacl, shapeIri: string): Set { const result = new Set(); diff --git a/lib/target.ts b/lib/target.ts new file mode 100644 index 0000000..466b9a3 --- /dev/null +++ b/lib/target.ts @@ -0,0 +1,161 @@ +import { ConstraintType, type IPredicate, type IShape, hasTargets, targetsOf } from './Shape'; +import type { IStarPatternWithDependencies } from './Triple'; +import { RDF } from './constant'; + +/** + * The constants the subject of a star pattern is restricted to, or undefined when it is a variable. + */ +export function constantSubjects(starPattern: IStarPatternWithDependencies): Set | undefined { + if (starPattern.subjectValues !== undefined) { + return starPattern.subjectValues; + } + return starPattern.isVariable ? undefined : new Set([starPattern.name]); +} + +/** + * The property constraints a shape implies without declaring them, as alternatives for a predicate. + * + * - A class target (sh:targetClass, or an implicit class target) makes every focus node an instance + * of a target class, so the shape implies `rdf:type` with one of those classes as value. + * - A subjects-of target (sh:targetSubjectsOf) makes every focus node carry the predicate. + * - A closed shape allows the properties listed by sh:ignoredProperties, without constraining them. + * + * The focus nodes of a shape are the union of the nodes of its targets, so an implied constraint is + * only required (minimum 1) when every focus node carries it: when all the targets are class + * targets, or when the only target is one subjects-of target. + * + * Targets only apply when the shape is used on its own (`useTargets`): SHACL ignores them when the + * shape is reached through sh:node. sh:ignoredProperties applies in both cases. + */ +export function impliedPredicates(shape: IShape, predicate: string, useTargets: boolean): IPredicate[] { + const implied: IPredicate[] = []; + if (useTargets) { + const { nodes, classes, subjectsOf, objectsOf } = targetsOf(shape); + if (predicate === RDF.type && classes.length > 0) { + const onlyClassTargets = nodes.length + subjectsOf.length + objectsOf.length === 0; + implied.push({ + name: predicate, + constraint: { type: ConstraintType.CLASS, value: new Set(classes) }, + cardinality: { min: onlyClassTargets ? 1 : 0, max: -1 }, + }); + } + if (subjectsOf.includes(predicate)) { + const onlyTarget = subjectsOf.length === 1 && nodes.length + classes.length + objectsOf.length === 0; + implied.push({ name: predicate, cardinality: { min: onlyTarget ? 1 : 0, max: -1 } }); + } + } + if (shape.closed && (shape.ignoredProperties ?? []).includes(predicate)) { + implied.push({ name: predicate, cardinality: { min: 0, max: -1 }, optional: true }); + } + return implied; +} + +/** + * Every predicate for which {@link impliedPredicates} yields a constraint. + */ +export function impliedPredicateNames(shape: IShape, useTargets: boolean): string[] { + const names = new Set(); + if (useTargets) { + const { classes, subjectsOf } = targetsOf(shape); + if (classes.length > 0) { + names.add(RDF.type); + } + for (const predicate of subjectsOf) { + names.add(predicate); + } + } + if (shape.closed) { + for (const predicate of shape.ignoredProperties ?? []) { + names.add(predicate); + } + } + return Array.from(names); +} + +/** + * Whether a shape whose only targets are node targets is about other nodes than the star pattern. + * Such a shape describes the listed nodes and nothing else, so it cannot contain a star pattern whose + * subject is a constant it does not list. Any other target leaves the subject undecided. + */ +export function isSubjectOutsideNodeTargets(shape: IShape, starPattern: IStarPatternWithDependencies): boolean { + const { nodes, classes, subjectsOf, objectsOf } = targetsOf(shape); + if (nodes.length === 0 || classes.length + subjectsOf.length + objectsOf.length > 0) { + return false; + } + const subjects = constantSubjects(starPattern); + if (subjects === undefined) { + return false; + } + return !Array.from(subjects).some(subject => nodes.includes(subject)); +} + +/** + * Whether a shape describes a star pattern: the shape's closedness and negative property constraints + * apply to the nodes matching the pattern. A shape without targets describes every star pattern. A + * shape with targets only describes the nodes of its targets, so it describes a star pattern only + * when every node matching it is in a target: + * + * - its subject is a node target; + * - it requires an `rdf:type` whose values are all target classes; + * - it requires a predicate whose subjects are targets (sh:targetSubjectsOf); or + * - it is reached through a required triple pattern whose predicate has objects as targets + * (sh:targetObjectsOf). + * + * @param incoming the predicates of the required triple patterns through which each star pattern is reached + */ +export function describesStarPattern( + shape: IShape, + starPattern: IStarPatternWithDependencies, + incoming: Map>, +): boolean { + if (!hasTargets(shape)) { + return true; + } + const { nodes, classes, subjectsOf, objectsOf } = targetsOf(shape); + + const subjects = constantSubjects(starPattern); + if (subjects !== undefined && subjects.size > 0 && Array.from(subjects).every(subject => nodes.includes(subject))) { + return true; + } + + for (const { triple } of starPattern.starPattern.values()) { + if (triple.isOptional === true) { + continue; + } + if (triple.predicate === RDF.type && classes.length > 0) { + const objects = Array.isArray(triple.object) ? triple.object : [ triple.object ]; + if (objects.length > 0 && objects.every(object => object.termType === 'NamedNode' && classes.includes(object.value))) { + return true; + } + } + if (subjectsOf.includes(triple.predicate)) { + return true; + } + } + + const incomingPredicates = incoming.get(starPattern.name); + return incomingPredicates !== undefined && objectsOf.some(predicate => incomingPredicates.has(predicate)); +} + +/** + * For each star pattern, the predicates of the required triple patterns whose object it is. + */ +export function incomingRequiredPredicates( + starPatterns: Map, +): Map> { + const incoming = new Map>(); + for (const starPattern of starPatterns.values()) { + for (const { triple, dependencies } of starPattern.starPattern.values()) { + if (dependencies === undefined || triple.isOptional === true) { + continue; + } + let predicates = incoming.get(dependencies.name); + if (predicates === undefined) { + predicates = new Set(); + incoming.set(dependencies.name, predicates); + } + predicates.add(triple.predicate); + } + } + return incoming; +} diff --git a/test/query.test.ts b/test/query.test.ts index c4ec92b..b9601ab 100644 --- a/test/query.test.ts +++ b/test/query.test.ts @@ -1623,7 +1623,7 @@ describe('query', () => { ], ]), name: test1seq1.value, - isVariable: false, + isVariable: true, } ]; @@ -1727,7 +1727,7 @@ describe('query', () => { ], ]), name: test1seq1.value, - isVariable: false, + isVariable: true, } ]; @@ -4285,7 +4285,7 @@ describe('query', () => { ] ]), name: knowFirstBranchInter.value, - isVariable: false + isVariable: true } ]; @@ -4330,7 +4330,7 @@ describe('query', () => { ] ]), name: knowSecondBranchInter.value, - isVariable: false + isVariable: true } ]; @@ -5220,7 +5220,7 @@ describe('query', () => { expect(union[0].starPatterns.size).toBe(1); for (const starPattern of union[0].starPatterns.values() ?? []) { expect(starPattern.name).toBeDefined(); - expect(starPattern.isVariable).toBe(false); + expect(starPattern.isVariable).toBe(true); for (const pattern of starPattern.starPattern.values() ?? []) { expect(pattern.dependencies).toBeDefined(); @@ -5234,7 +5234,7 @@ describe('query', () => { expect(union[1].starPatterns.size).toBe(1); for (const starPattern of union[1].starPatterns.values() ?? []) { expect(starPattern.name).toBeDefined(); - expect(starPattern.isVariable).toBe(false); + expect(starPattern.isVariable).toBe(true); for (const pattern of starPattern.starPattern.values() ?? []) { expect(pattern.dependencies).toBeDefined(); @@ -5897,7 +5897,7 @@ describe('query', () => { ] ]), name: `${SNVOC_PREFIX}knows_rootPerson`, - isVariable: false, + isVariable: true, }, ]; @@ -5939,7 +5939,7 @@ describe('query', () => { ] ]), name: `${SNVOC_PREFIX}knows_fr`, - isVariable: false, + isVariable: true, }, ]; @@ -6199,7 +6199,7 @@ describe('query', () => { ] ]), name: `${SNVOC_PREFIX}knows_rootPerson`, - isVariable: false, + isVariable: true, }, ]; @@ -6241,7 +6241,7 @@ describe('query', () => { ] ]), name: `${SNVOC_PREFIX}knows_fr`, - isVariable: false, + isVariable: true, }, ]; diff --git a/test/target.test.ts b/test/target.test.ts new file mode 100644 index 0000000..43dd64c --- /dev/null +++ b/test/target.test.ts @@ -0,0 +1,254 @@ +import { Parser as SPARQLParser } from '@traqula/parser-sparql-1-1'; +import { toAlgebra } from '@traqula/algebra-sparql-1-1'; +import * as N3 from 'n3'; +import { describe, expect, it } from 'vitest'; +import type { IShape } from '../lib/Shape'; +import { shaclShapeFromQuads } from '../lib/shacl'; +import { generateQuery, shapeToQuery, type IQuery } from '../lib/query'; +import { ContainmentResult, solveShapeQueryContainment, solveShapeShapeContainment } from '../lib/containment'; +import { RDF } from '../lib/constant'; + +const EX = 'http://example.org/'; +const PREFIXES = ` + @prefix sh: . + @prefix rdf: . + @prefix rdfs: . + @prefix owl: . + @prefix ex: . +`; + +async function parseShape(ttl: string, name: string): Promise { + const shape = await shaclShapeFromQuads(new N3.Parser().parse(PREFIXES + ttl), `${EX}${name}`); + if (shape instanceof Error) { + throw shape; + } + return shape; +} + +function parseQuery(body: string): IQuery { + return generateQuery(toAlgebra(new SPARQLParser().parse(`PREFIX ex: <${EX}> SELECT * WHERE { ${body} }`))); +} + +function classify(body: string, shapes: IShape[]): ContainmentResult { + return solveShapeQueryContainment({ query: parseQuery(body), shapes }).result; +} + +describe('SHACL targets', () => { + describe('parsing', () => { + it('should parse every kind of target, keeping a literal node target apart from an IRI', async () => { + const shape = await parseShape(` + ex:S a sh:NodeShape ; + sh:targetNode ex:Alice, "Alice" ; + sh:targetClass ex:Person ; + sh:targetSubjectsOf ex:knows ; + sh:targetObjectsOf ex:follows ; + sh:property [ sh:path ex:name ] .`, 'S'); + expect(shape.toObject().targets).toStrictEqual({ + nodes: [`${EX}Alice`, '"Alice"'], + classes: [`${EX}Person`], + subjectsOf: [`${EX}knows`], + objectsOf: [`${EX}follows`], + }); + }); + + it('should give a shape that is also a class an implicit class target', async () => { + const direct = await parseShape(`ex:Place a rdfs:Class, sh:NodeShape ; sh:property [ sh:path ex:name ] .`, 'Place'); + expect(direct.toObject().targets?.classes).toStrictEqual([`${EX}Place`]); + + // owl:Class is a SHACL instance of rdfs:Class only when the shapes graph says so + const owl = `ex:Place a owl:Class, sh:NodeShape ; sh:property [ sh:path ex:name ] .`; + expect((await parseShape(owl, 'Place')).toObject().targets).toBeUndefined(); + const owlSubClass = await parseShape(`owl:Class rdfs:subClassOf rdfs:Class . ${owl}`, 'Place'); + expect(owlSubClass.toObject().targets?.classes).toStrictEqual([`${EX}Place`]); + }); + + it('should accept a shape that only declares targets', async () => { + const shape = await parseShape(`ex:S a sh:NodeShape ; sh:targetClass ex:Place .`, 'S'); + expect(shape.positivePredicates).toStrictEqual([]); + expect(shape.toObject().targets?.classes).toStrictEqual([`${EX}Place`]); + }); + + it('should parse sh:ignoredProperties and leave the object of a shape without targets unchanged', async () => { + const shape = await parseShape(` + ex:S a sh:NodeShape ; sh:closed true ; sh:ignoredProperties ( rdf:type ex:label ) ; + sh:property [ sh:path ex:name ] .`, 'S'); + expect(shape.toObject().ignoredProperties).toStrictEqual([RDF.type, `${EX}label`]); + expect(Object.keys(shape.toObject())).not.toContain('targets'); + }); + }); + + describe('translation of an input shape', () => { + it('should require rdf:type for a class target and the predicate for a subjects-of target', async () => { + const classQuery = shapeToQuery(await parseShape(` + ex:In a sh:NodeShape ; sh:targetClass ex:Place ; sh:property [ sh:path ex:name ; sh:minCount 1 ] .`, 'In')); + const typeTriple = classQuery.starPatterns.get(`${EX}In`)!.starPattern.get(RDF.type)!.triple; + expect(typeTriple.isOptional).toBe(false); + expect(typeTriple.object).toEqual(expect.objectContaining({ termType: 'NamedNode', value: `${EX}Place` })); + + // an optional constraint on the predicate becomes required + const subjectsQuery = shapeToQuery(await parseShape(` + ex:In a sh:NodeShape ; sh:targetSubjectsOf ex:knows ; sh:property [ sh:path ex:knows ] .`, 'In')); + expect(subjectsQuery.starPatterns.get(`${EX}In`)!.starPattern.get(`${EX}knows`)!.triple.isOptional).toBe(false); + }); + + it('should restrict the subject for node targets and add an incoming pattern for an objects-of target', async () => { + const nodeQuery = shapeToQuery(await parseShape(` + ex:In a sh:NodeShape ; sh:targetNode ex:Alice, ex:Bob ; sh:property [ sh:path ex:name ; sh:minCount 1 ] .`, 'In')); + expect(nodeQuery.starPatterns.get(`${EX}In`)!.subjectValues).toStrictEqual(new Set([`${EX}Alice`, `${EX}Bob`])); + + const objectsQuery = shapeToQuery(await parseShape(` + ex:In a sh:NodeShape ; sh:targetObjectsOf ex:knows ; sh:property [ sh:path ex:name ; sh:minCount 1 ] .`, 'In')); + const incoming = objectsQuery.starPatterns.get(`${EX}In#targetObjectsOf`)!; + expect(incoming.starPattern.get(`${EX}knows`)!.dependencies?.name).toBe(`${EX}In`); + }); + + it('should turn several targets into UNION branches, and ignore combinations it cannot represent', async () => { + const union = shapeToQuery(await parseShape(` + ex:In a sh:NodeShape ; sh:targetClass ex:Place, ex:City ; sh:targetSubjectsOf ex:knows ; + sh:property [ sh:path ex:name ; sh:minCount 1 ] .`, 'In')); + expect(union.union).toHaveLength(1); + expect(union.union![0]).toHaveLength(3); + expect(union.unsupported).toBeUndefined(); + + const mixed = shapeToQuery(await parseShape(` + ex:In a sh:NodeShape ; sh:targetNode ex:Alice ; sh:targetClass ex:Place ; + sh:property [ sh:path ex:name ; sh:minCount 1 ] .`, 'In')); + expect(mixed.unsupported).toStrictEqual(['TARGETS']); + expect(mixed.starPatterns.get(`${EX}In`)!.starPattern.has(RDF.type)).toBe(false); + expect(mixed.starPatterns.get(`${EX}In`)!.subjectValues).toBeUndefined(); + }); + + it('should not translate the targets of a shape reached through sh:node', async () => { + const ttl = ` + ex:In a sh:NodeShape ; sh:property [ sh:path ex:address ; sh:node ex:Address ; sh:minCount 1 ] . + ex:Address a sh:NodeShape ; sh:targetClass ex:Address ; sh:property [ sh:path ex:city ; sh:minCount 1 ] .`; + const query = shapeToQuery(await parseShape(ttl, 'In'), { linkedShapes: [await parseShape(ttl, 'Address')] }); + expect(query.starPatterns.get(`${EX}Address`)!.starPattern.has(RDF.type)).toBe(false); + }); + + it('should ask for any triple about the target nodes of a shape without constraints', async () => { + const query = shapeToQuery(await parseShape(`ex:In a sh:NodeShape ; sh:targetNode ex:Alice .`, 'In')); + expect(query.starPatterns.size).toBe(0); + expect(query.matchesAnyTriple).toBe(true); + }); + }); + + describe('class targets of resource shapes', () => { + const typedQuery = '?x a ex:Place ; ex:name ?name .'; + + it('should match rdf:type against a class target, on open and closed shapes', async () => { + const open = await parseShape(`ex:R a sh:NodeShape ; sh:targetClass ex:Place ; sh:property [ sh:path ex:name ] .`, 'R'); + const closed = await parseShape(`ex:R a sh:NodeShape ; sh:targetClass ex:Place ; sh:closed true ; + sh:property [ sh:path ex:name ] .`, 'R'); + const ignoring = await parseShape(`ex:R a sh:NodeShape ; sh:targetClass ex:Place ; sh:closed true ; + sh:ignoredProperties ( rdf:type ) ; sh:property [ sh:path ex:name ] .`, 'R'); + for (const shape of [open, closed, ignoring]) { + expect(classify(typedQuery, [shape])).toBe(ContainmentResult.CONTAINED); + } + }); + + it('should not contain a request for another class', async () => { + const person = await parseShape(`ex:R a sh:NodeShape ; sh:targetClass ex:Person ; sh:property [ sh:path ex:name ] .`, 'R'); + expect(classify(typedQuery, [person])).toBe(ContainmentResult.ALIGNED); + }); + + it('should only reject the star patterns whose nodes are in the target of a closed shape', async () => { + const closedPerson = await parseShape(`ex:R a sh:NodeShape ; sh:targetClass ex:Person ; sh:closed true ; + sh:property [ sh:path ex:name ] .`, 'R'); + // places need not be persons, so the shape says nothing about them + expect(classify('?x a ex:Place ; ex:latitude ?l .', [closedPerson])).toBe(ContainmentResult.WEAKLY_REJECTED); + // a closed shape without targets still describes every node + const closedUntargeted = await parseShape(`ex:R a sh:NodeShape ; sh:closed true ; sh:property [ sh:path ex:name ] .`, 'R'); + expect(classify('?x a ex:Place ; ex:latitude ?l .', [closedUntargeted])).toBe(ContainmentResult.REJECTED); + }); + + it('should classify an input shape by its class target', async () => { + const input = await parseShape(`ex:In a sh:NodeShape ; sh:targetClass ex:Place ; + sh:property [ sh:path ex:name ; sh:minCount 1 ] .`, 'In'); + const place = await parseShape(`ex:R a sh:NodeShape ; sh:targetClass ex:Place ; sh:property [ sh:path ex:name ] .`, 'R'); + const person = await parseShape(`ex:R a sh:NodeShape ; sh:targetClass ex:Person ; sh:property [ sh:path ex:name ] .`, 'R'); + expect(solveShapeShapeContainment({ sourceShape: input, targetShapes: [place] }).result).toBe(ContainmentResult.CONTAINED); + expect(solveShapeShapeContainment({ sourceShape: input, targetShapes: [person] }).result).toBe(ContainmentResult.ALIGNED); + }); + + it('should require every target alternative of an input shape to be covered, as for sh:or', async () => { + const input = await parseShape(`ex:In a sh:NodeShape ; sh:targetClass ex:Place, ex:City ; + sh:property [ sh:path ex:name ; sh:minCount 1 ] .`, 'In'); + const city = await parseShape(`ex:C a sh:NodeShape ; sh:targetClass ex:City ; sh:property [ sh:path ex:name ] .`, 'C'); + const place = await parseShape(`ex:P a sh:NodeShape ; sh:targetClass ex:Place ; sh:property [ sh:path ex:name ] .`, 'P'); + expect(solveShapeShapeContainment({ sourceShape: input, targetShapes: [city] }).result).toBe(ContainmentResult.ALIGNED); + expect(solveShapeShapeContainment({ sourceShape: input, targetShapes: [city, place] }).result).toBe(ContainmentResult.CONTAINED); + }); + + it('should ignore the targets of a shape reached through sh:node', async () => { + const ttl = ` + ex:S1 a sh:NodeShape ; sh:property [ sh:path ex:address ; sh:node ex:S2 ] . + ex:S2 a sh:NodeShape ; sh:targetClass ex:Address ; sh:property [ sh:path ex:city ] .`; + const s1 = await parseShape(ttl, 'S1'); + const s2 = await parseShape(ttl, 'S2'); + const report = solveShapeQueryContainment({ query: parseQuery('?p ex:address ?a . ?a a ex:Address ; ex:city ?c .'), shapes: [s1, s2] }); + // ex:S2 used on its own describes addresses; through sh:node it does not type the value + expect(report.starPatternsContainment.get('a')!.result).toBe(ContainmentResult.CONTAINED); + expect(report.starPatternsContainment.get('p')!.result).toBe(ContainmentResult.ALIGNED); + }); + }); + + describe('node, subjects-of and objects-of targets of resource shapes', () => { + it('should only contain a constant subject listed by a shape whose targets are node targets', async () => { + const bob = await parseShape(`ex:R a sh:NodeShape ; sh:targetNode ex:Bob ; sh:property [ sh:path ex:name ] .`, 'R'); + const alice = await parseShape(`ex:R a sh:NodeShape ; sh:targetNode ex:Alice ; sh:property [ sh:path ex:name ] .`, 'R'); + const person = await parseShape(`ex:R a sh:NodeShape ; sh:targetClass ex:Person ; sh:property [ sh:path ex:name ] .`, 'R'); + expect(classify('ex:Alice ex:name ?n .', [bob])).toBe(ContainmentResult.ALIGNED); + expect(classify('ex:Alice ex:name ?n .', [alice])).toBe(ContainmentResult.CONTAINED); + // whether ex:Alice is a person cannot be decided, so it is not excluded + expect(classify('ex:Alice ex:name ?n .', [person])).toBe(ContainmentResult.CONTAINED); + // a blank node is a variable, not a constant + expect(classify('_:b ex:name ?n .', [bob])).toBe(ContainmentResult.CONTAINED); + }); + + it('should let a closed shape reject what it forbids for its node targets', async () => { + const alice = await parseShape(`ex:R a sh:NodeShape ; sh:targetNode ex:Alice ; sh:closed true ; + sh:property [ sh:path ex:name ] .`, 'R'); + expect(classify('ex:Alice ex:latitude ?l .', [alice])).toBe(ContainmentResult.REJECTED); + expect(classify('ex:Bob ex:latitude ?l .', [alice])).toBe(ContainmentResult.WEAKLY_REJECTED); + }); + + it('should imply the predicate of a subjects-of target', async () => { + const knower = await parseShape(`ex:R a sh:NodeShape ; sh:targetSubjectsOf ex:knows ; sh:property [ sh:path ex:name ] .`, 'R'); + expect(classify('?x ex:knows ?y ; ex:name ?n .', [knower])).toBe(ContainmentResult.CONTAINED); + const input = await parseShape(`ex:In a sh:NodeShape ; sh:targetSubjectsOf ex:knows ; + sh:property [ sh:path ex:name ; sh:minCount 1 ] .`, 'In'); + expect(solveShapeShapeContainment({ sourceShape: input, targetShapes: [knower] }).result).toBe(ContainmentResult.CONTAINED); + }); + + it('should only require an implied predicate that every focus node carries', async () => { + const query = '?x ex:name ?n . FILTER NOT EXISTS { ?x ex:knows ?k }'; + // every focus node knows someone, so none can satisfy the negation + const knowers = await parseShape(`ex:R a sh:NodeShape ; sh:targetSubjectsOf ex:knows ; sh:property [ sh:path ex:name ] .`, 'R'); + expect(classify(query, [knowers])).toBe(ContainmentResult.ALIGNED); + // a person need not know anyone + const knowersOrPersons = await parseShape(`ex:R a sh:NodeShape ; sh:targetSubjectsOf ex:knows ; sh:targetClass ex:Person ; + sh:property [ sh:path ex:name ] .`, 'R'); + expect(classify(query, [knowersOrPersons])).toBe(ContainmentResult.CONTAINED); + }); + + it('should describe the star patterns reached through the predicate of an objects-of target', async () => { + const knower = await parseShape(`ex:K a sh:NodeShape ; sh:targetSubjectsOf ex:knows ; sh:property [ sh:path ex:knows ] .`, 'K'); + const known = await parseShape(`ex:N a sh:NodeShape ; sh:targetObjectsOf ex:knows ; sh:closed true ; + sh:property [ sh:path ex:name ] .`, 'N'); + const report = solveShapeQueryContainment({ query: parseQuery('?x ex:knows ?y . ?y ex:latitude ?l .'), shapes: [knower, known] }); + // ex:K says nothing about ?y, while the closed ex:N forbids ex:latitude for it + expect(report.starPatternsContainment.get('y')!.result).toBe(ContainmentResult.REJECTED); + + const input = await parseShape(`ex:In a sh:NodeShape ; sh:targetObjectsOf ex:knows ; + sh:property [ sh:path ex:name ; sh:minCount 1 ] .`, 'In'); + expect(solveShapeShapeContainment({ sourceShape: input, targetShapes: [knower, known] }).result).toBe(ContainmentResult.CONTAINED); + }); + + it('should allow the ignored properties of a closed shape', async () => { + const shape = await parseShape(`ex:R a sh:NodeShape ; sh:closed true ; sh:ignoredProperties ( ex:label ) ; + sh:property [ sh:path ex:name ] .`, 'R'); + expect(classify('?x ex:label ?l ; ex:name ?n .', [shape])).toBe(ContainmentResult.CONTAINED); + }); + }); +}); From 49cac4adab5b74751d43aaf0e0060ae4ce718352 Mon Sep 17 00:00:00 2001 From: julianrojas87 Date: Fri, 9 Oct 2026 16:45:51 +0200 Subject: [PATCH 10/22] fix!: only sh:not [sh:path p; sh:minCount 1] forbids p Any other sh:not holds for some nodes that carry p (sh:minCount 2 allows one value), so reading it as forbidding p gave unsound REJECTED results. It is now ignored with an UNSUPPORTED_NOT diagnostic. Also keep every sh:or, sh:xone and sh:not value of a shape; the parser stored only one. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- lib/shacl.ts | 80 +++++++++++++++++++++-------- test/containment.test.ts | 47 ++++++++++++++++- test/shacl.test.ts | 61 ++++++++++++++++++++++ test/shape/shacl_shape_with_not.ttl | 3 +- 5 files changed, 168 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 61d477f..7c39409 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ It is implemented as a Node.js library to calculate the containment (subsumption - **Star Pattern Decomposition**: Breaks down complex SPARQL queries into star patterns (groups of triple patterns sharing the same subject). - **Alignment Detection**: Identifies how closely a query matches the constraints defined in a shape. - **Dependency Tracking**: Handles links between shapes, detecting when a star pattern depends on another to be fully bounded. -- **Explicit Negation**: A predicate declared under SHACL `sh:not` or limited by `sh:maxCount 0` (or a ShEx negative triple constraint) never matches. Because such a shape states that the property must be absent, a query needing it is `REJECTED` even when the shape is open. +- **Explicit Negation**: A predicate forbidden by SHACL `sh:not [ sh:path p ; sh:minCount 1 ]` or limited by `sh:maxCount 0` (or a ShEx negative triple constraint) never matches. Because such a shape states that the property must be absent, a query needing it is `REJECTED` even when the shape is open. A `sh:not` with any other parameter forbids nothing (`sh:not [ sh:path p ; sh:minCount 2 ]` allows one value), so it is ignored and reported as an `UNSUPPORTED_NOT` parser diagnostic. - **Negated Triple Patterns**: A `FILTER NOT EXISTS` or `MINUS` over a single triple pattern `?s p ?o` (with `?s` bound by a required triple pattern and `?o` fresh) states that `?s` must not carry `p`. A shape that requires `p` (`sh:minCount` of at least 1) contradicts it, so the star pattern is `REJECTED` even when the shape is open. A `sh:not` of an input shape is translated the same way. - **SHACL Targets**: `sh:targetNode`, `sh:targetClass` (including implicit class targets), `sh:targetSubjectsOf` and `sh:targetObjectsOf` say which nodes a shape describes. The targets of a source shape become patterns about its focus node; the targets of a candidate shape are matched, and limit which star patterns its closedness can reject. See [Targets](#targets). diff --git a/lib/shacl.ts b/lib/shacl.ts index 46562c6..ab4c9f5 100644 --- a/lib/shacl.ts +++ b/lib/shacl.ts @@ -23,6 +23,8 @@ const RDF_TRUE = DF.literal('true', DF.namedNode(XSD.boolean)); */ interface IPropertyShapeData { path?: string; + /** false when sh:path is a blank node, that is, a complex path */ + pathIsIri?: boolean; minCount?: number; maxCount?: number; minInclusive?: number; @@ -48,12 +50,12 @@ interface IMapTripleShacl { propertyData: Map; /** shape IRI → closed flag */ closedShape: Map; - /** shape IRI / blank-node → list head blank-node (from sh:or / sh:xone) */ - orLists: Map; - /** shape IRI / blank-node → list head blank-node (from sh:xone) */ - xoneLists: Map; - /** shape IRI / blank-node → list head blank-node (from sh:not) */ - notLinks: Map; + /** shape IRI / blank-node → list head blank-nodes, one per sh:or value */ + orLists: Map>; + /** shape IRI / blank-node → list head blank-nodes, one per sh:xone value */ + xoneLists: Map>; + /** shape IRI / blank-node → negated shapes, one per sh:not value */ + notLinks: Map>; /** RDF list first: node → value */ listFirst: Map; /** RDF list rest: node → next node */ @@ -66,6 +68,8 @@ interface IMapTripleShacl { types: Map>; /** class → rdfs:subClassOf values, to find the subclasses of rdfs:Class */ superClasses: Map>; + /** node → the SHACL predicates it is the subject of, to check the form of a sh:not value */ + shaclPredicates: Map>; } type IShapeTargetSets = { [K in keyof IShapeTargets]: Set }; @@ -84,6 +88,7 @@ function defaultMap(): IMapTripleShacl { ignoredLists: new Map(), types: new Map(), superClasses: new Map(), + shaclPredicates: new Map(), }; } @@ -140,6 +145,10 @@ function parseQuad(quad: RDF.Quad, map: IMapTripleShacl): void { const s = quad.subject.value; const o = quad.object.value; + if (quad.predicate.value.startsWith(SHACL.namespace)) { + addToSetMap(map.shaclPredicates, s, quad.predicate.value); + } + // sh:property → register property shape under the node shape if (quad.predicate.equals(SHACL.terms.property)) { let props = map.shapeProperties.get(s); @@ -157,7 +166,9 @@ function parseQuad(quad: RDF.Quad, map: IMapTripleShacl): void { // sh:path → predicate IRI for this property shape if (quad.predicate.equals(SHACL.terms.path)) { - getOrCreatePropData(map, s).path = o; + const data = getOrCreatePropData(map, s); + data.path = o; + data.pathIsIri = quad.object.termType === 'NamedNode'; return; } @@ -235,19 +246,19 @@ function parseQuad(quad: RDF.Quad, map: IMapTripleShacl): void { // sh:or → alternatives list head if (quad.predicate.equals(SHACL.terms.or)) { - map.orLists.set(s, o); + addToSetMap(map.orLists, s, o); return; } // sh:xone → exclusive-one-of list head (treated same as sh:or for query matching) if (quad.predicate.equals(SHACL.terms.xone)) { - map.xoneLists.set(s, o); + addToSetMap(map.xoneLists, s, o); return; } // sh:not → negation (blank node property shape that should become a negative predicate) if (quad.predicate.equals(SHACL.terms.not)) { - map.notLinks.set(s, o); + addToSetMap(map.notLinks, s, o); // Create entry for the negated shape's blank node const negData = getOrCreatePropData(map, o); negData.isNegated = true; @@ -342,7 +353,9 @@ function buildShape( // Collect sh:not negated property shapes from under the target shape const negatedPropIds = collectNotProps(map, shapeIri); - const hasOrList = map.orLists.has(shapeIri) || map.xoneLists.has(shapeIri); + // A shape conforms to every one of its sh:or and sh:xone values, so each becomes a disjunction + const orHeads = [...map.orLists.get(shapeIri) ?? [], ...map.xoneLists.get(shapeIri) ?? []]; + const hasOrList = orHeads.length > 0; const targets = collectTargets(map, shapeIri); const hasTarget = Object.values(targets).some(values => values.length > 0); @@ -371,16 +384,23 @@ function buildShape( // Negated property shapes from sh:not for (const negId of negatedPropIds) { - const data = map.propertyData.get(negId); - if (data?.path !== undefined) { - negativePredicates.push(data.path); + const forbidden = forbiddenPredicate(map, negId); + if (forbidden !== undefined) { + negativePredicates.push(forbidden); + } else { + addDiagnostic(options, { + level: 'warning', + code: 'UNSUPPORTED_NOT', + message: `sh:not of <${shapeIri}> does not forbid a single property and is ignored`, + shapeIri, + nodeId: negId, + }); } } // Resolve sh:or / sh:xone into oneOf branches const oneOfs: OneOf[] = []; - const orHead = map.orLists.get(shapeIri) ?? map.xoneLists.get(shapeIri); - if (orHead !== undefined) { + for (const orHead of orHeads) { const branch = resolveOrList(orHead, map, shapeIri, options); if (branch.length > 0) { oneOfs.push(branch); @@ -440,14 +460,30 @@ function subClassesOf(map: IMapTripleShacl, superClass: string): Set { return result; } -/** Collect all property-shape blank nodes reachable via sh:not from a shape IRI. */ +/** Collect the property shapes negated by the sh:not values of a shape. */ function collectNotProps(map: IMapTripleShacl, shapeIri: string): Set { - const result = new Set(); - const notTarget = map.notLinks.get(shapeIri); - if (notTarget !== undefined) { - result.add(notTarget); + return new Set(map.notLinks.get(shapeIri)); +} + +// The SHACL predicates that a sh:not value may carry besides sh:minCount and still forbid its path. The +// others are not parameters: they do not take part in validation. +const NEGATION_PREDICATES = new Set([ + 'path', 'minCount', 'name', 'description', 'order', 'group', 'defaultValue', 'message', 'severity', 'deactivated', +].map(name => SHACL.custom(name))); + +/** + * The predicate that a sh:not value forbids, if any. `sh:not [ sh:path p ; sh:minCount 1 ]`, with an IRI + * path and no other parameter, is the only form that a node conforms to exactly when it has no value for p. + * Any other form holds for some nodes that carry p, so it forbids nothing: `sh:not [ sh:path p ; sh:minCount 2 ]` + * allows one value, and `sh:not [ sh:path p ; sh:datatype xsd:string ]` requires a value that is not a string. + */ +function forbiddenPredicate(map: IMapTripleShacl, nodeId: string): string | undefined { + const data = map.propertyData.get(nodeId); + if (data?.path === undefined || data.pathIsIri !== true || data.minCount !== 1) { + return undefined; } - return result; + const predicates = map.shaclPredicates.get(nodeId) ?? new Set(); + return Array.from(predicates).every(predicate => NEGATION_PREDICATES.has(predicate)) ? data.path : undefined; } // SHACL bounds a cardinality only when it is declared: an absent sh:minCount is 0 and an absent diff --git a/test/containment.test.ts b/test/containment.test.ts index 27d7227..2361755 100644 --- a/test/containment.test.ts +++ b/test/containment.test.ts @@ -2172,11 +2172,33 @@ describe('supported profile and explicit negation', () => { }); }); + describe('several disjunctions', () => { + const shape = `${SHAPE_PREFIXES} + a sh:NodeShape ; + sh:closed true ; + sh:property [ sh:path foaf:name ] ; + sh:or ( [ sh:path ex:email ] [ sh:path ex:fax ] ) ; + sh:xone ( [ sh:path ex:phone ] [ sh:path ex:mobile ] ) .`; + + it.each([ + ['the first', 'ex:email'], + ['the second', 'ex:phone'], + ])('should match a predicate of %s disjunction of a closed shape', async (_, predicate) => { + expect(await classify(`SELECT * WHERE { ?s foaf:name ?n ; ${predicate} ?x }`, shape)) + .toBe(ContainmentResult.CONTAINED); + }); + + it('should match predicates of both disjunctions together', async () => { + expect(await classify('SELECT * WHERE { ?s ex:fax ?f ; ex:mobile ?m }', shape)) + .toBe(ContainmentResult.CONTAINED); + }); + }); + describe('sh:not', () => { const notShape = `${SHAPE_PREFIXES} a sh:NodeShape ; sh:property [ sh:path foaf:name ] ; - sh:not [ sh:path ex:secret ] .`; + sh:not [ sh:path ex:secret ; sh:minCount 1 ] .`; it('should reject a predicate the shape explicitly forbids, even on an open shape', async () => { expect(await classify('SELECT * WHERE { ?s ex:secret ?x }', notShape)) @@ -2187,6 +2209,29 @@ describe('supported profile and explicit negation', () => { expect(await classify('SELECT * WHERE { ?s foaf:name ?n }', notShape)) .toBe(ContainmentResult.CONTAINED); }); + + it('should reject a predicate forbidden by any of several sh:not values', async () => { + const shape = `${SHAPE_PREFIXES} + a sh:NodeShape ; + sh:property [ sh:path foaf:name ] ; + sh:not [ sh:path ex:secret ; sh:minCount 1 ], [ sh:path ex:pin ; sh:minCount 1 ] .`; + expect(await classify('SELECT * WHERE { ?s ex:secret ?x }', shape)).toBe(ContainmentResult.REJECTED); + expect(await classify('SELECT * WHERE { ?s ex:pin ?x }', shape)).toBe(ContainmentResult.REJECTED); + }); + + // Each of these negations holds for some nodes that carry ex:secret, so the open shape does not exclude it. + it.each([ + ['allows one value', '[ sh:path ex:secret ; sh:minCount 2 ]'], + ['requires a value that is not a string', '[ sh:path ex:secret ; sh:datatype xsd:string ]'], + ['only excludes the value ex:x', '[ sh:path ex:secret ; sh:minCount 1 ; sh:hasValue ex:x ]'], + ])('should not reject a predicate under a sh:not that %s', async (_, negation) => { + const shape = `${SHAPE_PREFIXES} @prefix xsd: . + a sh:NodeShape ; + sh:property [ sh:path foaf:name ] ; + sh:not ${negation} .`; + expect(await classify('SELECT * WHERE { ?s ex:secret ?x }', shape)) + .toBe(ContainmentResult.WEAKLY_REJECTED); + }); }); }); diff --git a/test/shacl.test.ts b/test/shacl.test.ts index 0ab98c9..c2373ab 100644 --- a/test/shacl.test.ts +++ b/test/shacl.test.ts @@ -7,6 +7,7 @@ import { streamifyArray } from 'streamify-array'; import { RDF as RDF_VOCAB } from '../lib/constant'; import { ConstraintType, type IConstraint, type OneOf, type IShape } from '../lib/Shape'; import { shaclShapeFromQuads } from '../lib/shacl'; +import type { ParserDiagnostic } from '../lib/parser-policy'; const DF = new DataFactory(); const n3Parser = new N3.Parser(); @@ -363,6 +364,24 @@ describe.each([ ])); }); + it(`${name}: should keep one disjunction for each sh:or and sh:xone value`, async () => { + const shape = await shaclShapeFromQuads(populateFunction(n3Parser.parse(` + @prefix sh: . + @prefix foaf: . + <${shapeIri}> a sh:NodeShape ; + sh:property [ sh:path foaf:name ] ; + sh:or ( [ sh:path foaf:mbox ] [ sh:path foaf:phone ] ) ; + sh:or ( [ sh:path foaf:homepage ] [ sh:path foaf:weblog ] ) ; + sh:xone ( [ sh:path foaf:age ] [ sh:path foaf:birthday ] ) .`)), shapeIri); + expect(shape).not.toBeInstanceOf(Error); + const disjunctions = (shape as IShape).oneOf.map(oneOf => oneOf.map(branch => branch.map(p => p.name))); + expect(new Set(disjunctions.map(oneOf => JSON.stringify(oneOf)))).toStrictEqual(new Set([ + [[`${FOAF_PREFIX}mbox`], [`${FOAF_PREFIX}phone`]], + [[`${FOAF_PREFIX}homepage`], [`${FOAF_PREFIX}weblog`]], + [[`${FOAF_PREFIX}age`], [`${FOAF_PREFIX}birthday`]], + ].map(oneOf => JSON.stringify(oneOf)))); + }); + // ── sh:not ─────────────────────────────────────────────────────────────── it(`${name}: should handle sh:not as a negative predicate`, async () => { @@ -372,6 +391,48 @@ describe.each([ expect((shape as IShape).negativePredicates).toStrictEqual([`${FOAF_PREFIX}prop2`]); }); + function shapeWithNegation(negation: string): any { + return populateFunction(n3Parser.parse(` + @prefix sh: . + @prefix foaf: . + @prefix xsd: . + <${shapeIri}> a sh:NodeShape ; + sh:property [ sh:path foaf:prop1 ] ; + sh:not ${negation} .`)); + } + + it(`${name}: should keep a negative predicate for each sh:not value`, async () => { + const shape = await shaclShapeFromQuads( + shapeWithNegation('[ sh:path foaf:prop2 ; sh:minCount 1 ], [ sh:path foaf:prop3 ; sh:minCount 1 ]'), + shapeIri, + ); + expect(new Set((shape as IShape).negativePredicates)).toStrictEqual(new Set([`${FOAF_PREFIX}prop2`, `${FOAF_PREFIX}prop3`])); + }); + + it(`${name}: should keep sh:not as a negative predicate when it also carries non-validating properties`, async () => { + const shape = await shaclShapeFromQuads( + shapeWithNegation('[ a sh:PropertyShape ; sh:path foaf:prop2 ; sh:minCount 1 ; sh:name "no prop2" ; sh:message "forbidden" ]'), + shapeIri, + ); + expect((shape as IShape).negativePredicates).toStrictEqual([`${FOAF_PREFIX}prop2`]); + }); + + it.each([ + ['without sh:minCount', '[ sh:path foaf:prop2 ]'], + ['with sh:minCount 2', '[ sh:path foaf:prop2 ; sh:minCount 2 ]'], + ['with another constraint', '[ sh:path foaf:prop2 ; sh:minCount 1 ; sh:datatype xsd:string ]'], + ['with sh:maxCount', '[ sh:path foaf:prop2 ; sh:minCount 1 ; sh:maxCount 3 ]'], + ['over a complex path', '[ sh:path [ sh:inversePath foaf:prop2 ] ; sh:minCount 1 ]'], + ['over a node shape', '[ sh:property [ sh:path foaf:prop2 ; sh:minCount 1 ] ]'], + ])(`${name}: should ignore a sh:not that does not forbid its property (%s) and report it`, async (_, negation) => { + const diagnostics: ParserDiagnostic[] = []; + const shape = await shaclShapeFromQuads(shapeWithNegation(negation), shapeIri, { diagnostics }); + expect(shape).not.toBeInstanceOf(Error); + expect((shape as IShape).positivePredicates).toStrictEqual([`${FOAF_PREFIX}prop1`]); + expect((shape as IShape).negativePredicates).toStrictEqual([]); + expect(diagnostics.map(d => d.code)).toStrictEqual(['UNSUPPORTED_NOT']); + }); + // ── Error: inconsistent predicates ──────────────────────────────────────── it(`${name}: should return an error for inconsistent positive and negative predicates`, async () => { diff --git a/test/shape/shacl_shape_with_not.ttl b/test/shape/shacl_shape_with_not.ttl index 39d1064..76642e6 100644 --- a/test/shape/shacl_shape_with_not.ttl +++ b/test/shape/shacl_shape_with_not.ttl @@ -1,7 +1,7 @@ @prefix sh: . @prefix foaf: . -# sh:not – shape negation: the property in sh:not's inner shape becomes a negative predicate +# sh:not – shape negation: `sh:not [ sh:path p ; sh:minCount 1 ]` forbids p, so p becomes a negative predicate a sh:NodeShape ; sh:property [ @@ -11,4 +11,5 @@ ] ; sh:not [ sh:path foaf:prop2 ; + sh:minCount 1 ; ] . From 67ae437f1da565dad90064122657aefd5654cdf1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 12:05:24 +0000 Subject: [PATCH 11/22] refactor: collapse identical open and closed branches in Bindings Both branches of `if (shape.closed === false)` had become the same code, so the condition only duplicated it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J27pqtwzm8UXeEv14dUbCX --- lib/Binding.ts | 41 +++++++++++------------------------------ 1 file changed, 11 insertions(+), 30 deletions(-) diff --git a/lib/Binding.ts b/lib/Binding.ts index c7afab6..a072791 100644 --- a/lib/Binding.ts +++ b/lib/Binding.ts @@ -242,41 +242,22 @@ export class Bindings implements IBindings { } } let boundedUnionFull = true; + let boundedUnion = true; const uncontainedUnionStarPatterns: IStarPatternWithDependencies[] = [] - - if (shape.closed === false) { - let boundedUnion = true; - for (const unionBinding of this.unionBindings) { - boundedUnion = ((unionBinding.hasOneContained && !this.strict) || - (this.strict && unionBinding.areAllContained)) && boundedUnion; - boundedUnionFull = boundedUnionFull && unionBinding.areAllContained; - if (!unionBinding.areAllContained) { - for (const binding of unionBinding.bindings) { - if (!binding.isFullyBounded()) { - uncontainedUnionStarPatterns.push(binding.starPattern); - } - } - } - } - this.fullyBounded = this.isEveryRequiredTripleBound(starPattern) && boundedUnion && !this.negativeContradiction - && !subjectOutsideTargets; - } else { - let boundedUnion = true; - for (const unionBinding of this.unionBindings) { - boundedUnion = ((unionBinding.hasOneContained && !this.strict) || - (this.strict && unionBinding.areAllContained)) && boundedUnion; - boundedUnionFull = boundedUnionFull && unionBinding.areAllContained; - if (!unionBinding.areAllContained) { - for (const binding of unionBinding.bindings) { - if (!binding.isFullyBounded()) { - uncontainedUnionStarPatterns.push(binding.starPattern); - } + for (const unionBinding of this.unionBindings) { + boundedUnion = ((unionBinding.hasOneContained && !this.strict) || + (this.strict && unionBinding.areAllContained)) && boundedUnion; + boundedUnionFull = boundedUnionFull && unionBinding.areAllContained; + if (!unionBinding.areAllContained) { + for (const binding of unionBinding.bindings) { + if (!binding.isFullyBounded()) { + uncontainedUnionStarPatterns.push(binding.starPattern); } } } - this.fullyBounded = this.isEveryRequiredTripleBound(starPattern) && boundedUnion && !this.negativeContradiction - && !subjectOutsideTargets; } + this.fullyBounded = this.isEveryRequiredTripleBound(starPattern) && boundedUnion && !this.negativeContradiction + && !subjectOutsideTargets; if (this.fullyBounded) { const cycle = new Set(); const rejectedValues = new Set(); From eb65a9b20e72bf2cf8ca14d762e70ba5c1b42853 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 12:08:06 +0000 Subject: [PATCH 12/22] refactor: build shapes directly instead of round-tripping through parts Both parsers built IPredicates, converted them to IPredicateParts and then back to IPredicates through buildShapeFromRaw, which was the identity on everything they produce. The parsers now build IPredicates once and create the shape with tryCreateShape, which only turns the inconsistency error into a returned value. IConstraintFacets is shared by IConstraint and by the parser state, so the SHACL property data feeds buildConstraint directly and ShEx keeps one facet record per node instead of six parallel maps copied into every call. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J27pqtwzm8UXeEv14dUbCX --- lib/Shape.ts | 202 +++++++++++++++------------------------------------ lib/shacl.ts | 66 ++++++----------- lib/shex.ts | 168 ++++++++++++++---------------------------- 3 files changed, 132 insertions(+), 304 deletions(-) diff --git a/lib/Shape.ts b/lib/Shape.ts index 9462036..4b23607 100644 --- a/lib/Shape.ts +++ b/lib/Shape.ts @@ -58,11 +58,9 @@ export type IPredicateJson = Omit & { } /** - * A constraint + * The numeric and string facets that restrict the literal values of a datatype constraint */ -export interface IConstraint { - value: Set; - type: ConstraintType; +export interface IConstraintFacets { minInclusive?: number; maxInclusive?: number; minExclusive?: number; @@ -71,6 +69,23 @@ export interface IConstraint { flags?: string; } +const FACET_KEYS: (keyof IConstraintFacets)[] = [ + 'minInclusive', + 'maxInclusive', + 'minExclusive', + 'maxExclusive', + 'pattern', + 'flags', +]; + +/** + * A constraint + */ +export interface IConstraint extends IConstraintFacets { + value: Set; + type: ConstraintType; +} + function toJsonConstraint(constraint: IConstraint): IConstraintJson { return { ...constraint, @@ -448,167 +463,64 @@ export function walkRdfList( }; } -export interface IConstraintParts { - shapeConstraint?: string; +/** + * What a shapes graph declares about the values of a property: a class, a shape or a datatype, and + * the facets of the datatype. + */ +export interface IConstraintDeclaration extends IConstraintFacets { classConstraint?: string; + shapeConstraint?: string; datatypeConstraint?: string; - minInclusive?: number; - maxInclusive?: number; - minExclusive?: number; - maxExclusive?: number; - pattern?: string; - flags?: string; } -export interface IPredicateParts { - name: string; - minCount?: number; - maxCount?: number; - constraintParts?: IConstraintParts; +/** + * Build the constraint of a property from its declaration. A class takes precedence over a shape, + * and a shape over a datatype. A datatype on rdf:type names a class. + */ +export function buildConstraint(declaration: IConstraintDeclaration, predicateName: string): IConstraint | undefined { + if (declaration.classConstraint !== undefined) { + return { value: new Set([declaration.classConstraint]), type: ConstraintType.CLASS }; + } + if (declaration.shapeConstraint !== undefined) { + return { value: new Set([declaration.shapeConstraint]), type: ConstraintType.SHAPE }; + } + if (declaration.datatypeConstraint === undefined) { + return undefined; + } + if (predicateName === RDF.type) { + return { value: new Set([declaration.datatypeConstraint]), type: ConstraintType.CLASS }; + } + return { value: new Set([declaration.datatypeConstraint]), type: ConstraintType.DATATYPE, ...definedFacets(declaration) }; } -export interface IRawShape { - name: string; - closed?: boolean; - positivePredicates: IPredicateParts[]; - negativePredicates?: string[]; - oneOf?: IRawOneOf[]; - targets?: Partial; - ignoredProperties?: string[]; +function definedFacets(facets: IConstraintFacets): IConstraintFacets { + const defined: IConstraintFacets = {}; + for (const key of FACET_KEYS) { + if (facets[key] !== undefined) { + Object.assign(defined, { [key]: facets[key] }); + } + } + return defined; } -export type IRawOneOfPath = IPredicateParts[]; -export type IRawOneOf = IRawOneOfPath[]; - /** - * Shared parser-neutral assembly from raw parser data into IShape. + * Build a shape, returning the error instead of throwing it when the shape is inconsistent. */ -export function buildShapeFromRaw(raw: IRawShape): IShape | ShapeError { +export function tryCreateShape(args: IShapeArgs): IShape | ShapeError { try { - const positivePredicates = raw.positivePredicates.map(buildPredicate); - const oneOf: OneOf[] = (raw.oneOf ?? []).map(currentOneOf => - currentOneOf.map(path => path.map(buildPredicate)), - ); - - return new Shape({ - name: raw.name, - positivePredicates, - negativePredicates: raw.negativePredicates ?? [], - closed: raw.closed ?? false, - oneOf, - targets: raw.targets, - ignoredProperties: raw.ignoredProperties, - }); + return new Shape(args); } catch (error: unknown) { - return error as ShapeError; - } -} - -export function predicateToParts(predicate: IPredicate): IPredicateParts { - return { - name: predicate.name, - minCount: predicate.cardinality?.min, - maxCount: predicate.cardinality?.max, - constraintParts: constraintToParts(predicate.constraint), - }; -} - -export function buildPredicate(parts: IPredicateParts): IPredicate { - const hasCardinality = parts.minCount !== undefined || parts.maxCount !== undefined; - return { - name: parts.name, - constraint: parts.constraintParts !== undefined ? buildConstraint(parts.constraintParts, parts.name) : undefined, - cardinality: hasCardinality - ? { - min: parts.minCount ?? 1, - max: parts.maxCount ?? 1, - } - : undefined, - }; -} - -export function buildConstraint(parts: IConstraintParts, predicateName?: string): IConstraint | undefined { - if (parts.classConstraint !== undefined) { - return { - value: new Set([parts.classConstraint]), - type: ConstraintType.CLASS, - }; - } - - if (parts.shapeConstraint !== undefined) { - return { - value: new Set([parts.shapeConstraint]), - type: ConstraintType.SHAPE, - }; - } - - if (parts.datatypeConstraint !== undefined) { - const facets: Partial = {}; - if (parts.minInclusive !== undefined) { - facets.minInclusive = parts.minInclusive; - } - if (parts.maxInclusive !== undefined) { - facets.maxInclusive = parts.maxInclusive; + if (error instanceof InconsistentPositiveAndNegativePredicateError) { + return error; } - if (parts.minExclusive !== undefined) { - facets.minExclusive = parts.minExclusive; - } - if (parts.maxExclusive !== undefined) { - facets.maxExclusive = parts.maxExclusive; - } - if (parts.pattern !== undefined) { - facets.pattern = parts.pattern; - } - if (parts.flags !== undefined) { - facets.flags = parts.flags; - } - - return { - value: new Set([parts.datatypeConstraint]), - type: predicateName === RDF.type - ? ConstraintType.CLASS - : ConstraintType.DATATYPE, - ...facets, - }; + throw error; } - - return undefined; } export function isNegativeCardinality(minCount?: number, maxCount?: number): boolean { return minCount === 0 && maxCount === 0; } -function constraintToParts(constraint: IConstraint | undefined): IConstraintParts | undefined { - if (constraint === undefined) { - return undefined; - } - - const firstValue = constraint.value.values().next().value as string | undefined; - const facets: IConstraintParts = { - minInclusive: constraint.minInclusive, - maxInclusive: constraint.maxInclusive, - minExclusive: constraint.minExclusive, - maxExclusive: constraint.maxExclusive, - pattern: constraint.pattern, - flags: constraint.flags, - }; - - if (firstValue === undefined) { - return facets; - } - - if (constraint.type === ConstraintType.SHAPE) { - facets.shapeConstraint = firstValue; - } else if (constraint.type === ConstraintType.CLASS) { - facets.classConstraint = firstValue; - } else if (constraint.type === ConstraintType.DATATYPE) { - facets.datatypeConstraint = firstValue; - } - - return facets; -} - export interface IParseShapeFromQuadsOptions extends IShapeParserOptions { format?: ShapeFormat | 'auto'; } diff --git a/lib/shacl.ts b/lib/shacl.ts index ab4c9f5..54d8c72 100644 --- a/lib/shacl.ts +++ b/lib/shacl.ts @@ -1,14 +1,7 @@ import type * as RDF from '@rdfjs/types'; import { DataFactory } from 'rdf-data-factory'; -import type { OneOf, IShape, IPredicate } from './Shape'; -import { - PoorlyFormatedShapeError, - walkRdfList, - buildShapeFromRaw, - predicateToParts, - buildPredicate as buildSharedPredicate, - isNegativeCardinality -} from './Shape'; +import type { OneOf, IShape, IPredicate, IConstraintDeclaration } from './Shape'; +import { PoorlyFormatedShapeError, walkRdfList, buildConstraint, tryCreateShape, isNegativeCardinality } from './Shape'; import type { ShapeError, IShapeTargets } from './Shape'; import { SHACL, RDF as RDF_VOCAB, RDFS, XSD } from './constant'; import { addDiagnostic, getPolicy, type IShapeParserOptions } from './parser-policy'; @@ -21,21 +14,12 @@ const RDF_TRUE = DF.literal('true', DF.namedNode(XSD.boolean)); /** * Raw quad data collected for a single SHACL property shape (blank node). */ -interface IPropertyShapeData { +interface IPropertyShapeData extends IConstraintDeclaration { path?: string; /** false when sh:path is a blank node, that is, a complex path */ pathIsIri?: boolean; minCount?: number; maxCount?: number; - minInclusive?: number; - maxInclusive?: number; - minExclusive?: number; - maxExclusive?: number; - pattern?: string; - flags?: string; - classConstraint?: string; // sh:class value - datatypeConstraint?: string; // sh:datatype value - nodeConstraint?: string; // sh:node value /** true when this property shape appears under sh:not */ isNegated: boolean; } @@ -226,13 +210,13 @@ function parseQuad(quad: RDF.Quad, map: IMapTripleShacl): void { return; } - // sh:class → SHAPE constraint + // sh:class → CLASS constraint if (quad.predicate.equals(SHACL.terms.class)) { getOrCreatePropData(map, s).classConstraint = o; return; } - // sh:datatype → TYPE constraint + // sh:datatype → DATATYPE constraint if (quad.predicate.equals(SHACL.terms.datatype)) { getOrCreatePropData(map, s).datatypeConstraint = o; return; @@ -240,7 +224,7 @@ function parseQuad(quad: RDF.Quad, map: IMapTripleShacl): void { // sh:node → SHAPE constraint (reference to another shape) if (quad.predicate.equals(SHACL.terms.node)) { - getOrCreatePropData(map, s).nodeConstraint = o; + getOrCreatePropData(map, s).shapeConstraint = o; return; } @@ -378,7 +362,7 @@ function buildShape( if (isNeg) { negativePredicates.push(data.path); } else { - positivePredicates.push(buildPredicate(data)); + positivePredicates.push(buildPredicate(data.path, data)); } } @@ -413,12 +397,12 @@ function buildShape( ? [] : walkRdfList(ignoredHead, { firstByNode: map.listFirst, restByNode: map.listRest }).values; - return buildShapeFromRaw({ + return tryCreateShape({ name: shapeIri, - positivePredicates: positivePredicates.map(predicate => predicateToParts(predicate)), + positivePredicates, negativePredicates, closed, - oneOf: oneOfs.map(currentOneOf => currentOneOf.map(path => path.map(predicateToParts))), + oneOf: oneOfs, targets, ignoredProperties, }); @@ -496,24 +480,16 @@ function isNegatedData(data: IPropertyShapeData): boolean { return isNegativeCardinality(data.minCount ?? SHACL_DEFAULT_MIN_COUNT, data.maxCount); } -/** Build an IPredicate from a property shape data entry. */ -function buildPredicate(data: IPropertyShapeData): IPredicate { - return buildSharedPredicate({ - name: data.path!, - minCount: data.minCount ?? SHACL_DEFAULT_MIN_COUNT, - maxCount: data.maxCount ?? SHACL_DEFAULT_MAX_COUNT, - constraintParts: { - classConstraint: data.classConstraint, - shapeConstraint: data.nodeConstraint, - datatypeConstraint: data.datatypeConstraint, - minInclusive: data.minInclusive, - maxInclusive: data.maxInclusive, - minExclusive: data.minExclusive, - maxExclusive: data.maxExclusive, - pattern: data.pattern, - flags: data.flags, +/** Build an IPredicate from a property shape data entry with the given path. */ +function buildPredicate(path: string, data: IPropertyShapeData): IPredicate { + return { + name: path, + constraint: buildConstraint(data, path), + cardinality: { + min: data.minCount ?? SHACL_DEFAULT_MIN_COUNT, + max: data.maxCount ?? SHACL_DEFAULT_MAX_COUNT, }, - }); + }; } /** @@ -568,14 +544,14 @@ function resolveOrMember(memberId: string, map: IMapTripleShacl): IPredicate[] { for (const propId of propIds) { const data = map.propertyData.get(propId); if (data?.path !== undefined && !isNegatedData(data)) { - predicates.push(buildPredicate(data)); + predicates.push(buildPredicate(data.path, data)); } } } // A member might itself be a single property shape (sh:path lives directly on the member) const directData = map.propertyData.get(memberId); if (directData?.path !== undefined && predicates.length === 0) { - predicates.push(buildPredicate(directData)); + predicates.push(buildPredicate(directData.path, directData)); } return predicates; } \ No newline at end of file diff --git a/lib/shex.ts b/lib/shex.ts index 3ac7070..559a76f 100644 --- a/lib/shex.ts +++ b/lib/shex.ts @@ -5,9 +5,11 @@ import type { ShapeError, OneOf, IShape, - IPredicate + IPredicate, + IConstraint, + IConstraintFacets, } from './Shape'; -import { PoorlyFormatedShapeError, walkRdfList, buildShapeFromRaw, predicateToParts, buildConstraint, isNegativeCardinality } from './Shape'; +import { PoorlyFormatedShapeError, walkRdfList, tryCreateShape, buildConstraint, isNegativeCardinality } from './Shape'; import { addDiagnostic, getPolicy, type IShapeParserOptions } from './parser-policy'; const DF = new DataFactory(); @@ -110,17 +112,7 @@ function concatShapeInfo( const positivePredicates: IPredicate[] = []; const negativePredicates: string[] = []; const argsFunctionPredicate: IAppendPredicateArgs = { - mapIdPredicate: mapTripleShex.mapIdPredicate, - mapIriCardinalityMin: mapTripleShex.mapIriCardinalityMin, - mapIriCardinalityMax: mapTripleShex.mapIriCardinalityMax, - mapIriConstraint: mapTripleShex.mapIriConstraint, - mapIriDatatype: mapTripleShex.mapIriDatatype, - mapIriMinInclusive: mapTripleShex.mapIriMinInclusive, - mapIriMaxInclusive: mapTripleShex.mapIriMaxInclusive, - mapIriMinExclusive: mapTripleShex.mapIriMinExclusive, - mapIriMaxExclusive: mapTripleShex.mapIriMaxExclusive, - mapIriPattern: mapTripleShex.mapIriPattern, - mapIriFlags: mapTripleShex.mapIriFlags, + map: mapTripleShex, positivePredicates, negativePredicates, oneOf: new Map(), @@ -143,8 +135,8 @@ function concatShapeInfo( } // If there is only one expression if (expressions === undefined) { - argsFunctionPredicate.current = expression; - const predicateAdded = appendPredicates((argsFunctionPredicate as Required)); + // an undefined expression without expressions has already returned an error above + const predicateAdded = expression !== undefined && appendPredicates(argsFunctionPredicate, expression); if (!predicateAdded) { return new PoorlyFormatedShapeError('there are no predicates in the shape'); } @@ -218,32 +210,27 @@ function concatShapeInfo( oneOfs.push(oneOf); } oneOfs = deleteIdenticalBranch(oneOfs) - return buildShapeFromRaw({ + return tryCreateShape({ name: shapeIri, - positivePredicates: positivePredicates.map(predicateToParts), + positivePredicates, negativePredicates, closed: isClosed, - oneOf: oneOfs.map(currentOneOf => currentOneOf.map(path => path.map(predicateToParts))), + oneOf: oneOfs, }); } /** * Interpret an RDF term of a constraint into an object * @param {RDF.Term | undefined} constraint - The constraint RDF term - * @param {Map} mapIriDatatype - A map of IRI and data type + * @param {IMapTripleShex} map - The ShEx shape information, for the datatype and facets of a node constraint + * @param {string} predicate - The predicate the constraint applies to * @returns {IConstraint | undefined} - The constraint or undefined if the constraint is not supported */ function interpretConstraint( constraint: RDF.Term | undefined, - mapIriDatatype: Map, - mapIriMinInclusive: Map, - mapIriMaxInclusive: Map, - mapIriMinExclusive: Map, - mapIriMaxExclusive: Map, - mapIriPattern: Map, - mapIriFlags: Map, - predicate?: string, -){ + map: IMapTripleShex, + predicate: string, +): IConstraint | undefined { if (constraint === undefined) { return undefined; } @@ -253,24 +240,9 @@ function interpretConstraint( } if (constraint.termType === 'BlankNode') { - const dataType = mapIriDatatype.get(constraint.value); - if (dataType !== undefined) { - const minInclusive = mapIriMinInclusive.get(constraint.value); - const maxInclusive = mapIriMaxInclusive.get(constraint.value); - const minExclusive = mapIriMinExclusive.get(constraint.value); - const maxExclusive = mapIriMaxExclusive.get(constraint.value); - const pattern = mapIriPattern.get(constraint.value); - const flags = mapIriFlags.get(constraint.value); - - return buildConstraint({ - datatypeConstraint: dataType, - minInclusive, - maxInclusive, - minExclusive, - maxExclusive, - pattern, - flags, - }, predicate); + const datatypeConstraint = map.mapIriDatatype.get(constraint.value); + if (datatypeConstraint !== undefined) { + return buildConstraint({ datatypeConstraint, ...map.mapIriFacets.get(constraint.value) }, predicate); } } @@ -278,32 +250,23 @@ function interpretConstraint( } /** - * Add the predicate to the predicat lists - * @param {Required} args - Argument to build a predicate + * Add the predicate of a triple expression to the predicate lists + * @param {IAppendPredicateArgs} args - The lists to add to, and the shape information + * @param {string} current - The triple expression * @returns {boolean} - return true if the predicate was added */ function appendPredicates( - args: Required, + args: IAppendPredicateArgs, + current: string, ): boolean { - const predicate = args.mapIdPredicate.get(args.current); + const predicate = args.map.mapIdPredicate.get(current); if (predicate !== undefined) { - const min = args.mapIriCardinalityMin.get(args.current); - const max = args.mapIriCardinalityMax.get(args.current); + const min = args.map.mapIriCardinalityMin.get(current); + const max = args.map.mapIriCardinalityMax.get(current); if (isNegativeCardinality(min, max)) { args.negativePredicates.push(predicate); } else { - const constraintIri = args.mapIriConstraint.get(args.current); - const constraint = interpretConstraint( - constraintIri, - args.mapIriDatatype, - args.mapIriMinInclusive, - args.mapIriMaxInclusive, - args.mapIriMinExclusive, - args.mapIriMaxExclusive, - args.mapIriPattern, - args.mapIriFlags, - predicate, - ); + const constraint = interpretConstraint(args.map.mapIriConstraint.get(current), args.map, predicate); args.positivePredicates.push({ name: predicate, cardinality: { @@ -330,17 +293,7 @@ function handleOneOf( const positivePredicates: IPredicate[] = []; const negativePredicates: string[] = []; const argsFunctionPredicate: IAppendPredicateArgs = { - mapIdPredicate: mapTripleShex.mapIdPredicate, - mapIriCardinalityMin: mapTripleShex.mapIriCardinalityMin, - mapIriCardinalityMax: mapTripleShex.mapIriCardinalityMax, - mapIriConstraint: mapTripleShex.mapIriConstraint, - mapIriDatatype: mapTripleShex.mapIriDatatype, - mapIriMinInclusive: mapTripleShex.mapIriMinInclusive, - mapIriMaxInclusive: mapTripleShex.mapIriMaxInclusive, - mapIriMinExclusive: mapTripleShex.mapIriMinExclusive, - mapIriMaxExclusive: mapTripleShex.mapIriMaxExclusive, - mapIriPattern: mapTripleShex.mapIriPattern, - mapIriFlags: mapTripleShex.mapIriFlags, + map: mapTripleShex, positivePredicates, negativePredicates, oneOf: new Map() @@ -433,8 +386,7 @@ function handleEachOf( // Traverse the RDF list while (current !== undefined) { if (!mapTripleShex.setIriOneOf.has(current)) { - argsFunctionPredicate.current = current; - appendPredicates((argsFunctionPredicate as Required)); + appendPredicates(argsFunctionPredicate, current); } else { const error = handleOneOf( current, @@ -529,23 +481,15 @@ function parseShapeQuads( if (quad.predicate.equals(SHEX.terms.datatype)) { mapTripleShex.mapIriDatatype.set(quad.subject.value, quad.object.value); } - if (quad.predicate.equals(SHEX.terms.mininclusive)) { - mapTripleShex.mapIriMinInclusive.set(quad.subject.value, Number(quad.object.value)); - } - if (quad.predicate.equals(SHEX.terms.maxinclusive)) { - mapTripleShex.mapIriMaxInclusive.set(quad.subject.value, Number(quad.object.value)); - } - if (quad.predicate.equals(SHEX.terms.minexclusive)) { - mapTripleShex.mapIriMinExclusive.set(quad.subject.value, Number(quad.object.value)); - } - if (quad.predicate.equals(SHEX.terms.maxexclusive)) { - mapTripleShex.mapIriMaxExclusive.set(quad.subject.value, Number(quad.object.value)); + const numericFacet = NUMERIC_FACETS.get(quad.predicate.value); + if (numericFacet !== undefined) { + getOrCreateFacets(mapTripleShex, quad.subject.value)[numericFacet] = Number(quad.object.value); } if (quad.predicate.equals(SHEX.terms.pattern)) { - mapTripleShex.mapIriPattern.set(quad.subject.value, quad.object.value); + getOrCreateFacets(mapTripleShex, quad.subject.value).pattern = quad.object.value; } if (quad.predicate.equals(SHEX.terms.flags)) { - mapTripleShex.mapIriFlags.set(quad.subject.value, quad.object.value); + getOrCreateFacets(mapTripleShex, quad.subject.value).flags = quad.object.value; } if (quad.object.equals(SHEX.terms.EachOf)) { mapTripleShex.setIriEachOf.add(quad.subject.value); @@ -555,6 +499,23 @@ function parseShapeQuads( } } +// The ShEx numeric facets, by the facet they set +const NUMERIC_FACETS = new Map([ + [SHEX.mininclusive, 'minInclusive'], + [SHEX.maxinclusive, 'maxInclusive'], + [SHEX.minexclusive, 'minExclusive'], + [SHEX.maxexclusive, 'maxExclusive'], +]); + +function getOrCreateFacets(mapTripleShex: IMapTripleShex, nodeId: string): IConstraintFacets { + let facets = mapTripleShex.mapIriFacets.get(nodeId); + if (facets === undefined) { + facets = {}; + mapTripleShex.mapIriFacets.set(nodeId, facets); + } + return facets; +} + interface IMapTripleShex { mapIdPredicate: Map; mapPrevCurrentList: Map; @@ -567,32 +528,16 @@ interface IMapTripleShex { mapIriCardinalityMin: Map; mapIriConstraint: Map; mapIriDatatype: Map; - mapIriMinInclusive: Map; - mapIriMaxInclusive: Map; - mapIriMinExclusive: Map; - mapIriMaxExclusive: Map; - mapIriPattern: Map; - mapIriFlags: Map; + mapIriFacets: Map; setIriEachOf: Set; setIriOneOf: Set; } interface IAppendPredicateArgs { - mapIdPredicate: Map; - mapIriCardinalityMin: Map; - mapIriCardinalityMax: Map; - mapIriConstraint: Map; - mapIriDatatype: Map; - mapIriMinInclusive: Map; - mapIriMaxInclusive: Map; - mapIriMinExclusive: Map; - mapIriMaxExclusive: Map; - mapIriPattern: Map; - mapIriFlags: Map; + map: IMapTripleShex; positivePredicates: IPredicate[]; negativePredicates: string[]; oneOf: Map; - current?: string; } function defaultMapTripleShex(): IMapTripleShex { @@ -608,12 +553,7 @@ function defaultMapTripleShex(): IMapTripleShex { mapIriCardinalityMin: new Map(), mapIriConstraint: new Map(), mapIriDatatype: new Map(), - mapIriMinInclusive: new Map(), - mapIriMaxInclusive: new Map(), - mapIriMinExclusive: new Map(), - mapIriMaxExclusive: new Map(), - mapIriPattern: new Map(), - mapIriFlags: new Map(), + mapIriFacets: new Map(), setIriEachOf: new Set(), setIriOneOf: new Set() }; From 24c23aa5f1b8052514f0cbce8738fe64e3063a0d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 12:09:53 +0000 Subject: [PATCH 13/22] refactor: split shape translation, negation and filter checks into modules query.ts had grown to 1343 lines with three unrelated jobs and containment.ts to 1158. They are now: - lib/shapeToQuery.ts: shape to IQuery translation (also exported from the package index, as before through query.ts) - lib/negation.ts: reading MINUS / NOT EXISTS as negated triple patterns - lib/filter-compatibility.ts: FILTER compatibility with shape constraints query.ts (804 lines) now only normalizes SPARQL algebra and no longer depends on the shape model; containment.ts (470 lines) only classifies. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J27pqtwzm8UXeEv14dUbCX --- index.ts | 1 + lib/containment.ts | 697 +----------------------------------- lib/filter-compatibility.ts | 697 ++++++++++++++++++++++++++++++++++++ lib/negation.ts | 108 ++++++ lib/query.ts | 543 +--------------------------- lib/shapeToQuery.ts | 445 +++++++++++++++++++++++ test/containment.test.ts | 3 +- test/shapeToQuery.test.ts | 2 +- test/target.test.ts | 3 +- 9 files changed, 1262 insertions(+), 1237 deletions(-) create mode 100644 lib/filter-compatibility.ts create mode 100644 lib/negation.ts create mode 100644 lib/shapeToQuery.ts diff --git a/index.ts b/index.ts index 11b4741..4e3ef81 100644 --- a/index.ts +++ b/index.ts @@ -1,5 +1,6 @@ export * from './lib/constant'; export * from './lib/query'; +export * from './lib/shapeToQuery'; export * from './lib/shex'; export * from './lib/shacl'; export * from './lib/containment'; diff --git a/lib/containment.ts b/lib/containment.ts index 039f934..b0030fa 100644 --- a/lib/containment.ts +++ b/lib/containment.ts @@ -1,9 +1,10 @@ import { Bindings, ContainmentType, IBindings } from './Binding' -import { generateStarPatternUnion, shapeToQuery, type IQuery, type UnsupportedConstruct } from './query'; -import { ConstraintType, IConstraint, IShape } from './Shape'; +import { generateStarPatternUnion, type IQuery, type UnsupportedConstruct } from './query'; +import { shapeToQuery } from './shapeToQuery'; +import type { IShape } from './Shape'; import type { IStarPatternWithDependencies } from './Triple'; import { describesStarPattern, incomingRequiredPredicates } from './target'; -import type { Term } from '@rdfjs/types'; +import { evaluateFiltersForShape, FilterTruth } from './filter-compatibility'; /** * Determine if a query is contained inside a shape. @@ -315,696 +316,6 @@ function getNestedDependencyNames(starPattern: IStarPatternWithDependencies, vis return nestedNames; } -enum FilterTruth { - TRUE = 'true', - FALSE = 'false', - UNKNOWN = 'unknown', -} - -interface IFilterEvalContext { - starPattern: IStarPatternWithDependencies; - shape: IShape; -} - -interface INumericBounds { - minInclusive?: number; - maxInclusive?: number; - minExclusive?: number; - maxExclusive?: number; -} - -interface IVariableDatatypeConstraint { - datatypes: Set; - bounds?: INumericBounds; - pattern?: string; - flags?: string; -} - -function evaluateFiltersForShape( - filters: unknown[] | undefined, - starPattern: IStarPatternWithDependencies, - shape: IShape, -): FilterTruth { - // Filters are evaluated conservatively: we only reject on proven contradiction. - if (filters === undefined || filters.length === 0) { - return FilterTruth.UNKNOWN; - } - - const context: IFilterEvalContext = { starPattern, shape }; - let status = FilterTruth.UNKNOWN; - for (const filter of filters) { - const result = evaluateFilterCompatibility(filter, context); - if (result === FilterTruth.FALSE) { - return FilterTruth.FALSE; - } - if (result === FilterTruth.TRUE) { - status = FilterTruth.TRUE; - } - } - return status; -} - -function evaluateFilterCompatibility(expression: unknown, context: IFilterEvalContext): FilterTruth { - if (typeof expression !== 'object' || expression === null) { - return FilterTruth.UNKNOWN; - } - - const castExpression = expression as { subType?: string; operator?: string; args?: unknown[] }; - if (castExpression.subType === 'term') { - // Boolean literals like FILTER(true/false) are intentionally ignored for containment. - return FilterTruth.UNKNOWN; - } - if (castExpression.subType !== 'operator') { - return FilterTruth.UNKNOWN; - } - - const args = castExpression.args ?? []; - switch (castExpression.operator) { - case '&&': - return combineLogicalAnd(args, context); - case '||': - return combineLogicalOr(args, context); - case '!': { - // Negation does not provide a safe contradiction signal in this conservative check. - return FilterTruth.UNKNOWN; - } - case '=': - case '!=': - case '<': - case '<=': - case '>': - case '>=': - return evaluateComparisonCompatibility(castExpression.operator, args[0], args[1], context); - case 'regex': - return evaluateRegexCompatibility(args, context); - default: - return FilterTruth.UNKNOWN; - } -} - -function combineLogicalAnd(args: unknown[], context: IFilterEvalContext): FilterTruth { - // AND is false as soon as one branch is false; true only if at least one branch is provably true. - let hasTrue = false; - for (const arg of args) { - const current = evaluateFilterCompatibility(arg, context); - if (current === FilterTruth.FALSE) { - return FilterTruth.FALSE; - } - if (current === FilterTruth.TRUE) { - hasTrue = true; - } - } - return hasTrue ? FilterTruth.TRUE : FilterTruth.UNKNOWN; -} - -function combineLogicalOr(args: unknown[], context: IFilterEvalContext): FilterTruth { - // OR is false only if every branch is false. - let hasNonFalse = false; - for (const arg of args) { - const current = evaluateFilterCompatibility(arg, context); - if (current !== FilterTruth.FALSE) { - hasNonFalse = true; - } - } - return hasNonFalse ? FilterTruth.UNKNOWN : FilterTruth.FALSE; -} - -function evaluateComparisonCompatibility(operator: string, left: unknown, right: unknown, context: IFilterEvalContext): FilterTruth { - // First check datatype() comparisons, then numeric inequalities. - const datatypeCheck = evaluateDatatypeCompatibility(operator, left, right, context) - ?? evaluateDatatypeCompatibility(operator, right, left, context); - if (datatypeCheck !== undefined) { - return datatypeCheck; - } - - if (operator === '<' || operator === '<=' || operator === '>' || operator === '>=') { - const numericCheck = evaluateNumericComparisonCompatibility(operator, left, right, context, true) - ?? evaluateNumericComparisonCompatibility(operator, right, left, context, false); - if (numericCheck !== undefined) { - return numericCheck; - } - } - - return FilterTruth.UNKNOWN; -} - -function evaluateNumericComparisonCompatibility( - operator: string, - variableExpr: unknown, - otherExpr: unknown, - context: IFilterEvalContext, - variableOnLeft: boolean, -): FilterTruth | undefined { - // This branch only reasons about variable-vs-literal numeric constraints. - const variableName = extractVariableName(variableExpr); - if (variableName === undefined) { - return undefined; - } - - const literal = extractLiteral(otherExpr); - if (literal === undefined || getNumericLiteralValue(literal) === undefined) { - return FilterTruth.UNKNOWN; - } - - const variableConstraint = extractVariableDatatypeConstraint(variableName, context); - if (variableConstraint === undefined) { - return FilterTruth.UNKNOWN; - } - - for (const datatype of variableConstraint.datatypes) { - if (isNumericDatatype(datatype)) { - const filterBounds = boundsFromNumericFilter(operator, getNumericLiteralValue(literal)!, variableOnLeft); - const shapeBounds = variableConstraint.bounds; - if (shapeBounds === undefined || filterBounds === undefined) { - return FilterTruth.UNKNOWN; - } - - // Contradiction when filter bounds and shape bounds have an empty intersection. - const bounds = intersectBounds(shapeBounds, filterBounds); - if (bounds === undefined) { - return FilterTruth.FALSE; - } - - return FilterTruth.UNKNOWN; - } - } - return FilterTruth.FALSE; -} - -function evaluateDatatypeCompatibility(operator: string, datatypeExpr: unknown, otherExpr: unknown, context: IFilterEvalContext): FilterTruth | undefined { - // Handle FILTER(datatype(?v) = iri) and FILTER(datatype(?v) != iri) forms. - const variableName = extractDatatypeVariableName(datatypeExpr); - if (variableName === undefined) { - return undefined; - } - - const expectedDatatype = extractNamedNodeValue(otherExpr); - if (expectedDatatype === undefined) { - return FilterTruth.UNKNOWN; - } - - const datatypes = extractVariableDatatypes(variableName, context); - if (datatypes === undefined) { - return FilterTruth.UNKNOWN; - } - - if (operator === '=' && !datatypes.has(expectedDatatype)) { - return FilterTruth.FALSE; - } - if (operator === '!=' && datatypes.size === 1 && datatypes.has(expectedDatatype)) { - return FilterTruth.FALSE; - } - return FilterTruth.UNKNOWN; -} - -function extractVariableDatatypes(variableName: string, context: IFilterEvalContext): Set | undefined { - return extractVariableDatatypeConstraint(variableName, context)?.datatypes; -} - -function extractVariableDatatypeConstraint(variableName: string, context: IFilterEvalContext): IVariableDatatypeConstraint | undefined { - let collectedDatatypes: Set | undefined; - let collectedBounds: INumericBounds | undefined; - let collectedPattern: string | undefined; - let collectedFlags: string | undefined; - - for (const { triple } of context.starPattern.starPattern.values()) { - const matchesVariable = (!Array.isArray(triple.object) && triple.object.termType === 'Variable' && triple.object.value === variableName) - || (Array.isArray(triple.object) && triple.boundVariable === variableName); - if (!matchesVariable) { - continue; - } - const predicateConstraint = context.shape.get(triple.predicate)?.constraint; - if (predicateConstraint?.type !== ConstraintType.DATATYPE || predicateConstraint.value.size === 0) { - continue; - } - - // Multiple occurrences of the same variable are merged by intersection. - if (collectedDatatypes === undefined) { - collectedDatatypes = new Set(predicateConstraint.value); - } else { - collectedDatatypes = new Set(Array.from(collectedDatatypes).filter((value) => predicateConstraint.value.has(value))); - } - - const bounds = extractNumericBounds(predicateConstraint); - if (bounds !== undefined) { - if (collectedBounds === undefined) { - collectedBounds = bounds; - } else { - const intersected = intersectBounds(collectedBounds, bounds); - if (intersected === undefined) { - return { - datatypes: collectedDatatypes, - bounds: { - minInclusive: 1, - maxInclusive: 0, - }, - }; - } - collectedBounds = intersected; - } - } - - // Regex reasoning is only kept when all matching constraints agree on pattern/flags. - if (predicateConstraint.pattern !== undefined) { - if (collectedPattern === undefined) { - collectedPattern = predicateConstraint.pattern; - collectedFlags = predicateConstraint.flags; - } else if (collectedPattern !== predicateConstraint.pattern || collectedFlags !== predicateConstraint.flags) { - return { - datatypes: collectedDatatypes, - bounds: collectedBounds, - }; - } - } - } - - if (collectedDatatypes === undefined) { - return undefined; - } - - return { - datatypes: collectedDatatypes, - bounds: collectedBounds, - pattern: collectedPattern, - flags: collectedFlags, - }; -} - -function evaluateRegexCompatibility(args: unknown[], context: IFilterEvalContext): FilterTruth { - // Supported form: FILTER(regex(str(?v), pattern, flags?)). - const variableName = extractRegexVariableName(args[0]); - if (variableName === undefined) { - return FilterTruth.UNKNOWN; - } - - const regexPattern = extractStringLiteralValue(args[1]); - if (regexPattern === undefined) { - return FilterTruth.UNKNOWN; - } - - const regexFlags = extractStringLiteralValue(args[2]) ?? ''; - const variableConstraint = extractVariableDatatypeConstraint(variableName, context); - if (variableConstraint?.pattern === undefined) { - return FilterTruth.UNKNOWN; - } - - // Equality alone is not strong enough to prove containment in this conservative pass, - // so we keep it as UNKNOWN and only return TRUE/FALSE for explicit implication/conflict. - if (variableConstraint.pattern === regexPattern && (variableConstraint.flags ?? '') === regexFlags) { - return FilterTruth.UNKNOWN; - } - - const shapePatternTest = buildRegex(variableConstraint.pattern, variableConstraint.flags); - const filterPatternTest = buildRegex(regexPattern, regexFlags); - if (shapePatternTest === undefined || filterPatternTest === undefined) { - return FilterTruth.UNKNOWN; - } - - // TRUE when the shape regex is strictly stronger and therefore always satisfies the filter. - if (doesRegexConstraintImplyFilter(variableConstraint.pattern, variableConstraint.flags, regexPattern, regexFlags)) { - return FilterTruth.TRUE; - } - - // FALSE only when no string can match both literal patterns. - if (hasRegexPrefixIncompatibility(variableConstraint.pattern, variableConstraint.flags, regexPattern, regexFlags)) { - return FilterTruth.FALSE; - } - - return FilterTruth.UNKNOWN; -} - -interface IAnchoredLiteralPrefix { - prefix: string; - anchoredEnd: boolean; -} - -function doesRegexConstraintImplyFilter( - shapePattern: string, - shapeFlags: string | undefined, - filterPattern: string, - filterFlags: string, -): boolean { - // We only reason about implication when both regexes run under the same flags. - const normalizedShapeFlags = shapeFlags ?? ''; - if (normalizedShapeFlags !== filterFlags) { - return false; - } - - const shapePrefix = extractAnchoredLiteralPrefix(shapePattern); - const filterPrefix = extractAnchoredLiteralPrefix(filterPattern); - if (shapePrefix === undefined || filterPrefix === undefined) { - return false; - } - - // If the filter is end-anchored (^x$), implication requires exact same anchored literal. - if (filterPrefix.anchoredEnd) { - return shapePrefix.anchoredEnd && shapePrefix.prefix === filterPrefix.prefix; - } - - // For simple start-anchored prefixes, ^foo implies ^f. - return shapePrefix.prefix.startsWith(filterPrefix.prefix); -} - -function extractRegexVariableName(expression: unknown): string | undefined { - // Accept both regex(?v, ...) and regex(str(?v), ...). - if (typeof expression !== 'object' || expression === null) { - return undefined; - } - - const castExpression = expression as { subType?: string; operator?: string; args?: unknown[] }; - if (castExpression.subType === 'operator' && castExpression.operator === 'str') { - return extractVariableName((castExpression.args ?? [])[0]); - } - - return extractVariableName(expression); -} - -function extractStringLiteralValue(expression: unknown): string | undefined { - const literal = extractLiteral(expression); - if (literal === undefined) { - return undefined; - } - return literal.value; -} - -function buildRegex(pattern: string, flags?: string): RegExp | undefined { - // Invalid regexes are ignored in conservative compatibility checks. - try { - return new RegExp(pattern, flags); - } catch { - return undefined; - } -} - -function hasRegexPrefixIncompatibility( - shapePattern: string, - shapeFlags: string | undefined, - filterPattern: string, - filterFlags: string, -): boolean { - // Flags change what a literal pattern matches (`i` folds case, `x` drops whitespace, `q` makes `^` and `$` literal, - // `m` anchors at every line), so a contradiction is only decided between patterns without flags. - if ((shapeFlags ?? '') !== '' || filterFlags !== '') { - return false; - } - - const shapePrefix = extractAnchoredLiteralPrefix(shapePattern); - const filterPrefix = extractAnchoredLiteralPrefix(filterPattern); - if (shapePrefix !== undefined && filterPrefix !== undefined) { - return !areAnchoredLiteralsCompatible(shapePrefix, filterPrefix); - } - - // A string can start with one literal and go on to contain another, so a prefix (^foo) contradicts no unanchored - // literal (bar), and neither do two unanchored literals. Only an exact literal (^foo$) excludes what it lacks. - const shapeContains = extractContainsLiteral(shapePattern); - const filterContains = extractContainsLiteral(filterPattern); - if (shapePrefix?.anchoredEnd === true && filterContains !== undefined) { - return !shapePrefix.prefix.includes(filterContains); - } - if (filterPrefix?.anchoredEnd === true && shapeContains !== undefined) { - return !filterPrefix.prefix.includes(shapeContains); - } - - return false; -} - -/** - * Whether some string matches both start-anchored literal patterns: ^foo and ^fo are compatible, ^foo and ^bar are not, - * and an end-anchored ^foo$ only admits prefixes of "foo". - */ -function areAnchoredLiteralsCompatible(first: IAnchoredLiteralPrefix, second: IAnchoredLiteralPrefix): boolean { - if (first.anchoredEnd && second.anchoredEnd) { - return first.prefix === second.prefix; - } - if (first.anchoredEnd) { - return first.prefix.startsWith(second.prefix); - } - if (second.anchoredEnd) { - return second.prefix.startsWith(first.prefix); - } - return first.prefix.startsWith(second.prefix) || second.prefix.startsWith(first.prefix); -} - -function extractAnchoredLiteralPrefix(pattern: string): IAnchoredLiteralPrefix | undefined { - const match = pattern.match(/^\^([A-Za-z0-9 _-]+)(\$)?$/); - if (match === null) { - return undefined; - } - - return { - prefix: match[1], - anchoredEnd: match[2] !== undefined, - }; -} - -function extractContainsLiteral(pattern: string): string | undefined { - const match = pattern.match(/^([A-Za-z0-9 _-]+)$/); - return match?.[1]; -} - -function extractNumericBounds(constraint: IConstraint): INumericBounds | undefined { - // Normalize optional SHACL numeric facets into a single interval object. - const bounds: INumericBounds = {}; - if (constraint.minInclusive !== undefined) { - bounds.minInclusive = constraint.minInclusive; - } - if (constraint.maxInclusive !== undefined) { - bounds.maxInclusive = constraint.maxInclusive; - } - if (constraint.minExclusive !== undefined) { - bounds.minExclusive = constraint.minExclusive; - } - if (constraint.maxExclusive !== undefined) { - bounds.maxExclusive = constraint.maxExclusive; - } - - return Object.keys(bounds).length === 0 ? undefined : bounds; -} - -function boundsFromNumericFilter(operator: string, literalValue: number, variableOnLeft: boolean): INumericBounds | undefined { - // Convert a comparison operator to an interval on the variable. - const normalizedOperator = normalizeComparisonOperator(operator, variableOnLeft); - switch (normalizedOperator) { - case '>': - return { minExclusive: literalValue }; - case '>=': - return { minInclusive: literalValue }; - case '<': - return { maxExclusive: literalValue }; - case '<=': - return { maxInclusive: literalValue }; - default: - return undefined; - } -} - -function normalizeComparisonOperator(operator: string, variableOnLeft: boolean): string { - // Re-orient operator so bounds are always computed as variable OP literal. - if (variableOnLeft) { - return operator; - } - if (operator === '>') { - return '<'; - } - if (operator === '>=') { - return '<='; - } - if (operator === '<') { - return '>'; - } - if (operator === '<=') { - return '>='; - } - return operator; -} - -function intersectBounds(left: INumericBounds, right: INumericBounds): INumericBounds | undefined { - // Compute interval intersection; undefined means empty intersection. - const lower = pickLowerBound( - mergeLowerBound(left.minInclusive, false, left.minExclusive, true), - mergeLowerBound(right.minInclusive, false, right.minExclusive, true), - ); - const upper = pickUpperBound( - mergeUpperBound(left.maxInclusive, false, left.maxExclusive, true), - mergeUpperBound(right.maxInclusive, false, right.maxExclusive, true), - ); - - if (lower !== undefined && upper !== undefined) { - if (lower.value > upper.value) { - return undefined; - } - if (lower.value === upper.value && (lower.exclusive || upper.exclusive)) { - return undefined; - } - } - - const bounds: INumericBounds = {}; - if (lower !== undefined) { - if (lower.exclusive) { - bounds.minExclusive = lower.value; - } else { - bounds.minInclusive = lower.value; - } - } - if (upper !== undefined) { - if (upper.exclusive) { - bounds.maxExclusive = upper.value; - } else { - bounds.maxInclusive = upper.value; - } - } - - return bounds; -} - -function mergeLowerBound(inclusive: number | undefined, inclusiveExclusive: boolean, exclusive: number | undefined, exclusiveExclusive: boolean): { value: number; exclusive: boolean } | undefined { - // Pick the tighter lower bound from inclusive/exclusive candidates. - const candidates: Array<{ value: number; exclusive: boolean }> = []; - if (inclusive !== undefined) { - candidates.push({ value: inclusive, exclusive: inclusiveExclusive }); - } - if (exclusive !== undefined) { - candidates.push({ value: exclusive, exclusive: exclusiveExclusive }); - } - if (candidates.length === 0) { - return undefined; - } - return pickLowerBound(candidates[0], candidates[1]); -} - -function mergeUpperBound(inclusive: number | undefined, inclusiveExclusive: boolean, exclusive: number | undefined, exclusiveExclusive: boolean): { value: number; exclusive: boolean } | undefined { - // Pick the tighter upper bound from inclusive/exclusive candidates. - const candidates: Array<{ value: number; exclusive: boolean }> = []; - if (inclusive !== undefined) { - candidates.push({ value: inclusive, exclusive: inclusiveExclusive }); - } - if (exclusive !== undefined) { - candidates.push({ value: exclusive, exclusive: exclusiveExclusive }); - } - if (candidates.length === 0) { - return undefined; - } - return pickUpperBound(candidates[0], candidates[1]); -} - -function pickLowerBound(a?: { value: number; exclusive: boolean }, b?: { value: number; exclusive: boolean }): { value: number; exclusive: boolean } | undefined { - // Lower bound with the greatest value is the most restrictive. - if (a === undefined) { - return b; - } - if (b === undefined) { - return a; - } - if (a.value > b.value) { - return a; - } - if (b.value > a.value) { - return b; - } - return { value: a.value, exclusive: a.exclusive || b.exclusive }; -} - -function pickUpperBound(a?: { value: number; exclusive: boolean }, b?: { value: number; exclusive: boolean }): { value: number; exclusive: boolean } | undefined { - // Upper bound with the smallest value is the most restrictive. - if (a === undefined) { - return b; - } - if (b === undefined) { - return a; - } - if (a.value < b.value) { - return a; - } - if (b.value < a.value) { - return b; - } - return { value: a.value, exclusive: a.exclusive || b.exclusive }; -} - -function extractVariableName(expression: unknown): string | undefined { - // Extract a SPARQL variable from a term expression. - if (typeof expression !== 'object' || expression === null) { - return undefined; - } - const castExpression = expression as { subType?: string; term?: Term }; - if (castExpression.subType !== 'term' || castExpression.term === undefined || castExpression.term.termType !== 'Variable') { - return undefined; - } - return castExpression.term.value; -} - -function extractDatatypeVariableName(expression: unknown): string | undefined { - // Extract variable name from datatype(?v). - if (typeof expression !== 'object' || expression === null) { - return undefined; - } - const castExpression = expression as { subType?: string; operator?: string; args?: unknown[] }; - if (castExpression.subType !== 'operator' || castExpression.operator !== 'datatype') { - return undefined; - } - return extractVariableName((castExpression.args ?? [])[0]); -} - -function extractLiteral(expression: unknown): Term | undefined { - // Extract literal term from a term expression. - if (typeof expression !== 'object' || expression === null) { - return undefined; - } - const castExpression = expression as { subType?: string; term?: Term }; - if (castExpression.subType !== 'term' || castExpression.term === undefined || castExpression.term.termType !== 'Literal') { - return undefined; - } - return castExpression.term; -} - -function extractNamedNodeValue(expression: unknown): string | undefined { - // Extract named node IRI value from a term expression. - if (typeof expression !== 'object' || expression === null) { - return undefined; - } - const castExpression = expression as { subType?: string; term?: Term }; - if (castExpression.subType !== 'term' || castExpression.term === undefined || castExpression.term.termType !== 'NamedNode') { - return undefined; - } - return castExpression.term.value; -} - -function getNumericLiteralValue(term: Term): number | undefined { - // Parse numeric literal value only when datatype is numeric. - if (term.termType !== 'Literal') { - return undefined; - } - if (!isNumericDatatype(term.datatype.value)) { - return undefined; - } - - const value = Number(term.value); - return Number.isNaN(value) ? undefined : value; -} - -function isNumericDatatype(datatype: string): boolean { - // Accepted numeric XML Schema datatypes for numeric filter reasoning. - const numericDatatypes = new Set([ - 'http://www.w3.org/2001/XMLSchema#integer', - 'http://www.w3.org/2001/XMLSchema#decimal', - 'http://www.w3.org/2001/XMLSchema#double', - 'http://www.w3.org/2001/XMLSchema#float', - 'http://www.w3.org/2001/XMLSchema#nonNegativeInteger', - 'http://www.w3.org/2001/XMLSchema#nonPositiveInteger', - 'http://www.w3.org/2001/XMLSchema#positiveInteger', - 'http://www.w3.org/2001/XMLSchema#negativeInteger', - 'http://www.w3.org/2001/XMLSchema#long', - 'http://www.w3.org/2001/XMLSchema#int', - 'http://www.w3.org/2001/XMLSchema#short', - 'http://www.w3.org/2001/XMLSchema#byte', - 'http://www.w3.org/2001/XMLSchema#unsignedLong', - 'http://www.w3.org/2001/XMLSchema#unsignedInt', - 'http://www.w3.org/2001/XMLSchema#unsignedShort', - 'http://www.w3.org/2001/XMLSchema#unsignedByte', - ]); - return numericDatatypes.has(datatype); -} - function groupShapeBydependencies(shapes: IShape[], dependentShapes?: IShape[]): IShapeWithDependencies[] { // Build per-shape dependency maps that include all other visible shapes. const resp: IShapeWithDependencies[] = []; diff --git a/lib/filter-compatibility.ts b/lib/filter-compatibility.ts new file mode 100644 index 0000000..5fe28b4 --- /dev/null +++ b/lib/filter-compatibility.ts @@ -0,0 +1,697 @@ +import type { Term } from '@rdfjs/types'; +import { ConstraintType, type IConstraint, type IShape } from './Shape'; +import type { IStarPatternWithDependencies } from './Triple'; + +/** + * Whether the FILTER expressions of a query are compatible with what a shape says about the values + * of a star pattern. Filters are evaluated conservatively: FALSE only on a proven contradiction. + */ +export enum FilterTruth { + TRUE = 'true', + FALSE = 'false', + UNKNOWN = 'unknown', +} + +interface IFilterEvalContext { + starPattern: IStarPatternWithDependencies; + shape: IShape; +} + +interface INumericBounds { + minInclusive?: number; + maxInclusive?: number; + minExclusive?: number; + maxExclusive?: number; +} + +interface IVariableDatatypeConstraint { + datatypes: Set; + bounds?: INumericBounds; + pattern?: string; + flags?: string; +} + +export function evaluateFiltersForShape( + filters: unknown[] | undefined, + starPattern: IStarPatternWithDependencies, + shape: IShape, +): FilterTruth { + // Filters are evaluated conservatively: we only reject on proven contradiction. + if (filters === undefined || filters.length === 0) { + return FilterTruth.UNKNOWN; + } + + const context: IFilterEvalContext = { starPattern, shape }; + let status = FilterTruth.UNKNOWN; + for (const filter of filters) { + const result = evaluateFilterCompatibility(filter, context); + if (result === FilterTruth.FALSE) { + return FilterTruth.FALSE; + } + if (result === FilterTruth.TRUE) { + status = FilterTruth.TRUE; + } + } + return status; +} + +function evaluateFilterCompatibility(expression: unknown, context: IFilterEvalContext): FilterTruth { + if (typeof expression !== 'object' || expression === null) { + return FilterTruth.UNKNOWN; + } + + const castExpression = expression as { subType?: string; operator?: string; args?: unknown[] }; + if (castExpression.subType === 'term') { + // Boolean literals like FILTER(true/false) are intentionally ignored for containment. + return FilterTruth.UNKNOWN; + } + if (castExpression.subType !== 'operator') { + return FilterTruth.UNKNOWN; + } + + const args = castExpression.args ?? []; + switch (castExpression.operator) { + case '&&': + return combineLogicalAnd(args, context); + case '||': + return combineLogicalOr(args, context); + case '!': { + // Negation does not provide a safe contradiction signal in this conservative check. + return FilterTruth.UNKNOWN; + } + case '=': + case '!=': + case '<': + case '<=': + case '>': + case '>=': + return evaluateComparisonCompatibility(castExpression.operator, args[0], args[1], context); + case 'regex': + return evaluateRegexCompatibility(args, context); + default: + return FilterTruth.UNKNOWN; + } +} + +function combineLogicalAnd(args: unknown[], context: IFilterEvalContext): FilterTruth { + // AND is false as soon as one branch is false; true only if at least one branch is provably true. + let hasTrue = false; + for (const arg of args) { + const current = evaluateFilterCompatibility(arg, context); + if (current === FilterTruth.FALSE) { + return FilterTruth.FALSE; + } + if (current === FilterTruth.TRUE) { + hasTrue = true; + } + } + return hasTrue ? FilterTruth.TRUE : FilterTruth.UNKNOWN; +} + +function combineLogicalOr(args: unknown[], context: IFilterEvalContext): FilterTruth { + // OR is false only if every branch is false. + let hasNonFalse = false; + for (const arg of args) { + const current = evaluateFilterCompatibility(arg, context); + if (current !== FilterTruth.FALSE) { + hasNonFalse = true; + } + } + return hasNonFalse ? FilterTruth.UNKNOWN : FilterTruth.FALSE; +} + +function evaluateComparisonCompatibility(operator: string, left: unknown, right: unknown, context: IFilterEvalContext): FilterTruth { + // First check datatype() comparisons, then numeric inequalities. + const datatypeCheck = evaluateDatatypeCompatibility(operator, left, right, context) + ?? evaluateDatatypeCompatibility(operator, right, left, context); + if (datatypeCheck !== undefined) { + return datatypeCheck; + } + + if (operator === '<' || operator === '<=' || operator === '>' || operator === '>=') { + const numericCheck = evaluateNumericComparisonCompatibility(operator, left, right, context, true) + ?? evaluateNumericComparisonCompatibility(operator, right, left, context, false); + if (numericCheck !== undefined) { + return numericCheck; + } + } + + return FilterTruth.UNKNOWN; +} + +function evaluateNumericComparisonCompatibility( + operator: string, + variableExpr: unknown, + otherExpr: unknown, + context: IFilterEvalContext, + variableOnLeft: boolean, +): FilterTruth | undefined { + // This branch only reasons about variable-vs-literal numeric constraints. + const variableName = extractVariableName(variableExpr); + if (variableName === undefined) { + return undefined; + } + + const literal = extractLiteral(otherExpr); + if (literal === undefined || getNumericLiteralValue(literal) === undefined) { + return FilterTruth.UNKNOWN; + } + + const variableConstraint = extractVariableDatatypeConstraint(variableName, context); + if (variableConstraint === undefined) { + return FilterTruth.UNKNOWN; + } + + for (const datatype of variableConstraint.datatypes) { + if (isNumericDatatype(datatype)) { + const filterBounds = boundsFromNumericFilter(operator, getNumericLiteralValue(literal)!, variableOnLeft); + const shapeBounds = variableConstraint.bounds; + if (shapeBounds === undefined || filterBounds === undefined) { + return FilterTruth.UNKNOWN; + } + + // Contradiction when filter bounds and shape bounds have an empty intersection. + const bounds = intersectBounds(shapeBounds, filterBounds); + if (bounds === undefined) { + return FilterTruth.FALSE; + } + + return FilterTruth.UNKNOWN; + } + } + return FilterTruth.FALSE; +} + +function evaluateDatatypeCompatibility(operator: string, datatypeExpr: unknown, otherExpr: unknown, context: IFilterEvalContext): FilterTruth | undefined { + // Handle FILTER(datatype(?v) = iri) and FILTER(datatype(?v) != iri) forms. + const variableName = extractDatatypeVariableName(datatypeExpr); + if (variableName === undefined) { + return undefined; + } + + const expectedDatatype = extractNamedNodeValue(otherExpr); + if (expectedDatatype === undefined) { + return FilterTruth.UNKNOWN; + } + + const datatypes = extractVariableDatatypes(variableName, context); + if (datatypes === undefined) { + return FilterTruth.UNKNOWN; + } + + if (operator === '=' && !datatypes.has(expectedDatatype)) { + return FilterTruth.FALSE; + } + if (operator === '!=' && datatypes.size === 1 && datatypes.has(expectedDatatype)) { + return FilterTruth.FALSE; + } + return FilterTruth.UNKNOWN; +} + +function extractVariableDatatypes(variableName: string, context: IFilterEvalContext): Set | undefined { + return extractVariableDatatypeConstraint(variableName, context)?.datatypes; +} + +function extractVariableDatatypeConstraint(variableName: string, context: IFilterEvalContext): IVariableDatatypeConstraint | undefined { + let collectedDatatypes: Set | undefined; + let collectedBounds: INumericBounds | undefined; + let collectedPattern: string | undefined; + let collectedFlags: string | undefined; + + for (const { triple } of context.starPattern.starPattern.values()) { + const matchesVariable = (!Array.isArray(triple.object) && triple.object.termType === 'Variable' && triple.object.value === variableName) + || (Array.isArray(triple.object) && triple.boundVariable === variableName); + if (!matchesVariable) { + continue; + } + const predicateConstraint = context.shape.get(triple.predicate)?.constraint; + if (predicateConstraint?.type !== ConstraintType.DATATYPE || predicateConstraint.value.size === 0) { + continue; + } + + // Multiple occurrences of the same variable are merged by intersection. + if (collectedDatatypes === undefined) { + collectedDatatypes = new Set(predicateConstraint.value); + } else { + collectedDatatypes = new Set(Array.from(collectedDatatypes).filter((value) => predicateConstraint.value.has(value))); + } + + const bounds = extractNumericBounds(predicateConstraint); + if (bounds !== undefined) { + if (collectedBounds === undefined) { + collectedBounds = bounds; + } else { + const intersected = intersectBounds(collectedBounds, bounds); + if (intersected === undefined) { + return { + datatypes: collectedDatatypes, + bounds: { + minInclusive: 1, + maxInclusive: 0, + }, + }; + } + collectedBounds = intersected; + } + } + + // Regex reasoning is only kept when all matching constraints agree on pattern/flags. + if (predicateConstraint.pattern !== undefined) { + if (collectedPattern === undefined) { + collectedPattern = predicateConstraint.pattern; + collectedFlags = predicateConstraint.flags; + } else if (collectedPattern !== predicateConstraint.pattern || collectedFlags !== predicateConstraint.flags) { + return { + datatypes: collectedDatatypes, + bounds: collectedBounds, + }; + } + } + } + + if (collectedDatatypes === undefined) { + return undefined; + } + + return { + datatypes: collectedDatatypes, + bounds: collectedBounds, + pattern: collectedPattern, + flags: collectedFlags, + }; +} + +function evaluateRegexCompatibility(args: unknown[], context: IFilterEvalContext): FilterTruth { + // Supported form: FILTER(regex(str(?v), pattern, flags?)). + const variableName = extractRegexVariableName(args[0]); + if (variableName === undefined) { + return FilterTruth.UNKNOWN; + } + + const regexPattern = extractStringLiteralValue(args[1]); + if (regexPattern === undefined) { + return FilterTruth.UNKNOWN; + } + + const regexFlags = extractStringLiteralValue(args[2]) ?? ''; + const variableConstraint = extractVariableDatatypeConstraint(variableName, context); + if (variableConstraint?.pattern === undefined) { + return FilterTruth.UNKNOWN; + } + + // Equality alone is not strong enough to prove containment in this conservative pass, + // so we keep it as UNKNOWN and only return TRUE/FALSE for explicit implication/conflict. + if (variableConstraint.pattern === regexPattern && (variableConstraint.flags ?? '') === regexFlags) { + return FilterTruth.UNKNOWN; + } + + const shapePatternTest = buildRegex(variableConstraint.pattern, variableConstraint.flags); + const filterPatternTest = buildRegex(regexPattern, regexFlags); + if (shapePatternTest === undefined || filterPatternTest === undefined) { + return FilterTruth.UNKNOWN; + } + + // TRUE when the shape regex is strictly stronger and therefore always satisfies the filter. + if (doesRegexConstraintImplyFilter(variableConstraint.pattern, variableConstraint.flags, regexPattern, regexFlags)) { + return FilterTruth.TRUE; + } + + // FALSE only when no string can match both literal patterns. + if (hasRegexPrefixIncompatibility(variableConstraint.pattern, variableConstraint.flags, regexPattern, regexFlags)) { + return FilterTruth.FALSE; + } + + return FilterTruth.UNKNOWN; +} + +interface IAnchoredLiteralPrefix { + prefix: string; + anchoredEnd: boolean; +} + +function doesRegexConstraintImplyFilter( + shapePattern: string, + shapeFlags: string | undefined, + filterPattern: string, + filterFlags: string, +): boolean { + // We only reason about implication when both regexes run under the same flags. + const normalizedShapeFlags = shapeFlags ?? ''; + if (normalizedShapeFlags !== filterFlags) { + return false; + } + + const shapePrefix = extractAnchoredLiteralPrefix(shapePattern); + const filterPrefix = extractAnchoredLiteralPrefix(filterPattern); + if (shapePrefix === undefined || filterPrefix === undefined) { + return false; + } + + // If the filter is end-anchored (^x$), implication requires exact same anchored literal. + if (filterPrefix.anchoredEnd) { + return shapePrefix.anchoredEnd && shapePrefix.prefix === filterPrefix.prefix; + } + + // For simple start-anchored prefixes, ^foo implies ^f. + return shapePrefix.prefix.startsWith(filterPrefix.prefix); +} + +function extractRegexVariableName(expression: unknown): string | undefined { + // Accept both regex(?v, ...) and regex(str(?v), ...). + if (typeof expression !== 'object' || expression === null) { + return undefined; + } + + const castExpression = expression as { subType?: string; operator?: string; args?: unknown[] }; + if (castExpression.subType === 'operator' && castExpression.operator === 'str') { + return extractVariableName((castExpression.args ?? [])[0]); + } + + return extractVariableName(expression); +} + +function extractStringLiteralValue(expression: unknown): string | undefined { + const literal = extractLiteral(expression); + if (literal === undefined) { + return undefined; + } + return literal.value; +} + +function buildRegex(pattern: string, flags?: string): RegExp | undefined { + // Invalid regexes are ignored in conservative compatibility checks. + try { + return new RegExp(pattern, flags); + } catch { + return undefined; + } +} + +function hasRegexPrefixIncompatibility( + shapePattern: string, + shapeFlags: string | undefined, + filterPattern: string, + filterFlags: string, +): boolean { + // Flags change what a literal pattern matches (`i` folds case, `x` drops whitespace, `q` makes `^` and `$` literal, + // `m` anchors at every line), so a contradiction is only decided between patterns without flags. + if ((shapeFlags ?? '') !== '' || filterFlags !== '') { + return false; + } + + const shapePrefix = extractAnchoredLiteralPrefix(shapePattern); + const filterPrefix = extractAnchoredLiteralPrefix(filterPattern); + if (shapePrefix !== undefined && filterPrefix !== undefined) { + return !areAnchoredLiteralsCompatible(shapePrefix, filterPrefix); + } + + // A string can start with one literal and go on to contain another, so a prefix (^foo) contradicts no unanchored + // literal (bar), and neither do two unanchored literals. Only an exact literal (^foo$) excludes what it lacks. + const shapeContains = extractContainsLiteral(shapePattern); + const filterContains = extractContainsLiteral(filterPattern); + if (shapePrefix?.anchoredEnd === true && filterContains !== undefined) { + return !shapePrefix.prefix.includes(filterContains); + } + if (filterPrefix?.anchoredEnd === true && shapeContains !== undefined) { + return !filterPrefix.prefix.includes(shapeContains); + } + + return false; +} + +/** + * Whether some string matches both start-anchored literal patterns: ^foo and ^fo are compatible, ^foo and ^bar are not, + * and an end-anchored ^foo$ only admits prefixes of "foo". + */ +function areAnchoredLiteralsCompatible(first: IAnchoredLiteralPrefix, second: IAnchoredLiteralPrefix): boolean { + if (first.anchoredEnd && second.anchoredEnd) { + return first.prefix === second.prefix; + } + if (first.anchoredEnd) { + return first.prefix.startsWith(second.prefix); + } + if (second.anchoredEnd) { + return second.prefix.startsWith(first.prefix); + } + return first.prefix.startsWith(second.prefix) || second.prefix.startsWith(first.prefix); +} + +function extractAnchoredLiteralPrefix(pattern: string): IAnchoredLiteralPrefix | undefined { + const match = pattern.match(/^\^([A-Za-z0-9 _-]+)(\$)?$/); + if (match === null) { + return undefined; + } + + return { + prefix: match[1], + anchoredEnd: match[2] !== undefined, + }; +} + +function extractContainsLiteral(pattern: string): string | undefined { + const match = pattern.match(/^([A-Za-z0-9 _-]+)$/); + return match?.[1]; +} + +function extractNumericBounds(constraint: IConstraint): INumericBounds | undefined { + // Normalize optional SHACL numeric facets into a single interval object. + const bounds: INumericBounds = {}; + if (constraint.minInclusive !== undefined) { + bounds.minInclusive = constraint.minInclusive; + } + if (constraint.maxInclusive !== undefined) { + bounds.maxInclusive = constraint.maxInclusive; + } + if (constraint.minExclusive !== undefined) { + bounds.minExclusive = constraint.minExclusive; + } + if (constraint.maxExclusive !== undefined) { + bounds.maxExclusive = constraint.maxExclusive; + } + + return Object.keys(bounds).length === 0 ? undefined : bounds; +} + +function boundsFromNumericFilter(operator: string, literalValue: number, variableOnLeft: boolean): INumericBounds | undefined { + // Convert a comparison operator to an interval on the variable. + const normalizedOperator = normalizeComparisonOperator(operator, variableOnLeft); + switch (normalizedOperator) { + case '>': + return { minExclusive: literalValue }; + case '>=': + return { minInclusive: literalValue }; + case '<': + return { maxExclusive: literalValue }; + case '<=': + return { maxInclusive: literalValue }; + default: + return undefined; + } +} + +function normalizeComparisonOperator(operator: string, variableOnLeft: boolean): string { + // Re-orient operator so bounds are always computed as variable OP literal. + if (variableOnLeft) { + return operator; + } + if (operator === '>') { + return '<'; + } + if (operator === '>=') { + return '<='; + } + if (operator === '<') { + return '>'; + } + if (operator === '<=') { + return '>='; + } + return operator; +} + +function intersectBounds(left: INumericBounds, right: INumericBounds): INumericBounds | undefined { + // Compute interval intersection; undefined means empty intersection. + const lower = pickLowerBound( + mergeLowerBound(left.minInclusive, false, left.minExclusive, true), + mergeLowerBound(right.minInclusive, false, right.minExclusive, true), + ); + const upper = pickUpperBound( + mergeUpperBound(left.maxInclusive, false, left.maxExclusive, true), + mergeUpperBound(right.maxInclusive, false, right.maxExclusive, true), + ); + + if (lower !== undefined && upper !== undefined) { + if (lower.value > upper.value) { + return undefined; + } + if (lower.value === upper.value && (lower.exclusive || upper.exclusive)) { + return undefined; + } + } + + const bounds: INumericBounds = {}; + if (lower !== undefined) { + if (lower.exclusive) { + bounds.minExclusive = lower.value; + } else { + bounds.minInclusive = lower.value; + } + } + if (upper !== undefined) { + if (upper.exclusive) { + bounds.maxExclusive = upper.value; + } else { + bounds.maxInclusive = upper.value; + } + } + + return bounds; +} + +function mergeLowerBound(inclusive: number | undefined, inclusiveExclusive: boolean, exclusive: number | undefined, exclusiveExclusive: boolean): { value: number; exclusive: boolean } | undefined { + // Pick the tighter lower bound from inclusive/exclusive candidates. + const candidates: Array<{ value: number; exclusive: boolean }> = []; + if (inclusive !== undefined) { + candidates.push({ value: inclusive, exclusive: inclusiveExclusive }); + } + if (exclusive !== undefined) { + candidates.push({ value: exclusive, exclusive: exclusiveExclusive }); + } + if (candidates.length === 0) { + return undefined; + } + return pickLowerBound(candidates[0], candidates[1]); +} + +function mergeUpperBound(inclusive: number | undefined, inclusiveExclusive: boolean, exclusive: number | undefined, exclusiveExclusive: boolean): { value: number; exclusive: boolean } | undefined { + // Pick the tighter upper bound from inclusive/exclusive candidates. + const candidates: Array<{ value: number; exclusive: boolean }> = []; + if (inclusive !== undefined) { + candidates.push({ value: inclusive, exclusive: inclusiveExclusive }); + } + if (exclusive !== undefined) { + candidates.push({ value: exclusive, exclusive: exclusiveExclusive }); + } + if (candidates.length === 0) { + return undefined; + } + return pickUpperBound(candidates[0], candidates[1]); +} + +function pickLowerBound(a?: { value: number; exclusive: boolean }, b?: { value: number; exclusive: boolean }): { value: number; exclusive: boolean } | undefined { + // Lower bound with the greatest value is the most restrictive. + if (a === undefined) { + return b; + } + if (b === undefined) { + return a; + } + if (a.value > b.value) { + return a; + } + if (b.value > a.value) { + return b; + } + return { value: a.value, exclusive: a.exclusive || b.exclusive }; +} + +function pickUpperBound(a?: { value: number; exclusive: boolean }, b?: { value: number; exclusive: boolean }): { value: number; exclusive: boolean } | undefined { + // Upper bound with the smallest value is the most restrictive. + if (a === undefined) { + return b; + } + if (b === undefined) { + return a; + } + if (a.value < b.value) { + return a; + } + if (b.value < a.value) { + return b; + } + return { value: a.value, exclusive: a.exclusive || b.exclusive }; +} + +function extractVariableName(expression: unknown): string | undefined { + // Extract a SPARQL variable from a term expression. + if (typeof expression !== 'object' || expression === null) { + return undefined; + } + const castExpression = expression as { subType?: string; term?: Term }; + if (castExpression.subType !== 'term' || castExpression.term === undefined || castExpression.term.termType !== 'Variable') { + return undefined; + } + return castExpression.term.value; +} + +function extractDatatypeVariableName(expression: unknown): string | undefined { + // Extract variable name from datatype(?v). + if (typeof expression !== 'object' || expression === null) { + return undefined; + } + const castExpression = expression as { subType?: string; operator?: string; args?: unknown[] }; + if (castExpression.subType !== 'operator' || castExpression.operator !== 'datatype') { + return undefined; + } + return extractVariableName((castExpression.args ?? [])[0]); +} + +function extractLiteral(expression: unknown): Term | undefined { + // Extract literal term from a term expression. + if (typeof expression !== 'object' || expression === null) { + return undefined; + } + const castExpression = expression as { subType?: string; term?: Term }; + if (castExpression.subType !== 'term' || castExpression.term === undefined || castExpression.term.termType !== 'Literal') { + return undefined; + } + return castExpression.term; +} + +function extractNamedNodeValue(expression: unknown): string | undefined { + // Extract named node IRI value from a term expression. + if (typeof expression !== 'object' || expression === null) { + return undefined; + } + const castExpression = expression as { subType?: string; term?: Term }; + if (castExpression.subType !== 'term' || castExpression.term === undefined || castExpression.term.termType !== 'NamedNode') { + return undefined; + } + return castExpression.term.value; +} + +function getNumericLiteralValue(term: Term): number | undefined { + // Parse numeric literal value only when datatype is numeric. + if (term.termType !== 'Literal') { + return undefined; + } + if (!isNumericDatatype(term.datatype.value)) { + return undefined; + } + + const value = Number(term.value); + return Number.isNaN(value) ? undefined : value; +} + +function isNumericDatatype(datatype: string): boolean { + // Accepted numeric XML Schema datatypes for numeric filter reasoning. + const numericDatatypes = new Set([ + 'http://www.w3.org/2001/XMLSchema#integer', + 'http://www.w3.org/2001/XMLSchema#decimal', + 'http://www.w3.org/2001/XMLSchema#double', + 'http://www.w3.org/2001/XMLSchema#float', + 'http://www.w3.org/2001/XMLSchema#nonNegativeInteger', + 'http://www.w3.org/2001/XMLSchema#nonPositiveInteger', + 'http://www.w3.org/2001/XMLSchema#positiveInteger', + 'http://www.w3.org/2001/XMLSchema#negativeInteger', + 'http://www.w3.org/2001/XMLSchema#long', + 'http://www.w3.org/2001/XMLSchema#int', + 'http://www.w3.org/2001/XMLSchema#short', + 'http://www.w3.org/2001/XMLSchema#byte', + 'http://www.w3.org/2001/XMLSchema#unsignedLong', + 'http://www.w3.org/2001/XMLSchema#unsignedInt', + 'http://www.w3.org/2001/XMLSchema#unsignedShort', + 'http://www.w3.org/2001/XMLSchema#unsignedByte', + ]); + return numericDatatypes.has(datatype); +} diff --git a/lib/negation.ts b/lib/negation.ts new file mode 100644 index 0000000..46b09f8 --- /dev/null +++ b/lib/negation.ts @@ -0,0 +1,108 @@ +import { Algebra } from '@traqula/algebra-transformations-1-1'; +import type { UnsupportedConstruct } from './query'; + +/** A negated triple pattern, waiting to be attached to the star pattern of its subject. */ +export interface INegatedPattern { subject: string, predicate: string, construct: 'MINUS' | 'NOT EXISTS' } + +/** + * Return the graph pattern of a `NOT EXISTS` (or `!EXISTS`) expression, or undefined when the + * expression is anything else. + */ +export function getNegatedExistenceInput(expression: Algebra.Expression): Algebra.Operation | undefined { + if (expression.subType === Algebra.ExpressionTypes.EXISTENCE) { + return expression.not ? expression.input : undefined; + } + if (expression.subType === Algebra.ExpressionTypes.OPERATOR && expression.operator === '!' && + expression.args.length === 1 && expression.args[0].subType === Algebra.ExpressionTypes.EXISTENCE && + !expression.args[0].not) { + return expression.args[0].input; + } + return undefined; +} + +/** + * Read a negated graph pattern as a negated triple pattern `?x p ?o`. The pattern must be a single + * triple pattern with a variable subject and an IRI predicate; its subject must be bound by a + * required triple pattern of `scope`, the group the negation applies to (the filtered group of a + * FILTER, the left operand of a MINUS); and its object must be a blank node or a variable that + * does not occur in `scope`. The negation then removes exactly the solutions whose subject + * carries `p`, so `MINUS` and `NOT EXISTS` coincide. Any other form returns undefined. + */ +export function toNegatedPattern(negated: Algebra.Operation, scope: Algebra.Operation): + Omit | undefined { + if (negated.type !== Algebra.Types.BGP || negated.patterns.length !== 1) { + return undefined; + } + const { subject, predicate, object, graph } = negated.patterns[0]; + if (subject.termType !== 'Variable' || predicate.termType !== 'NamedNode' || + graph.termType !== 'DefaultGraph') { + return undefined; + } + const freshObject = object.termType === 'BlankNode' || + (object.termType === 'Variable' && !occursIn(object.value, scope)); + if (!freshObject || !getRequiredSubjects(scope).has(subject.value)) { + return undefined; + } + return { subject: subject.value, predicate: predicate.value }; +} + +/** + * Subjects of the triple patterns with an IRI predicate that every solution of `operation` binds. + * The approximation is conservative: operations it does not know contribute no subject. + */ +function getRequiredSubjects(operation: Algebra.Operation): Set { + switch (operation.type) { + case Algebra.Types.BGP: + return new Set(operation.patterns + .filter(pattern => pattern.subject.termType === 'Variable' && pattern.predicate.termType === 'NamedNode') + .map(pattern => pattern.subject.value)); + case Algebra.Types.JOIN: + return new Set(operation.input.flatMap(input => Array.from(getRequiredSubjects(input)))); + case Algebra.Types.LEFT_JOIN: + case Algebra.Types.MINUS: + return getRequiredSubjects(operation.input[0]); + case Algebra.Types.FILTER: + case Algebra.Types.EXTEND: + case Algebra.Types.DISTINCT: + case Algebra.Types.REDUCED: + case Algebra.Types.ORDER_BY: + case Algebra.Types.GRAPH: + return getRequiredSubjects(operation.input); + case Algebra.Types.UNION: { + const [first, ...rest] = operation.input.map(getRequiredSubjects); + return new Set(Array.from(first ?? []).filter(subject => rest.every(branch => branch.has(subject)))); + } + default: + return new Set(); + } +} + +/** + * Whether the variable occurs anywhere in the operation, including its expressions. + */ +function occursIn(variable: string, operation: unknown): boolean { + if (typeof operation !== 'object' || operation === null) { + return false; + } + const node = operation as { termType?: unknown, value?: unknown }; + if (node.termType === 'Variable' && node.value === variable) { + return true; + } + return Object.values(operation).some(child => occursIn(variable, child)); +} + +/** + * Report every `EXISTS` and `NOT EXISTS` of an expression as a discarded construct. + */ +export function reportExistence(expression: Algebra.Expression, unsupported: Set, negated = false): void { + if (expression.subType === Algebra.ExpressionTypes.EXISTENCE) { + unsupported.add(expression.not !== negated ? 'NOT EXISTS' : 'EXISTS'); + return; + } + if (expression.subType === Algebra.ExpressionTypes.OPERATOR || expression.subType === Algebra.ExpressionTypes.NAMED) { + const negatesArgument = expression.subType === Algebra.ExpressionTypes.OPERATOR && expression.operator === '!'; + for (const argument of expression.args) { + reportExistence(argument, unsupported, negatesArgument); + } + } +} diff --git a/lib/query.ts b/lib/query.ts index 7913e63..b371188 100644 --- a/lib/query.ts +++ b/lib/query.ts @@ -2,8 +2,8 @@ import type { Term } from '@rdfjs/types'; import { algebraUtils, Algebra } from '@traqula/algebra-transformations-1-1'; import { DataFactory } from 'rdf-data-factory'; import { ITripleWithDependencies, Triple, type IStarPatternWithDependencies, ITriple } from './Triple'; -import { ConstraintType, ICardinality, IPredicate, IShape, targetsOf } from './Shape'; -import { RDF, XSD } from './constant'; +import type { ICardinality } from './Shape'; +import { getNegatedExistenceInput, type INegatedPattern, reportExistence, toNegatedPattern } from './negation'; const DF = new DataFactory(); @@ -25,9 +25,6 @@ type NpsPath = Omit & { interface IAccumulatedTriples { triples: Map, isVariable: boolean } -/** A negated triple pattern, waiting to be attached to the star pattern of its subject. */ -interface INegatedPattern { subject: string, predicate: string, construct: 'MINUS' | 'NOT EXISTS' } - /** Facts about the input discovered while walking the algebra, not carried by the star patterns. */ interface INormalizationState { matchesAnyTriple: boolean, negatedPatterns: INegatedPattern[] } @@ -68,177 +65,6 @@ export interface IQuery { */ export type UnsupportedConstruct = 'MINUS' | 'SERVICE' | 'EXISTS' | 'NOT EXISTS' | 'TARGETS'; -export interface IShapeToQueryOptions { - linkedShapes?: IShape[] | Map; -} - -interface IShapeToQueryBuildContext { - shapeIndex: Map; - starPatterns: Map; - filters: Algebra.Expression[]; - unions: IQuery[][]; - visitedShapes: Set; - variableCounts: Map; - unsupported: Set; -} - -/** - * Translation from an IShape to an IQuery representation. - * - * Supported mapping: - * - Positive predicates -> triple patterns - * - Negative predicates -> negated triple patterns (excluded predicates of the star pattern) - * - Datatype + numeric/regex facets -> FILTER expressions - * - SHAPE constraints -> dependent star patterns (when linked shapes are provided) - * - oneOf/xone branches -> UNION branches - * - Targets of the given shape -> patterns about its focus node (see {@link addTargetsOfInputShape}) - * - * Not supported (yet): - * - closed/open world semantics - */ -export function shapeToQuery(shape: IShape, options?: IShapeToQueryOptions): IQuery { - const shapeIndex = normalizeShapeIndex(shape, options?.linkedShapes); - const context: IShapeToQueryBuildContext = { - shapeIndex, - starPatterns: new Map(), - filters: [], - unions: [], - visitedShapes: new Set(), - variableCounts: new Map(), - unsupported: new Set(), - }; - - buildStarPatternFromShape(shape, context); - addTargetsOfInputShape(shape, context); - const prunedSubjectRestriction = pruneEmptyStarPatterns(context); - - const query: IQuery = { starPatterns: context.starPatterns }; - if (context.filters.length > 0) { - query.filters = context.filters; - } - if (context.unions.length > 0) { - query.union = context.unions; - } - if (context.unsupported.size > 0) { - query.unsupported = Array.from(context.unsupported); - } - // A shape about given nodes that constrains none of their properties asks for any triple about them - if (context.starPatterns.size === 0 && prunedSubjectRestriction) { - query.matchesAnyTriple = true; - } - - return query; -} - -/** - * Translate the targets of the input shape into patterns about its focus node. Only the input - * shape's own targets are translated: SHACL ignores the targets of the shapes it reaches through - * sh:node. - * - * - A class target requires `rdf:type` with the class as value. - * - A subjects-of target requires the predicate. - * - Several class and subjects-of targets are alternatives, because the focus nodes of a shape are - * the union of the nodes of its targets: each target becomes a branch of a UNION, as the branches - * of an sh:or do. - * - Node targets restrict the subject of the focus star pattern to the target nodes. - * - An objects-of target adds a star pattern whose triple pattern has the focus node as object. - * - * Node and objects-of targets are not representable as alternatives of other targets. When they are - * combined with other targets, or when several objects-of targets are declared, the targets are - * ignored and reported, which can only over-estimate relevance. - */ -function addTargetsOfInputShape(shape: IShape, context: IShapeToQueryBuildContext): void { - const { nodes, classes, subjectsOf, objectsOf } = targetsOf(shape); - const kinds = [ nodes, classes, subjectsOf, objectsOf ].filter(values => values.length > 0).length; - const focus = context.starPatterns.get(shape.name); - if (kinds === 0 || focus === undefined) { - return; - } - - if (nodes.length > 0 || objectsOf.length > 0) { - if (kinds > 1 || objectsOf.length > 1) { - context.unsupported.add('TARGETS'); - return; - } - if (nodes.length > 0) { - focus.subjectValues = new Set(nodes); - return; - } - const incomingName = `${shape.name}#targetObjectsOf`; - const triple = new Triple({ subject: incomingName, predicate: objectsOf[0], object: DF.namedNode(shape.name) }); - context.starPatterns.set(incomingName, { - starPattern: new Map([[ objectsOf[0], { triple, dependencies: focus } ]]), - name: incomingName, - isVariable: true, - }); - return; - } - - const alternatives: ITripleWithDependencies[] = []; - for (const value of classes) { - alternatives.push({ triple: new Triple({ subject: shape.name, predicate: RDF.type, object: DF.namedNode(value) }) }); - } - for (const predicate of subjectsOf) { - // A declared property constraint on the predicate keeps its object and dependency, but is now required - const declared = focus.starPattern.get(predicate); - const object = declared?.triple.object ?? DF.variable(nextVariableName(shape.name, predicate, context.variableCounts)); - alternatives.push({ triple: new Triple({ subject: shape.name, predicate, object }), dependencies: declared?.dependencies }); - } - - if (alternatives.length === 1) { - focus.starPattern.set(alternatives[0].triple.predicate, alternatives[0]); - return; - } - context.unions.push(alternatives.map(alternative => ({ - starPatterns: new Map([[ shape.name, { - starPattern: new Map([[ alternative.triple.predicate, alternative ]]), - name: shape.name, - isVariable: true, - } ]]), - }))); -} - -/** - * Remove the star patterns that constrain nothing: no triple pattern, no excluded predicate and no - * UNION branch. They come from shapes without positive constraints, such as a shape that only - * declares targets, and would otherwise count as unmatched. A triple pattern that pointed to one of - * them is kept, and matches on its predicate alone. - * @returns whether a removed star pattern restricted its subject to given nodes - */ -function pruneEmptyStarPatterns(context: IShapeToQueryBuildContext): boolean { - const inUnion = new Set(); - for (const union of context.unions) { - for (const branch of union) { - for (const name of branch.starPatterns.keys()) { - inUnion.add(name); - } - } - } - const removed = new Set(); - let prunedSubjectRestriction = false; - for (const [ name, starPattern ] of context.starPatterns) { - if (starPattern.starPattern.size === 0 && (starPattern.excludedPredicates?.size ?? 0) === 0 && !inUnion.has(name)) { - removed.add(name); - prunedSubjectRestriction = prunedSubjectRestriction || starPattern.subjectValues !== undefined; - } - } - for (const name of removed) { - context.starPatterns.delete(name); - } - const remaining = [ - ...context.starPatterns.values(), - ...context.unions.flatMap(union => union.flatMap(branch => [ ...branch.starPatterns.values() ])), - ]; - for (const starPattern of remaining) { - for (const tripleWithDependencies of starPattern.starPattern.values()) { - if (tripleWithDependencies.dependencies !== undefined && removed.has(tripleWithDependencies.dependencies.name)) { - tripleWithDependencies.dependencies = undefined; - } - } - } - return prunedSubjectRestriction; -} - /** * Extract the star pattern with the requested subject from each UNION branch set. */ @@ -259,155 +85,6 @@ export function generateStarPatternUnion(union: IQuery[][], starPatternName: str return resp; } -/** - * Build a lookup table for the root shape and any linked shapes that may be referenced. - */ -function normalizeShapeIndex(rootShape: IShape, linkedShapes?: IShape[] | Map): Map { - const index = new Map(); - index.set(rootShape.name, rootShape); - - if (linkedShapes === undefined) { - return index; - } - - if (linkedShapes instanceof Map) { - for (const [name, shape] of linkedShapes) { - index.set(name, shape); - } - return index; - } - - for (const shape of linkedShapes) { - index.set(shape.name, shape); - } - - return index; -} - -/** - * Convert one shape into a root star pattern and recursively materialize reachable linked shapes. - */ -function buildStarPatternFromShape(shape: IShape, context: IShapeToQueryBuildContext): void { - if (context.visitedShapes.has(shape.name)) { - return; - } - context.visitedShapes.add(shape.name); - - const starPattern: IStarPatternWithDependencies = { - starPattern: new Map(), - name: shape.name, - isVariable: true, - }; - - for (const predicateName of shape.positivePredicates) { - const predicate = shape.get(predicateName); - if (predicate === undefined || predicate.negative === true) { - continue; - } - - const tripleWithDependencies = predicateToTripleWithDependencies(shape.name, predicate, context, context.filters); - starPattern.starPattern.set(predicate.name, tripleWithDependencies); - } - const negativePredicates = shape.negativePredicates ?? []; - if (negativePredicates.length > 0) { - starPattern.excludedPredicates = new Set(negativePredicates); - } - - context.starPatterns.set(shape.name, starPattern); - - addOneOfAsUnion(shape, context); -} - -/** - * Translate shape alternatives into UNION branches attached to the generated query. - */ -function addOneOfAsUnion(shape: IShape, context: IShapeToQueryBuildContext): void { - for (const oneOf of shape.oneOf) { - const unionBranches: IQuery[] = []; - for (const branch of oneOf) { - const branchStar: IStarPatternWithDependencies = { - starPattern: new Map(), - name: shape.name, - isVariable: true, - }; - const branchFilters: Algebra.Expression[] = []; - - for (const predicate of branch) { - if (predicate.negative === true) { - continue; - } - const tripleWithDependencies = predicateToTripleWithDependencies(shape.name, predicate, context, branchFilters); - branchStar.starPattern.set(predicate.name, tripleWithDependencies); - } - - const branchQuery: IQuery = { - starPatterns: new Map([[shape.name, branchStar]]), - }; - if (branchFilters.length > 0) { - branchQuery.filters = branchFilters; - } - unionBranches.push(branchQuery); - } - - if (unionBranches.length > 0) { - context.unions.push(unionBranches); - } - } -} - -/** - * Turn one shape predicate into a triple pattern and any dependency/filter information it implies. - */ -function predicateToTripleWithDependencies( - shapeName: string, - predicate: IPredicate, - context: IShapeToQueryBuildContext, - filterCollector: Algebra.Expression[], -): ITripleWithDependencies { - const constraint = predicate.constraint; - let object: Term; - let dependencies: IStarPatternWithDependencies | undefined; - - if (constraint?.type === ConstraintType.SHAPE && constraint.value.size > 0) { - const linkedShapeName = Array.from(constraint.value)[0]; - object = DF.namedNode(linkedShapeName); - - const linkedShape = context.shapeIndex.get(linkedShapeName); - if (linkedShape !== undefined) { - buildStarPatternFromShape(linkedShape, context); - dependencies = context.starPatterns.get(linkedShapeName); - } - } else if ( - constraint?.type === ConstraintType.CLASS - && predicate.name === RDF.type - && constraint.value.size > 0 - ) { - object = DF.namedNode(Array.from(constraint.value)[0]); - } else { - object = DF.variable(nextVariableName(shapeName, predicate.name, context.variableCounts)); - addDatatypeFiltersFromConstraint(constraint, object, filterCollector); - } - - const triple: ITriple = new Triple({ - subject: shapeName, - predicate: predicate.name, - object, - cardinality: predicate.cardinality, - isOptional: predicate.optional, - }); - - return { triple, dependencies }; -} - -/** - * Produce a stable variable name for generated query objects, deduplicated per predicate occurrence. - */ -function nextVariableName(shapeName: string, predicate: string, variableCounts: Map): string { - const base = toVariableBase(`${shapeName}_${predicate}`); - const currentCount = variableCounts.get(base) ?? 0; - variableCounts.set(base, currentCount + 1); - return currentCount === 0 ? base : `${base}_${currentCount}`; -} /** * Whether a subject is a variable of the pattern. A blank node in a SPARQL pattern behaves as a @@ -417,120 +94,6 @@ function isVariableTerm(term: Term): boolean { return term.termType === 'Variable' || term.termType === 'BlankNode'; } -/** - * Normalize arbitrary IRIs/shape names into SPARQL-safe variable base names. - */ -function toVariableBase(raw: string): string { - const normalized = raw.replace(/[^A-Za-z0-9_]/g, '_').replace(/^_+/, ''); - if (normalized.length === 0) { - return 'v'; - } - if (/^[0-9]/.test(normalized)) { - return `v_${normalized}`; - } - return normalized; -} - -/** - * Translate datatype, numeric, and regex shape facets into FILTER expressions on a generated variable. - */ -function addDatatypeFiltersFromConstraint( - constraint: IPredicate['constraint'], - object: Term, - collector: Algebra.Expression[], -): void { - if (constraint?.type !== ConstraintType.DATATYPE || object.termType !== 'Variable') { - return; - } - - const datatypeFilters = Array.from(constraint.value).map((datatypeIri) => - operatorExpression('=', [ - operatorExpression('datatype', [termExpression(object)]), - termExpression(DF.namedNode(datatypeIri)), - ]), - ); - - if (datatypeFilters.length === 1) { - collector.push(datatypeFilters[0]); - } else if (datatypeFilters.length > 1) { - collector.push(reduceOrExpressions(datatypeFilters)); - } - - if (constraint.minInclusive !== undefined) { - collector.push(operatorExpression('>=', [ - termExpression(object), - termExpression(numberLiteral(constraint.minInclusive)), - ])); - } - if (constraint.maxInclusive !== undefined) { - collector.push(operatorExpression('<=', [ - termExpression(object), - termExpression(numberLiteral(constraint.maxInclusive)), - ])); - } - if (constraint.minExclusive !== undefined) { - collector.push(operatorExpression('>', [ - termExpression(object), - termExpression(numberLiteral(constraint.minExclusive)), - ])); - } - if (constraint.maxExclusive !== undefined) { - collector.push(operatorExpression('<', [ - termExpression(object), - termExpression(numberLiteral(constraint.maxExclusive)), - ])); - } - - if (constraint.pattern !== undefined) { - const regexArgs: Algebra.Expression[] = [ - operatorExpression('str', [termExpression(object)]), - termExpression(DF.literal(constraint.pattern)), - ]; - if (constraint.flags !== undefined) { - regexArgs.push(termExpression(DF.literal(constraint.flags))); - } - collector.push(operatorExpression('regex', regexArgs)); - } -} - -/** - * Fold multiple expressions into a left-associated logical OR tree. - */ -function reduceOrExpressions(expressions: Algebra.Expression[]): Algebra.Expression { - let current = expressions[0]; - for (let i = 1; i < expressions.length; i++) { - current = operatorExpression('||', [current, expressions[i]]); - } - return current; -} - -/** - * Build an xsd:decimal literal for numeric facet comparisons. - */ -function numberLiteral(value: number): Term { - return DF.literal(String(value), DF.namedNode(XSD.decimal)); -} - -/** - * Wrap an RDF term in the algebra expression shape expected by filter constructors. - */ -function termExpression(term: Term): Algebra.Expression { - return { - subType: 'term', - term, - } as unknown as Algebra.Expression; -} - -/** - * Construct a generic operator expression node for the algebra filter representation. - */ -function operatorExpression(operator: string, args: Algebra.Expression[]): Algebra.Expression { - return { - subType: 'operator', - operator, - args, - } as unknown as Algebra.Expression; -} /** * Divide a query into star patterns @@ -844,108 +407,6 @@ namespace QueryHandler { } } - /** - * Return the graph pattern of a `NOT EXISTS` (or `!EXISTS`) expression, or undefined when the - * expression is anything else. - */ - function getNegatedExistenceInput(expression: Algebra.Expression): Algebra.Operation | undefined { - if (expression.subType === Algebra.ExpressionTypes.EXISTENCE) { - return expression.not ? expression.input : undefined; - } - if (expression.subType === Algebra.ExpressionTypes.OPERATOR && expression.operator === '!' && - expression.args.length === 1 && expression.args[0].subType === Algebra.ExpressionTypes.EXISTENCE && - !expression.args[0].not) { - return expression.args[0].input; - } - return undefined; - } - - /** - * Read a negated graph pattern as a negated triple pattern `?x p ?o`. The pattern must be a single - * triple pattern with a variable subject and an IRI predicate; its subject must be bound by a - * required triple pattern of `scope`, the group the negation applies to (the filtered group of a - * FILTER, the left operand of a MINUS); and its object must be a blank node or a variable that - * does not occur in `scope`. The negation then removes exactly the solutions whose subject - * carries `p`, so `MINUS` and `NOT EXISTS` coincide. Any other form returns undefined. - */ - function toNegatedPattern(negated: Algebra.Operation, scope: Algebra.Operation): - Omit | undefined { - if (negated.type !== Algebra.Types.BGP || negated.patterns.length !== 1) { - return undefined; - } - const { subject, predicate, object, graph } = negated.patterns[0]; - if (subject.termType !== 'Variable' || predicate.termType !== 'NamedNode' || - graph.termType !== 'DefaultGraph') { - return undefined; - } - const freshObject = object.termType === 'BlankNode' || - (object.termType === 'Variable' && !occursIn(object.value, scope)); - if (!freshObject || !getRequiredSubjects(scope).has(subject.value)) { - return undefined; - } - return { subject: subject.value, predicate: predicate.value }; - } - - /** - * Subjects of the triple patterns with an IRI predicate that every solution of `operation` binds. - * The approximation is conservative: operations it does not know contribute no subject. - */ - function getRequiredSubjects(operation: Algebra.Operation): Set { - switch (operation.type) { - case Algebra.Types.BGP: - return new Set(operation.patterns - .filter(pattern => pattern.subject.termType === 'Variable' && pattern.predicate.termType === 'NamedNode') - .map(pattern => pattern.subject.value)); - case Algebra.Types.JOIN: - return new Set(operation.input.flatMap(input => Array.from(getRequiredSubjects(input)))); - case Algebra.Types.LEFT_JOIN: - case Algebra.Types.MINUS: - return getRequiredSubjects(operation.input[0]); - case Algebra.Types.FILTER: - case Algebra.Types.EXTEND: - case Algebra.Types.DISTINCT: - case Algebra.Types.REDUCED: - case Algebra.Types.ORDER_BY: - case Algebra.Types.GRAPH: - return getRequiredSubjects(operation.input); - case Algebra.Types.UNION: { - const [first, ...rest] = operation.input.map(getRequiredSubjects); - return new Set(Array.from(first ?? []).filter(subject => rest.every(branch => branch.has(subject)))); - } - default: - return new Set(); - } - } - - /** - * Whether the variable occurs anywhere in the operation, including its expressions. - */ - function occursIn(variable: string, operation: unknown): boolean { - if (typeof operation !== 'object' || operation === null) { - return false; - } - const node = operation as { termType?: unknown, value?: unknown }; - if (node.termType === 'Variable' && node.value === variable) { - return true; - } - return Object.values(operation).some(child => occursIn(variable, child)); - } - - /** - * Report every `EXISTS` and `NOT EXISTS` of an expression as a discarded construct. - */ - function reportExistence(expression: Algebra.Expression, unsupported: Set, negated = false): void { - if (expression.subType === Algebra.ExpressionTypes.EXISTENCE) { - unsupported.add(expression.not !== negated ? 'NOT EXISTS' : 'EXISTS'); - return; - } - if (expression.subType === Algebra.ExpressionTypes.OPERATOR || expression.subType === Algebra.ExpressionTypes.NAMED) { - const negatesArgument = expression.subType === Algebra.ExpressionTypes.OPERATOR && expression.operator === '!'; - for (const argument of expression.args) { - reportExistence(argument, unsupported, negatesArgument); - } - } - } /** * Dispatch property-path parsing to direct, cardinality, negated-set, or alternative handlers. diff --git a/lib/shapeToQuery.ts b/lib/shapeToQuery.ts new file mode 100644 index 0000000..3739bb4 --- /dev/null +++ b/lib/shapeToQuery.ts @@ -0,0 +1,445 @@ +import type { Term } from '@rdfjs/types'; +import type { Algebra } from '@traqula/algebra-transformations-1-1'; +import { DataFactory } from 'rdf-data-factory'; +import { type ITripleWithDependencies, Triple, type IStarPatternWithDependencies, type ITriple } from './Triple'; +import { ConstraintType, type IPredicate, type IShape, targetsOf } from './Shape'; +import { RDF, XSD } from './constant'; +import type { IQuery, UnsupportedConstruct } from './query'; + +const DF = new DataFactory(); + +export interface IShapeToQueryOptions { + linkedShapes?: IShape[] | Map; +} + +interface IShapeToQueryBuildContext { + shapeIndex: Map; + starPatterns: Map; + filters: Algebra.Expression[]; + unions: IQuery[][]; + visitedShapes: Set; + variableCounts: Map; + unsupported: Set; +} + +/** + * Translation from an IShape to an IQuery representation. + * + * Supported mapping: + * - Positive predicates -> triple patterns + * - Negative predicates -> negated triple patterns (excluded predicates of the star pattern) + * - Datatype + numeric/regex facets -> FILTER expressions + * - SHAPE constraints -> dependent star patterns (when linked shapes are provided) + * - oneOf/xone branches -> UNION branches + * - Targets of the given shape -> patterns about its focus node (see {@link addTargetsOfInputShape}) + * + * Not supported (yet): + * - closed/open world semantics + */ +export function shapeToQuery(shape: IShape, options?: IShapeToQueryOptions): IQuery { + const shapeIndex = normalizeShapeIndex(shape, options?.linkedShapes); + const context: IShapeToQueryBuildContext = { + shapeIndex, + starPatterns: new Map(), + filters: [], + unions: [], + visitedShapes: new Set(), + variableCounts: new Map(), + unsupported: new Set(), + }; + + buildStarPatternFromShape(shape, context); + addTargetsOfInputShape(shape, context); + const prunedSubjectRestriction = pruneEmptyStarPatterns(context); + + const query: IQuery = { starPatterns: context.starPatterns }; + if (context.filters.length > 0) { + query.filters = context.filters; + } + if (context.unions.length > 0) { + query.union = context.unions; + } + if (context.unsupported.size > 0) { + query.unsupported = Array.from(context.unsupported); + } + // A shape about given nodes that constrains none of their properties asks for any triple about them + if (context.starPatterns.size === 0 && prunedSubjectRestriction) { + query.matchesAnyTriple = true; + } + + return query; +} + +/** + * Translate the targets of the input shape into patterns about its focus node. Only the input + * shape's own targets are translated: SHACL ignores the targets of the shapes it reaches through + * sh:node. + * + * - A class target requires `rdf:type` with the class as value. + * - A subjects-of target requires the predicate. + * - Several class and subjects-of targets are alternatives, because the focus nodes of a shape are + * the union of the nodes of its targets: each target becomes a branch of a UNION, as the branches + * of an sh:or do. + * - Node targets restrict the subject of the focus star pattern to the target nodes. + * - An objects-of target adds a star pattern whose triple pattern has the focus node as object. + * + * Node and objects-of targets are not representable as alternatives of other targets. When they are + * combined with other targets, or when several objects-of targets are declared, the targets are + * ignored and reported, which can only over-estimate relevance. + */ +function addTargetsOfInputShape(shape: IShape, context: IShapeToQueryBuildContext): void { + const { nodes, classes, subjectsOf, objectsOf } = targetsOf(shape); + const kinds = [ nodes, classes, subjectsOf, objectsOf ].filter(values => values.length > 0).length; + const focus = context.starPatterns.get(shape.name); + if (kinds === 0 || focus === undefined) { + return; + } + + if (nodes.length > 0 || objectsOf.length > 0) { + if (kinds > 1 || objectsOf.length > 1) { + context.unsupported.add('TARGETS'); + return; + } + if (nodes.length > 0) { + focus.subjectValues = new Set(nodes); + return; + } + const incomingName = `${shape.name}#targetObjectsOf`; + const triple = new Triple({ subject: incomingName, predicate: objectsOf[0], object: DF.namedNode(shape.name) }); + context.starPatterns.set(incomingName, { + starPattern: new Map([[ objectsOf[0], { triple, dependencies: focus } ]]), + name: incomingName, + isVariable: true, + }); + return; + } + + const alternatives: ITripleWithDependencies[] = []; + for (const value of classes) { + alternatives.push({ triple: new Triple({ subject: shape.name, predicate: RDF.type, object: DF.namedNode(value) }) }); + } + for (const predicate of subjectsOf) { + // A declared property constraint on the predicate keeps its object and dependency, but is now required + const declared = focus.starPattern.get(predicate); + const object = declared?.triple.object ?? DF.variable(nextVariableName(shape.name, predicate, context.variableCounts)); + alternatives.push({ triple: new Triple({ subject: shape.name, predicate, object }), dependencies: declared?.dependencies }); + } + + if (alternatives.length === 1) { + focus.starPattern.set(alternatives[0].triple.predicate, alternatives[0]); + return; + } + context.unions.push(alternatives.map(alternative => ({ + starPatterns: new Map([[ shape.name, { + starPattern: new Map([[ alternative.triple.predicate, alternative ]]), + name: shape.name, + isVariable: true, + } ]]), + }))); +} + +/** + * Remove the star patterns that constrain nothing: no triple pattern, no excluded predicate and no + * UNION branch. They come from shapes without positive constraints, such as a shape that only + * declares targets, and would otherwise count as unmatched. A triple pattern that pointed to one of + * them is kept, and matches on its predicate alone. + * @returns whether a removed star pattern restricted its subject to given nodes + */ +function pruneEmptyStarPatterns(context: IShapeToQueryBuildContext): boolean { + const inUnion = new Set(); + for (const union of context.unions) { + for (const branch of union) { + for (const name of branch.starPatterns.keys()) { + inUnion.add(name); + } + } + } + const removed = new Set(); + let prunedSubjectRestriction = false; + for (const [ name, starPattern ] of context.starPatterns) { + if (starPattern.starPattern.size === 0 && (starPattern.excludedPredicates?.size ?? 0) === 0 && !inUnion.has(name)) { + removed.add(name); + prunedSubjectRestriction = prunedSubjectRestriction || starPattern.subjectValues !== undefined; + } + } + for (const name of removed) { + context.starPatterns.delete(name); + } + const remaining = [ + ...context.starPatterns.values(), + ...context.unions.flatMap(union => union.flatMap(branch => [ ...branch.starPatterns.values() ])), + ]; + for (const starPattern of remaining) { + for (const tripleWithDependencies of starPattern.starPattern.values()) { + if (tripleWithDependencies.dependencies !== undefined && removed.has(tripleWithDependencies.dependencies.name)) { + tripleWithDependencies.dependencies = undefined; + } + } + } + return prunedSubjectRestriction; +} + +/** + * Build a lookup table for the root shape and any linked shapes that may be referenced. + */ +function normalizeShapeIndex(rootShape: IShape, linkedShapes?: IShape[] | Map): Map { + const index = new Map(); + index.set(rootShape.name, rootShape); + + if (linkedShapes === undefined) { + return index; + } + + if (linkedShapes instanceof Map) { + for (const [name, shape] of linkedShapes) { + index.set(name, shape); + } + return index; + } + + for (const shape of linkedShapes) { + index.set(shape.name, shape); + } + + return index; +} + +/** + * Convert one shape into a root star pattern and recursively materialize reachable linked shapes. + */ +function buildStarPatternFromShape(shape: IShape, context: IShapeToQueryBuildContext): void { + if (context.visitedShapes.has(shape.name)) { + return; + } + context.visitedShapes.add(shape.name); + + const starPattern: IStarPatternWithDependencies = { + starPattern: new Map(), + name: shape.name, + isVariable: true, + }; + + for (const predicateName of shape.positivePredicates) { + const predicate = shape.get(predicateName); + if (predicate === undefined || predicate.negative === true) { + continue; + } + + const tripleWithDependencies = predicateToTripleWithDependencies(shape.name, predicate, context, context.filters); + starPattern.starPattern.set(predicate.name, tripleWithDependencies); + } + const negativePredicates = shape.negativePredicates ?? []; + if (negativePredicates.length > 0) { + starPattern.excludedPredicates = new Set(negativePredicates); + } + + context.starPatterns.set(shape.name, starPattern); + + addOneOfAsUnion(shape, context); +} + +/** + * Translate shape alternatives into UNION branches attached to the generated query. + */ +function addOneOfAsUnion(shape: IShape, context: IShapeToQueryBuildContext): void { + for (const oneOf of shape.oneOf) { + const unionBranches: IQuery[] = []; + for (const branch of oneOf) { + const branchStar: IStarPatternWithDependencies = { + starPattern: new Map(), + name: shape.name, + isVariable: true, + }; + const branchFilters: Algebra.Expression[] = []; + + for (const predicate of branch) { + if (predicate.negative === true) { + continue; + } + const tripleWithDependencies = predicateToTripleWithDependencies(shape.name, predicate, context, branchFilters); + branchStar.starPattern.set(predicate.name, tripleWithDependencies); + } + + const branchQuery: IQuery = { + starPatterns: new Map([[shape.name, branchStar]]), + }; + if (branchFilters.length > 0) { + branchQuery.filters = branchFilters; + } + unionBranches.push(branchQuery); + } + + if (unionBranches.length > 0) { + context.unions.push(unionBranches); + } + } +} + +/** + * Turn one shape predicate into a triple pattern and any dependency/filter information it implies. + */ +function predicateToTripleWithDependencies( + shapeName: string, + predicate: IPredicate, + context: IShapeToQueryBuildContext, + filterCollector: Algebra.Expression[], +): ITripleWithDependencies { + const constraint = predicate.constraint; + let object: Term; + let dependencies: IStarPatternWithDependencies | undefined; + + if (constraint?.type === ConstraintType.SHAPE && constraint.value.size > 0) { + const linkedShapeName = Array.from(constraint.value)[0]; + object = DF.namedNode(linkedShapeName); + + const linkedShape = context.shapeIndex.get(linkedShapeName); + if (linkedShape !== undefined) { + buildStarPatternFromShape(linkedShape, context); + dependencies = context.starPatterns.get(linkedShapeName); + } + } else if ( + constraint?.type === ConstraintType.CLASS + && predicate.name === RDF.type + && constraint.value.size > 0 + ) { + object = DF.namedNode(Array.from(constraint.value)[0]); + } else { + object = DF.variable(nextVariableName(shapeName, predicate.name, context.variableCounts)); + addDatatypeFiltersFromConstraint(constraint, object, filterCollector); + } + + const triple: ITriple = new Triple({ + subject: shapeName, + predicate: predicate.name, + object, + cardinality: predicate.cardinality, + isOptional: predicate.optional, + }); + + return { triple, dependencies }; +} + +/** + * Produce a stable variable name for generated query objects, deduplicated per predicate occurrence. + */ +function nextVariableName(shapeName: string, predicate: string, variableCounts: Map): string { + const base = toVariableBase(`${shapeName}_${predicate}`); + const currentCount = variableCounts.get(base) ?? 0; + variableCounts.set(base, currentCount + 1); + return currentCount === 0 ? base : `${base}_${currentCount}`; +} + +/** + * Normalize arbitrary IRIs/shape names into SPARQL-safe variable base names. + */ +function toVariableBase(raw: string): string { + const normalized = raw.replace(/[^A-Za-z0-9_]/g, '_').replace(/^_+/, ''); + if (normalized.length === 0) { + return 'v'; + } + if (/^[0-9]/.test(normalized)) { + return `v_${normalized}`; + } + return normalized; +} + +/** + * Translate datatype, numeric, and regex shape facets into FILTER expressions on a generated variable. + */ +function addDatatypeFiltersFromConstraint( + constraint: IPredicate['constraint'], + object: Term, + collector: Algebra.Expression[], +): void { + if (constraint?.type !== ConstraintType.DATATYPE || object.termType !== 'Variable') { + return; + } + + const datatypeFilters = Array.from(constraint.value).map((datatypeIri) => + operatorExpression('=', [ + operatorExpression('datatype', [termExpression(object)]), + termExpression(DF.namedNode(datatypeIri)), + ]), + ); + + if (datatypeFilters.length === 1) { + collector.push(datatypeFilters[0]); + } else if (datatypeFilters.length > 1) { + collector.push(reduceOrExpressions(datatypeFilters)); + } + + if (constraint.minInclusive !== undefined) { + collector.push(operatorExpression('>=', [ + termExpression(object), + termExpression(numberLiteral(constraint.minInclusive)), + ])); + } + if (constraint.maxInclusive !== undefined) { + collector.push(operatorExpression('<=', [ + termExpression(object), + termExpression(numberLiteral(constraint.maxInclusive)), + ])); + } + if (constraint.minExclusive !== undefined) { + collector.push(operatorExpression('>', [ + termExpression(object), + termExpression(numberLiteral(constraint.minExclusive)), + ])); + } + if (constraint.maxExclusive !== undefined) { + collector.push(operatorExpression('<', [ + termExpression(object), + termExpression(numberLiteral(constraint.maxExclusive)), + ])); + } + + if (constraint.pattern !== undefined) { + const regexArgs: Algebra.Expression[] = [ + operatorExpression('str', [termExpression(object)]), + termExpression(DF.literal(constraint.pattern)), + ]; + if (constraint.flags !== undefined) { + regexArgs.push(termExpression(DF.literal(constraint.flags))); + } + collector.push(operatorExpression('regex', regexArgs)); + } +} + +/** + * Fold multiple expressions into a left-associated logical OR tree. + */ +function reduceOrExpressions(expressions: Algebra.Expression[]): Algebra.Expression { + let current = expressions[0]; + for (let i = 1; i < expressions.length; i++) { + current = operatorExpression('||', [current, expressions[i]]); + } + return current; +} + +/** + * Build an xsd:decimal literal for numeric facet comparisons. + */ +function numberLiteral(value: number): Term { + return DF.literal(String(value), DF.namedNode(XSD.decimal)); +} + +/** + * Wrap an RDF term in the algebra expression shape expected by filter constructors. + */ +function termExpression(term: Term): Algebra.Expression { + return { + subType: 'term', + term, + } as unknown as Algebra.Expression; +} + +/** + * Construct a generic operator expression node for the algebra filter representation. + */ +function operatorExpression(operator: string, args: Algebra.Expression[]): Algebra.Expression { + return { + subType: 'operator', + operator, + args, + } as unknown as Algebra.Expression; +} diff --git a/test/containment.test.ts b/test/containment.test.ts index 2361755..063ef1e 100644 --- a/test/containment.test.ts +++ b/test/containment.test.ts @@ -6,7 +6,8 @@ import { IStarPatternWithDependencies, Triple } from '../lib/Triple'; import { ContainmentResult, EmptyQueryError, IContainmentResult, IResult as ContainmentReport, StarPatternName, solveShapeQueryContainment, solveShapeShapeContainment } from '../lib/containment'; import { DataFactory } from 'rdf-data-factory'; import { BaseQuad } from '@rdfjs/types'; -import { IQuery, generateQuery, shapeToQuery } from '../lib/query'; +import { IQuery, generateQuery } from '../lib/query'; +import { shapeToQuery } from '../lib/shapeToQuery'; import { RDF as RDF_VOCAB } from '../lib/constant'; import type * as RDF from '@rdfjs/types'; import * as N3 from 'n3'; diff --git a/test/shapeToQuery.test.ts b/test/shapeToQuery.test.ts index 1d99576..33fab0d 100644 --- a/test/shapeToQuery.test.ts +++ b/test/shapeToQuery.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { ConstraintType, type IShape, Shape } from '../lib/Shape'; -import { shapeToQuery } from '../lib/query'; +import { shapeToQuery } from '../lib/shapeToQuery'; import { solveShapeQueryContainment, ContainmentResult } from '../lib/containment'; import { RDF } from '../lib/constant'; diff --git a/test/target.test.ts b/test/target.test.ts index 43dd64c..43e859b 100644 --- a/test/target.test.ts +++ b/test/target.test.ts @@ -4,7 +4,8 @@ import * as N3 from 'n3'; import { describe, expect, it } from 'vitest'; import type { IShape } from '../lib/Shape'; import { shaclShapeFromQuads } from '../lib/shacl'; -import { generateQuery, shapeToQuery, type IQuery } from '../lib/query'; +import { generateQuery, type IQuery } from '../lib/query'; +import { shapeToQuery } from '../lib/shapeToQuery'; import { ContainmentResult, solveShapeQueryContainment, solveShapeShapeContainment } from '../lib/containment'; import { RDF } from '../lib/constant'; From 4ad42e156115b319804c51732bcd6f382ce2cdd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 12:12:51 +0000 Subject: [PATCH 14/22] refactor: give shapes required runtime fields and targets one model IShape inherited the optional fields of its serialized form, so callers filled in defaults the Shape constructor had already set (targetsOf and `?? []`). targets, ignoredProperties and negativePredicates are now required on IShape; only toObject leaves them out when they are empty. target.ts classifies the focus nodes of a shape once (focusNodes) and lists its target alternatives (targetAlternatives). impliedPredicates and the shape to query translation both use them, instead of each adding up array lengths and stating the "only alternative is required" rule apart. Node targets are keyed with nodeTargetKey: an IRI is its value, as before, and a literal or blank node is its N-Triples form. Plain string literals keep the key they had; typed and language-tagged literals no longer collide, and a blank node no longer matches an IRI with the same text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J27pqtwzm8UXeEv14dUbCX --- lib/Shape.ts | 28 +++------- lib/shacl.ts | 5 +- lib/shapeToQuery.ts | 75 +++++++++++++++++---------- lib/target.ts | 123 +++++++++++++++++++++++++++++++++----------- test/target.test.ts | 15 ++++++ 5 files changed, 164 insertions(+), 82 deletions(-) diff --git a/lib/Shape.ts b/lib/Shape.ts index 4b23607..8d01131 100644 --- a/lib/Shape.ts +++ b/lib/Shape.ts @@ -31,8 +31,12 @@ export interface IShape extends IShapeObj { * @returns {Set} - IRIs of the shape necessary for the constraint */ getLinkedShapeIri: () => Set; + // A shape always has the fields that its serialized form leaves out when they are empty + negativePredicates: string[]; oneOf: OneOf[]; oneOfIndexed: OneOfIndexed[]; + targets: IShapeTargets; + ignoredProperties: string[]; } /** @@ -128,7 +132,7 @@ export const enum ConstraintType { * describing any node. */ export interface IShapeTargets { - // sh:targetNode values (a literal is kept in its JSON-quoted form) + // sh:targetNode values, as keys made by nodeTargetKey: an IRI is its value nodes: string[]; // sh:targetClass values, and the shape itself when it is also an rdfs:Class (implicit class target) classes: string[]; @@ -167,26 +171,6 @@ export interface IShapeArgs { ignoredProperties?: string[]; } -/** - * The targets of a shape, with an empty list for every kind it does not declare. - */ -export function targetsOf(shape: IShapeObj): IShapeTargets { - return { - nodes: shape.targets?.nodes ?? [], - classes: shape.targets?.classes ?? [], - subjectsOf: shape.targets?.subjectsOf ?? [], - objectsOf: shape.targets?.objectsOf ?? [], - }; -} - -/** - * Whether a shape declares at least one target. - */ -export function hasTargets(shape: IShapeObj): boolean { - const { nodes, classes, subjectsOf, objectsOf } = targetsOf(shape); - return nodes.length + classes.length + subjectsOf.length + objectsOf.length > 0; -} - export type OneOf = OneOfPath[]; export type OneOfJson = OneOfPathJson[]; export type OneOfPath = IPredicate[]; @@ -348,7 +332,7 @@ export class Shape implements IShape { negativePredicates: this.negativePredicates, oneOf: this.oneOf }; - if (hasTargets(this)) { + if (Object.values(this.targets).some(values => values.length > 0)) { obj.targets = this.targets; } if (this.ignoredProperties.length > 0) { diff --git a/lib/shacl.ts b/lib/shacl.ts index 54d8c72..df08e86 100644 --- a/lib/shacl.ts +++ b/lib/shacl.ts @@ -5,6 +5,7 @@ import { PoorlyFormatedShapeError, walkRdfList, buildConstraint, tryCreateShape, import type { ShapeError, IShapeTargets } from './Shape'; import { SHACL, RDF as RDF_VOCAB, RDFS, XSD } from './constant'; import { addDiagnostic, getPolicy, type IShapeParserOptions } from './parser-policy'; +import { nodeTargetKey } from './target'; const DF = new DataFactory(); const RDF_TRUE = DF.literal('true', DF.namedNode(XSD.boolean)); @@ -251,9 +252,7 @@ function parseQuad(quad: RDF.Quad, map: IMapTripleShacl): void { // Targets: the nodes the shape describes when it is used on its own if (quad.predicate.equals(SHACL.terms.targetNode)) { - // A literal can be a node target too; keep it distinct from an IRI with the same text - const node = quad.object.termType === 'Literal' ? JSON.stringify(o) : o; - getOrCreateTargets(map, s).nodes.add(node); + getOrCreateTargets(map, s).nodes.add(nodeTargetKey(quad.object)); return; } if (quad.predicate.equals(SHACL.terms.targetClass)) { diff --git a/lib/shapeToQuery.ts b/lib/shapeToQuery.ts index 3739bb4..bcdf7be 100644 --- a/lib/shapeToQuery.ts +++ b/lib/shapeToQuery.ts @@ -2,8 +2,9 @@ import type { Term } from '@rdfjs/types'; import type { Algebra } from '@traqula/algebra-transformations-1-1'; import { DataFactory } from 'rdf-data-factory'; import { type ITripleWithDependencies, Triple, type IStarPatternWithDependencies, type ITriple } from './Triple'; -import { ConstraintType, type IPredicate, type IShape, targetsOf } from './Shape'; +import { ConstraintType, type IPredicate, type IShape } from './Shape'; import { RDF, XSD } from './constant'; +import { focusNodes, type ITargetAlternative } from './target'; import type { IQuery, UnsupportedConstruct } from './query'; const DF = new DataFactory(); @@ -88,41 +89,60 @@ export function shapeToQuery(shape: IShape, options?: IShapeToQueryOptions): IQu * ignored and reported, which can only over-estimate relevance. */ function addTargetsOfInputShape(shape: IShape, context: IShapeToQueryBuildContext): void { - const { nodes, classes, subjectsOf, objectsOf } = targetsOf(shape); - const kinds = [ nodes, classes, subjectsOf, objectsOf ].filter(values => values.length > 0).length; const focus = context.starPatterns.get(shape.name); - if (kinds === 0 || focus === undefined) { + const focusNodesOfShape = focusNodes(shape.targets); + if (focus === undefined) { return; } - if (nodes.length > 0 || objectsOf.length > 0) { - if (kinds > 1 || objectsOf.length > 1) { + switch (focusNodesOfShape.kind) { + case 'any': + return; + case 'mixed': context.unsupported.add('TARGETS'); return; - } - if (nodes.length > 0) { - focus.subjectValues = new Set(nodes); + case 'nodes': + focus.subjectValues = new Set(focusNodesOfShape.nodes); + return; + case 'objectsOf': { + const { predicate } = focusNodesOfShape; + const incomingName = `${shape.name}#targetObjectsOf`; + const triple = new Triple({ subject: incomingName, predicate, object: DF.namedNode(shape.name) }); + context.starPatterns.set(incomingName, { + starPattern: new Map([[ predicate, { triple, dependencies: focus } ]]), + name: incomingName, + isVariable: true, + }); return; } - const incomingName = `${shape.name}#targetObjectsOf`; - const triple = new Triple({ subject: incomingName, predicate: objectsOf[0], object: DF.namedNode(shape.name) }); - context.starPatterns.set(incomingName, { - starPattern: new Map([[ objectsOf[0], { triple, dependencies: focus } ]]), - name: incomingName, - isVariable: true, - }); - return; + case 'alternatives': + addTargetAlternatives(shape.name, focus, focusNodesOfShape.alternatives, context); } +} +/** + * Require the focus node to satisfy one of the target alternatives: each target class, or each + * subjects-of predicate, is a branch of a UNION, or a triple pattern of the focus star pattern when + * it is the only one. + */ +function addTargetAlternatives( + shapeName: string, + focus: IStarPatternWithDependencies, + targetAlternatives: ITargetAlternative[], + context: IShapeToQueryBuildContext, +): void { const alternatives: ITripleWithDependencies[] = []; - for (const value of classes) { - alternatives.push({ triple: new Triple({ subject: shape.name, predicate: RDF.type, object: DF.namedNode(value) }) }); - } - for (const predicate of subjectsOf) { + for (const { predicate, classes } of targetAlternatives) { + if (classes !== undefined) { + for (const value of classes) { + alternatives.push({ triple: new Triple({ subject: shapeName, predicate, object: DF.namedNode(value) }) }); + } + continue; + } // A declared property constraint on the predicate keeps its object and dependency, but is now required const declared = focus.starPattern.get(predicate); - const object = declared?.triple.object ?? DF.variable(nextVariableName(shape.name, predicate, context.variableCounts)); - alternatives.push({ triple: new Triple({ subject: shape.name, predicate, object }), dependencies: declared?.dependencies }); + const object = declared?.triple.object ?? DF.variable(nextVariableName(shapeName, predicate, context.variableCounts)); + alternatives.push({ triple: new Triple({ subject: shapeName, predicate, object }), dependencies: declared?.dependencies }); } if (alternatives.length === 1) { @@ -130,9 +150,9 @@ function addTargetsOfInputShape(shape: IShape, context: IShapeToQueryBuildContex return; } context.unions.push(alternatives.map(alternative => ({ - starPatterns: new Map([[ shape.name, { + starPatterns: new Map([[ shapeName, { starPattern: new Map([[ alternative.triple.predicate, alternative ]]), - name: shape.name, + name: shapeName, isVariable: true, } ]]), }))); @@ -228,9 +248,8 @@ function buildStarPatternFromShape(shape: IShape, context: IShapeToQueryBuildCon const tripleWithDependencies = predicateToTripleWithDependencies(shape.name, predicate, context, context.filters); starPattern.starPattern.set(predicate.name, tripleWithDependencies); } - const negativePredicates = shape.negativePredicates ?? []; - if (negativePredicates.length > 0) { - starPattern.excludedPredicates = new Set(negativePredicates); + if (shape.negativePredicates.length > 0) { + starPattern.excludedPredicates = new Set(shape.negativePredicates); } context.starPatterns.set(shape.name, starPattern); diff --git a/lib/target.ts b/lib/target.ts index 466b9a3..90be96a 100644 --- a/lib/target.ts +++ b/lib/target.ts @@ -1,6 +1,7 @@ -import { ConstraintType, type IPredicate, type IShape, hasTargets, targetsOf } from './Shape'; +import type { Term } from '@rdfjs/types'; +import { ConstraintType, type IPredicate, type IShape, type IShapeTargets } from './Shape'; import type { IStarPatternWithDependencies } from './Triple'; -import { RDF } from './constant'; +import { RDF, XSD } from './constant'; /** * The constants the subject of a star pattern is restricted to, or undefined when it is a variable. @@ -12,6 +13,77 @@ export function constantSubjects(starPattern: IStarPatternWithDependencies): Set return starPattern.isVariable ? undefined : new Set([starPattern.name]); } +/** + * The focus nodes a shape selects through its targets when it is used on its own: + * + * - `any`: the shape declares no target and describes any node; + * - `nodes`: only node targets (sh:targetNode); + * - `objectsOf`: a single objects-of target (sh:targetObjectsOf) and no other target; + * - `alternatives`: only class and subjects-of targets, see {@link targetAlternatives}; + * - `mixed`: node or objects-of targets combined with other targets, or several objects-of targets. + */ +export type FocusNodes = + | { kind: 'any' } + | { kind: 'nodes', nodes: string[] } + | { kind: 'objectsOf', predicate: string } + | { kind: 'alternatives', alternatives: ITargetAlternative[] } + | { kind: 'mixed' }; + +/** + * A predicate that some focus nodes carry because of a target: `rdf:type` with one of the target + * classes, or the predicate of a subjects-of target. + */ +export interface ITargetAlternative { + predicate: string; + // the target classes, for the alternative of the class targets + classes?: string[]; +} + +export function focusNodes(targets: IShapeTargets): FocusNodes { + const { nodes, classes, subjectsOf, objectsOf } = targets; + const kinds = [ nodes, classes, subjectsOf, objectsOf ].filter(values => values.length > 0).length; + if (kinds === 0) { + return { kind: 'any' }; + } + if (nodes.length === 0 && objectsOf.length === 0) { + return { kind: 'alternatives', alternatives: targetAlternatives(targets) }; + } + if (kinds > 1 || objectsOf.length > 1) { + return { kind: 'mixed' }; + } + return nodes.length > 0 ? { kind: 'nodes', nodes } : { kind: 'objectsOf', predicate: objectsOf[0] }; +} + +/** + * The alternatives the class and subjects-of targets offer: one for all the class targets, which + * all imply `rdf:type`, and one per subjects-of target. The focus nodes of a shape are the union of + * the nodes of its targets, so an alternative holds for every focus node only when it is the only + * alternative and the shape has no other target. + */ +export function targetAlternatives({ classes, subjectsOf }: IShapeTargets): ITargetAlternative[] { + const alternatives: ITargetAlternative[] = classes.length > 0 ? [{ predicate: RDF.type, classes }] : []; + return alternatives.concat(subjectsOf.map(predicate => ({ predicate }))); +} + +/** + * The key of an RDF term as a node target. An IRI is its value, as the constant subject of a star + * pattern is; a literal or a blank node is its N-Triples form, so that it neither matches an IRI + * with the same text nor loses its datatype or language. + */ +export function nodeTargetKey(term: Term): string { + if (term.termType === 'BlankNode') { + return `_:${term.value}`; + } + if (term.termType !== 'Literal') { + return term.value; + } + const value = JSON.stringify(term.value); + if (term.language !== '') { + return `${value}@${term.language}`; + } + return term.datatype.value === XSD.string ? value : `${value}^^<${term.datatype.value}>`; +} + /** * The property constraints a shape implies without declaring them, as alternatives for a predicate. * @@ -20,9 +92,8 @@ export function constantSubjects(starPattern: IStarPatternWithDependencies): Set * - A subjects-of target (sh:targetSubjectsOf) makes every focus node carry the predicate. * - A closed shape allows the properties listed by sh:ignoredProperties, without constraining them. * - * The focus nodes of a shape are the union of the nodes of its targets, so an implied constraint is - * only required (minimum 1) when every focus node carries it: when all the targets are class - * targets, or when the only target is one subjects-of target. + * A constraint implied by a target is only required (minimum 1) when every focus node carries it, + * see {@link targetAlternatives}. * * Targets only apply when the shape is used on its own (`useTargets`): SHACL ignores them when the * shape is reached through sh:node. sh:ignoredProperties applies in both cases. @@ -30,21 +101,22 @@ export function constantSubjects(starPattern: IStarPatternWithDependencies): Set export function impliedPredicates(shape: IShape, predicate: string, useTargets: boolean): IPredicate[] { const implied: IPredicate[] = []; if (useTargets) { - const { nodes, classes, subjectsOf, objectsOf } = targetsOf(shape); - if (predicate === RDF.type && classes.length > 0) { - const onlyClassTargets = nodes.length + subjectsOf.length + objectsOf.length === 0; + const alternatives = targetAlternatives(shape.targets); + const required = alternatives.length === 1 && focusNodes(shape.targets).kind === 'alternatives'; + for (const alternative of alternatives) { + if (alternative.predicate !== predicate) { + continue; + } implied.push({ name: predicate, - constraint: { type: ConstraintType.CLASS, value: new Set(classes) }, - cardinality: { min: onlyClassTargets ? 1 : 0, max: -1 }, + ...alternative.classes !== undefined ? + { constraint: { type: ConstraintType.CLASS, value: new Set(alternative.classes) } } : + {}, + cardinality: { min: required ? 1 : 0, max: -1 }, }); } - if (subjectsOf.includes(predicate)) { - const onlyTarget = subjectsOf.length === 1 && nodes.length + classes.length + objectsOf.length === 0; - implied.push({ name: predicate, cardinality: { min: onlyTarget ? 1 : 0, max: -1 } }); - } } - if (shape.closed && (shape.ignoredProperties ?? []).includes(predicate)) { + if (shape.closed && shape.ignoredProperties.includes(predicate)) { implied.push({ name: predicate, cardinality: { min: 0, max: -1 }, optional: true }); } return implied; @@ -56,16 +128,12 @@ export function impliedPredicates(shape: IShape, predicate: string, useTargets: export function impliedPredicateNames(shape: IShape, useTargets: boolean): string[] { const names = new Set(); if (useTargets) { - const { classes, subjectsOf } = targetsOf(shape); - if (classes.length > 0) { - names.add(RDF.type); - } - for (const predicate of subjectsOf) { + for (const { predicate } of targetAlternatives(shape.targets)) { names.add(predicate); } } if (shape.closed) { - for (const predicate of shape.ignoredProperties ?? []) { + for (const predicate of shape.ignoredProperties) { names.add(predicate); } } @@ -78,15 +146,12 @@ export function impliedPredicateNames(shape: IShape, useTargets: boolean): strin * subject is a constant it does not list. Any other target leaves the subject undecided. */ export function isSubjectOutsideNodeTargets(shape: IShape, starPattern: IStarPatternWithDependencies): boolean { - const { nodes, classes, subjectsOf, objectsOf } = targetsOf(shape); - if (nodes.length === 0 || classes.length + subjectsOf.length + objectsOf.length > 0) { - return false; - } + const focus = focusNodes(shape.targets); const subjects = constantSubjects(starPattern); - if (subjects === undefined) { + if (focus.kind !== 'nodes' || subjects === undefined) { return false; } - return !Array.from(subjects).some(subject => nodes.includes(subject)); + return !Array.from(subjects).some(subject => focus.nodes.includes(subject)); } /** @@ -108,10 +173,10 @@ export function describesStarPattern( starPattern: IStarPatternWithDependencies, incoming: Map>, ): boolean { - if (!hasTargets(shape)) { + if (focusNodes(shape.targets).kind === 'any') { return true; } - const { nodes, classes, subjectsOf, objectsOf } = targetsOf(shape); + const { nodes, classes, subjectsOf, objectsOf } = shape.targets; const subjects = constantSubjects(starPattern); if (subjects !== undefined && subjects.size > 0 && Array.from(subjects).every(subject => nodes.includes(subject))) { diff --git a/test/target.test.ts b/test/target.test.ts index 43e859b..a0af0e1 100644 --- a/test/target.test.ts +++ b/test/target.test.ts @@ -52,6 +52,21 @@ describe('SHACL targets', () => { }); }); + it('should keep the datatype and language of a literal node target, and a blank node apart from an IRI', async () => { + const shape = await parseShape(` + @prefix xsd: . + ex:S a sh:NodeShape ; + sh:targetNode "1"^^xsd:integer, "1", "Alice"@en, _:alice ; + sh:property [ sh:path ex:name ] .`, 'S'); + const nodes = shape.targets.nodes; + expect(nodes.slice(0, 3)).toStrictEqual([ + '"1"^^', + '"1"', + '"Alice"@en', + ]); + expect(nodes[3]).toMatch(/^_:/); + }); + it('should give a shape that is also a class an implicit class target', async () => { const direct = await parseShape(`ex:Place a rdfs:Class, sh:NodeShape ; sh:property [ sh:path ex:name ] .`, 'Place'); expect(direct.toObject().targets?.classes).toStrictEqual([`${EX}Place`]); From a19427c9efb77cab1fd7a04ec44ca63776fc3834 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 12:14:43 +0000 Subject: [PATCH 15/22] refactor: drop the useTargets flag by stripping targets at sh:node SHACL ignores the targets of a shape reached through sh:node. Instead of a flag passed to Bindings, UnionBinding, impliedPredicates and impliedPredicateNames, the nested binding now receives withoutTargets(shape), a memoized copy without targets, at the one place sh:node is resolved. The Bindings constructor is back to its signature on main. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J27pqtwzm8UXeEv14dUbCX --- lib/Binding.ts | 24 ++++++++++-------------- lib/Shape.ts | 33 ++++++++++++++++++++++++++++++++- lib/shacl.ts | 4 ++-- lib/target.ts | 39 +++++++++++++++++---------------------- 4 files changed, 61 insertions(+), 39 deletions(-) diff --git a/lib/Binding.ts b/lib/Binding.ts index a072791..0bccef0 100644 --- a/lib/Binding.ts +++ b/lib/Binding.ts @@ -1,4 +1,4 @@ -import { ConstraintType, IConstraint, IPredicate, IShape, OneOfPathIndexed } from "./Shape"; +import { ConstraintType, IConstraint, IPredicate, IShape, OneOfPathIndexed, withoutTargets } from "./Shape"; import { IStarPatternWithDependencies, type ITriple, Triple } from "./Triple"; import { RDF } from "./constant"; import { impliedPredicateNames, impliedPredicates, isSubjectOutsideNodeTargets } from "./target"; @@ -131,16 +131,13 @@ export class Bindings implements IBindings { private dependencyEvidence = new Map(); private typeOfContainment: IContainmentType = { result: ContainmentType.NONE, unContaineStarPattern: [] }; private alreadyTraversed: Map; - // Whether the shape is used on its own, so that its targets apply; false when it is reached through sh:node - private readonly useTargets: boolean; public readonly starPattern: IStarPatternWithDependencies; - public constructor(shape: IShape, starPattern: IStarPatternWithDependencies, linkedShape: Map, unionStarPattern?: IStarPatternWithDependencies[][], strict?: boolean, alreadyTraversed?: Map, useTargets?: boolean) { + public constructor(shape: IShape, starPattern: IStarPatternWithDependencies, linkedShape: Map, unionStarPattern?: IStarPatternWithDependencies[][], strict?: boolean, alreadyTraversed?: Map) { this.starPattern = starPattern; this.strict = strict ?? false; this.closedShape = shape.closed; this.alreadyTraversed = alreadyTraversed ?? new Map(); - this.useTargets = useTargets ?? true; for (const { triple } of starPattern.starPattern.values()) { this.bindings.set(triple.predicate, undefined); this.allOptional = this.allOptional && triple.isOptional === true; @@ -148,7 +145,7 @@ export class Bindings implements IBindings { for (const predicate of shape.getAll()) { this.shapePredicateBind.set(predicate.name, false); } - for (const predicate of impliedPredicateNames(shape, this.useTargets)) { + for (const predicate of impliedPredicateNames(shape)) { if (!this.shapePredicateBind.has(predicate)) { this.shapePredicateBind.set(predicate, false); } @@ -163,7 +160,7 @@ export class Bindings implements IBindings { private calculateBinding(shape: IShape, starPattern: IStarPatternWithDependencies, linkedShape: Map, unionStarPattern: IStarPatternWithDependencies[][]): void { for (const union of unionStarPattern) { - this.unionBindings.push(new UnionBinding(shape, union, linkedShape, this.useTargets)); + this.unionBindings.push(new UnionBinding(shape, union, linkedShape)); } const negatedTriples: ITriple[] = []; for (const { triple, dependencies } of starPattern.starPattern.values()) { @@ -186,7 +183,7 @@ export class Bindings implements IBindings { } // constraints the shape implies through its targets or sh:ignoredProperties if (!negatedByShape) { - predicates = predicates.concat(impliedPredicates(shape, triple.predicate, this.useTargets)); + predicates = predicates.concat(impliedPredicates(shape, triple.predicate)); } if (singlePredicate === undefined && predicates.length === 0) { @@ -214,7 +211,7 @@ export class Bindings implements IBindings { if (!this.strict) { for (const excludedPredicate of starPattern.excludedPredicates ?? []) { const declaredPredicate = shape.get(excludedPredicate); - const required = [declaredPredicate, ...impliedPredicates(shape, excludedPredicate, this.useTargets)] + const required = [declaredPredicate, ...impliedPredicates(shape, excludedPredicate)] .some(predicate => predicate !== undefined && predicate.negative !== true && (predicate.cardinality?.min ?? 0) >= 1); if (declaredPredicate?.negative !== true && required) { this.negativeContradiction = true; @@ -222,7 +219,7 @@ export class Bindings implements IBindings { } } // A shape about given nodes only cannot contain a star pattern about another constant subject. - const subjectOutsideTargets = this.useTargets && isSubjectOutsideNodeTargets(shape, starPattern); + const subjectOutsideTargets = isSubjectOutsideNodeTargets(shape, starPattern); // negative triple in a strict containment mean that the we can take any values // see paper https://link.springer.com/chapter/10.1007/978-3-319-25007-6_1 if (!this.strict) { @@ -411,8 +408,7 @@ export class Bindings implements IBindings { if (currentLinkedShape === undefined) { return ConstraintResult.RESPECT; } - // SHACL ignores the targets of a shape reached through sh:node - const nestedBinding = new Bindings(currentLinkedShape, dependencies, linkedShape, [], this.strict, undefined, false); + const nestedBinding = new Bindings(withoutTargets(currentLinkedShape), dependencies, linkedShape, [], this.strict); this.recordDependencyEvidence(dependencies.name, currentLinkedShape, nestedBinding); for (const [starPatternName, evidence] of nestedBinding.dependencyEvidence) { for (const entry of evidence) { @@ -668,12 +664,12 @@ export class UnionBinding { public readonly shape: IShape; public readonly linkedShape: Map; - public constructor(shape: IShape, union: IStarPatternWithDependencies[], linkedShape: Map, useTargets?: boolean) { + public constructor(shape: IShape, union: IStarPatternWithDependencies[], linkedShape: Map) { this.shape = shape; this.linkedShape = linkedShape; this.bindings = []; for (const starPattern of union) { - this.bindings.push(new Bindings(shape, starPattern, linkedShape, undefined, undefined, undefined, useTargets)); + this.bindings.push(new Bindings(shape, starPattern, linkedShape)); } this.hasOneContained = this.determineHasOneAtLeastContainment(); this.areAllContained = this.determineAllContained(); diff --git a/lib/Shape.ts b/lib/Shape.ts index 8d01131..e506d93 100644 --- a/lib/Shape.ts +++ b/lib/Shape.ts @@ -332,7 +332,7 @@ export class Shape implements IShape { negativePredicates: this.negativePredicates, oneOf: this.oneOf }; - if (Object.values(this.targets).some(values => values.length > 0)) { + if (declaresTargets(this.targets)) { obj.targets = this.targets; } if (this.ignoredProperties.length > 0) { @@ -501,6 +501,37 @@ export function tryCreateShape(args: IShapeArgs): IShape | ShapeError { } } +/** + * Whether there is at least one target. + */ +export function declaresTargets(targets: IShapeTargets): boolean { + return Object.values(targets).some(values => values.length > 0); +} + +const TARGETLESS_SHAPES = new WeakMap(); + +/** + * The shape as SHACL applies it when the shape is reached through sh:node: without its targets. + */ +export function withoutTargets(shape: IShape): IShape { + if (!declaresTargets(shape.targets)) { + return shape; + } + let targetless = TARGETLESS_SHAPES.get(shape); + if (targetless === undefined) { + targetless = new Shape({ + name: shape.name, + positivePredicates: shape.positivePredicates.map(predicate => shape.get(predicate) ?? predicate), + negativePredicates: shape.negativePredicates.map(predicate => shape.get(predicate) ?? predicate), + closed: shape.closed, + oneOf: shape.oneOf, + ignoredProperties: shape.ignoredProperties, + }); + TARGETLESS_SHAPES.set(shape, targetless); + } + return targetless; +} + export function isNegativeCardinality(minCount?: number, maxCount?: number): boolean { return minCount === 0 && maxCount === 0; } diff --git a/lib/shacl.ts b/lib/shacl.ts index df08e86..873bab0 100644 --- a/lib/shacl.ts +++ b/lib/shacl.ts @@ -1,7 +1,7 @@ import type * as RDF from '@rdfjs/types'; import { DataFactory } from 'rdf-data-factory'; import type { OneOf, IShape, IPredicate, IConstraintDeclaration } from './Shape'; -import { PoorlyFormatedShapeError, walkRdfList, buildConstraint, tryCreateShape, isNegativeCardinality } from './Shape'; +import { PoorlyFormatedShapeError, walkRdfList, buildConstraint, tryCreateShape, isNegativeCardinality, declaresTargets } from './Shape'; import type { ShapeError, IShapeTargets } from './Shape'; import { SHACL, RDF as RDF_VOCAB, RDFS, XSD } from './constant'; import { addDiagnostic, getPolicy, type IShapeParserOptions } from './parser-policy'; @@ -340,7 +340,7 @@ function buildShape( const orHeads = [...map.orLists.get(shapeIri) ?? [], ...map.xoneLists.get(shapeIri) ?? []]; const hasOrList = orHeads.length > 0; const targets = collectTargets(map, shapeIri); - const hasTarget = Object.values(targets).some(values => values.length > 0); + const hasTarget = declaresTargets(targets); // A shape that only declares targets still describes nodes: they exist and are of the target // kind, even though the shape constrains none of their properties. diff --git a/lib/target.ts b/lib/target.ts index 90be96a..f1c7599 100644 --- a/lib/target.ts +++ b/lib/target.ts @@ -95,26 +95,23 @@ export function nodeTargetKey(term: Term): string { * A constraint implied by a target is only required (minimum 1) when every focus node carries it, * see {@link targetAlternatives}. * - * Targets only apply when the shape is used on its own (`useTargets`): SHACL ignores them when the - * shape is reached through sh:node. sh:ignoredProperties applies in both cases. + * A shape reached through sh:node has no targets here: SHACL ignores them (see withoutTargets). */ -export function impliedPredicates(shape: IShape, predicate: string, useTargets: boolean): IPredicate[] { +export function impliedPredicates(shape: IShape, predicate: string): IPredicate[] { const implied: IPredicate[] = []; - if (useTargets) { - const alternatives = targetAlternatives(shape.targets); - const required = alternatives.length === 1 && focusNodes(shape.targets).kind === 'alternatives'; - for (const alternative of alternatives) { - if (alternative.predicate !== predicate) { - continue; - } - implied.push({ - name: predicate, - ...alternative.classes !== undefined ? - { constraint: { type: ConstraintType.CLASS, value: new Set(alternative.classes) } } : - {}, - cardinality: { min: required ? 1 : 0, max: -1 }, - }); + const alternatives = targetAlternatives(shape.targets); + const required = alternatives.length === 1 && focusNodes(shape.targets).kind === 'alternatives'; + for (const alternative of alternatives) { + if (alternative.predicate !== predicate) { + continue; } + implied.push({ + name: predicate, + ...alternative.classes !== undefined ? + { constraint: { type: ConstraintType.CLASS, value: new Set(alternative.classes) } } : + {}, + cardinality: { min: required ? 1 : 0, max: -1 }, + }); } if (shape.closed && shape.ignoredProperties.includes(predicate)) { implied.push({ name: predicate, cardinality: { min: 0, max: -1 }, optional: true }); @@ -125,12 +122,10 @@ export function impliedPredicates(shape: IShape, predicate: string, useTargets: /** * Every predicate for which {@link impliedPredicates} yields a constraint. */ -export function impliedPredicateNames(shape: IShape, useTargets: boolean): string[] { +export function impliedPredicateNames(shape: IShape): string[] { const names = new Set(); - if (useTargets) { - for (const { predicate } of targetAlternatives(shape.targets)) { - names.add(predicate); - } + for (const { predicate } of targetAlternatives(shape.targets)) { + names.add(predicate); } if (shape.closed) { for (const predicate of shape.ignoredProperties) { From 3f7611c684bc2a71dc2b68bd2943c12c50fc4fb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 12:16:13 +0000 Subject: [PATCH 16/22] refactor: record candidate and sh:node evidence through one function The classification of a star pattern was written twice: once for candidate shapes in updateContainmentStats and once, inline, for the shapes reached through sh:node. Both now build an IShapeEvidence record and pass it to recordEvidence. Bindings.hasRootMatch replaces the two copies of the "bound triple or undecided dependency" check. For sh:node evidence, a contained pattern no longer needs to be added to the root matches separately: a nested binding has no UNION, so it is only fully bound when it binds a triple, which is already a root match. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J27pqtwzm8UXeEv14dUbCX --- lib/Binding.ts | 12 +++- lib/containment.ts | 139 ++++++++++++++++++++++----------------------- 2 files changed, 79 insertions(+), 72 deletions(-) diff --git a/lib/Binding.ts b/lib/Binding.ts index 0bccef0..543379a 100644 --- a/lib/Binding.ts +++ b/lib/Binding.ts @@ -40,6 +40,12 @@ export interface IBindings { * @returns {boolean} whether a dependency was left undecided rather than refuted */ hasNonRefutedDependency: () => boolean; + /** + * Indicate that the root star pattern matched the shape: a triple pattern is bound, or its + * predicate matched while its sh:node dependency was left undecided (see hasNonRefutedDependency). + * @returns {boolean} whether the star pattern matched the shape at its root + */ + hasRootMatch: () => boolean; /** * Shapes that nested star patterns were resolved against while checking sh:node constraints. * A referenced shape governs its nested star pattern even when it is not a candidate shape @@ -593,6 +599,10 @@ export class Bindings implements IBindings { return this.negativeContradiction; } + public hasRootMatch(): boolean { + return this.getBoundTriple().length > 0 || this.hasNonRefutedDependency(); + } + public hasNonRefutedDependency(): boolean { return this.nonRefutedDependency.size > 0; } @@ -610,7 +620,7 @@ export class Bindings implements IBindings { starPatternName, shapeName: shape.name, closed: shape.closed, - hasMatch: nested.getBoundTriple().length > 0 || nested.hasNonRefutedDependency(), + hasMatch: nested.hasRootMatch(), contained: nested.isFullyBounded() && nested.containmentType().result === ContainmentType.FULL, bindings: nested, diff --git a/lib/containment.ts b/lib/containment.ts index b0030fa..ac68dba 100644 --- a/lib/containment.ts +++ b/lib/containment.ts @@ -67,10 +67,21 @@ export function solveShapeQueryContainment({ query, shapes, dependentShapes, dec for (const [starPatternName, starPattern] of query.starPatterns) { const starPatternUnion = generateStarPatternUnion(query.union ?? [], starPatternName); const bindings = new Bindings(shape, starPattern, dependencies, starPatternUnion); - const filterCompatibility = evaluateFiltersForShape(query.filters, starPattern, shape); bindingResultofShape.set(starPatternName, { result: bindings, shape }); + // A shape with targets only constrains the nodes of its targets. When the nodes matching the star + // pattern need not be among them, its closedness and negative constraints say nothing about those + // nodes: it neither supports nor softens a rejection, and its matches count as on an open shape. const describes = describesStarPattern(shape, starPattern, incoming); - updateContainmentStats(classificationStats, starPatternName, shape, bindings, groupedShapes, filterCompatibility !== FilterTruth.FALSE, describes); + recordEvidence(classificationStats.get(starPatternName)!, { + shapeName: shape.name, + bindings, + describes, + closed: describes && shape.closed, + contradicts: describes && bindings.hasNegativeContradiction(), + filterCompatible: evaluateFiltersForShape(query.filters, starPattern, shape) !== FilterTruth.FALSE, + hasMatch: bindings.hasRootMatch(), + contained: isContainedByCandidate(bindings, groupedShapes, shape), + }); } } @@ -85,30 +96,13 @@ export function solveShapeQueryContainment({ query, shapes, dependentShapes, dec for (const { result } of starPatternBindings.values()) { for (const evidence of result.getDependencyEvidence()) { const stats = classificationStats.get(evidence.starPatternName); - if (stats === undefined) { - continue; - } - // As for a candidate shape, a contradiction is evidence of incompatibility: the referenced - // shape counts as closed and contributes no match. - const contradicts = evidence.bindings.hasNegativeContradiction(); - if (evidence.closed || contradicts) { - stats.hasClosedShape = true; - } else { - stats.hasOpenShape = true; - } - if (contradicts) { - continue; - } - if (evidence.contained) { - stats.containedTargets.add(evidence.shapeName); - } - if (evidence.hasMatch || evidence.contained) { - stats.bindings.set(evidence.shapeName, evidence.bindings); - if (evidence.closed) { - stats.rootTargetsClosed.add(evidence.shapeName); - } else { - stats.rootTargetsOpen.add(evidence.shapeName); - } + if (stats !== undefined) { + recordEvidence(stats, { + ...evidence, + describes: true, + contradicts: evidence.bindings.hasNegativeContradiction(), + filterCompatible: true, + }); } } } @@ -199,24 +193,34 @@ export function solveShapeShapeContainment({ sourceShape, sourceLinkedShapes, ta return solveShapeQueryContainment({ query, shapes: targetShapes, decidingShapes }); } -function updateContainmentStats( - classificationStats: Map, - starPatternName: StarPatternName, - shape: IShape, - bindings: IBindings, - groupedShapes: IShapeWithDependencies[], - filterCompatible = true, - describes = true, -): void { - const stats = classificationStats.get(starPatternName)!; - // A shape with targets only constrains the nodes of its targets. When the nodes matching the star - // pattern need not be among them, its closedness and negative constraints say nothing about those - // nodes: it neither supports nor softens a rejection, and its matches count as on an open shape. - const closed = describes && shape.closed; - // A shape that explicitly forbids a required predicate (sh:not) proves incompatibility, so it - // must not count as the "open shape" that would soften REJECTED into WEAKLY_REJECTED. - const contradicts = describes && bindings.hasNegativeContradiction(); - if (!describes) { +/** + * What one shape says about one star pattern. + */ +interface IShapeEvidence { + shapeName: ShapeName; + bindings: IBindings; + // the shape describes the star pattern, see describesStarPattern + describes: boolean; + // the shape is closed and describes the star pattern + closed: boolean; + // the shape describes the star pattern and forbids what it requires, see IBindings.hasNegativeContradiction + contradicts: boolean; + // no FILTER of the query contradicts the shape + filterCompatible: boolean; + // the star pattern matched the shape at its root, see IBindings.hasRootMatch + hasMatch: boolean; + // the shape contains the star pattern + contained: boolean; +} + +/** + * Add the evidence of a shape to the classification state of a star pattern. A shape that + * contradicts the star pattern counts as closed, so it does not soften a rejection, and it + * contributes no match. + */ +function recordEvidence(stats: IContainmentStats, evidence: IShapeEvidence): void { + const { shapeName, bindings, closed, contradicts } = evidence; + if (!evidence.describes) { stats.hasUndescribedShape = true; } else if (closed || contradicts) { stats.hasClosedShape = true; @@ -224,39 +228,32 @@ function updateContainmentStats( stats.hasOpenShape = true; } - if (!filterCompatible || contradicts) { + if (!evidence.filterCompatible || contradicts) { return; } - - // Track root-level alignment independently from full containment. A predicate whose sh:node - // dependency was neither established nor refuted still matched this shape, so it counts here: - // otherwise a root pattern the shape does declare would be reported as no match at all. - const hasRootMatch = bindings.getBoundTriple().length > 0 || bindings.hasNonRefutedDependency(); - if (hasRootMatch) { - stats.bindings.set(shape.name, bindings); - if (closed) { - stats.rootTargetsClosed.add(shape.name); - } else { - stats.rootTargetsOpen.add(shape.name); - } + // Root-level alignment is tracked independently from full containment. + if (evidence.hasMatch) { + stats.bindings.set(shapeName, bindings); + (closed ? stats.rootTargetsClosed : stats.rootTargetsOpen).add(shapeName); } - - // Full binding means this shape fully contains the star pattern. - if (bindings.isFullyBounded() && bindings.containmentType().result === ContainmentType.FULL) { - stats.bindings.set(shape.name, bindings); - stats.containedTargets.add(shape.name); + if (evidence.contained) { + stats.bindings.set(shapeName, bindings); + stats.containedTargets.add(shapeName); } +} - // Preserve previous behavior where a disjunction can be considered fully covered - // when unresolved alternatives are contained by another candidate shape. - if (bindings.isFullyBounded() && bindings.containmentType().result === ContainmentType.PARTIAL) { - const unContaineStarPattern = bindings.containmentType().unContaineStarPattern!; - const hasDisjuncContainment = findDisjunctContainment(unContaineStarPattern, groupedShapes, shape); - if (hasDisjuncContainment) { - stats.bindings.set(shape.name, bindings); - stats.containedTargets.add(shape.name); - } +/** + * Whether a candidate shape contains the star pattern: it binds it fully, or it binds it fully but + * for disjunctive branches that another candidate shape contains. + */ +function isContainedByCandidate(bindings: IBindings, groupedShapes: IShapeWithDependencies[], shape: IShape): boolean { + if (!bindings.isFullyBounded()) { + return false; } + const containment = bindings.containmentType(); + return containment.result === ContainmentType.FULL || + (containment.result === ContainmentType.PARTIAL && + findDisjunctContainment(containment.unContaineStarPattern!, groupedShapes, shape)); } function findDisjunctContainment(starPatterns: IStarPatternWithDependencies[], groupedShapes: IShapeWithDependencies[], shapeExcluded: IShape): boolean { From e1a6a0e5587d6ebb4276bb7c23350613765a5d3a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 12:18:17 +0000 Subject: [PATCH 17/22] fix: make the aggregate result a plain field of IResult `result` was defined as a non-enumerable property, so spreading, JSON.stringify or structuredClone lost it, and the 34 tests comparing whole reports with toStrictEqual never checked it. Both reports are now built by buildResult with `result` as an ordinary field, and those tests assert the aggregate result too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J27pqtwzm8UXeEv14dUbCX --- lib/containment.ts | 49 ++++++++++++++++++++-------------------- test/containment.test.ts | 48 +++++++++++++++++++++++++++------------ 2 files changed, 58 insertions(+), 39 deletions(-) diff --git a/lib/containment.ts b/lib/containment.ts index ac68dba..cf56800 100644 --- a/lib/containment.ts +++ b/lib/containment.ts @@ -138,20 +138,26 @@ export function solveShapeQueryContainment({ query, shapes, dependentShapes, dec starPatternsContainment.set(starPatternName, currentResult); } - const report: Omit = { + return buildResult( + getQueryContainmentResult(starPatternsContainment), starPatternsContainment, - visitShapeBoundedResource: generateVisitStatus(bindingResult, shapes), - ...(query.unsupported !== undefined ? { unsupported: query.unsupported } : {}), - }; - - Object.defineProperty(report, 'result', { - value: getQueryContainmentResult(starPatternsContainment), - enumerable: false, - writable: false, - }); - - return report as IResult; + generateVisitStatus(bindingResult, shapes), + query.unsupported, + ); +} +function buildResult( + result: ContainmentResult, + starPatternsContainment: Map, + visitShapeBoundedResource: Map, + unsupported: UnsupportedConstruct[] | undefined, +): IResult { + return { + result, + starPatternsContainment, + visitShapeBoundedResource, + ...(unsupported !== undefined ? { unsupported } : {}), + }; } /** @@ -160,19 +166,12 @@ export function solveShapeQueryContainment({ query, shapes, dependentShapes, dec * indications were all false would tell a client to fetch nothing for a query that wants everything. */ function buildMatchesAnyTripleResult(shapes: IShape[], unsupported?: UnsupportedConstruct[]): IResult { - const report: Omit = { - starPatternsContainment: new Map(), - visitShapeBoundedResource: new Map(shapes.map(shape => [shape.name, true])), - ...(unsupported !== undefined ? { unsupported } : {}), - }; - - Object.defineProperty(report, 'result', { - value: ContainmentResult.CONTAINED, - enumerable: false, - writable: false, - }); - - return report as IResult; + return buildResult( + ContainmentResult.CONTAINED, + new Map(), + new Map(shapes.map(shape => [shape.name, true])), + unsupported, + ); } export interface IShapeContainmentArg { diff --git a/test/containment.test.ts b/test/containment.test.ts index 063ef1e..a585b91 100644 --- a/test/containment.test.ts +++ b/test/containment.test.ts @@ -188,7 +188,7 @@ describe('solveShapeQueryContainment', () => { }; const shapes: IShape[] = []; const expectedResult: IResult = { - + result: ContainmentResult.REJECTED, starPatternsContainment: new Map([["x", { result: ContainmentResult.REJECTED, bindings:new Map() }]]), visitShapeBoundedResource: new Map() }; @@ -206,7 +206,7 @@ describe('solveShapeQueryContainment', () => { const shapes: IShape[] = [shape, shapeP1, shapeP2, shapeP3, shapeP4, shapeP5]; const expectedResult: IResult = { - + result: ContainmentResult.CONTAINED, starPatternsContainment: new Map([ ["x", { result: ContainmentResult.CONTAINED, target: [shape.name], bindings:expect.any(Map) }] ]), @@ -233,6 +233,7 @@ describe('solveShapeQueryContainment', () => { const shapes: IShape[] = [shape, shapeP1, shapeP2, shapeP3, shapeP4, shapeP5]; const expectedResult: IResult = { + result: ContainmentResult.REJECTED, starPatternsContainment: new Map([["z", { result: ContainmentResult.REJECTED, bindings: new Map() }]]), visitShapeBoundedResource: new Map([ [shape.name, false], @@ -257,6 +258,7 @@ describe('solveShapeQueryContainment', () => { const shapes: IShape[] = [shape, shapeP1, shapeP2, shapeP3, shapeP4, shapeP5]; const expectedResult: IResult = { + result: ContainmentResult.REJECTED, starPatternsContainment: new Map([["z", { result: ContainmentResult.REJECTED, bindings: new Map() }]]), visitShapeBoundedResource: new Map([ [shape.name, false], @@ -291,6 +293,7 @@ describe('solveShapeQueryContainment', () => { }); const expectedResult: IResult = { + result: ContainmentResult.WEAKLY_REJECTED, starPatternsContainment: new Map([ ["z", { result: ContainmentResult.WEAKLY_REJECTED, bindings: new Map() }] ]), @@ -318,6 +321,7 @@ describe('solveShapeQueryContainment', () => { }); const expectedResult: IResult = { + result: ContainmentResult.REJECTED, starPatternsContainment: new Map([ ["z", { result: ContainmentResult.REJECTED, bindings: new Map() }] ]), @@ -357,6 +361,7 @@ describe('solveShapeQueryContainment', () => { const shape = parsedShape as IShape; const expectedResult: IResult = { + result: ContainmentResult.CONTAINED, starPatternsContainment: new Map([ ["s", { result: ContainmentResult.CONTAINED, target: [shape.name], bindings: expect.any(Map) }], ]), @@ -397,6 +402,7 @@ describe('solveShapeQueryContainment', () => { const shape = parsedShape as IShape; const expectedResult: IResult = { + result: ContainmentResult.CONTAINED, starPatternsContainment: new Map([ ["s", { result: ContainmentResult.CONTAINED, target: [shape.name], bindings: expect.any(Map) }], ]), @@ -437,6 +443,7 @@ describe('solveShapeQueryContainment', () => { const shape = parsedShape as IShape; const expectedResult: IResult = { + result: ContainmentResult.REJECTED, starPatternsContainment: new Map([ ["s", { result: ContainmentResult.REJECTED, bindings: new Map() }], ]), @@ -493,6 +500,7 @@ describe('solveShapeQueryContainment', () => { const shapes: IShape[] = [shape]; const expectedResult: IResult = { + result: ContainmentResult.CONTAINED, starPatternsContainment: new Map([ ["x", { result: ContainmentResult.CONTAINED, target: [shape.name], bindings: expect.any(Map) }], ]), @@ -533,6 +541,7 @@ describe('solveShapeQueryContainment', () => { const shape = parsedShape as IShape; const expectedResult: IResult = { + result: ContainmentResult.CONTAINED, starPatternsContainment: new Map([ ["s", { result: ContainmentResult.CONTAINED, target: [shape.name], bindings: expect.any(Map) }], ]), @@ -574,6 +583,7 @@ describe('solveShapeQueryContainment', () => { const shape = parsedShape as IShape; const expectedResult: IResult = { + result: ContainmentResult.REJECTED, starPatternsContainment: new Map([ ["s", { result: ContainmentResult.REJECTED, bindings: new Map() }], ]), @@ -616,6 +626,7 @@ describe('solveShapeQueryContainment', () => { const shape = parsedShape as IShape; const expectedResult: IResult = { + result: ContainmentResult.CONTAINED, starPatternsContainment: new Map([ ["s", { result: ContainmentResult.CONTAINED, target: [shape.name], bindings: expect.any(Map) }], ]), @@ -658,6 +669,7 @@ describe('solveShapeQueryContainment', () => { const shape = parsedShape as IShape; const expectedResult: IResult = { + result: ContainmentResult.REJECTED, starPatternsContainment: new Map([ ["s", { result: ContainmentResult.REJECTED, bindings: new Map() }], ]), @@ -699,6 +711,7 @@ describe('solveShapeQueryContainment', () => { const shape = parsedShape as IShape; const expectedResult: IResult = { + result: ContainmentResult.REJECTED, starPatternsContainment: new Map([ ["s", { result: ContainmentResult.REJECTED, bindings: new Map() }], ]), @@ -738,6 +751,7 @@ describe('solveShapeQueryContainment', () => { const shape = parsedShape as IShape; const expectedResult: IResult = { + result: ContainmentResult.REJECTED, starPatternsContainment: new Map([ ["s", { result: ContainmentResult.REJECTED, bindings: new Map() }], ]), @@ -774,6 +788,7 @@ describe('solveShapeQueryContainment', () => { const query = generateQuery(querySparql); const expectedResult: IResult = { + result: ContainmentResult.CONTAINED, starPatternsContainment: new Map([ ["x", { result: ContainmentResult.CONTAINED, target: [constrainedShape.name], bindings: expect.any(Map) }], ]), @@ -810,6 +825,7 @@ describe('solveShapeQueryContainment', () => { const query = generateQuery(querySparql); const expectedResult: IResult = { + result: ContainmentResult.CONTAINED, starPatternsContainment: new Map([ ["x", { result: ContainmentResult.CONTAINED, target: [constrainedShape.name], bindings: expect.any(Map) }], ]), @@ -847,6 +863,7 @@ describe('solveShapeQueryContainment', () => { const query = generateQuery(querySparql); const expectedResult: IResult = { + result: ContainmentResult.CONTAINED, starPatternsContainment: new Map([ ["x", { result: ContainmentResult.CONTAINED, target: [constrainedShape.name], bindings: expect.any(Map) }], ]), @@ -884,6 +901,7 @@ describe('solveShapeQueryContainment', () => { const query = generateQuery(querySparql); const expectedResult: IResult = { + result: ContainmentResult.CONTAINED, starPatternsContainment: new Map([ ["x", { result: ContainmentResult.CONTAINED, target: [constrainedShape.name], bindings: expect.any(Map) }], ]), @@ -920,6 +938,7 @@ describe('solveShapeQueryContainment', () => { const query = generateQuery(querySparql); const expectedResult: IResult = { + result: ContainmentResult.REJECTED, starPatternsContainment: new Map([ ["x", { result: ContainmentResult.REJECTED, bindings: new Map() }], ]), @@ -967,7 +986,7 @@ describe('solveShapeQueryContainment', () => { ["w", { result: ContainmentResult.UNALINGED, target: [shapeP3.name, shapeP4.name], bindings:expect.any(Map) }], ]); const expectedResult: IResult = { - + result: ContainmentResult.UNALINGED, starPatternsContainment: expectedStarPatternsContainment, visitShapeBoundedResource: new Map([ [shape.name, true], @@ -1007,6 +1026,7 @@ describe('solveShapeQueryContainment', () => { ["w", { result: ContainmentResult.UNALINGED, target: [shapeP3.name, shapeP4.name], bindings:expect.any(Map) }], ]); const expectedResult: IResult = { + result: ContainmentResult.UNALINGED, starPatternsContainment: expectedStarPatternsContainment, visitShapeBoundedResource: new Map([ [shape.name, false], @@ -1038,7 +1058,7 @@ describe('solveShapeQueryContainment', () => { ["z", { result: ContainmentResult.CONTAINED, bindings:expect.any(Map), target: [shape.name] }], ]); const expectedResult: IResult = { - + result: ContainmentResult.UNALINGED, starPatternsContainment: expectedStarPatternsContainment, visitShapeBoundedResource: new Map([ [shape.name, true], @@ -1406,7 +1426,7 @@ describe('solveShapeQueryContainment', () => { ]); - expect(resp).toStrictEqual({ visitShapeBoundedResource, starPatternsContainment }); + expect(resp).toStrictEqual({ result: ContainmentResult.CONTAINED, visitShapeBoundedResource, starPatternsContainment }); }); test('interactive-short-2', async () => { @@ -1473,7 +1493,7 @@ describe('solveShapeQueryContainment', () => { ]); - expect(resp).toStrictEqual({ visitShapeBoundedResource, starPatternsContainment }); + expect(resp).toStrictEqual({ result: ContainmentResult.CONTAINED, visitShapeBoundedResource, starPatternsContainment }); }); test('interactive-short-4', async () => { @@ -1517,7 +1537,7 @@ describe('solveShapeQueryContainment', () => { ]); - expect(resp).toStrictEqual({ visitShapeBoundedResource, starPatternsContainment }); + expect(resp).toStrictEqual({ result: ContainmentResult.CONTAINED, visitShapeBoundedResource, starPatternsContainment }); }); test('interactive-short-5', async () => { @@ -1549,7 +1569,7 @@ describe('solveShapeQueryContainment', () => { ]); - expect(resp).toStrictEqual({ visitShapeBoundedResource, starPatternsContainment }); + expect(resp).toStrictEqual({ result: ContainmentResult.CONTAINED, visitShapeBoundedResource, starPatternsContainment }); }); }); @@ -1630,7 +1650,7 @@ describe('solveShapeQueryContainment', () => { ]); - expect(resp).toStrictEqual({ visitShapeBoundedResource, starPatternsContainment }); + expect(resp).toStrictEqual({ result: ContainmentResult.CONTAINED, visitShapeBoundedResource, starPatternsContainment }); }); test('interactive-discover-2 comment missing', async () => { @@ -1711,7 +1731,7 @@ describe('solveShapeQueryContainment', () => { ["tag", { result: ContainmentResult.REJECTED, target: undefined, bindings:expect.any(Map), }], ]); - expect(resp).toStrictEqual({ visitShapeBoundedResource, starPatternsContainment }); + expect(resp).toStrictEqual({ result: ContainmentResult.REJECTED, visitShapeBoundedResource, starPatternsContainment }); }); test('interactive-discover-4', async () => { @@ -1745,7 +1765,7 @@ describe('solveShapeQueryContainment', () => { ["location", { result: ContainmentResult.REJECTED, target: undefined, bindings:expect.any(Map), }], ]); - expect(resp).toStrictEqual({ visitShapeBoundedResource, starPatternsContainment }); + expect(resp).toStrictEqual({ result: ContainmentResult.REJECTED, visitShapeBoundedResource, starPatternsContainment }); }); test('interactive-discover-5', async () => { @@ -1772,7 +1792,7 @@ describe('solveShapeQueryContainment', () => { ["message", { result: ContainmentResult.CONTAINED, target: ["http://example.com#Comment", "http://example.com#Post"], bindings:expect.any(Map), }], ]); - expect(resp).toStrictEqual({ visitShapeBoundedResource, starPatternsContainment }); + expect(resp).toStrictEqual({ result: ContainmentResult.CONTAINED, visitShapeBoundedResource, starPatternsContainment }); }); it('interactive-discover-6', async () => { @@ -1808,7 +1828,7 @@ describe('solveShapeQueryContainment', () => { ], ]); - expect(resp).toStrictEqual({ visitShapeBoundedResource, starPatternsContainment }); + expect(resp).toStrictEqual({ result: ContainmentResult.UNALINGED, visitShapeBoundedResource, starPatternsContainment }); }); it('interactive-discover-7', async () => { @@ -1885,7 +1905,7 @@ describe('solveShapeQueryContainment', () => { ["otherMessage", { result: ContainmentResult.CONTAINED, target: ["http://example.com#Comment", "http://example.com#Post"], bindings:expect.any(Map), }], ]); - expect(resp).toStrictEqual({ visitShapeBoundedResource, starPatternsContainment }); + expect(resp).toStrictEqual({ result: ContainmentResult.CONTAINED, visitShapeBoundedResource, starPatternsContainment }); }); }); From 74c90bb8722809d039ea37264158cb990701565a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 12:21:41 +0000 Subject: [PATCH 18/22] fix!: apply strictRdfLists the same way to every RDF list The malformed-list check was copied into three places and the copies had drifted apart. ShEx returned a parse error under the strict policy; SHACL only labelled the diagnostic 'error' and went on with the partial list, although the policy says malformed lists are parse errors. ShEx also used walkRdfList only for its malformed flag and then walked the list again by hand, re-reading the rest of a oneOf from each member, and that loop never ended on a cyclic list under the non-strict policy. readRdfList (parser-policy.ts) now reads every list a parser uses: sh:or, sh:xone and sh:ignoredProperties in SHACL, eachOf and oneOf in ShEx. A malformed list is reported, is a parse error under the strict policy (the default), and otherwise contributes its members up to the defect. The ShEx handlers iterate those members once and share a parse context instead of forwarding the shape IRI and options as unused-looking arguments. BREAKING CHANGE: under the default policy, a SHACL shape with a malformed sh:or, sh:xone or sh:ignoredProperties list is now a PoorlyFormatedShapeError. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J27pqtwzm8UXeEv14dUbCX --- lib/parser-policy.ts | 37 ++++++++ lib/shacl.ts | 49 ++++------- lib/shex.ts | 181 ++++++++++++-------------------------- test/shape-parser.test.ts | 35 ++++++++ 4 files changed, 146 insertions(+), 156 deletions(-) diff --git a/lib/parser-policy.ts b/lib/parser-policy.ts index b35425e..e00e5e3 100644 --- a/lib/parser-policy.ts +++ b/lib/parser-policy.ts @@ -1,3 +1,5 @@ +import { type IRdfListIndex, PoorlyFormatedShapeError, walkRdfList } from './Shape'; + export type ShapeFormat = 'shacl' | 'shex'; export interface ParserDiagnostic { @@ -33,3 +35,38 @@ export function addDiagnostic( ): void { options?.diagnostics?.push(diagnostic); } + +/** + * Where an RDF list is read, for its diagnostic. + */ +export interface IRdfListContext { + shapeIri: string; + // the construct the list belongs to, such as `sh:or` + construct: string; + options?: IShapeParserOptions; +} + +/** + * The members of an RDF list of a shapes graph. A malformed list is reported as a diagnostic; it is + * a parse error under the strict policy (the default), and otherwise its members up to the defect + * are used. + */ +export function readRdfList( + head: string, + index: IRdfListIndex, + context: IRdfListContext, +): string[] | PoorlyFormatedShapeError { + const walked = walkRdfList(head, index); + if (!walked.malformed) { + return walked.values; + } + const { strictRdfLists } = getPolicy(context.options); + addDiagnostic(context.options, { + level: strictRdfLists ? 'error' : 'warning', + code: 'MALFORMED_RDF_LIST', + message: `Malformed RDF list while resolving ${context.construct} for <${context.shapeIri}>`, + shapeIri: context.shapeIri, + nodeId: head, + }); + return strictRdfLists ? new PoorlyFormatedShapeError('An RDF list is poorly defined') : walked.values; +} diff --git a/lib/shacl.ts b/lib/shacl.ts index 873bab0..5fd9710 100644 --- a/lib/shacl.ts +++ b/lib/shacl.ts @@ -1,10 +1,10 @@ import type * as RDF from '@rdfjs/types'; import { DataFactory } from 'rdf-data-factory'; import type { OneOf, IShape, IPredicate, IConstraintDeclaration } from './Shape'; -import { PoorlyFormatedShapeError, walkRdfList, buildConstraint, tryCreateShape, isNegativeCardinality, declaresTargets } from './Shape'; +import { PoorlyFormatedShapeError, buildConstraint, tryCreateShape, isNegativeCardinality, declaresTargets } from './Shape'; import type { ShapeError, IShapeTargets } from './Shape'; import { SHACL, RDF as RDF_VOCAB, RDFS, XSD } from './constant'; -import { addDiagnostic, getPolicy, type IShapeParserOptions } from './parser-policy'; +import { addDiagnostic, type IRdfListContext, type IShapeParserOptions, readRdfList } from './parser-policy'; import { nodeTargetKey } from './target'; const DF = new DataFactory(); @@ -384,7 +384,11 @@ function buildShape( // Resolve sh:or / sh:xone into oneOf branches const oneOfs: OneOf[] = []; for (const orHead of orHeads) { - const branch = resolveOrList(orHead, map, shapeIri, options); + const members = readList(orHead, map, { shapeIri, construct: 'sh:or/sh:xone', options }); + if (members instanceof PoorlyFormatedShapeError) { + return members; + } + const branch = resolveOrList(members, map); if (branch.length > 0) { oneOfs.push(branch); } @@ -394,7 +398,10 @@ function buildShape( const ignoredHead = map.ignoredLists.get(shapeIri); const ignoredProperties = ignoredHead === undefined ? [] - : walkRdfList(ignoredHead, { firstByNode: map.listFirst, restByNode: map.listRest }).values; + : readList(ignoredHead, map, { shapeIri, construct: 'sh:ignoredProperties', options }); + if (ignoredProperties instanceof PoorlyFormatedShapeError) { + return ignoredProperties; + } return tryCreateShape({ name: shapeIri, @@ -491,44 +498,24 @@ function buildPredicate(path: string, data: IPropertyShapeData): IPredicate { }; } +function readList(head: string, map: IMapTripleShacl, context: IRdfListContext): string[] | PoorlyFormatedShapeError { + return readRdfList(head, { firstByNode: map.listFirst, restByNode: map.listRest }, context); +} + /** - * Walk an RDF list headed at `head` and collect each member as an - * IPredicate[], returning them as a single OneOf (array of paths/branches). + * Collect the members of an sh:or (or sh:xone) list as a single OneOf: one branch per member. * * Each list member is itself a shape-like blank node that should have * sh:property children. We collect those into one OneOfPath per member. */ -function resolveOrList( - head: string, - map: IMapTripleShacl, - shapeIri: string, - options?: IShapeParserOptions, -): OneOf { +function resolveOrList(members: string[], map: IMapTripleShacl): OneOf { const result: OneOf = []; - - const policy = getPolicy(options); - const walked = walkRdfList(head, { - firstByNode: map.listFirst, - restByNode: map.listRest, - }); - - if (walked.malformed) { - addDiagnostic(options, { - level: policy.strictRdfLists ? 'error' : 'warning', - code: 'MALFORMED_RDF_LIST', - message: `Malformed RDF list while resolving sh:or/sh:xone for <${shapeIri}>`, - shapeIri, - nodeId: head, - }); - } - - for (const memberId of walked.values) { + for (const memberId of members) { const branch = resolveOrMember(memberId, map); if (branch.length > 0) { result.push(branch); } } - return result; } diff --git a/lib/shex.ts b/lib/shex.ts index 559a76f..7fb9757 100644 --- a/lib/shex.ts +++ b/lib/shex.ts @@ -9,8 +9,8 @@ import type { IConstraint, IConstraintFacets, } from './Shape'; -import { PoorlyFormatedShapeError, walkRdfList, tryCreateShape, buildConstraint, isNegativeCardinality } from './Shape'; -import { addDiagnostic, getPolicy, type IShapeParserOptions } from './parser-policy'; +import { PoorlyFormatedShapeError, tryCreateShape, buildConstraint, isNegativeCardinality } from './Shape'; +import { type IShapeParserOptions, readRdfList } from './parser-policy'; const DF = new DataFactory(); const RDF_TRUE = DF.literal('true', DF.namedNode(XSD.boolean)); @@ -133,6 +133,7 @@ function concatShapeInfo( } else { expressions = mapTripleShex.mapLogicLinkIdExpressions.get(expression); } + const context: IShexParseContext = { map: mapTripleShex, shapeIri, options }; // If there is only one expression if (expressions === undefined) { // an undefined expression without expressions has already returned an error above @@ -140,55 +141,17 @@ function concatShapeInfo( if (!predicateAdded) { return new PoorlyFormatedShapeError('there are no predicates in the shape'); } - } else if (expression === undefined) { - const current = mapTripleShex.mapPrevCurrentList.get(expressions); - const next = mapTripleShex.mapPrevNextList.get(expressions); - const error = handleEachOf(current, next, mapTripleShex, argsFunctionPredicate, shapeIri, options); - if (error !== undefined) { - return error; - } - } else if (mapTripleShex.setIriEachOf.has(expression)) { - const policy = getPolicy(options); - const walked = walkRdfList(expressions, { - firstByNode: mapTripleShex.mapPrevCurrentList, - restByNode: mapTripleShex.mapPrevNextList, - }); - if (walked.malformed) { - addDiagnostic(options, { - level: policy.strictRdfLists ? 'error' : 'warning', - code: 'MALFORMED_RDF_LIST', - message: `Malformed RDF list while resolving eachOf for <${shapeIri}>`, - shapeIri, - nodeId: expressions, - }); - if (policy.strictRdfLists) { - return new PoorlyFormatedShapeError('An RDF list is poorly defined'); - } + } else if (expression === undefined || mapTripleShex.setIriEachOf.has(expression)) { + const members = readExpressionList(expressions, 'eachOf', context); + if (members instanceof PoorlyFormatedShapeError) { + return members; } - - const current = mapTripleShex.mapPrevCurrentList.get(expressions); - const next = mapTripleShex.mapPrevNextList.get(expressions); - const error = handleEachOf( - current, - next, - mapTripleShex, - argsFunctionPredicate, - shapeIri, - options, - ); + const error = handleEachOf(members, argsFunctionPredicate, context); if (error !== undefined) { return error; } } else if (mapTripleShex.setIriOneOf.has(expression)) { - const error = handleOneOf( - expression, - expression, - mapTripleShex, - argsFunctionPredicate, - mapTripleShex.setIriEachOf.has(expression), - shapeIri, - options, - ); + const error = handleOneOf(expression, expression, argsFunctionPredicate, mapTripleShex.setIriEachOf.has(expression), context); if (error !== undefined) { return error; } @@ -281,78 +244,63 @@ function appendPredicates( return false; } +/** + * What the ShEx list handlers need besides the lists they fill. + */ +interface IShexParseContext { + map: IMapTripleShex; + shapeIri: string; + options?: IShapeParserOptions; +} + +function readExpressionList(head: string, construct: string, context: IShexParseContext): string[] | PoorlyFormatedShapeError { + return readRdfList(head, { + firstByNode: context.map.mapPrevCurrentList, + restByNode: context.map.mapPrevNextList, + }, { shapeIri: context.shapeIri, construct, options: context.options }); +} + +/** + * Add the triple expressions of a oneOf (or of an eachOf inside a oneOf) as branches of the oneOf + * stored at `index` in `prevArgsFunctionPredicate`. Nested oneOf and eachOf expressions add their + * branches to the same oneOf. + */ function handleOneOf( iri: string, index: string, - mapTripleShex: IMapTripleShex, prevArgsFunctionPredicate: IAppendPredicateArgs, eachOf: boolean, - shapeIri: string, - options?: IShapeParserOptions, + context: IShexParseContext, ): undefined | PoorlyFormatedShapeError { + const { map } = context; const positivePredicates: IPredicate[] = []; const negativePredicates: string[] = []; const argsFunctionPredicate: IAppendPredicateArgs = { - map: mapTripleShex, + map, positivePredicates, negativePredicates, oneOf: new Map() }; - const expressions = mapTripleShex.mapLogicLinkIdExpressions.get(iri); + const expressions = map.mapLogicLinkIdExpressions.get(iri); // we don't really support validation of format /* istanbul ignore next */ if (expressions === undefined) { return new PoorlyFormatedShapeError('There are no expressions in a one of'); } - const policy = getPolicy(options); - const walked = walkRdfList(expressions, { - firstByNode: mapTripleShex.mapPrevCurrentList, - restByNode: mapTripleShex.mapPrevNextList, - }); - - if (walked.malformed) { - addDiagnostic(options, { - level: policy.strictRdfLists ? 'error' : 'warning', - code: 'MALFORMED_RDF_LIST', - message: `Malformed RDF list while resolving oneOf for <${shapeIri}>`, - shapeIri, - nodeId: expressions, - }); - if (policy.strictRdfLists) { - return new PoorlyFormatedShapeError('An RDF list is poorly defined'); - } + const members = readExpressionList(expressions, 'oneOf', context); + if (members instanceof PoorlyFormatedShapeError) { + return members; } - - let current = mapTripleShex.mapPrevCurrentList.get(expressions); - let next = mapTripleShex.mapPrevNextList.get(expressions); - while (current !== undefined) { - if (!mapTripleShex.setIriOneOf.has(current) && !mapTripleShex.setIriEachOf.has(current)) { - const error = handleEachOf(current, next, mapTripleShex, argsFunctionPredicate, shapeIri, options); - if (error !== undefined) { - return error; - } - } else { - const error = handleOneOf( - current, - iri, - mapTripleShex, - prevArgsFunctionPredicate, - mapTripleShex.setIriEachOf.has(current), - shapeIri, - options, - ); - if (error !== undefined) { - return error; - } + for (const member of members) { + if (!map.setIriOneOf.has(member) && !map.setIriEachOf.has(member)) { + appendPredicates(argsFunctionPredicate, member); + continue; } - - if (next === undefined) { - return new PoorlyFormatedShapeError('An RDF list is poorly defined'); + const error = handleOneOf(member, iri, prevArgsFunctionPredicate, map.setIriEachOf.has(member), context); + if (error !== undefined) { + return error; } - - current = mapTripleShex.mapPrevCurrentList.get(next); - next = mapTripleShex.mapPrevNextList.get(next); } const currentOneOf = prevArgsFunctionPredicate.oneOf.get(index); if (currentOneOf === undefined) { @@ -375,41 +323,24 @@ function handleOneOf( return undefined; } +/** + * Add the triple expressions of an eachOf to the predicate lists; a oneOf among them adds a oneOf. + */ function handleEachOf( - current: string | undefined, - next: string | undefined, - mapTripleShex: IMapTripleShex, + members: string[], argsFunctionPredicate: IAppendPredicateArgs, - _shapeIri: string, - _options?: IShapeParserOptions, + context: IShexParseContext, ): undefined | PoorlyFormatedShapeError { - // Traverse the RDF list - while (current !== undefined) { - if (!mapTripleShex.setIriOneOf.has(current)) { - appendPredicates(argsFunctionPredicate, current); - } else { - const error = handleOneOf( - current, - current, - mapTripleShex, - argsFunctionPredicate, - mapTripleShex.setIriEachOf.has(current), - _shapeIri, - _options, - ); - if (error !== undefined) { - return error; - } + for (const member of members) { + if (!context.map.setIriOneOf.has(member)) { + appendPredicates(argsFunctionPredicate, member); + continue; } - - if (next === undefined) { - return new PoorlyFormatedShapeError('An RDF list is poorly defined'); + const error = handleOneOf(member, member, argsFunctionPredicate, context.map.setIriEachOf.has(member), context); + if (error !== undefined) { + return error; } - - current = mapTripleShex.mapPrevCurrentList.get(next); - next = mapTripleShex.mapPrevNextList.get(next); } - return undefined; } diff --git a/test/shape-parser.test.ts b/test/shape-parser.test.ts index d806bc9..e37252b 100644 --- a/test/shape-parser.test.ts +++ b/test/shape-parser.test.ts @@ -76,6 +76,41 @@ describe('parseShapeFromQuads', () => { expect(diagnostics.some(d => d.code === 'MALFORMED_RDF_LIST')).toBe(true); }); + + describe('a malformed SHACL list', () => { + // The second cell of the sh:or list has no rdf:rest + const malformedOr = ` + @prefix sh: . + @prefix rdf: . + @prefix ex: . + <${shapeIri}> a sh:NodeShape ; + sh:property [ sh:path ex:name ] ; + sh:or _:list . + _:list rdf:first [ sh:path ex:label ] ; rdf:rest _:rest . + _:rest rdf:first [ sh:path ex:title ] .`; + + it('is a parse error by default', async () => { + const diagnostics: ParserDiagnostic[] = []; + const shape = await parseShapeFromQuads(n3Parser.parse(malformedOr), shapeIri, { format: 'shacl', diagnostics }); + + expect(shape).toBeInstanceOf(Error); + expect(diagnostics.map(d => [ d.code, d.level ])).toStrictEqual([[ 'MALFORMED_RDF_LIST', 'error' ]]); + }); + + it('keeps its members up to the defect when the policy is non-strict', async () => { + const diagnostics: ParserDiagnostic[] = []; + const shape = await parseShapeFromQuads(n3Parser.parse(malformedOr), shapeIri, { + format: 'shacl', + diagnostics, + policy: { strictRdfLists: false }, + }); + + expect(shape).not.toBeInstanceOf(Error); + expect((shape as IShape).oneOf.map(oneOf => oneOf.map(path => path.map(predicate => predicate.name)))) + .toStrictEqual([[[ 'http://example.org/label' ]]]); + expect(diagnostics.map(d => [ d.code, d.level ])).toStrictEqual([[ 'MALFORMED_RDF_LIST', 'warning' ]]); + }); + }); }); function canonical(shape: IShape): { positive: string[]; negative: string[]; cards: Record } { From 36977329ff40c9a4675e88309d47b718f7f8db3b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 12:24:23 +0000 Subject: [PATCH 19/22] refactor: move the parser entry point out of the shape model parseShapeFromQuads lived in Shape.ts and used `await import('./shacl')` to get around the import cycle with the parsers. It is now in lib/shape-parser.ts (exported from the package index as before) and imports them statically. The stream handling was written three times, typing the stream error as any. forEachQuad (parser-policy.ts) now feeds the quads of an array or a stream to a callback and returns the stream error, as before. Both parsers still consume a stream quad by quad. The 170-line if/return chain of the SHACL parseQuad is now a table of handlers by predicate IRI. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J27pqtwzm8UXeEv14dUbCX --- index.ts | 1 + lib/Shape.ts | 100 +-------------- lib/parser-policy.ts | 23 +++- lib/shacl.ts | 261 ++++++++++---------------------------- lib/shape-parser.ts | 77 +++++++++++ lib/shex.ts | 76 +---------- test/shape-parser.test.ts | 2 +- 7 files changed, 175 insertions(+), 365 deletions(-) create mode 100644 lib/shape-parser.ts diff --git a/index.ts b/index.ts index 4e3ef81..fb10481 100644 --- a/index.ts +++ b/index.ts @@ -9,3 +9,4 @@ export * from './lib/Triple'; export * from './lib/Binding'; export * from './lib/target'; export * from './lib/parser-policy'; +export * from './lib/shape-parser'; diff --git a/lib/Shape.ts b/lib/Shape.ts index e506d93..a268853 100644 --- a/lib/Shape.ts +++ b/lib/Shape.ts @@ -1,6 +1,4 @@ -import type * as RDFJS from '@rdfjs/types'; -import { RDF, SHACL, SHEX } from './constant'; -import type { IShapeParserOptions, ShapeFormat } from './parser-policy'; +import { RDF } from './constant'; /** * A shape interface @@ -536,102 +534,6 @@ export function isNegativeCardinality(minCount?: number, maxCount?: number): boo return minCount === 0 && maxCount === 0; } -export interface IParseShapeFromQuadsOptions extends IShapeParserOptions { - format?: ShapeFormat | 'auto'; -} - -/** - * Unified parser entrypoint for SHACL and ShEx shape graphs. - */ -export async function parseShapeFromQuads( - quads: RDFJS.Stream | RDFJS.Quad[], - shapeIri: string, - options?: IParseShapeFromQuadsOptions, -): Promise { - const format = options?.format ?? 'auto'; - - if (Array.isArray(quads)) { - return parseFromArray(quads, shapeIri, format, options); - } - - return new Promise(resolve => { - const buffer: RDFJS.Quad[] = []; - quads.on('data', (quad: RDFJS.Quad) => { - buffer.push(quad); - }); - quads.on('error', (error: any) => { - resolve(error); - }); - quads.on('end', () => { - parseFromArray(buffer, shapeIri, format, options).then(resolve); - }); - }); -} - -async function parseFromArray( - quads: RDFJS.Quad[], - shapeIri: string, - requestedFormat: ShapeFormat | 'auto', - options?: IParseShapeFromQuadsOptions, -): Promise { - const format = requestedFormat === 'auto' - ? detectShapeFormat(quads) - : requestedFormat; - - if (format === undefined) { - return new PoorlyFormatedShapeError('Cannot detect shape format automatically. Please provide options.format.'); - } - - if (format === 'shacl') { - const module = await import('./shacl'); - return module.shaclShapeFromQuads(quads, shapeIri, options); - } - - const module = await import('./shex'); - return module.shexShapeFromQuads(quads, shapeIri, options); -} - -function detectShapeFormat(quads: RDFJS.Quad[]): ShapeFormat | undefined { - let shaclScore = 0; - let shexScore = 0; - - for (const quad of quads) { - if ( - quad.predicate.equals(SHACL.terms.property) - || quad.predicate.equals(SHACL.terms.path) - || quad.predicate.equals(SHACL.terms.or) - || quad.predicate.equals(SHACL.terms.xone) - || quad.predicate.equals(SHACL.terms.node) - || quad.predicate.equals(SHACL.terms.datatype) - || quad.predicate.equals(SHACL.terms.targetNode) - || quad.predicate.equals(SHACL.terms.targetClass) - || quad.predicate.equals(SHACL.terms.targetSubjectsOf) - || quad.predicate.equals(SHACL.terms.targetObjectsOf) - ) { - shaclScore++; - } - - if ( - quad.predicate.equals(SHEX.terms.expression) - || quad.predicate.equals(SHEX.terms.expressions) - || quad.predicate.equals(SHEX.terms.predicate) - || quad.predicate.equals(SHEX.terms.valueExpr) - || quad.object.equals(SHEX.terms.Shape) - || quad.object.equals(SHEX.terms.EachOf) - || quad.object.equals(SHEX.terms.OneOf) - ) { - shexScore++; - } - } - - if (shaclScore > shexScore) { - return 'shacl'; - } - if (shexScore > shaclScore) { - return 'shex'; - } - return undefined; -} /** * An error to indicate that there is an inconsistency with the positive and negative properties */ diff --git a/lib/parser-policy.ts b/lib/parser-policy.ts index e00e5e3..e4f3f70 100644 --- a/lib/parser-policy.ts +++ b/lib/parser-policy.ts @@ -1,4 +1,5 @@ -import { type IRdfListIndex, PoorlyFormatedShapeError, walkRdfList } from './Shape'; +import type * as RDF from '@rdfjs/types'; +import { type IRdfListIndex, PoorlyFormatedShapeError, type ShapeError, walkRdfList } from './Shape'; export type ShapeFormat = 'shacl' | 'shex'; @@ -70,3 +71,23 @@ export function readRdfList( }); return strictRdfLists ? new PoorlyFormatedShapeError('An RDF list is poorly defined') : walked.values; } + +/** + * Pass every quad of a shapes graph, given as an array or as a stream, to `onQuad`. The promise + * resolves when all the quads are passed, with the error of the stream if it fails: the parsers + * return their errors rather than throw them. + */ +export function forEachQuad( + quads: RDF.Stream | RDF.Quad[], + onQuad: (quad: RDF.Quad) => void, +): Promise { + if (Array.isArray(quads)) { + quads.forEach(onQuad); + return Promise.resolve(undefined); + } + return new Promise(resolve => { + quads.on('data', onQuad); + quads.on('error', (error: ShapeError) => resolve(error)); + quads.on('end', () => resolve(undefined)); + }); +} diff --git a/lib/shacl.ts b/lib/shacl.ts index 5fd9710..937bb9b 100644 --- a/lib/shacl.ts +++ b/lib/shacl.ts @@ -4,7 +4,7 @@ import type { OneOf, IShape, IPredicate, IConstraintDeclaration } from './Shape' import { PoorlyFormatedShapeError, buildConstraint, tryCreateShape, isNegativeCardinality, declaresTargets } from './Shape'; import type { ShapeError, IShapeTargets } from './Shape'; import { SHACL, RDF as RDF_VOCAB, RDFS, XSD } from './constant'; -import { addDiagnostic, type IRdfListContext, type IShapeParserOptions, readRdfList } from './parser-policy'; +import { addDiagnostic, forEachQuad, type IRdfListContext, type IShapeParserOptions, readRdfList } from './parser-policy'; import { nodeTargetKey } from './target'; const DF = new DataFactory(); @@ -86,213 +86,88 @@ function defaultMap(): IMapTripleShacl { * @param {string} shapeIri - The IRI of the desired node shape * @returns {Promise} The parsed shape or an error */ -export function shaclShapeFromQuads( +export async function shaclShapeFromQuads( quads: RDF.Stream | RDF.Quad[], shapeIri: string, options?: IShapeParserOptions, -): Promise { - if (Array.isArray(quads)) { - return new Promise(resolve => { - resolve(shapeFromQuadArray(quads, shapeIri, options)); - }); - } - return shapeFromQuadStream(quads, shapeIri, options); -} - -function shapeFromQuadStream( - quadStream: RDF.Stream, - shapeIri: string, - options?: IShapeParserOptions, ): Promise { const map = defaultMap(); - return new Promise(resolve => { - quadStream.on('data', (quad: RDF.Quad) => { parseQuad(quad, map); }); - quadStream.on('error', (error: any) => { resolve(error); }); - quadStream.on('end', () => { resolve(buildShape(map, shapeIri, options)); }); - }); -} - -function shapeFromQuadArray( - quads: RDF.Quad[], - shapeIri: string, - options?: IShapeParserOptions, -): IShape | ShapeError { - const map = defaultMap(); - for (const quad of quads) { - parseQuad(quad, map); - } - return buildShape(map, shapeIri, options); + const error = await forEachQuad(quads, quad => parseQuad(quad, map)); + return error ?? buildShape(map, shapeIri, options); } // ── Quad parsing ─────────────────────────────────────────────────────────── -function parseQuad(quad: RDF.Quad, map: IMapTripleShacl): void { - const s = quad.subject.value; - const o = quad.object.value; - - if (quad.predicate.value.startsWith(SHACL.namespace)) { - addToSetMap(map.shaclPredicates, s, quad.predicate.value); - } - - // sh:property → register property shape under the node shape - if (quad.predicate.equals(SHACL.terms.property)) { - let props = map.shapeProperties.get(s); - if (props === undefined) { - props = new Set(); - map.shapeProperties.set(s, props); - } - props.add(o); - // Ensure an entry exists so we can detect even empty property shapes - if (!map.propertyData.has(o)) { - map.propertyData.set(o, { isNegated: false }); - } - return; - } - - // sh:path → predicate IRI for this property shape - if (quad.predicate.equals(SHACL.terms.path)) { - const data = getOrCreatePropData(map, s); - data.path = o; - data.pathIsIri = quad.object.termType === 'NamedNode'; - return; - } - - // sh:minCount - if (quad.predicate.equals(SHACL.terms.minCount)) { - getOrCreatePropData(map, s).minCount = Number(o); - return; - } - - // sh:maxCount - if (quad.predicate.equals(SHACL.terms.maxCount)) { - getOrCreatePropData(map, s).maxCount = Number(o); - return; - } - - // sh:minInclusive - if (quad.predicate.equals(SHACL.terms.minInclusive)) { - getOrCreatePropData(map, s).minInclusive = Number(o); - return; - } - - // sh:maxInclusive - if (quad.predicate.equals(SHACL.terms.maxInclusive)) { - getOrCreatePropData(map, s).maxInclusive = Number(o); - return; - } - - // sh:minExclusive - if (quad.predicate.equals(SHACL.terms.minExclusive)) { - getOrCreatePropData(map, s).minExclusive = Number(o); - return; - } +type QuadHandler = (map: IMapTripleShacl, subject: string, object: RDF.Term) => void; - // sh:maxExclusive - if (quad.predicate.equals(SHACL.terms.maxExclusive)) { - getOrCreatePropData(map, s).maxExclusive = Number(o); - return; - } - - // sh:pattern - if (quad.predicate.equals(SHACL.terms.pattern)) { - getOrCreatePropData(map, s).pattern = o; - return; - } - - // sh:flags - if (quad.predicate.equals(SHACL.terms.flags)) { - getOrCreatePropData(map, s).flags = o; - return; - } - - // sh:closed - if (quad.predicate.equals(SHACL.terms.closed)) { - map.closedShape.set(s, quad.object.equals(RDF_TRUE)); - return; - } - - // sh:class → CLASS constraint - if (quad.predicate.equals(SHACL.terms.class)) { - getOrCreatePropData(map, s).classConstraint = o; - return; - } - - // sh:datatype → DATATYPE constraint - if (quad.predicate.equals(SHACL.terms.datatype)) { - getOrCreatePropData(map, s).datatypeConstraint = o; - return; - } - - // sh:node → SHAPE constraint (reference to another shape) - if (quad.predicate.equals(SHACL.terms.node)) { - getOrCreatePropData(map, s).shapeConstraint = o; - return; - } - - // sh:or → alternatives list head - if (quad.predicate.equals(SHACL.terms.or)) { - addToSetMap(map.orLists, s, o); - return; - } - - // sh:xone → exclusive-one-of list head (treated same as sh:or for query matching) - if (quad.predicate.equals(SHACL.terms.xone)) { - addToSetMap(map.xoneLists, s, o); - return; - } - - // sh:not → negation (blank node property shape that should become a negative predicate) - if (quad.predicate.equals(SHACL.terms.not)) { - addToSetMap(map.notLinks, s, o); - // Create entry for the negated shape's blank node - const negData = getOrCreatePropData(map, o); - negData.isNegated = true; - return; - } +/** Set a field of the property shape data of the subject from the object. */ +function setPropertyData( + key: K, + read: (object: RDF.Term) => IPropertyShapeData[K], +): QuadHandler { + return (map, subject, object): void => { + getOrCreatePropData(map, subject)[key] = read(object); + }; +} +const asNumber = (object: RDF.Term): number => Number(object.value); +const asValue = (object: RDF.Term): string => object.value; + +/** What each predicate of a shapes graph records, by predicate IRI. */ +const QUAD_HANDLERS = new Map([ + // sh:property registers a property shape under the node shape, even an empty one + [SHACL.property, (map, subject, object): void => { + addToSetMap(map.shapeProperties, subject, object.value); + getOrCreatePropData(map, object.value); + }], + [SHACL.path, (map, subject, object): void => { + const data = getOrCreatePropData(map, subject); + data.path = object.value; + data.pathIsIri = object.termType === 'NamedNode'; + }], + [SHACL.minCount, setPropertyData('minCount', asNumber)], + [SHACL.maxCount, setPropertyData('maxCount', asNumber)], + [SHACL.minInclusive, setPropertyData('minInclusive', asNumber)], + [SHACL.maxInclusive, setPropertyData('maxInclusive', asNumber)], + [SHACL.minExclusive, setPropertyData('minExclusive', asNumber)], + [SHACL.maxExclusive, setPropertyData('maxExclusive', asNumber)], + [SHACL.pattern, setPropertyData('pattern', asValue)], + [SHACL.flags, setPropertyData('flags', asValue)], + [SHACL.class, setPropertyData('classConstraint', asValue)], + [SHACL.datatype, setPropertyData('datatypeConstraint', asValue)], + // sh:node references another shape + [SHACL.node, setPropertyData('shapeConstraint', asValue)], + [SHACL.closed, (map, subject, object): void => { map.closedShape.set(subject, object.equals(RDF_TRUE)); }], + // sh:or and sh:xone are list heads of alternatives (sh:xone is treated as sh:or for query matching) + [SHACL.or, (map, subject, object): void => { addToSetMap(map.orLists, subject, object.value); }], + [SHACL.xone, (map, subject, object): void => { addToSetMap(map.xoneLists, subject, object.value); }], + // sh:not negates a property shape, which can become a negative predicate + [SHACL.not, (map, subject, object): void => { + addToSetMap(map.notLinks, subject, object.value); + getOrCreatePropData(map, object.value).isNegated = true; + }], // Targets: the nodes the shape describes when it is used on its own - if (quad.predicate.equals(SHACL.terms.targetNode)) { - getOrCreateTargets(map, s).nodes.add(nodeTargetKey(quad.object)); - return; - } - if (quad.predicate.equals(SHACL.terms.targetClass)) { - getOrCreateTargets(map, s).classes.add(o); - return; - } - if (quad.predicate.equals(SHACL.terms.targetSubjectsOf)) { - getOrCreateTargets(map, s).subjectsOf.add(o); - return; - } - if (quad.predicate.equals(SHACL.terms.targetObjectsOf)) { - getOrCreateTargets(map, s).objectsOf.add(o); - return; - } - - // sh:ignoredProperties → list of properties a closed shape allows without declaring them - if (quad.predicate.equals(SHACL.terms.ignoredProperties)) { - map.ignoredLists.set(s, o); - return; - } - + [SHACL.targetNode, (map, subject, object): void => { getOrCreateTargets(map, subject).nodes.add(nodeTargetKey(object)); }], + [SHACL.targetClass, (map, subject, object): void => { getOrCreateTargets(map, subject).classes.add(object.value); }], + [SHACL.targetSubjectsOf, (map, subject, object): void => { getOrCreateTargets(map, subject).subjectsOf.add(object.value); }], + [SHACL.targetObjectsOf, (map, subject, object): void => { getOrCreateTargets(map, subject).objectsOf.add(object.value); }], + // sh:ignoredProperties lists the properties a closed shape allows without declaring them + [SHACL.ignoredProperties, (map, subject, object): void => { map.ignoredLists.set(subject, object.value); }], // rdf:type and rdfs:subClassOf, to recognize a shape that is also a class (implicit class target) - if (quad.predicate.equals(RDF_VOCAB.terms.type)) { - addToSetMap(map.types, s, o); - return; - } - if (quad.predicate.equals(RDFS.terms.subClassOf)) { - addToSetMap(map.superClasses, s, o); - return; - } + [RDF_VOCAB.type, (map, subject, object): void => { addToSetMap(map.types, subject, object.value); }], + [RDFS.subClassOf, (map, subject, object): void => { addToSetMap(map.superClasses, subject, object.value); }], + // rdf:first and rdf:rest of the RDF lists + [RDF_VOCAB.first, (map, subject, object): void => { map.listFirst.set(subject, object.value); }], + [RDF_VOCAB.rest, (map, subject, object): void => { map.listRest.set(subject, object.value); }], +]); - // rdf:first / rdf:rest for RDF lists (used by sh:or / sh:xone) - if (quad.predicate.equals(RDF_VOCAB.terms.first)) { - map.listFirst.set(s, o); - return; - } - if (quad.predicate.equals(RDF_VOCAB.terms.rest)) { - map.listRest.set(s, o); - return; +function parseQuad(quad: RDF.Quad, map: IMapTripleShacl): void { + const subject = quad.subject.value; + const predicate = quad.predicate.value; + if (predicate.startsWith(SHACL.namespace)) { + addToSetMap(map.shaclPredicates, subject, predicate); } + QUAD_HANDLERS.get(predicate)?.(map, subject, quad.object); } function getOrCreateTargets(map: IMapTripleShacl, shapeIri: string): IShapeTargetSets { diff --git a/lib/shape-parser.ts b/lib/shape-parser.ts new file mode 100644 index 0000000..833855d --- /dev/null +++ b/lib/shape-parser.ts @@ -0,0 +1,77 @@ +import type * as RDFJS from '@rdfjs/types'; +import { SHACL, SHEX } from './constant'; +import { forEachQuad, type IShapeParserOptions, type ShapeFormat } from './parser-policy'; +import { type IShape, PoorlyFormatedShapeError, type ShapeError } from './Shape'; +import { shaclShapeFromQuads } from './shacl'; +import { shexShapeFromQuads } from './shex'; + +export interface IParseShapeFromQuadsOptions extends IShapeParserOptions { + format?: ShapeFormat | 'auto'; +} + +/** + * Unified parser entrypoint for SHACL and ShEx shape graphs. + */ +export async function parseShapeFromQuads( + quads: RDFJS.Stream | RDFJS.Quad[], + shapeIri: string, + options?: IParseShapeFromQuadsOptions, +): Promise { + // The format is detected from the whole graph, so a stream is collected first + const collected: RDFJS.Quad[] = []; + const error = await forEachQuad(quads, quad => collected.push(quad)); + if (error !== undefined) { + return error; + } + + const requestedFormat = options?.format ?? 'auto'; + const format = requestedFormat === 'auto' ? detectShapeFormat(collected) : requestedFormat; + if (format === undefined) { + return new PoorlyFormatedShapeError('Cannot detect shape format automatically. Please provide options.format.'); + } + return format === 'shacl' ? + shaclShapeFromQuads(collected, shapeIri, options) : + shexShapeFromQuads(collected, shapeIri, options); +} + +function detectShapeFormat(quads: RDFJS.Quad[]): ShapeFormat | undefined { + let shaclScore = 0; + let shexScore = 0; + + for (const quad of quads) { + if ( + quad.predicate.equals(SHACL.terms.property) + || quad.predicate.equals(SHACL.terms.path) + || quad.predicate.equals(SHACL.terms.or) + || quad.predicate.equals(SHACL.terms.xone) + || quad.predicate.equals(SHACL.terms.node) + || quad.predicate.equals(SHACL.terms.datatype) + || quad.predicate.equals(SHACL.terms.targetNode) + || quad.predicate.equals(SHACL.terms.targetClass) + || quad.predicate.equals(SHACL.terms.targetSubjectsOf) + || quad.predicate.equals(SHACL.terms.targetObjectsOf) + ) { + shaclScore++; + } + + if ( + quad.predicate.equals(SHEX.terms.expression) + || quad.predicate.equals(SHEX.terms.expressions) + || quad.predicate.equals(SHEX.terms.predicate) + || quad.predicate.equals(SHEX.terms.valueExpr) + || quad.object.equals(SHEX.terms.Shape) + || quad.object.equals(SHEX.terms.EachOf) + || quad.object.equals(SHEX.terms.OneOf) + ) { + shexScore++; + } + } + + if (shaclScore > shexScore) { + return 'shacl'; + } + if (shexScore > shaclScore) { + return 'shex'; + } + return undefined; +} diff --git a/lib/shex.ts b/lib/shex.ts index 7fb9757..bdc4c2a 100644 --- a/lib/shex.ts +++ b/lib/shex.ts @@ -10,7 +10,7 @@ import type { IConstraintFacets, } from './Shape'; import { PoorlyFormatedShapeError, tryCreateShape, buildConstraint, isNegativeCardinality } from './Shape'; -import { type IShapeParserOptions, readRdfList } from './parser-policy'; +import { forEachQuad, type IShapeParserOptions, readRdfList } from './parser-policy'; const DF = new DataFactory(); const RDF_TRUE = DF.literal('true', DF.namedNode(XSD.boolean)); @@ -22,80 +22,14 @@ const RDF_TRUE = DF.literal('true', DF.namedNode(XSD.boolean)); * @returns {Promise} The shape * @todo support for `OR` statement */ -export function shexShapeFromQuads( +export async function shexShapeFromQuads( quads: RDF.Stream | RDF.Quad[], shapeIri: string, options?: IShapeParserOptions, ): Promise { - if (Array.isArray(quads)) { - return new Promise(resolve => { - resolve(shapeFromQuadArray(quads, shapeIri, options)); - }); - } - return shapeFromQuadStream(quads, shapeIri, options); -} - -/** - * Parse a Shex shape from a quad stream - * @param {RDF.Stream} quads - Quads representing a shape - * @param {string} shapeIri - The iri of the desired shape - * @returns {Promise} The shape - */ -function shapeFromQuadStream( - quadSteam: RDF.Stream, - shapeIri: string, - options?: IShapeParserOptions, -): Promise { - const mapTripleShex: IMapTripleShex = defaultMapTripleShex(); - - return new Promise(resolve => { - quadSteam.on('data', (quad: RDF.Quad) => { - parseShapeQuads( - quad, - mapTripleShex, - ); - }); - - quadSteam.on('error', (error: any) => { - resolve(error); - }); - quadSteam.on('end', () => { - const shape = concatShapeInfo( - mapTripleShex, - shapeIri, - options, - ); - resolve(shape); - }); - }); -} - -/** - * Parse a Shex shape from an array of quad - * @param {RDF.Quad[]} quads - Quads representing a shape - * @param {string} shapeIri - The iri of the desired shape - * @returns {Promise} The shape - */ -function shapeFromQuadArray( - quads: RDF.Quad[], - shapeIri: string, - options?: IShapeParserOptions, -): IShape | ShapeError { - const mapTripleShex: IMapTripleShex = defaultMapTripleShex(); - - for (const quad of quads) { - parseShapeQuads( - quad, - mapTripleShex, - ); - } - - const shape = concatShapeInfo( - mapTripleShex, - shapeIri, - options, - ); - return shape; + const mapTripleShex = defaultMapTripleShex(); + const error = await forEachQuad(quads, quad => parseShapeQuads(quad, mapTripleShex)); + return error ?? concatShapeInfo(mapTripleShex, shapeIri, options); } /** diff --git a/test/shape-parser.test.ts b/test/shape-parser.test.ts index e37252b..640ea84 100644 --- a/test/shape-parser.test.ts +++ b/test/shape-parser.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'fs'; import type * as RDF from '@rdfjs/types'; import * as N3 from 'n3'; import { describe, expect, it } from 'vitest'; -import { parseShapeFromQuads } from '../lib/Shape'; +import { parseShapeFromQuads } from '../lib/shape-parser'; import type { IShape } from '../lib/Shape'; import type { ParserDiagnostic } from '../lib/parser-policy'; From 179e9f3d08030a965f9256f4dd6921d4dae59a96 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 12:25:01 +0000 Subject: [PATCH 20/22] refactor: build shape FILTER expressions with AlgebraFactory termExpression and operatorExpression hand-built expression objects without their `type` and cast them through `as unknown as`. Traqula's AlgebraFactory builds the same expressions as valid algebra. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J27pqtwzm8UXeEv14dUbCX --- lib/shapeToQuery.ts | 64 ++++++++++++++++----------------------------- 1 file changed, 22 insertions(+), 42 deletions(-) diff --git a/lib/shapeToQuery.ts b/lib/shapeToQuery.ts index bcdf7be..489056c 100644 --- a/lib/shapeToQuery.ts +++ b/lib/shapeToQuery.ts @@ -1,5 +1,5 @@ import type { Term } from '@rdfjs/types'; -import type { Algebra } from '@traqula/algebra-transformations-1-1'; +import { type Algebra, AlgebraFactory } from '@traqula/algebra-transformations-1-1'; import { DataFactory } from 'rdf-data-factory'; import { type ITripleWithDependencies, Triple, type IStarPatternWithDependencies, type ITriple } from './Triple'; import { ConstraintType, type IPredicate, type IShape } from './Shape'; @@ -8,6 +8,7 @@ import { focusNodes, type ITargetAlternative } from './target'; import type { IQuery, UnsupportedConstruct } from './query'; const DF = new DataFactory(); +const AF = new AlgebraFactory(); export interface IShapeToQueryOptions { linkedShapes?: IShape[] | Map; @@ -375,9 +376,9 @@ function addDatatypeFiltersFromConstraint( } const datatypeFilters = Array.from(constraint.value).map((datatypeIri) => - operatorExpression('=', [ - operatorExpression('datatype', [termExpression(object)]), - termExpression(DF.namedNode(datatypeIri)), + AF.createOperatorExpression('=', [ + AF.createOperatorExpression('datatype', [AF.createTermExpression(object)]), + AF.createTermExpression(DF.namedNode(datatypeIri)), ]), ); @@ -388,39 +389,39 @@ function addDatatypeFiltersFromConstraint( } if (constraint.minInclusive !== undefined) { - collector.push(operatorExpression('>=', [ - termExpression(object), - termExpression(numberLiteral(constraint.minInclusive)), + collector.push(AF.createOperatorExpression('>=', [ + AF.createTermExpression(object), + AF.createTermExpression(numberLiteral(constraint.minInclusive)), ])); } if (constraint.maxInclusive !== undefined) { - collector.push(operatorExpression('<=', [ - termExpression(object), - termExpression(numberLiteral(constraint.maxInclusive)), + collector.push(AF.createOperatorExpression('<=', [ + AF.createTermExpression(object), + AF.createTermExpression(numberLiteral(constraint.maxInclusive)), ])); } if (constraint.minExclusive !== undefined) { - collector.push(operatorExpression('>', [ - termExpression(object), - termExpression(numberLiteral(constraint.minExclusive)), + collector.push(AF.createOperatorExpression('>', [ + AF.createTermExpression(object), + AF.createTermExpression(numberLiteral(constraint.minExclusive)), ])); } if (constraint.maxExclusive !== undefined) { - collector.push(operatorExpression('<', [ - termExpression(object), - termExpression(numberLiteral(constraint.maxExclusive)), + collector.push(AF.createOperatorExpression('<', [ + AF.createTermExpression(object), + AF.createTermExpression(numberLiteral(constraint.maxExclusive)), ])); } if (constraint.pattern !== undefined) { const regexArgs: Algebra.Expression[] = [ - operatorExpression('str', [termExpression(object)]), - termExpression(DF.literal(constraint.pattern)), + AF.createOperatorExpression('str', [AF.createTermExpression(object)]), + AF.createTermExpression(DF.literal(constraint.pattern)), ]; if (constraint.flags !== undefined) { - regexArgs.push(termExpression(DF.literal(constraint.flags))); + regexArgs.push(AF.createTermExpression(DF.literal(constraint.flags))); } - collector.push(operatorExpression('regex', regexArgs)); + collector.push(AF.createOperatorExpression('regex', regexArgs)); } } @@ -430,7 +431,7 @@ function addDatatypeFiltersFromConstraint( function reduceOrExpressions(expressions: Algebra.Expression[]): Algebra.Expression { let current = expressions[0]; for (let i = 1; i < expressions.length; i++) { - current = operatorExpression('||', [current, expressions[i]]); + current = AF.createOperatorExpression('||', [current, expressions[i]]); } return current; } @@ -441,24 +442,3 @@ function reduceOrExpressions(expressions: Algebra.Expression[]): Algebra.Express function numberLiteral(value: number): Term { return DF.literal(String(value), DF.namedNode(XSD.decimal)); } - -/** - * Wrap an RDF term in the algebra expression shape expected by filter constructors. - */ -function termExpression(term: Term): Algebra.Expression { - return { - subType: 'term', - term, - } as unknown as Algebra.Expression; -} - -/** - * Construct a generic operator expression node for the algebra filter representation. - */ -function operatorExpression(operator: string, args: Algebra.Expression[]): Algebra.Expression { - return { - subType: 'operator', - operator, - args, - } as unknown as Algebra.Expression; -} From 924deec46b5ea7ccc2c9af7caf68a45fe15dc0fd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 12:26:43 +0000 Subject: [PATCH 21/22] refactor: walk the SPARQL algebra with one normalization state collectFromAlgebra took eight positional arguments and every handler took its own subset in its own order. INormalizationState held two of the seven accumulators, and default values meant a recursive call that left one out silently dropped what it collected. The state now holds the triples, VALUES bindings, unions, filters, discarded constructs and negations. collectFromAlgebra and buildQuery take it as their one argument, and createNormalizationState makes the branch built for one alternative of a property path, which shares the VALUES bindings of its query as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J27pqtwzm8UXeEv14dUbCX --- lib/query.ts | 199 +++++++++++++++++++++++---------------------------- 1 file changed, 89 insertions(+), 110 deletions(-) diff --git a/lib/query.ts b/lib/query.ts index b371188..16ec951 100644 --- a/lib/query.ts +++ b/lib/query.ts @@ -25,8 +25,35 @@ type NpsPath = Omit & { interface IAccumulatedTriples { triples: Map, isVariable: boolean } -/** Facts about the input discovered while walking the algebra, not carried by the star patterns. */ -interface INormalizationState { matchesAnyTriple: boolean, negatedPatterns: INegatedPattern[] } +/** What normalizing a query branch accumulates while walking its algebra. */ +interface INormalizationState { + // triple patterns by subject + triples: Map; + // VALUES bindings by variable name + values: Map; + unions: IQuery[][]; + filters: Algebra.Expression[]; + unsupported: Set; + // an unrestricted triple pattern (a variable predicate) was found + matchesAnyTriple: boolean; + negatedPatterns: INegatedPattern[]; +} + +/** + * An empty normalization state. The branch built for one alternative of a property path shares the + * VALUES bindings of the query it belongs to. + */ +function createNormalizationState(values = new Map()): INormalizationState { + return { + triples: new Map(), + values, + unions: [], + filters: [], + unsupported: new Set(), + matchesAnyTriple: false, + negatedPatterns: [], + }; +} /** * A query divided into star patterns @@ -102,30 +129,16 @@ function isVariableTerm(term: Term): boolean { * @todo add support for the bind operator */ export function generateQuery(algebraQuery: Algebra.Operation, optional?: boolean): IQuery { - const accumulatedTriples = new Map(); - // the binding value to the value - const accumulatedValues = new Map(); - const accumulatedUnion: IQuery[][] = []; - const accumulatedFilters: Algebra.Expression[] = []; - const accumulatedUnsupported = new Set(); - const state: INormalizationState = { matchesAnyTriple: false, negatedPatterns: [] }; - - QueryHandler.collectFromAlgebra(algebraQuery, accumulatedTriples, accumulatedValues, accumulatedUnion, accumulatedFilters, optional, accumulatedUnsupported, state); - - return buildQuery(accumulatedTriples, accumulatedValues, accumulatedUnion, accumulatedFilters, accumulatedUnsupported, state); + const state = createNormalizationState(); + QueryHandler.collectFromAlgebra(algebraQuery, state, optional); + return buildQuery(state); } /** * Finalize the accumulated parsing state into the query structure used by containment. */ -function buildQuery( - tripleArgs: Map, - values: Map, - accumulatedUnion: IQuery[][], - filters: Algebra.Expression[] = [], - unsupported: Set = new Set(), - state: INormalizationState = { matchesAnyTriple: false, negatedPatterns: [] }, -): IQuery { +function buildQuery(state: INormalizationState): IQuery { + const { triples: tripleArgs, values, unions: accumulatedUnion, filters, unsupported } = state; const innerQuery = new Map(); const resp: IQuery = { starPatterns: innerQuery }; if (state.matchesAnyTriple) { @@ -282,20 +295,15 @@ namespace QueryHandler { */ export function collectFromAlgebra( rootAlgebra: Algebra.Operation, - accumulatedTriples: Map, - accumulatedValues: Map, - accumulatedUnion: IQuery[][], - accumulatedFilters: Algebra.Expression[], + state: INormalizationState, optional?: boolean, - unsupported: Set = new Set(), - state: INormalizationState = { matchesAnyTriple: false, negatedPatterns: [] }, ): void { algebraUtils.visitOperation( rootAlgebra, { [Algebra.Types.PATTERN]: { preVisitor: () => ({ continue: false }), - visitor: handlePattern(accumulatedTriples, optional, () => { state.matchesAnyTriple = true; }), + visitor: handlePattern(state, optional), }, // MINUS removes solutions and SERVICE evaluates elsewhere, so neither describes data the // candidate resource must hold. Their triple patterns must not be collected as required @@ -303,30 +311,30 @@ namespace QueryHandler { // pattern is kept as a negated triple pattern instead. [Algebra.Types.MINUS]: { preVisitor: () => ({ continue: false }), - visitor: handleMinus(accumulatedTriples, accumulatedValues, accumulatedFilters, accumulatedUnion, unsupported, state, optional), + visitor: handleMinus(state, optional), }, [Algebra.Types.SERVICE]: { preVisitor: () => ({ continue: false }), - visitor: (): void => { unsupported.add('SERVICE'); }, + visitor: (): void => { state.unsupported.add('SERVICE'); }, }, [Algebra.Types.VALUES]: { preVisitor: () => ({ continue: false }), - visitor: handleValues(accumulatedValues), + visitor: handleValues(state.values), }, [Algebra.Types.UNION]: { preVisitor: () => ({ continue: false }), - visitor: handleUnion(accumulatedUnion, unsupported, optional), + visitor: handleUnion(state, optional), }, [Algebra.Types.LEFT_JOIN]: { preVisitor: () => ({ continue: false }), - visitor: handleLeftJoin(accumulatedTriples, accumulatedValues, accumulatedFilters, accumulatedUnion, unsupported, state), + visitor: handleLeftJoin(state), }, [Algebra.Types.PATH]: { preVisitor: () => ({ continue: false }), - visitor: handlePropertyPath(accumulatedTriples, accumulatedUnion, accumulatedValues, optional), + visitor: handlePropertyPath(state, optional), }, [Algebra.Types.FILTER]: { - visitor: handleFilter(accumulatedFilters, unsupported, state, optional), + visitor: handleFilter(state, optional), // Ignore the expression subtree (e.g. FILTER NOT EXISTS) to avoid collecting inner // patterns that belong to the filter condition, not the query body. preVisitor: () => ({ ignoreKeys: new Set(['expression']) }), @@ -340,34 +348,23 @@ namespace QueryHandler { * candidate resource has to hold: it becomes a negated triple pattern when it is a single triple * pattern of the supported form, and is discarded otherwise. */ - function handleMinus(accumulatedTriples: Map, - accumulatedValues: Map, - accumulatedFilters: Algebra.Expression[], - accumulatedUnion: IQuery[][], - unsupported: Set, - state: INormalizationState, - optional?: boolean): (element: Algebra.Minus) => void { + function handleMinus(state: INormalizationState, optional?: boolean): (element: Algebra.Minus) => void { return (element: Algebra.Minus): void => { const [left, right] = element.input; const negatedPattern = optional === true ? undefined : toNegatedPattern(right, left); if (negatedPattern !== undefined) { state.negatedPatterns.push({ ...negatedPattern, construct: 'MINUS' }); } else { - unsupported.add('MINUS'); + state.unsupported.add('MINUS'); } - collectFromAlgebra(left, accumulatedTriples, accumulatedValues, accumulatedUnion, accumulatedFilters, optional, unsupported, state); + collectFromAlgebra(left, state, optional); } } /** * Split a LEFT JOIN into required and optional branches and collect both with the right optional flag. */ - function handleLeftJoin(accumulatedTriples: Map, - accumulatedValues: Map, - accumulatedFilters: Algebra.Expression[], - accumulatedUnion: IQuery[][], - unsupported: Set, - state: INormalizationState): (element: Algebra.LeftJoin) => void { + function handleLeftJoin(state: INormalizationState): (element: Algebra.LeftJoin) => void { return (element: Algebra.LeftJoin): void => { const joinElement = element.input; const requiredElements = joinElement[0]; @@ -376,10 +373,10 @@ namespace QueryHandler { // A filter of the OPTIONAL group only decides whether the optional part binds; it is not // evaluated, but an EXISTS or NOT EXISTS inside it is still reported as discarded. if (element.expression !== undefined) { - reportExistence(element.expression, unsupported); + reportExistence(element.expression, state.unsupported); } - collectFromAlgebra(requiredElements, accumulatedTriples, accumulatedValues, accumulatedUnion, accumulatedFilters, undefined, unsupported, state); - collectFromAlgebra(optionalElements, accumulatedTriples, accumulatedValues, accumulatedUnion, accumulatedFilters, true, unsupported, state); + collectFromAlgebra(requiredElements, state); + collectFromAlgebra(optionalElements, state, true); } } @@ -389,10 +386,7 @@ namespace QueryHandler { * pattern; any other `EXISTS` or `NOT EXISTS` stays in the expression, where it is undecidable, * and is reported as discarded. */ - function handleFilter(accumulatedFilters: Algebra.Expression[], - unsupported: Set, - state: INormalizationState, - optional?: boolean): (element: Algebra.Filter) => void { + function handleFilter(state: INormalizationState, optional?: boolean): (element: Algebra.Filter) => void { return (element: Algebra.Filter): void => { const negatedInput = getNegatedExistenceInput(element.expression); const negatedPattern = negatedInput === undefined || optional === true ? @@ -402,8 +396,8 @@ namespace QueryHandler { state.negatedPatterns.push({ ...negatedPattern, construct: 'NOT EXISTS' }); return; } - reportExistence(element.expression, unsupported); - accumulatedFilters.push(element.expression); + reportExistence(element.expression, state.unsupported); + state.filters.push(element.expression); } } @@ -411,30 +405,25 @@ namespace QueryHandler { /** * Dispatch property-path parsing to direct, cardinality, negated-set, or alternative handlers. */ - function handlePropertyPath( - accumulatedTriples: Map, - accumulatedUnion: IQuery[][], - accumulatedValues: Map, - optional?: boolean, - ): (element: Algebra.Path) => void { + function handlePropertyPath(state: INormalizationState, optional?: boolean): (element: Algebra.Path) => void { return (element: Algebra.Path): void => { const path = element.predicate.type; if (element.predicate.type === Algebra.Types.ALT) { - accumulatedUnion.push( + state.unions.push( handleAltPropertyPath( element.subject, element.predicate.input, element.object, - accumulatedValues, + state.values, optional ) ); } else if (isCardinalityPropertyPath(element.predicate)) { const triple = handleCardinalityPropertyPath(element as CardinalityPath, optional); - handleDirectPropertyPath(element, accumulatedTriples, triple); + handleDirectPropertyPath(element, state.triples, triple); } else if (path === Algebra.Types.NPS) { const triple = handleNegatedPropertySet(element as NpsPath, optional); - handleDirectPropertyPath(element, accumulatedTriples, triple); + handleDirectPropertyPath(element, state.triples, triple); } }; } @@ -442,7 +431,7 @@ namespace QueryHandler { /** * Convert a SPARQL UNION into a list of independently generated query branches. */ - function handleUnion(accumulatedUnion: IQuery[][], unsupported: Set, optional?: boolean): (element: Algebra.Union) => void { + function handleUnion(state: INormalizationState, optional?: boolean): (element: Algebra.Union) => void { return (element: Algebra.Union): void => { const branches: Algebra.Operation[] = element.input; const currentUnion: IQuery[] = []; @@ -450,11 +439,11 @@ namespace QueryHandler { const branchQuery = generateQuery(branch, optional); // A construct discarded inside a branch is discarded from the query as a whole. for (const construct of branchQuery.unsupported ?? []) { - unsupported.add(construct); + state.unsupported.add(construct); } currentUnion.push(branchQuery); } - accumulatedUnion.push(currentUnion); + state.unions.push(currentUnion); }; } @@ -480,11 +469,7 @@ namespace QueryHandler { /** * Convert a basic triple pattern into the internal Triple representation keyed by subject. */ - function handlePattern( - accumulatedTriples: Map, - optional?: boolean, - wildcard?: () => void, - ): (element: Algebra.Pattern) => void { + function handlePattern(state: INormalizationState, optional?: boolean): (element: Algebra.Pattern) => void { return (quad: Algebra.Pattern): void => { const subject = quad.subject as Term; const predicate = quad.predicate as Term; @@ -492,22 +477,21 @@ namespace QueryHandler { if (predicate.termType !== 'NamedNode') { // Shapes constrain named predicates, so an unrestricted pattern yields no star pattern. // It still asks for every triple of the resource, which is recorded separately. - wildcard?.(); + state.matchesAnyTriple = true; + return; } - if (predicate.termType === 'NamedNode') { - const startPattern = accumulatedTriples.get(subject.value); - const triple: ITriple = new Triple({ - subject: subject.value, - predicate: quad.predicate.value, - object, - isOptional: optional - }); - if (startPattern === undefined) { - accumulatedTriples.set(subject.value, - { triples: new Map([[triple.toString(), triple]]), isVariable: isVariableTerm(subject) }); - } else { - startPattern.triples.set(triple.toString(), triple); - } + const startPattern = state.triples.get(subject.value); + const triple: ITriple = new Triple({ + subject: subject.value, + predicate: quad.predicate.value, + object, + isOptional: optional + }); + if (startPattern === undefined) { + state.triples.set(subject.value, + { triples: new Map([[triple.toString(), triple]]), isVariable: isVariableTerm(subject) }); + } else { + startPattern.triples.set(triple.toString(), triple); } }; } @@ -622,9 +606,7 @@ namespace QueryHandler { */ function handleSeqPath( element: Algebra.Seq, - accumulatedValues: Map, - accumulatedTriples: Map, - accumulatedUnion: IQuery[][], + state: INormalizationState, subject: Term, object: Term, optional?: boolean): void { @@ -639,11 +621,11 @@ namespace QueryHandler { object : DF.blankNode(`${(predicates[0]).iri.value}_${subject.value}`); if (path.type === Algebra.Types.LINK) { - handleLink(path, accumulatedTriples, currentSubject, currentObject, undefined, optional); + handleLink(path, state.triples, currentSubject, currentObject, undefined, optional); } else if (path.type === Algebra.Types.NPS) { - handleNegatedPropertyLink(path, accumulatedTriples, currentSubject, currentObject, optional) + handleNegatedPropertyLink(path, state.triples, currentSubject, currentObject, optional) } else if (path.type === Algebra.Types.ALT) { - accumulatedUnion.push(handleAltPropertyPath(currentSubject, path.input, currentObject, accumulatedValues, optional)); + state.unions.push(handleAltPropertyPath(currentSubject, path.input, currentObject, state.values, optional)); } } @@ -659,12 +641,9 @@ namespace QueryHandler { object: Term, optional?: boolean ): IQuery { - const accumulatedTriples = new Map(); - const accumulatedUnion: IQuery[][] = []; - - handleSeqPath(element, accumulatedValues, accumulatedTriples, accumulatedUnion, subject, object, optional); - - return buildQuery(accumulatedTriples, accumulatedValues, accumulatedUnion); + const branch = createNormalizationState(accumulatedValues); + handleSeqPath(element, branch, subject, object, optional); + return buildQuery(branch); } /** @@ -717,9 +696,9 @@ namespace QueryHandler { cardinality?: ICardinality, optional?: boolean, ): IQuery { - const accumulatedTriples = new Map(); - handleLink(element, accumulatedTriples, subject, object, cardinality, optional); - return buildQuery(accumulatedTriples, accumulatedValues, []); + const branch = createNormalizationState(accumulatedValues); + handleLink(element, branch.triples, subject, object, cardinality, optional); + return buildQuery(branch); } /** @@ -764,9 +743,9 @@ namespace QueryHandler { object: Term, optional?: boolean, ): IQuery { - const accumulatedTriples = new Map(); - handleNegatedPropertyLink(element, accumulatedTriples, subject, object, optional); - return buildQuery(accumulatedTriples, accumulatedValues, []); + const branch = createNormalizationState(accumulatedValues); + handleNegatedPropertyLink(element, branch.triples, subject, object, optional); + return buildQuery(branch); } /** From 896b9d72498cb1a80a3ec9e89d14362cecbd61de Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 12:28:27 +0000 Subject: [PATCH 22/22] chore: fix the eslint errors `npx eslint .` reported 14 errors and 2 warnings; it now reports none. The containment tests also use the real IResult instead of an alias that left out `result`, which no longer type-checked once the expectations assert the aggregate result. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J27pqtwzm8UXeEv14dUbCX --- lib/constant.ts | 8 ++------ lib/containment.ts | 2 +- lib/filter-compatibility.ts | 4 ++-- lib/query.ts | 4 ++-- test/Shape.test.ts | 4 ++-- test/containment.test.ts | 19 +++++++++++-------- test/query.test.ts | 1 - 7 files changed, 20 insertions(+), 22 deletions(-) diff --git a/lib/constant.ts b/lib/constant.ts index 41ebd9c..e139a72 100644 --- a/lib/constant.ts +++ b/lib/constant.ts @@ -4,17 +4,13 @@ import { createUriAndTermNamespace } from '@treecg/types'; type TermVocabulary = { namespace: NamedNode; custom: (input: string) => NamedNode; -} & { - [K in T]: NamedNode; -}; +} & Record>; type Vocabulary = { namespace: string; custom: (input: string) => string; terms: TermVocabulary; -} & { - [K in T]: string; -}; +} & Record; function createVocabulary( baseUri: string, diff --git a/lib/containment.ts b/lib/containment.ts index cf56800..558fd98 100644 --- a/lib/containment.ts +++ b/lib/containment.ts @@ -269,7 +269,7 @@ function findDisjunctContainment(starPatterns: IStarPatternWithDependencies[], g return haveContainment; } -function getNestedMatchingTargets(starPattern: IStarPatternWithDependencies, classificationStats: Map, visited: Set = new Set()): Set { +function getNestedMatchingTargets(starPattern: IStarPatternWithDependencies, classificationStats: Map, visited = new Set()): Set { // Include shapes that only become reachable through nested star-pattern dependencies. const nestedNames = getNestedDependencyNames(starPattern, visited); const nestedTargets = new Set(); diff --git a/lib/filter-compatibility.ts b/lib/filter-compatibility.ts index 5fe28b4..16e844f 100644 --- a/lib/filter-compatibility.ts +++ b/lib/filter-compatibility.ts @@ -550,7 +550,7 @@ function intersectBounds(left: INumericBounds, right: INumericBounds): INumericB function mergeLowerBound(inclusive: number | undefined, inclusiveExclusive: boolean, exclusive: number | undefined, exclusiveExclusive: boolean): { value: number; exclusive: boolean } | undefined { // Pick the tighter lower bound from inclusive/exclusive candidates. - const candidates: Array<{ value: number; exclusive: boolean }> = []; + const candidates: { value: number; exclusive: boolean }[] = []; if (inclusive !== undefined) { candidates.push({ value: inclusive, exclusive: inclusiveExclusive }); } @@ -565,7 +565,7 @@ function mergeLowerBound(inclusive: number | undefined, inclusiveExclusive: bool function mergeUpperBound(inclusive: number | undefined, inclusiveExclusive: boolean, exclusive: number | undefined, exclusiveExclusive: boolean): { value: number; exclusive: boolean } | undefined { // Pick the tighter upper bound from inclusive/exclusive candidates. - const candidates: Array<{ value: number; exclusive: boolean }> = []; + const candidates: { value: number; exclusive: boolean }[] = []; if (inclusive !== undefined) { candidates.push({ value: inclusive, exclusive: inclusiveExclusive }); } diff --git a/lib/query.ts b/lib/query.ts index 16ec951..5c763bc 100644 --- a/lib/query.ts +++ b/lib/query.ts @@ -611,14 +611,14 @@ namespace QueryHandler { object: Term, optional?: boolean): void { const predicates = element.input; - let currentObject: Term = DF.blankNode(`${(predicates[0]).iri.value}_${subject.value}`); + let currentObject: Term = DF.blankNode(`${(predicates[0] as Algebra.Link).iri.value}_${subject.value}`); for (let i = 0; i < predicates.length; i++) { const path = predicates[i]; const currentSubject: Term = i === 0 ? subject : currentObject; currentObject = i === predicates.length - 1 ? - object : DF.blankNode(`${(predicates[0]).iri.value}_${subject.value}`); + object : DF.blankNode(`${(predicates[0] as Algebra.Link).iri.value}_${subject.value}`); if (path.type === Algebra.Types.LINK) { handleLink(path, state.triples, currentSubject, currentObject, undefined, optional); diff --git a/test/Shape.test.ts b/test/Shape.test.ts index faa60de..419ff4e 100644 --- a/test/Shape.test.ts +++ b/test/Shape.test.ts @@ -277,7 +277,7 @@ describe('Shape', () => { const shape = new Shape({ name: 'foo', positivePredicates, negativePredicates }); it('should get all predicates', () => { - // eslint-disable-next-line @typescript-eslint/require-array-sort-compare + const expectedPredicates = [ { name: 'a' }, { @@ -295,7 +295,7 @@ describe('Shape', () => { { name: 'f', negative: true }, ].sort(); - // eslint-disable-next-line @typescript-eslint/require-array-sort-compare + expect(shape.getAll().sort()).toStrictEqual(expectedPredicates); }); diff --git a/test/containment.test.ts b/test/containment.test.ts index a585b91..f397aaa 100644 --- a/test/containment.test.ts +++ b/test/containment.test.ts @@ -3,7 +3,7 @@ import { toAlgebra } from '@traqula/algebra-sparql-1-1'; import { describe, expect, it, test } from 'vitest'; import { ConstraintType, IShape, Shape } from '../lib/Shape'; import { IStarPatternWithDependencies, Triple } from '../lib/Triple'; -import { ContainmentResult, EmptyQueryError, IContainmentResult, IResult as ContainmentReport, StarPatternName, solveShapeQueryContainment, solveShapeShapeContainment } from '../lib/containment'; +import { ContainmentResult, EmptyQueryError, IContainmentResult, IResult, StarPatternName, solveShapeQueryContainment, solveShapeShapeContainment } from '../lib/containment'; import { DataFactory } from 'rdf-data-factory'; import { BaseQuad } from '@rdfjs/types'; import { IQuery, generateQuery } from '../lib/query'; @@ -19,7 +19,6 @@ import { shaclShapeFromQuads } from '../lib/shacl'; const DF = new DataFactory(); const n3Parser = new N3.Parser(); const sparqlParser = new SPARQLParser(); -type IResult = Omit; async function parseShaclShape(quads: RDF.Quad[], name: string): Promise { const parsed = await shaclShapeFromQuads(quads, name); @@ -2111,7 +2110,7 @@ describe('supported profile and explicit negation', () => { a sh:NodeShape ; sh:property [ sh:path ex:city ], [ sh:path ex:zip ; sh:minCount 1 ] .`; - async function report(rawQuery: string): Promise { + async function report(rawQuery: string): Promise { const query = generateQuery(toAlgebra(new SPARQLParser().parse(`${P} ${rawQuery}`))); const quads = new N3.Parser().parse(shapes); const person = await parseShaclShape(quads, 'http://example.org/PersonShape'); @@ -2163,7 +2162,7 @@ describe('supported profile and explicit negation', () => { describe('queries that ask for every triple', () => { // `?s ?p ?o` is maximally unselective rather than unanswerable: it asks for every triple of // the resource, so any resource is relevant and the honest result is CONTAINED. - async function report(rawQuery: string) { + async function report(rawQuery: string): Promise<{ query: IQuery; result: IResult }> { const query = generateQuery(toAlgebra(new SPARQLParser().parse(`${P} ${rawQuery}`))); const candidate = await parseShaclShape(new N3.Parser().parse(closedNameShape), 'http://example.org/S'); return { query, result: solveShapeQueryContainment({ query, shapes: [candidate] }) }; @@ -2322,7 +2321,7 @@ describe('nested dependencies and partial matches', () => { // ex:PersonShape is closed and declares foaf:knows, constrained by sh:node ex:FriendShape. // Whether the nested pattern is refuted depends on ex:FriendShape: closed, it forbids anything // it does not declare; open, it is merely silent. - const shapesTtl = (friendClosed: boolean) => ` + const shapesTtl = (friendClosed: boolean): string => ` @prefix sh: . @prefix foaf: . @prefix ex: . @@ -2338,7 +2337,11 @@ describe('nested dependencies and partial matches', () => { const NOT_DECLARED = 'SELECT * WHERE { ?p foaf:knows ?f . ?f foaf:mbox ?m }'; const DECLARED = 'SELECT * WHERE { ?p foaf:knows ?f . ?f foaf:name ?n }'; - async function classify(rawQuery: string, friendClosed: boolean, asDependency: boolean) { + async function classify( + rawQuery: string, + friendClosed: boolean, + asDependency: boolean, + ): Promise<{ root: ContainmentResult; nested: ContainmentResult; aggregate: ContainmentResult }> { const query = generateQuery(toAlgebra(new SPARQLParser().parse(`${PREFIXES} ${rawQuery}`))); const quads = new N3.Parser().parse(shapesTtl(friendClosed)); const person = await parseShaclShape(quads, 'http://example.org/PersonShape'); @@ -2463,7 +2466,7 @@ describe('role filtering with decidingShapes', () => { return shapes; } - async function solve(userShape: string, placement: 'deciding' | 'unfiltered' | 'dependent' | 'dropped'): Promise { + async function solve(userShape: string, placement: 'deciding' | 'unfiltered' | 'dependent' | 'dropped'): Promise { const [response, request, address] = await parseShapes(serviceTtl, SERVICE_SHAPES); const userShapes = await parseShapes(userTtl, USER_SHAPES); const sourceShape = userShapes.find(shape => shape.name === userShape)!; @@ -2480,7 +2483,7 @@ describe('role filtering with decidingShapes', () => { } } - function summarize(report: ContainmentReport): { aggregate: ContainmentResult; starPatterns: Map; visit: Map } { + function summarize(report: IResult): { aggregate: ContainmentResult; starPatterns: Map; visit: Map } { return { aggregate: report.result, starPatterns: new Map(Array.from(report.starPatternsContainment, ([name, { result }]) => [name, result])), diff --git a/test/query.test.ts b/test/query.test.ts index b9601ab..a37da85 100644 --- a/test/query.test.ts +++ b/test/query.test.ts @@ -26,7 +26,6 @@ describe('query', () => { const x = resp.starPatterns.get('x')!.starPattern; expect(x).toBeDefined(); const element = x.get('http://exemple.ca')!; - element.triple expect(element.triple.predicate).toBe('http://exemple.ca'); expect((element.triple.object as any).termType).toBe('Variable'); expect((element.triple.object as any).value).toBe('z');