diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000..628fe89 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,3 @@ +{ + "extends": ["github>smessie/renovate-presets:js"] +} diff --git a/README.md b/README.md index 64178cc..e633f71 100644 --- a/README.md +++ b/README.md @@ -86,16 +86,17 @@ const personShape = await shexShapeFromQuads(shexQuads, "http://example.org/Pers The library returns a report where each star pattern is assigned one of the following `ContainmentResult` values: -| Result | Description | -| :------------- | :----------------------------------------------------------- | -| **`CONTAIN`** | All triple patterns in the graph star pattern are covered by the shape's constraints. | -| **`ALIGNED`** | At least one triple pattern matches, but some parts of the graph star pattern are not covered. | -| **`DEPEND`** | The pattern is reachable via a property that links to another shape (nested containment). | -| **`REJECTED`** | No part of the star pattern matches any property defined in the shape. | +| Result | Description | +| :----------------- | :---------- | +| **`CONTAINED`** | All query star patterns, including nested ones, are matched by the shape. | +| **`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. | ### Examples of Containment Results -#### 1. `CONTAIN` +#### 1. `CONTAINED` The star pattern for `?person` is fully covered by the shape. @@ -120,9 +121,44 @@ The star pattern for `?person` is fully covered by the shape. sh:property [ sh:path foaf:mbox ] . ``` +Another `CONTAINED` case combines a FILTER expression with compatible shape constraints. + +* **Query**: + + ```sparql + PREFIX ex: + PREFIX xsd: + + SELECT * WHERE { + ?person ex:age ?age . + FILTER(?age > 18) + } + ``` + +* **Shape**: + + ```turtle + @prefix sh: . + @prefix xsd: . + + a sh:NodeShape ; + sh:closed true ; + sh:property [ + sh:path ; + sh:datatype xsd:integer ; + sh:minInclusive 18 ; + sh:maxInclusive 35 + ] . + ``` + +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. + #### 2. `ALIGNED` -The query matches one property (`foaf:name`), but contains `ex:age` which is not defined in the closed shape. +The query matches one property (`foaf:name`), but contains `ex:age` which is not defined in the open shape. * **Query**: @@ -139,20 +175,70 @@ The query matches one property (`foaf:name`), but contains `ex:age` which is not ```turtle a sh:NodeShape ; - sh:closed true ; sh:property [ sh:path foaf:name ] . ``` -#### 3. `DEPEND` +Another `ALIGNED` case with FILTER and constraints: + +* **Query**: + + ```sparql + PREFIX ex: + PREFIX xsd: + SELECT * WHERE { + ?person ex:age ?age ; + ex:nickname ?nick . + FILTER(?age > 18) + } + ``` -The `?person` pattern matches the shape's link to another shape. Its full containment depends on whether `?friend` also matches its shape. +* **Shape**: + + ```turtle + @prefix sh: . + @prefix xsd: . + + a sh:NodeShape ; + sh:property [ + sh:path ; + sh:datatype xsd:integer + ] . + ``` + +`ex:age` aligns and the numeric FILTER is compatible with the datatype constraint, while `ex:nickname` is not constrained by this open shape. + +#### 3. `UNALINGED` + +The root star pattern has partial matching triples against closed shapes. * **Query**: ```sparql PREFIX foaf: + PREFIX ex: SELECT * WHERE { - ?person foaf:knows ?friend . + ?person foaf:name ?name ; + ex:age ?age . + } + ``` + +* **Shape**: + + ```turtle + a sh:NodeShape ; + sh:closed true ; + sh:property [ sh:path foaf:name ] . + ``` + +Another `UNALINGED` case is when the root star pattern does not match, but a nested star pattern (reachable through a linked variable) does on a open or closed shape. + +* **Query**: + + ```sparql + PREFIX ex: + PREFIX foaf: + SELECT * WHERE { + ?person ex:unknownLink ?friend . ?friend foaf:name ?friendName . } ``` @@ -161,16 +247,93 @@ The `?person` pattern matches the shape's link to another shape. Its full contai ```turtle a sh:NodeShape ; - sh:property [ - sh:path foaf:knows ; - sh:node + sh:property [ + sh:path foaf:knows ; + sh:node ] . - + a sh:NodeShape ; sh:property [ sh:path foaf:name ] . ``` -#### 4. `REJECTED` +Another `UNALINGED` case with FILTER and constraints: + +* **Query**: + + ```sparql + PREFIX ex: + PREFIX xsd: + SELECT * WHERE { + ?person ex:age ?age ; + ex:status ?status . + FILTER(?age > 18) + } + ``` + +* **Shape**: + + ```turtle + @prefix sh: . + @prefix xsd: . + + a sh:NodeShape ; + sh:closed true ; + sh:property [ + sh:path ; + sh:datatype xsd:integer + ] . + ``` + +On a closed shape, `ex:age` matches but `ex:status` does not, so containment is partial (`UNALINGED`) even though the FILTER is constraint-compatible. + +#### 4. `WEAKLY_REJECTED` + +No triple pattern matches and at least one candidate shape is open. + +* **Query**: + + ```sparql + PREFIX schema: + SELECT * WHERE { + ?person schema:birthDate ?date . + } + ``` + +* **Shape**: + + ```turtle + a sh:NodeShape ; + sh:property [ sh:path foaf:name ] . + ``` + +Another `WEAKLY_REJECTED` case with FILTER and constraints: + +* **Query**: + + ```sparql + PREFIX ex: + SELECT * WHERE { + ?person ex:age ?age . + FILTER(?age > 18) + } + ``` + +* **Shape**: + + ```turtle + @prefix sh: . + @prefix xsd: . + + a sh:NodeShape ; + sh:property [ + sh:path ; + sh:datatype xsd:string + ] . + ``` + +The numeric FILTER contradicts the string datatype constraint. Because the shape is open, the result is `WEAKLY_REJECTED`. + +#### 5. `REJECTED` The query uses `schema:birthDate`, but the shape only defines `foaf:name`. @@ -187,14 +350,73 @@ The query uses `schema:birthDate`, but the shape only defines `foaf:name`. ```turtle a sh:NodeShape ; + sh:closed true ; sh:property [ sh:path foaf:name ] . ``` +Another `REJECTED` case with FILTER and constraints: + +* **Query**: + + ```sparql + PREFIX ex: + SELECT * WHERE { + ?person ex:age ?age . + FILTER(?age > 18) + } + ``` + +* **Shape**: + + ```turtle + @prefix sh: . + @prefix xsd: . + + a sh:NodeShape ; + sh:closed true ; + sh:property [ + sh:path ; + sh:datatype xsd:string + ] . + ``` + +The same numeric FILTER/constraint contradiction on a closed shape yields `REJECTED`. + +Another `REJECTED` case due to min/max value constraints: + +* **Query**: + + ```sparql + PREFIX ex: + SELECT * WHERE { + ?person ex:age ?age . + FILTER(?age > 35) + } + ``` + +* **Shape**: + + ```turtle + @prefix sh: . + @prefix xsd: . + + a sh:NodeShape ; + sh:closed true ; + sh:property [ + sh:path ; + sh:datatype xsd:integer ; + sh:minInclusive 18 ; + sh:maxInclusive 35 + ] . + ``` + +The FILTER range (`> 35`) conflicts with the shape range (`18..35`), so the result is `REJECTED`. + ## SPARQL Limitations The detection logic is focused on **Triple Patterns** and **Star Patterns**. Currently, the following SPARQL features are not (yet) supported: -- **Filter Expressions**: Logic inside `FILTER` clauses is not checked against shape constraints. +- **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. - **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. diff --git a/lib/Binding.ts b/lib/Binding.ts index 75205c1..c403e9d 100644 --- a/lib/Binding.ts +++ b/lib/Binding.ts @@ -1,5 +1,6 @@ -import { ConstraintType, IContraint, IPredicate, IShape, OneOfPathIndexed } from "./Shape"; +import { ConstraintType, IConstraint, IPredicate, IShape, OneOfPathIndexed } from "./Shape"; import { IStarPatternWithDependencies, type ITriple, Triple } from "./Triple"; +import { TYPE_DEFINITION } from "./constant"; /** * A binding from a query to a shape @@ -114,10 +115,6 @@ export class Bindings implements IBindings { } const negatedTriples: ITriple[] = []; for (const { triple, dependencies } of starPattern.starPattern.values()) { - if (!this.closedShape) { - this.bindings.set(triple.predicate, triple); - continue; - } if (triple.predicate === Triple.NEGATIVE_PREDICATE_SET) { negatedTriples.push(triple); continue; @@ -132,17 +129,19 @@ export class Bindings implements IBindings { } } - if (singlePredicate === undefined && - predicates.length === 0 - && triple.isOptional !== true && - !this.strict) { - this.unboundTriple.push(triple); - } else { - if (singlePredicate !== undefined) { - predicates.push(singlePredicate); + 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) { + this.unboundTriple.push(triple); } - this.evaluateConstraint(predicates, triple, linkedShape, shape, dependencies); + continue; + } + + if (singlePredicate !== undefined) { + predicates.push(singlePredicate); } + this.evaluateConstraint(predicates, triple, linkedShape, shape, dependencies); @@ -169,7 +168,22 @@ export class Bindings implements IBindings { const uncontainedUnionStarPatterns: IStarPatternWithDependencies[] = [] if (shape.closed === false) { - this.fullyBounded = starPattern.starPattern.size !== 0; + 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.getBoundTriple().length === starPattern.starPattern.size + && starPattern.starPattern.size !== 0 + && boundedUnion; } else { let boundedUnion = true; for (const unionBinding of this.unionBindings) { @@ -249,7 +263,7 @@ export class Bindings implements IBindings { continue; } - const typeConstraintResult = Bindings.handleShapeType(constraint, triple); + const typeConstraintResult = Bindings.handleTypeConstraint(constraint, triple); if (typeConstraintResult === ConstraintResult.NOT_RESPECT) { validConstraint = validConstraint || false; continue; @@ -303,7 +317,7 @@ export class Bindings implements IBindings { } private handleShapeConstraint( - constraint: IContraint, + constraint: IConstraint, triple: ITriple, linkedShape: Map, currentShape: IShape, @@ -338,44 +352,119 @@ export class Bindings implements IBindings { return ConstraintResult.INAPPLICABLE; } - private static handleShapeType( - constraint: IContraint, + private static handleTypeConstraint( + constraint: IConstraint, triple: ITriple): ConstraintResult { - if (constraint.type === ConstraintType.TYPE && - !Array.isArray(triple.object) && - triple.object.termType === "Literal" - && constraint.value.has(triple.object.datatype.value) - ) { - return ConstraintResult.RESPECT; - } else if (constraint.type === ConstraintType.TYPE && + if (constraint.type === ConstraintType.CLASS && + triple.predicate === TYPE_DEFINITION.value && !Array.isArray(triple.object) && triple.object.termType === "NamedNode" && constraint.value.has(triple.object.value)) { return ConstraintResult.RESPECT; - } else if (constraint.type === ConstraintType.TYPE && + } else if (constraint.type === ConstraintType.CLASS && + triple.predicate === TYPE_DEFINITION.value && !Array.isArray(triple.object) && - triple.object.termType === "Literal" - && !constraint.value.has(triple.object.datatype.value)) { + 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 && + Array.isArray(triple.object)) { + for (const object of triple.object) { + if (constraint.value.has(object.value)) { + return ConstraintResult.RESPECT; + } + } + return ConstraintResult.NOT_RESPECT; + } else if (constraint.type === ConstraintType.CLASS && triple.predicate !== TYPE_DEFINITION.value) { return ConstraintResult.NOT_RESPECT; } - else if (constraint.type === ConstraintType.TYPE && + + if (constraint.type === ConstraintType.DATATYPE && !Array.isArray(triple.object) && - triple.object.termType === "NamedNode" - && !constraint.value.has(triple.object.value)) { + triple.object.termType === "Literal" + && constraint.value.has(triple.object.datatype.value) + ) { + if (!Bindings.respectNumericFacets(constraint, triple.object.value)) { + return ConstraintResult.NOT_RESPECT; + } + if (!Bindings.respectPatternFacet(constraint, triple.object.value)) { + return ConstraintResult.NOT_RESPECT; + } + return ConstraintResult.RESPECT; + } else if (constraint.type === ConstraintType.DATATYPE && + !Array.isArray(triple.object) && + triple.object.termType === "Literal" + && !constraint.value.has(triple.object.datatype.value)) { return ConstraintResult.NOT_RESPECT; - } else if (constraint.type === ConstraintType.TYPE && + } else if (constraint.type === ConstraintType.DATATYPE && Array.isArray(triple.object)) { + let hasDatatypeMatch = false; for (const object of triple.object) { - if (constraint.value.has(object.value) || (object.termType === "Literal" && constraint.value.has(object.datatype.value))) { + if (object.termType === "Literal" && constraint.value.has(object.datatype.value)) { + hasDatatypeMatch = true; + if (!Bindings.respectNumericFacets(constraint, object.value)) { + continue; + } + if (!Bindings.respectPatternFacet(constraint, object.value)) { + continue; + } return ConstraintResult.RESPECT; } } + if (hasDatatypeMatch) { + return ConstraintResult.NOT_RESPECT; + } + return ConstraintResult.NOT_RESPECT; } return ConstraintResult.INAPPLICABLE; } + private static respectNumericFacets(constraint: IConstraint, literalValue: string): boolean { + const hasNumericFacet = constraint.minInclusive !== undefined + || constraint.maxInclusive !== undefined + || constraint.minExclusive !== undefined + || constraint.maxExclusive !== undefined; + + if (!hasNumericFacet) { + return true; + } + + const value = Number(literalValue); + if (Number.isNaN(value)) { + return false; + } + + if (constraint.minInclusive !== undefined && value < constraint.minInclusive) { + return false; + } + if (constraint.maxInclusive !== undefined && value > constraint.maxInclusive) { + return false; + } + if (constraint.minExclusive !== undefined && value <= constraint.minExclusive) { + return false; + } + if (constraint.maxExclusive !== undefined && value >= constraint.maxExclusive) { + return false; + } + + return true; + } + + private static respectPatternFacet(constraint: IConstraint, literalValue: string): boolean { + if (constraint.pattern === undefined) { + return true; + } + + try { + return new RegExp(constraint.pattern, constraint.flags).test(literalValue); + } catch { + return false; + } + } + public isFullyBounded(): boolean { return this.fullyBounded; } diff --git a/lib/Shape.ts b/lib/Shape.ts index 04971bb..1ea1ea7 100644 --- a/lib/Shape.ts +++ b/lib/Shape.ts @@ -36,7 +36,7 @@ export interface IShape extends IShapeObj { */ export interface IPredicate { name: string; - constraint?: IContraint; + constraint?: IConstraint; cardinality?: ICardinality; negative?: boolean; optional?: boolean; @@ -50,25 +50,31 @@ function toJsonPredicate(predicate: IPredicate): IPredicateJson { } export type IPredicateJson = Omit & { - constraint?: IContraintJson; + constraint?: IConstraintJson; } /** * A constraint */ -export interface IContraint { +export interface IConstraint { value: Set; type: ConstraintType; + minInclusive?: number; + maxInclusive?: number; + minExclusive?: number; + maxExclusive?: number; + pattern?: string; + flags?: string; } -function toJsonConstraint(constraint: IContraint): IContraintJson { +function toJsonConstraint(constraint: IConstraint): IConstraintJson { return { ...constraint, value: Array.from(constraint.value) } } -export type IContraintJson = Omit & { +export type IConstraintJson = Omit & { value: string[] }; @@ -92,8 +98,10 @@ export type IShapeJson = Omit; - filterExpression?: string; name: string; isVariable: boolean; } @@ -45,6 +44,8 @@ export interface ITripleArgs { // the object was a variable and a VALUES clase was used // to bind it to multiple values object: Term | Term[]; + // the original variable name when VALUES rewrote the object to a list of terms + boundVariable?: string; isOptional?: boolean; cardinality?: ICardinality; negatedSet?: Set; @@ -65,6 +66,7 @@ export class Triple implements ITriple { public readonly predicate: string; public readonly subject: string; public readonly object: Term | Term[]; + public readonly boundVariable?: string; // the cardinality of the predicate public readonly cardinality?: ICardinality; public readonly negatedSet?: Set; @@ -74,10 +76,11 @@ export class Triple implements ITriple { * * @param {ITripleArgs} tripleObject - A triple object */ - public constructor({ subject, predicate, object, cardinality, negatedSet: negative, isOptional }: ITripleArgs) { + public constructor({ subject, predicate, object, boundVariable, cardinality, negatedSet: negative, isOptional }: ITripleArgs) { this.predicate = predicate; this.object = object; this.subject = subject; + this.boundVariable = boundVariable; this.cardinality = cardinality; this.negatedSet = negative; this.isOptional = isOptional ?? false; @@ -90,6 +93,7 @@ export class Triple implements ITriple { Object.freeze(this.predicate); Object.freeze(this.isOptional); Object.freeze(this.object); + Object.freeze(this.boundVariable); Object.freeze(this.subject); Object.freeze(this); } @@ -99,13 +103,17 @@ export class Triple implements ITriple { * @returns {ITripleArgs} a Triple object */ public toObject(): ITripleArgs { - return { + const tripleObject: ITripleArgs = { subject: this.subject, predicate: this.predicate, object: this.object, cardinality: this.cardinality, isOptional: this.isOptional }; + if (this.boundVariable !== undefined) { + tripleObject.boundVariable = this.boundVariable; + } + return tripleObject; } public getLinkedStarPattern(): string | undefined { diff --git a/lib/constant.ts b/lib/constant.ts index ea2267b..d1032fd 100644 --- a/lib/constant.ts +++ b/lib/constant.ts @@ -36,6 +36,12 @@ 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`); diff --git a/lib/containment.ts b/lib/containment.ts index 4553110..969363a 100644 --- a/lib/containment.ts +++ b/lib/containment.ts @@ -1,8 +1,8 @@ -import { Bindings, ContainmentType, IBindings, IDependentStarPattern } from './Binding' -import { TYPE_DEFINITION } from './constant'; +import { Bindings, ContainmentType, IBindings } from './Binding' import { generateStarPatternUnion, type IQuery } from './query'; -import { IShape } from './Shape'; -import type { IStarPatternWithDependencies, ITriple } from './Triple'; +import { ConstraintType, IConstraint, IShape } from './Shape'; +import type { IStarPatternWithDependencies } from './Triple'; +import type { Term } from '@rdfjs/types'; /** * Determine if a query is contained inside a shape. @@ -14,90 +14,61 @@ import type { IStarPatternWithDependencies, ITriple } from './Triple'; export function solveShapeQueryContainment({ query, shapes, decidingShapes }: IContainementArg): IResult { const bindingResult = new Map>(); const starPatternsContainment = new Map(); - const queryStarPattern: QueryStarPattern = new Map(); + const classificationStats = new Map(); const groupedShapes = groupShapeBydependencies(shapes); - for (const [starPatternsName, starPatternWithDependencies] of query.starPatterns) { - for (const { triple } of starPatternWithDependencies.starPattern.values()) { - queryStarPattern.set(triple.toString(), triple); - } + // Initialize per-star-pattern classification state. + for (const [starPatternsName] of query.starPatterns) { starPatternsContainment.set(starPatternsName, { result: ContainmentResult.REJECTED, bindings: new Map() }); + classificationStats.set(starPatternsName, { + hasOpenShape: false, + hasClosedShape: false, + rootTargetsOpen: new Set(), + rootTargetsClosed: new Set(), + containedTargets: new Set(), + bindings: new Map() + }); } - // dependent, origin - const nestedContainedStarPatterns = new Map>(); - + // Evaluate each star pattern against each candidate shape. for (const { shape, dependencies } of groupedShapes) { bindingResult.set(shape.name, new Map()); const bindingResultofShape = bindingResult.get(shape.name)!; for (const [starPatternName, starPattern] of query.starPatterns) { const starPatternUnion = generateStarPatternUnion(query.union ?? [], starPatternName); const bindings = new Bindings(shape, starPattern, dependencies, starPatternUnion); - const currentNestedStarPattern = bindings.getNestedContainedStarPatternName(); - for (const starPattern of currentNestedStarPattern) { - const currentNestedContainedStarPatterns = nestedContainedStarPatterns.get(starPattern.starPattern); - if (currentNestedContainedStarPatterns === undefined) { - nestedContainedStarPatterns.set(starPattern.starPattern, new Map([[starPatternName, starPattern]])); - } else { - currentNestedContainedStarPatterns.set(starPatternName, starPattern); - } - } + const filterCompatibility = evaluateFiltersForShape(query.filters, starPattern, shape); bindingResultofShape.set(starPatternName, { result: bindings, shape }); - for (const triple of bindings.getBoundTriple()) { - queryStarPattern.set(triple.toString(), undefined); - } + updateContainmentStats(classificationStats, starPatternName, shape, bindings, groupedShapes, decidingShapes, filterCompatibility !== FilterTruth.FALSE); } } - for (const [shapeName, starPatternBinding] of bindingResult) { - const currentShapeBindingResult = bindingResult.get(shapeName)!; - for (const [starPatternName, bindingResult] of starPatternBinding) { - const originOfDependency = nestedContainedStarPatterns.get(starPatternName); - // validate that there are no cycles if there are cycles we analyse the star pattern independently - for (const [origin, dependencyInfo] of originOfDependency ?? []) { - if (!nestedContainedStarPatterns.has(origin)) { - currentShapeBindingResult.set(starPatternName, { ...bindingResult, dependent: dependencyInfo }); - break; - } - } - } - } + // Collapse collected stats into the final containment label per star pattern. + for (const [starPatternName, starPattern] of query.starPatterns) { + const stats = classificationStats.get(starPatternName)!; + const rootTargetsOpen = Array.from(stats.rootTargetsOpen); + const rootTargetsClosed = Array.from(stats.rootTargetsClosed); + const containedTargets = Array.from(stats.containedTargets); + const nestedTargets = Array.from(getNestedMatchingTargets(starPattern, classificationStats)); - for (const starPatternBinding of bindingResult.values()) { - for (const [starPatternName, result] of starPatternBinding) { - if (result.dependent === undefined) { - updateStarPatternContainment(starPatternsContainment, result.result, starPatternName, result.shape, groupedShapes, decidingShapes); + let currentResult: IContainmentResult; + if (containedTargets.length > 0) { + currentResult = { result: ContainmentResult.CONTAINED, target: containedTargets, bindings: new Map(stats.bindings) }; + } else if (rootTargetsOpen.length > 0) { + currentResult = { result: ContainmentResult.ALIGNED, target: rootTargetsOpen, bindings: new Map(stats.bindings) }; + } else { + const unalignedTargets = Array.from(new Set(rootTargetsClosed.concat(nestedTargets))); + if (unalignedTargets.length > 0) { + const bindings = rootTargetsClosed.length > 0 ? new Map(stats.bindings) : new Map(); + currentResult = { result: ContainmentResult.UNALINGED, target: unalignedTargets, bindings }; } else { - // check the shape contained related to the dependent star pattern - const dependendShapes = []; - for (const [shapeName, nestedBinding] of bindingResult) { - const nestedResult = nestedBinding.get(starPatternName); - if (nestedResult !== undefined) { - if (nestedResult.result.isFullyBounded()) { - dependendShapes.push(shapeName); - } - } - } - - const constraintTarget = []; - for (const nestedBinding of bindingResult.values()) { - const originBinding = nestedBinding.get(result.dependent.origin); - if (originBinding !== undefined) { - const constraintShape = originBinding.result.getNestedContainedStarPatternNameShapesContained().get(starPatternName); - if (constraintShape !== undefined) { - constraintTarget.push(constraintShape); - } - } - } - // we check if there is less shapes contained than the constraint of the dependency - const target = constraintTarget.length >= dependendShapes.length - || constraintTarget.length === 0 - ? dependendShapes : result.dependent.shape; - - starPatternsContainment.set(starPatternName, { result: ContainmentResult.DEPEND, target: target?.length === 0 ? undefined : target, bindings: new Map() }); + const rejectedResult = stats.hasOpenShape ? ContainmentResult.WEAKLY_REJECTED : ContainmentResult.REJECTED; + currentResult = { result: rejectedResult, bindings: new Map() }; } } + + starPatternsContainment.set(starPatternName, currentResult); } return { @@ -107,59 +78,66 @@ export function solveShapeQueryContainment({ query, shapes, decidingShapes }: IC } -function updateStarPatternContainment(starPatternsContainment: Map, bindings: IBindings, starPatternName: StarPatternName, shape: IShape, groupedShapes: IShapeWithDependencies[], decidingShapes?: Set): void { - const prevContainmentResult = starPatternsContainment.get(starPatternName)!; +function updateContainmentStats( + classificationStats: Map, + starPatternName: StarPatternName, + 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; + } - if (bindings.shouldVisitShape() && bindings.getUnboundedTriple().length > 0 && prevContainmentResult.result !== ContainmentResult.CONTAIN && prevContainmentResult.result !== ContainmentResult.PARTIALY_CONTAIN) { - starPatternsContainment.set(starPatternName, { - result: ContainmentResult.ALIGNED, - target: (prevContainmentResult.target ?? []).concat(shape.name), - bindings: prevContainmentResult.bindings.set(shape.name, bindings) - }); + const stats = classificationStats.get(starPatternName)!; + if (shape.closed) { + stats.hasClosedShape = true; + } else { + stats.hasOpenShape = true; + } + if (!filterCompatible) { + return; } - if (bindings.shouldVisitShape() && bindings.isFullyBounded()) { - if (bindings.containmentType().result === ContainmentType.FULL) { - starPatternsContainment.set(starPatternName, { - result: ContainmentResult.CONTAIN, - target: prevContainmentResult.result === ContainmentResult.ALIGNED ? [shape.name] : - (prevContainmentResult.target ?? []).concat(shape.name), - bindings: prevContainmentResult.bindings.set(shape.name, bindings) - }); + + // Track root-level alignment independently from full containment. + const hasRootMatch = bindings.getBoundTriple().length > 0; + if (hasRootMatch) { + stats.bindings.set(shape.name, bindings); + if (shape.closed) { + stats.rootTargetsClosed.add(shape.name); + } else { + stats.rootTargetsOpen.add(shape.name); } - if (bindings.containmentType().result === ContainmentType.PARTIAL) { - const unContaineStarPattern = bindings.containmentType().unContaineStarPattern!; - const hasDisjuncContainment = findDisjunctContainment(unContaineStarPattern, groupedShapes, shape, decidingShapes); - if (hasDisjuncContainment) { - starPatternsContainment.set(starPatternName, { - result: ContainmentResult.CONTAIN, - target: prevContainmentResult.result === ContainmentResult.ALIGNED ? [shape.name] : - (prevContainmentResult.target ?? []).concat(shape.name), - bindings: prevContainmentResult.bindings.set(shape.name, bindings) - }); - } else { - starPatternsContainment.set(starPatternName, { - result: ContainmentResult.PARTIALY_CONTAIN, - target: prevContainmentResult.result === ContainmentResult.ALIGNED ? [shape.name] : - (prevContainmentResult.target ?? []).concat(shape.name), - bindings: prevContainmentResult.bindings.set(shape.name, bindings) - }); - } + } + + // 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); + } + + // Preserve previous behavior where a disjunction can be considered fully covered + // when unresolved alternatives are contained by another deciding shape. + if (bindings.isFullyBounded() && bindings.containmentType().result === ContainmentType.PARTIAL) { + const unContaineStarPattern = bindings.containmentType().unContaineStarPattern!; + const hasDisjuncContainment = findDisjunctContainment(unContaineStarPattern, groupedShapes, shape, decidingShapes); + if (hasDisjuncContainment) { + stats.bindings.set(shape.name, bindings); + stats.containedTargets.add(shape.name); } } } -/** - * Check if there is a disjunction. IMPORTANT!! Do not consider nested disjunction. - * @param {IStarPatternWithDependencies[]} starPatterns - * @param {IShapeWithDependencies[]} groupedShapes - * @param {IShape} shapeExcluded - * @returns Whether the disjunction is contained into a shape - */ + function findDisjunctContainment(starPatterns: IStarPatternWithDependencies[], groupedShapes: IShapeWithDependencies[], shapeExcluded: IShape, decidingShapes?: Set): 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 && (decidingShapes === undefined || decidingShapes.has(shape.name))) { const bindings = new Bindings(shape, starPattern, dependencies); haveContainment = haveContainment || bindings.isFullyBounded(); } @@ -168,7 +146,717 @@ function findDisjunctContainment(starPatterns: IStarPatternWithDependencies[], g return haveContainment; } +function getNestedMatchingTargets(starPattern: IStarPatternWithDependencies, classificationStats: Map, visited: Set = new Set()): Set { + // Include shapes that only become reachable through nested star-pattern dependencies. + const nestedNames = getNestedDependencyNames(starPattern, visited); + const nestedTargets = new Set(); + + for (const nestedName of nestedNames) { + const stats = classificationStats.get(nestedName); + if (stats === undefined) { + continue; + } + for (const target of stats.rootTargetsOpen) { + nestedTargets.add(target); + } + for (const target of stats.rootTargetsClosed) { + nestedTargets.add(target); + } + } + + return nestedTargets; +} + +function getNestedDependencyNames(starPattern: IStarPatternWithDependencies, visited: Set): Set { + const nestedNames = new Set(); + // Break cycles in recursive dependency graphs. + if (visited.has(starPattern.name)) { + return nestedNames; + } + visited.add(starPattern.name); + + for (const { dependencies } of starPattern.starPattern.values()) { + if (dependencies === undefined) { + continue; + } + + nestedNames.add(dependencies.name); + for (const nestedName of getNestedDependencyNames(dependencies, visited)) { + nestedNames.add(nestedName); + } + } + + 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 when prefix/literal heuristics identify an obvious incompatibility. + if (hasRegexPrefixIncompatibility(variableConstraint.pattern, regexPattern)) { + 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, filterPattern: string): boolean { + 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; + } + + 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 (filterPrefix !== undefined && shapeContains !== undefined && !filterPrefix.prefix.includes(shapeContains) && !shapeContains.includes(filterPrefix.prefix)) { + return true; + } + + return false; +} + +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[] = []; for (let i = 0; i < shapes.length; i++) { const target = shapes[i]; @@ -184,6 +872,7 @@ function groupShapeBydependencies(shapes: IShape[], dependentShapes?: IShape[]): function generateVisitStatus(bindings: Map>, shapes: IShape[]): Map { + // A shape is visitable if at least one star pattern binding marks it as followable. const visitShapeBoundedResource = new Map(); for (const shape of shapes) { visitShapeBoundedResource.set(shape.name, false); @@ -208,7 +897,15 @@ function generateVisitStatus(bindings: Map; + rootTargetsClosed: Set; + containedTargets: Set; + bindings: Map; } interface IShapeWithDependencies { @@ -217,7 +914,6 @@ interface IShapeWithDependencies { } export type StarPatternName = string; -type QueryStarPattern = Map; /** * The argument of the report alignment function @@ -264,14 +960,14 @@ export type IContainmentResult = Readonly<{ * The result of a containement */ export enum ContainmentResult { - // Is subsum - CONTAIN, - // One union statement is subsum - PARTIALY_CONTAIN, - // Has at least one binding + // All root and nested star patterns are fully covered. + CONTAINED, + // At least one triple in the root star pattern matches on an open shape. ALIGNED, - // Is a dependency of a subsuming star pattern - DEPEND, - // Has no binding + // Root only matches on closed shapes, or only nested star patterns match. + UNALINGED, + // No triple matches and at least one candidate shape is open. + WEAKLY_REJECTED, + // No triple matches and all candidate shapes are closed. REJECTED, } diff --git a/lib/query.ts b/lib/query.ts index 0218cba..e77913f 100644 --- a/lib/query.ts +++ b/lib/query.ts @@ -29,7 +29,10 @@ export interface IQuery { // star patterns indexed by subject starPatterns: Map; union?: IQuery[][]; - filterExpression?: string; + // top-level filter expressions attached to this query branch + filters?: Algebra.Expression[]; + // VALUES bindings indexed by variable name + values?: Map; } export function generateStarPatternUnion(union: IQuery[][], starPatternName: string): IStarPatternWithDependencies[][] { @@ -60,22 +63,30 @@ export function generateQuery(algebraQuery: Algebra.Operation, optional?: boolea // the binding value to the value const accumulatedValues = new Map(); const accumulatedUnion: IQuery[][] = []; + const accumulatedFilters: Algebra.Expression[] = []; - QueryHandler.collectFromAlgebra(algebraQuery, accumulatedTriples, accumulatedValues, accumulatedUnion, optional); + QueryHandler.collectFromAlgebra(algebraQuery, accumulatedTriples, accumulatedValues, accumulatedUnion, accumulatedFilters, optional); - return buildQuery(accumulatedTriples, accumulatedValues, accumulatedUnion); + return buildQuery(accumulatedTriples, accumulatedValues, accumulatedUnion, accumulatedFilters); } function buildQuery( tripleArgs: Map, values: Map, - accumulatedUnion: IQuery[][] + accumulatedUnion: IQuery[][], + filters: Algebra.Expression[] = [], ): IQuery { const innerQuery = new Map(); - const resp: IQuery = { starPatterns: innerQuery, filterExpression: "" }; + const resp: IQuery = { starPatterns: innerQuery }; if (accumulatedUnion.length > 0) { resp.union = accumulatedUnion; } + if (filters.length > 0) { + resp.filters = [...filters]; + } + if (values.size > 0) { + resp.values = new Map(values); + } // generate the root star patterns for (const [starPatternSubject, { triples, isVariable }] of tripleArgs) { @@ -87,6 +98,7 @@ function buildQuery( subject: triple.subject, predicate: triple.predicate, object: value, + boundVariable: triple.object.value, cardinality: triple.cardinality, negatedSet: triple.negatedSet }); @@ -194,6 +206,7 @@ namespace QueryHandler { accumulatedTriples: Map, accumulatedValues: Map, accumulatedUnion: IQuery[][], + accumulatedFilters: Algebra.Expression[], optional?: boolean ): void { algebraUtils.visitOperation( @@ -213,13 +226,14 @@ namespace QueryHandler { }, [Algebra.Types.LEFT_JOIN]: { preVisitor: () => ({ continue: false }), - visitor: handleLeftJoin(accumulatedTriples, accumulatedValues, accumulatedUnion), + visitor: handleLeftJoin(accumulatedTriples, accumulatedValues, accumulatedFilters, accumulatedUnion), }, [Algebra.Types.PATH]: { preVisitor: () => ({ continue: false }), 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. preVisitor: () => ({ ignoreKeys: new Set(['expression']) }), @@ -230,14 +244,21 @@ namespace QueryHandler { function handleLeftJoin(accumulatedTriples: Map, accumulatedValues: Map, + accumulatedFilters: Algebra.Expression[], accumulatedUnion: IQuery[][]): (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); - collectFromAlgebra(optionalElements, accumulatedTriples, accumulatedValues, accumulatedUnion, true); + collectFromAlgebra(requiredElements, accumulatedTriples, accumulatedValues, accumulatedUnion, accumulatedFilters); + collectFromAlgebra(optionalElements, accumulatedTriples, accumulatedValues, accumulatedUnion, accumulatedFilters, true); + } + } + + function handleFilter(accumulatedFilters: Algebra.Expression[]): (element: Algebra.Filter) => void { + return (element: Algebra.Filter): void => { + accumulatedFilters.push(element.expression); } } diff --git a/lib/shacl.ts b/lib/shacl.ts index d79d610..bdefdb5 100644 --- a/lib/shacl.ts +++ b/lib/shacl.ts @@ -1,6 +1,6 @@ import type * as RDF from '@rdfjs/types'; import { DataFactory } from 'rdf-data-factory'; -import type { IContraint, OneOf, IShape, IPredicate } from './Shape'; +import type { IConstraint, OneOf, IShape, IPredicate } from './Shape'; import { Shape, ConstraintType, PoorlyFormatedShapeError } from './Shape'; import type { ShapeError } from './Shape'; @@ -9,6 +9,12 @@ import { 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, @@ -33,6 +39,12 @@ interface IPropertyShapeData { path?: string; 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 @@ -158,6 +170,42 @@ function parseQuad(quad: RDF.Quad, map: IMapTripleShacl): void { return; } + // sh:minInclusive + if (quad.predicate.equals(SHACL_MIN_INCLUSIVE)) { + getOrCreatePropData(map, s).minInclusive = Number(o); + return; + } + + // sh:maxInclusive + if (quad.predicate.equals(SHACL_MAX_INCLUSIVE)) { + getOrCreatePropData(map, s).maxInclusive = Number(o); + return; + } + + // sh:minExclusive + if (quad.predicate.equals(SHACL_MIN_EXCLUSIVE)) { + getOrCreatePropData(map, s).minExclusive = Number(o); + return; + } + + // sh:maxExclusive + if (quad.predicate.equals(SHACL_MAX_EXCLUSIVE)) { + getOrCreatePropData(map, s).maxExclusive = Number(o); + return; + } + + // sh:pattern + if (quad.predicate.equals(SHACL_PATTERN)) { + getOrCreatePropData(map, s).pattern = o; + return; + } + + // sh:flags + if (quad.predicate.equals(SHACL_FLAGS)) { + getOrCreatePropData(map, s).flags = o; + return; + } + // sh:closed if (quad.predicate.equals(SHACL_CLOSED)) { map.closedShape.set(s, quad.object.equals(RDF_TRUE)); @@ -322,12 +370,12 @@ function buildPredicate(data: IPropertyShapeData): IPredicate { }; } -function resolveConstraint(data: IPropertyShapeData): IContraint | undefined { +function resolveConstraint(data: IPropertyShapeData): IConstraint | undefined { if (data.classConstraint !== undefined) { - // sh:class constrains the RDF type of the object (analogous to ShEx datatype) → TYPE + // sh:class constrains the object to be an instance/class IRI. return { value: new Set([data.classConstraint]), - type: ConstraintType.TYPE, + type: ConstraintType.CLASS, }; } if (data.nodeConstraint !== undefined) { @@ -338,9 +386,29 @@ function resolveConstraint(data: IPropertyShapeData): IContraint | undefined { }; } 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.TYPE, + type: ConstraintType.DATATYPE, + ...numericFacets, }; } return undefined; diff --git a/lib/shex.ts b/lib/shex.ts index dd73425..d5089a7 100644 --- a/lib/shex.ts +++ b/lib/shex.ts @@ -2,6 +2,7 @@ import type * as RDF from '@rdfjs/types'; import { SHEX_PREDICATE, SHEX_EXPRESSION, + TYPE_DEFINITION, IRI_FIRST_RDF_LIST, SHEX_EXPRESSIONS, IRI_REST_RDF_LIST, @@ -16,7 +17,7 @@ import { SHEX_ONE_OF, } from './constant'; import type { - IContraint, + IConstraint, ShapeError, OneOf, IShape, @@ -182,12 +183,13 @@ function concatShapeInfo( * 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 - * @returns {IContraint | undefined} - The constraint or undefined if the constraint is not supported + * @returns {IConstraint | undefined} - The constraint or undefined if the constraint is not supported */ function interpretConstraint( constraint: RDF.Term | undefined, mapIriDatatype: Map, -): IContraint | undefined { + predicate?: string, +): IConstraint | undefined { if (constraint === undefined) { return undefined; } @@ -204,7 +206,9 @@ function interpretConstraint( if (dataType !== undefined) { return { value: new Set([dataType]), - type: ConstraintType.TYPE, + type: predicate === TYPE_DEFINITION.value + ? ConstraintType.CLASS + : ConstraintType.DATATYPE, }; } } @@ -228,7 +232,7 @@ function appendPredicates( args.negativePredicates.push(predicate); } else { const constraintIri = args.mapIriConstraint.get(args.current); - const constraint = interpretConstraint(constraintIri, args.mapIriDatatype); + const constraint = interpretConstraint(constraintIri, args.mapIriDatatype, predicate); args.positivePredicates.push({ name: predicate, cardinality: { diff --git a/package-lock.json b/package-lock.json index 811bc76..16d7cd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "query-shape-detection", - "version": "0.4.2", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "query-shape-detection", - "version": "0.4.2", + "version": "1.0.0", "license": "MIT", "dependencies": { "@traqula/algebra-sparql-1-1": "^1.1.6", diff --git a/package.json b/package.json index 1c0a0b8..14207bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "query-shape-detection", - "version": "0.4.2", + "version": "1.0.0", "main": "./build/index.js", "types": "./build/index.d.ts", "license": "MIT", diff --git a/test/Binding.test.ts b/test/Binding.test.ts index 09fdbcb..08c1d50 100644 --- a/test/Binding.test.ts +++ b/test/Binding.test.ts @@ -12,6 +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'; const n3Parser = new N3.Parser(); const sparqlParser = new SPARQLParser(); @@ -154,7 +155,7 @@ describe('Bindings', () => { }; - const expectedBindings = new Map([[triple.predicate, triple], [triple2.predicate, triple2]]); + const expectedBindings = new Map([[triple.predicate, undefined], [triple2.predicate, triple2]]); const bindings = new Bindings(shape, starPattern, linkedShape); @@ -163,7 +164,7 @@ describe('Bindings', () => { expect(bindings.shouldVisitShape()).toBe(true); expect(bindings.getUnboundedTriple()).toStrictEqual([]); expect(bindings.getBindings()).toStrictEqual(expectedBindings); - expect(bindings.getBoundTriple()).toStrictEqual([triple, triple2]); + expect(bindings.getBoundTriple()).toStrictEqual([triple2]); expect(bindings.getNestedContainedStarPatternName()).toStrictEqual([]); }); @@ -191,16 +192,16 @@ describe('Bindings', () => { }; - const expectedBindings = new Map([[triple.predicate, triple]]); + const expectedBindings = new Map([[triple.predicate, undefined]]); const bindings = new Bindings(shape, starPattern, linkedShape); - expect(bindings.isFullyBounded()).toBe(true); - expect(bindings.shouldVisitShape()).toBe(true); + expect(bindings.isFullyBounded()).toBe(false); + expect(bindings.shouldVisitShape()).toBe(false); expect(bindings.getUnboundedTriple()).toStrictEqual([]); expect(bindings.getBindings()).toStrictEqual(expectedBindings); - expect(bindings.getBoundTriple()).toStrictEqual([triple]); + expect(bindings.getBoundTriple()).toStrictEqual([]); expect(bindings.getNestedContainedStarPatternName()).toStrictEqual([]); }); @@ -1682,7 +1683,7 @@ describe('Bindings', () => { { name: "p0", constraint: { - type: ConstraintType.TYPE, + type: ConstraintType.DATATYPE, value: new Set(["t0"]) } } @@ -1727,9 +1728,9 @@ describe('Bindings', () => { const shape: Shape = new Shape({ name: 'foo', positivePredicates: [ { - name: "p0", + name: TYPE_DEFINITION.value, constraint: { - type: ConstraintType.TYPE, + type: ConstraintType.CLASS, value: new Set(["t0"]) } } @@ -1739,13 +1740,13 @@ describe('Bindings', () => { const triple: Triple = new Triple({ subject: 'y', - predicate: 'p0', + predicate: TYPE_DEFINITION.value, object: DF.namedNode('t0') }); const starPattern: IStarPatternWithDependencies = { starPattern: new Map([ [ - 'p0', + TYPE_DEFINITION.value, { triple, dependencies: undefined @@ -1776,7 +1777,7 @@ describe('Bindings', () => { { name: "p0", constraint: { - type: ConstraintType.TYPE, + type: ConstraintType.DATATYPE, value: new Set(["t_0"]) } } @@ -1821,23 +1822,23 @@ describe('Bindings', () => { const shape: Shape = new Shape({ name: 'foo', positivePredicates: [ { - name: "p0", + name: TYPE_DEFINITION.value, constraint: { - type: ConstraintType.TYPE, + type: ConstraintType.CLASS, value: new Set(["t0"]) } }, { name: "p1", constraint: { - type: ConstraintType.TYPE, + type: ConstraintType.DATATYPE, value: new Set(["t1"]) } }, { name: "p2", constraint: { - type: ConstraintType.TYPE, + type: ConstraintType.DATATYPE, value: new Set(["t2"]) } } @@ -1847,7 +1848,7 @@ describe('Bindings', () => { const triple1: Triple = new Triple({ subject: 'y', - predicate: 'p0', + predicate: TYPE_DEFINITION.value, object: [DF.namedNode('t0')] }); const triple2: Triple = new Triple({ @@ -1912,21 +1913,21 @@ describe('Bindings', () => { { name: "p0", constraint: { - type: ConstraintType.TYPE, + type: ConstraintType.DATATYPE, value: new Set(["t0"]) } }, { name: "p1", constraint: { - type: ConstraintType.TYPE, + type: ConstraintType.DATATYPE, value: new Set(["t1"]) } }, { name: "p2", constraint: { - type: ConstraintType.TYPE, + type: ConstraintType.DATATYPE, value: new Set(["t_2"]) } } @@ -2030,7 +2031,7 @@ describe('Bindings', () => { { name: "p1", constraint: { - type: ConstraintType.TYPE, + type: ConstraintType.DATATYPE, value: new Set(["t0"]) } } @@ -2327,7 +2328,7 @@ describe('Bindings', () => { { name: "p1", constraint: { - type: ConstraintType.TYPE, + type: ConstraintType.DATATYPE, value: new Set(["t_0"]) } } diff --git a/test/Shape.test.ts b/test/Shape.test.ts index 39c413b..faa60de 100644 --- a/test/Shape.test.ts +++ b/test/Shape.test.ts @@ -120,7 +120,7 @@ describe('Shape', () => { { name: 'b' }, { name: 'c', - constraint: { value: new Set(["1", "2"]), type: ConstraintType.TYPE }, + constraint: { value: new Set(["1", "2"]), type: ConstraintType.DATATYPE }, cardinality: { min: 1, max: 5 }, negative: false, optional: false @@ -129,7 +129,7 @@ describe('Shape', () => { const negativePredicates = [{ name: 'd', - constraint: { value: new Set(["1", "2"]), type: ConstraintType.TYPE }, + constraint: { value: new Set(["1", "2"]), type: ConstraintType.DATATYPE }, cardinality: { min: 1, max: 5 }, negative: true, optional: false @@ -142,7 +142,7 @@ describe('Shape', () => { { name: 'b', optional: false, constraint: undefined }, { name: 'c', - constraint: { value: ["1", "2"], type: ConstraintType.TYPE }, + constraint: { value: ["1", "2"], type: ConstraintType.DATATYPE }, cardinality: { min: 1, max: 5 }, negative: false, optional: false @@ -150,7 +150,7 @@ describe('Shape', () => { ], negativePredicates: [{ name: 'd', - constraint: { value: ["1", "2"], type: ConstraintType.TYPE }, + constraint: { value: ["1", "2"], type: ConstraintType.DATATYPE }, cardinality: { min: 1, max: 5 }, negative: true, optional: false @@ -169,7 +169,7 @@ describe('Shape', () => { { name: 'b' }, { name: 'c', - constraint: { value: new Set(["1", "2"]), type: ConstraintType.TYPE }, + constraint: { value: new Set(["1", "2"]), type: ConstraintType.DATATYPE }, cardinality: { min: 0, max: 5 }, negative: false, optional: true @@ -191,7 +191,7 @@ describe('Shape', () => { { name: 'b', constraint: undefined, optional: false }, { name: 'c', - constraint: { value: ["1", "2"], type: ConstraintType.TYPE }, + constraint: { value: ["1", "2"], type: ConstraintType.DATATYPE }, cardinality: { min: 0, max: 5 }, negative: false, optional: true @@ -340,7 +340,7 @@ describe('Shape', () => { name: 'foo', positivePredicates: ['a', 'b', { name: 'c', constraint: { value: new Set(['bar']), - type: ConstraintType.TYPE, + type: ConstraintType.DATATYPE, }, }] }); diff --git a/test/containment.test.ts b/test/containment.test.ts index 7e03572..c5ccd58 100644 --- a/test/containment.test.ts +++ b/test/containment.test.ts @@ -13,6 +13,7 @@ import * as N3 from 'n3'; import { readFileSync } from 'fs'; import { streamifyArray } from 'streamify-array'; import { shexShapeFromQuads } from '../lib/shex'; +import { shaclShapeFromQuads } from '../lib/shacl'; const DF = new DataFactory(); const n3Parser = new N3.Parser(); @@ -53,7 +54,7 @@ describe('solveShapeQueryContainment', () => { { name: "https://www.example.ca/p1", constraint: { - type: ConstraintType.TYPE, + type: ConstraintType.DATATYPE, value: new Set(["https://www.example.ca/t0"]) } } @@ -132,6 +133,10 @@ describe('solveShapeQueryContainment', () => { ], closed: true }); + /********************************************************************* + * General tests cases + * *******************************************************************/ + it('should return an empty result given an empty query and no shape', () => { const query: IQuery = { starPatterns: new Map() @@ -189,7 +194,7 @@ describe('solveShapeQueryContainment', () => { const expectedResult: IResult = { starPatternsContainment: new Map([ - ["x", { result: ContainmentResult.CONTAIN, target: [shape.name], bindings:expect.any(Map) }] + ["x", { result: ContainmentResult.CONTAINED, target: [shape.name], bindings:expect.any(Map) }] ]), visitShapeBoundedResource: new Map([ [shape.name, true], @@ -252,43 +257,654 @@ describe('solveShapeQueryContainment', () => { expect(solveShapeQueryContainment({ query, shapes })).toStrictEqual(expectedResult); }); - it('should handle a query contained in every shape', () => { - const query = generateMatchingQuery(); - const shapes: IShape[] = [shape, shapeP1, shapeP2, shapeP3, shapeP4, shapeP5]; - const expectedStarPatternsContainment = new Map([ - ["x", { result: ContainmentResult.CONTAIN, target: [shape.name], bindings:expect.any(Map) }], - ["y", { result: ContainmentResult.DEPEND, target: ['foo1'], bindings:expect.any(Map) }], - ["z", { - result: ContainmentResult.DEPEND, target: [ - "foo2", - ], - bindings:expect.any(Map) - }], - ["w", { result: ContainmentResult.DEPEND, target: ['foo3'], bindings:expect.any(Map) }], - ["w1", { result: ContainmentResult.DEPEND, target: ['foo4'], bindings:expect.any(Map) }], - ["w2", { - result: ContainmentResult.DEPEND, target: [ - "foo5", - ], - bindings:expect.any(Map) - }], + /********************************************************************* + * Specific tests cases + * *******************************************************************/ + it('should return WEAKLY_REJECTED when no triple matches on an open shape', () => { + const zStarPattern = generateZAlternatifStarPattern(); + const query: IQuery = { + starPatterns: new Map([ + ["z", zStarPattern] + ]) + }; + const openShape: IShape = new Shape({ + name: 'fooOpen', + positivePredicates: [ + 'https://www.example.ca/p0' + ], + closed: false + }); - ]); const expectedResult: IResult = { + starPatternsContainment: new Map([ + ["z", { result: ContainmentResult.WEAKLY_REJECTED, bindings: new Map() }] + ]), + visitShapeBoundedResource: new Map([ + [openShape.name, false], + ]) + }; - starPatternsContainment: expectedStarPatternsContainment, + expect(solveShapeQueryContainment({ query, shapes: [openShape] })).toStrictEqual(expectedResult); + }); + + it('should return REJECTED when no triple matches on a closed shape', () => { + const zStarPattern = generateZAlternatifStarPattern(); + const query: IQuery = { + starPatterns: new Map([ + ["z", zStarPattern] + ]) + }; + const closedShape: IShape = new Shape({ + name: 'fooClosed', + positivePredicates: [ + 'https://www.example.ca/p0' + ], + closed: true + }); + + const expectedResult: IResult = { + starPatternsContainment: new Map([ + ["z", { result: ContainmentResult.REJECTED, bindings: new Map() }] + ]), + visitShapeBoundedResource: new Map([ + [closedShape.name, false], + ]) + }; + + expect(solveShapeQueryContainment({ query, shapes: [closedShape] })).toStrictEqual(expectedResult); + }); + + it('should keep containment with FILTER regex and SHACL pattern constraint', async () => { + const shapeIri = 'https://www.example.ca/patternShape'; + const shacl = ` + PREFIX ex: + PREFIX sh: + PREFIX xsd: + + ex:patternShape a sh:NodeShape ; + sh:property [ + sh:path ex:nickname ; + sh:datatype xsd:string ; + sh:pattern "^foo" + ] . + `; + const parsedShape = await shaclShapeFromQuads(n3Parser.parse(shacl), shapeIri); + expect(parsedShape).not.toBeInstanceOf(Error); + + const queryString = ` + PREFIX ex: + SELECT * WHERE { + ?s ex:nickname ?nickname . + FILTER(regex(str(?nickname), "^foo")) + }`; + const querySparql = toAlgebra(sparqlParser.parse(queryString)); + const query = generateQuery(querySparql); + const shape = parsedShape as IShape; + + const expectedResult: IResult = { + starPatternsContainment: new Map([ + ["s", { result: ContainmentResult.CONTAINED, target: [shape.name], bindings: expect.any(Map) }], + ]), + visitShapeBoundedResource: new Map([ + [shape.name, true], + ]) + }; + + expect(solveShapeQueryContainment({ query, shapes: [shape] })).toStrictEqual(expectedResult); + }); + + it('should keep containment when SHACL pattern implies FILTER regex prefix', async () => { + const shapeIri = 'https://www.example.ca/patternShape'; + 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 "^foo" + ] . + `; + const parsedShape = await shaclShapeFromQuads(n3Parser.parse(shacl), shapeIri); + expect(parsedShape).not.toBeInstanceOf(Error); + + const queryString = ` + PREFIX ex: + SELECT * WHERE { + ?s ex:nickname ?nickname . + FILTER(regex(str(?nickname), "^f")) + }`; + const querySparql = toAlgebra(sparqlParser.parse(queryString)); + const query = generateQuery(querySparql); + const shape = parsedShape as IShape; + + const expectedResult: IResult = { + starPatternsContainment: new Map([ + ["s", { result: ContainmentResult.CONTAINED, target: [shape.name], bindings: expect.any(Map) }], + ]), + visitShapeBoundedResource: new Map([ + [shape.name, true], + ]) + }; + + expect(solveShapeQueryContainment({ query, shapes: [shape] })).toStrictEqual(expectedResult); + }); + + it('should reject when FILTER regex contradicts SHACL pattern constraint', async () => { + const shapeIri = 'https://www.example.ca/patternShape'; + 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 "^foo" + ] . + `; + const parsedShape = await shaclShapeFromQuads(n3Parser.parse(shacl), shapeIri); + expect(parsedShape).not.toBeInstanceOf(Error); + + const queryString = ` + PREFIX ex: + SELECT * WHERE { + ?s ex:nickname ?nickname . + FILTER(regex(str(?nickname), "^bar")) + }`; + const querySparql = toAlgebra(sparqlParser.parse(queryString)); + const query = generateQuery(querySparql); + const shape = parsedShape as IShape; + + const expectedResult: IResult = { + starPatternsContainment: new Map([ + ["s", { result: ContainmentResult.REJECTED, bindings: new Map() }], + ]), + visitShapeBoundedResource: new Map([ + [shape.name, true], + ]) + }; + + expect(solveShapeQueryContainment({ query, shapes: [shape] })).toStrictEqual(expectedResult); + }); + + it('should support FILTER datatype(?v) comparison with VALUES', () => { + const queryString = ` + PREFIX ex: + PREFIX xsd: + SELECT * WHERE { + ?x ex:p0 ?v . + FILTER(datatype(?v) = xsd:integer) + }`; + const querySparql = toAlgebra(sparqlParser.parse(queryString)); + const query = generateQuery(querySparql); + const shapes: IShape[] = [shape]; + + const expectedResult: IResult = { + starPatternsContainment: new Map([ + ["x", { result: ContainmentResult.CONTAINED, target: [shape.name], bindings: expect.any(Map) }], + ]), visitShapeBoundedResource: new Map([ [shape.name, true], - [shapeP1.name, true], - [shapeP2.name, true], - [shapeP3.name, true], - [shapeP4.name, true], - [shapeP5.name, true] ]) }; expect(solveShapeQueryContainment({ query, shapes })).toStrictEqual(expectedResult); + }); + + it('should keep containment with FILTER numeric comparison and SHACL datatype constraint', async () => { + const shapeIri = 'https://www.example.ca/myShape'; + const shacl = ` + PREFIX ex: + PREFIX sh: + PREFIX xsd: + + ex:myShape a sh:NodeShape ; + sh:property [ + sh:path ex:hasAge ; + sh:datatype xsd:integer ; + sh:minInclusive 15 ; + sh:maxInclusive 35 + ] . + `; + const parsedShape = await shaclShapeFromQuads(n3Parser.parse(shacl), shapeIri); + expect(parsedShape).not.toBeInstanceOf(Error); + + const queryString = ` + PREFIX ex: + SELECT * WHERE { + ?s ex:hasAge ?age . + FILTER (?age > 18) + }`; + const querySparql = toAlgebra(sparqlParser.parse(queryString)); + const query = generateQuery(querySparql); + const shape = parsedShape as IShape; + + const expectedResult: IResult = { + starPatternsContainment: new Map([ + ["s", { result: ContainmentResult.CONTAINED, target: [shape.name], bindings: expect.any(Map) }], + ]), + visitShapeBoundedResource: new Map([ + [shape.name, true], + ]) + }; + + expect(solveShapeQueryContainment({ query, shapes: [shape] })).toStrictEqual(expectedResult); + }); + + it('should reject when FILTER numeric bound contradicts SHACL inclusive facets', async () => { + const shapeIri = 'https://www.example.ca/myShape'; + const shacl = ` + PREFIX ex: + PREFIX sh: + PREFIX xsd: + + ex:myShape a sh:NodeShape ; + sh:closed true ; + sh:property [ + sh:path ex:hasAge ; + sh:datatype xsd:integer ; + sh:minInclusive 15 ; + sh:maxInclusive 35 + ] . + `; + const parsedShape = await shaclShapeFromQuads(n3Parser.parse(shacl), shapeIri); + expect(parsedShape).not.toBeInstanceOf(Error); + + const queryString = ` + PREFIX ex: + SELECT * WHERE { + ?s ex:hasAge ?age . + FILTER (?age > 40) + }`; + const querySparql = toAlgebra(sparqlParser.parse(queryString)); + const query = generateQuery(querySparql); + const shape = parsedShape as IShape; + + const expectedResult: IResult = { + starPatternsContainment: new Map([ + ["s", { result: ContainmentResult.REJECTED, bindings: new Map() }], + ]), + visitShapeBoundedResource: new Map([ + [shape.name, true], + ]) + }; + + expect(solveShapeQueryContainment({ query, shapes: [shape] })).toStrictEqual(expectedResult); + }); + + it('should keep containment when VALUES literal respects SHACL numeric facets', async () => { + const shapeIri = 'https://www.example.ca/myShape'; + const shacl = ` + PREFIX ex: + PREFIX sh: + PREFIX xsd: + + ex:myShape a sh:NodeShape ; + sh:closed true ; + sh:property [ + sh:path ex:hasAge ; + sh:datatype xsd:integer ; + sh:minInclusive 15 ; + sh:maxInclusive 35 + ] . + `; + const parsedShape = await shaclShapeFromQuads(n3Parser.parse(shacl), shapeIri); + expect(parsedShape).not.toBeInstanceOf(Error); + + const queryString = ` + PREFIX ex: + PREFIX xsd: + SELECT * WHERE { + ?s ex:hasAge ?age . + VALUES ?age { "20"^^xsd:integer } + }`; + const querySparql = toAlgebra(sparqlParser.parse(queryString)); + const query = generateQuery(querySparql); + const shape = parsedShape as IShape; + + const expectedResult: IResult = { + starPatternsContainment: new Map([ + ["s", { result: ContainmentResult.CONTAINED, target: [shape.name], bindings: expect.any(Map) }], + ]), + visitShapeBoundedResource: new Map([ + [shape.name, true], + ]) + }; + + expect(solveShapeQueryContainment({ query, shapes: [shape] })).toStrictEqual(expectedResult); + }); + + it('should reject when all VALUES literals violate SHACL numeric facets', async () => { + const shapeIri = 'https://www.example.ca/myShape'; + const shacl = ` + PREFIX ex: + PREFIX sh: + PREFIX xsd: + + ex:myShape a sh:NodeShape ; + sh:closed true ; + sh:property [ + sh:path ex:hasAge ; + sh:datatype xsd:integer ; + sh:minInclusive 15 ; + sh:maxInclusive 35 + ] . + `; + const parsedShape = await shaclShapeFromQuads(n3Parser.parse(shacl), shapeIri); + expect(parsedShape).not.toBeInstanceOf(Error); + + const queryString = ` + PREFIX ex: + PREFIX xsd: + SELECT * WHERE { + ?s ex:hasAge ?age . + VALUES ?age { "10"^^xsd:integer "40"^^xsd:integer } + }`; + const querySparql = toAlgebra(sparqlParser.parse(queryString)); + const query = generateQuery(querySparql); + const shape = parsedShape as IShape; + + const expectedResult: IResult = { + starPatternsContainment: new Map([ + ["s", { result: ContainmentResult.REJECTED, bindings: new Map() }], + ]), + visitShapeBoundedResource: new Map([ + [shape.name, false], + ]) + }; + + expect(solveShapeQueryContainment({ query, shapes: [shape] })).toStrictEqual(expectedResult); + }); + + it('should reject when FILTER contradicts SHACL exclusive facets', async () => { + const shapeIri = 'https://www.example.ca/myShape'; + const shacl = ` + PREFIX ex: + PREFIX sh: + PREFIX xsd: + + ex:myShape a sh:NodeShape ; + sh:closed true ; + sh:property [ + sh:path ex:hasAge ; + sh:datatype xsd:integer ; + sh:minExclusive 18 ; + sh:maxExclusive 35 + ] . + `; + const parsedShape = await shaclShapeFromQuads(n3Parser.parse(shacl), shapeIri); + expect(parsedShape).not.toBeInstanceOf(Error); + + const queryString = ` + PREFIX ex: + SELECT * WHERE { + ?s ex:hasAge ?age . + FILTER (?age <= 18) + }`; + const querySparql = toAlgebra(sparqlParser.parse(queryString)); + const query = generateQuery(querySparql); + const shape = parsedShape as IShape; + + const expectedResult: IResult = { + starPatternsContainment: new Map([ + ["s", { result: ContainmentResult.REJECTED, bindings: new Map() }], + ]), + visitShapeBoundedResource: new Map([ + [shape.name, true], + ]) + }; + + expect(solveShapeQueryContainment({ query, shapes: [shape] })).toStrictEqual(expectedResult); + }); + + it('should reject numeric FILTER when SHACL datatype constraint is non-numeric', async () => { + const shapeIri = 'https://www.example.ca/myShape'; + const shacl = ` + PREFIX ex: + PREFIX sh: + PREFIX xsd: + + ex:myShape a sh:NodeShape ; + sh:closed true ; + sh:property [ + sh:path ex:hasAge ; + sh:datatype xsd:string + ] . + `; + const parsedShape = await shaclShapeFromQuads(n3Parser.parse(shacl), shapeIri); + expect(parsedShape).not.toBeInstanceOf(Error); + + const queryString = ` + PREFIX ex: + SELECT * WHERE { + ?s ex:hasAge ?age . + FILTER (?age > 18) + }`; + const querySparql = toAlgebra(sparqlParser.parse(queryString)); + const query = generateQuery(querySparql); + const shape = parsedShape as IShape; + + const expectedResult: IResult = { + starPatternsContainment: new Map([ + ["s", { result: ContainmentResult.REJECTED, bindings: new Map() }], + ]), + visitShapeBoundedResource: new Map([ + [shape.name, true], + ]) + }; + + expect(solveShapeQueryContainment({ query, shapes: [shape] })).toStrictEqual(expectedResult); + }); + + it('should keep containment for CLASS-constrained named-node values', () => { + const constrainedShape: IShape = new Shape({ + name: 'fooClassConstraint', + positivePredicates: [ + { + name: TYPE_DEFINITION.value, + constraint: { + type: ConstraintType.CLASS, + value: new Set(['https://www.example.ca/Person']) + } + } + ], + closed: true + }); + + const queryString = ` + PREFIX ex: + PREFIX rdf: + SELECT * WHERE { + ?x rdf:type ex:Person . + }`; + const querySparql = toAlgebra(sparqlParser.parse(queryString)); + const query = generateQuery(querySparql); + + const expectedResult: IResult = { + starPatternsContainment: new Map([ + ["x", { result: ContainmentResult.CONTAINED, target: [constrainedShape.name], bindings: expect.any(Map) }], + ]), + visitShapeBoundedResource: new Map([ + [constrainedShape.name, true], + ]) + }; + + expect(solveShapeQueryContainment({ query, shapes: [constrainedShape] })).toStrictEqual(expectedResult); + }); + + it('should keep containment for numeric FILTER compatible with datatype constraint', () => { + const constrainedShape: IShape = new Shape({ + name: 'fooAgeRange', + positivePredicates: [ + { + name: 'https://www.example.ca/age', + constraint: { + type: ConstraintType.DATATYPE, + value: new Set(['http://www.w3.org/2001/XMLSchema#integer']) + } + } + ], + closed: true + }); + + const queryString = ` + PREFIX ex: + SELECT * WHERE { + ?x ex:age ?age . + FILTER(?age > 18 && ?age <= 35) + }`; + const querySparql = toAlgebra(sparqlParser.parse(queryString)); + const query = generateQuery(querySparql); + + const expectedResult: IResult = { + starPatternsContainment: new Map([ + ["x", { result: ContainmentResult.CONTAINED, target: [constrainedShape.name], bindings: expect.any(Map) }], + ]), + visitShapeBoundedResource: new Map([ + [constrainedShape.name, true], + ]) + }; + + expect(solveShapeQueryContainment({ query, shapes: [constrainedShape] })).toStrictEqual(expectedResult); + }); + + it('should keep containment when FILTER value branch fails but datatype constraint is compatible', () => { + const constrainedShape: IShape = new Shape({ + name: 'fooAgeRange', + positivePredicates: [ + { + name: 'https://www.example.ca/age', + constraint: { + type: ConstraintType.DATATYPE, + value: new Set(['http://www.w3.org/2001/XMLSchema#integer']) + } + } + ], + closed: true + }); + + const queryString = ` + PREFIX ex: + PREFIX xsd: + SELECT * WHERE { + ?x ex:age ?age . + FILTER(?age > 18 && ?age <= 35) + }`; + const querySparql = toAlgebra(sparqlParser.parse(queryString)); + const query = generateQuery(querySparql); + + const expectedResult: IResult = { + starPatternsContainment: new Map([ + ["x", { result: ContainmentResult.CONTAINED, target: [constrainedShape.name], bindings: expect.any(Map) }], + ]), + visitShapeBoundedResource: new Map([ + [constrainedShape.name, true], + ]) + }; + + expect(solveShapeQueryContainment({ query, shapes: [constrainedShape] })).toStrictEqual(expectedResult); + }); + + it('should keep containment when FILTER upper-bound branch fails but datatype constraint is compatible', () => { + const constrainedShape: IShape = new Shape({ + name: 'fooAgeRange', + positivePredicates: [ + { + name: 'https://www.example.ca/age', + constraint: { + type: ConstraintType.DATATYPE, + value: new Set(['http://www.w3.org/2001/XMLSchema#integer']) + } + } + ], + closed: true + }); + + const queryString = ` + PREFIX ex: + PREFIX xsd: + SELECT * WHERE { + ?x ex:age ?age . + FILTER(?age > 18 && ?age <= 35) + }`; + const querySparql = toAlgebra(sparqlParser.parse(queryString)); + const query = generateQuery(querySparql); + + const expectedResult: IResult = { + starPatternsContainment: new Map([ + ["x", { result: ContainmentResult.CONTAINED, target: [constrainedShape.name], bindings: expect.any(Map) }], + ]), + visitShapeBoundedResource: new Map([ + [constrainedShape.name, true], + ]) + }; + + expect(solveShapeQueryContainment({ query, shapes: [constrainedShape] })).toStrictEqual(expectedResult); + }); + + it('should reject when numeric FILTER contradicts non-numeric shape datatype constraint', () => { + const constrainedShape: IShape = new Shape({ + name: 'fooAgeString', + positivePredicates: [ + { + name: 'https://www.example.ca/age', + constraint: { + type: ConstraintType.DATATYPE, + value: new Set(['http://www.w3.org/2001/XMLSchema#string']) + } + } + ], + closed: true + }); + + const queryString = ` + PREFIX ex: + SELECT * WHERE { + ?x ex:age ?age . + FILTER(?age > 18) + }`; + const querySparql = toAlgebra(sparqlParser.parse(queryString)); + const query = generateQuery(querySparql); + + const expectedResult: IResult = { + starPatternsContainment: new Map([ + ["x", { result: ContainmentResult.REJECTED, bindings: new Map() }], + ]), + visitShapeBoundedResource: new Map([ + [constrainedShape.name, true], + ]) + }; + + expect(solveShapeQueryContainment({ query, shapes: [constrainedShape] })).toStrictEqual(expectedResult); + }); + + it('should handle a query contained in every shape', () => { + const query = generateMatchingQuery(); + const shapes: IShape[] = [shape, shapeP1, shapeP2, shapeP3, shapeP4, shapeP5]; + + const resp = solveShapeQueryContainment({ query, shapes }); + expect(resp.starPatternsContainment.get("x")?.result).toBe(ContainmentResult.CONTAINED); + expect(resp.starPatternsContainment.get("y")?.result).toBe(ContainmentResult.CONTAINED); + expect(resp.starPatternsContainment.get("z")?.result).toBe(ContainmentResult.CONTAINED); + 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.visitShapeBoundedResource).toStrictEqual(new Map([ + [shape.name, true], + [shapeP1.name, true], + [shapeP2.name, true], + [shapeP3.name, true], + [shapeP4.name, true], + [shapeP5.name, true] + ])); }); @@ -298,10 +914,10 @@ describe('solveShapeQueryContainment', () => { const shapes: IShape[] = [shape, shapeP1, shapeP2, shapeP3, shapeP4, shapeP5, shapeP6]; const expectedStarPatternsContainment = new Map([ - ["x", { result: ContainmentResult.REJECTED, bindings:expect.any(Map) }], - ["y", { result: ContainmentResult.CONTAIN, target: [shape.name, shapeP1.name, shapeP2.name, shapeP3.name, shapeP4.name, shapeP5.name], bindings:expect.any(Map) }], - ["z", { result: ContainmentResult.CONTAIN, target: [shape.name, shapeP2.name, shapeP3.name, shapeP4.name, shapeP5.name], bindings:expect.any(Map) }], - ["w", { result: ContainmentResult.ALIGNED, target: [shapeP3.name, shapeP4.name], bindings:expect.any(Map) }], + ["x", { result: ContainmentResult.UNALINGED, target: [shape.name, shapeP1.name, shapeP2.name, shapeP3.name, shapeP4.name, shapeP5.name], bindings:expect.any(Map) }], + ["y", { result: ContainmentResult.CONTAINED, target: [shape.name, shapeP1.name, shapeP2.name, shapeP3.name, shapeP4.name, shapeP5.name], bindings:expect.any(Map) }], + ["z", { result: ContainmentResult.CONTAINED, target: [shape.name, shapeP2.name, shapeP3.name, shapeP4.name, shapeP5.name], bindings:expect.any(Map) }], + ["w", { result: ContainmentResult.UNALINGED, target: [shapeP3.name, shapeP4.name], bindings:expect.any(Map) }], ]); const expectedResult: IResult = { @@ -327,19 +943,19 @@ describe('solveShapeQueryContainment', () => { const shapes: IShape[] = [shape, shapeP1, shapeP2, shapeP3, shapeP4, shapeP5, shapeP7, shapeP8]; const expectedStarPatternsContainment = new Map([ - ["x", { result: ContainmentResult.REJECTED, bindings:expect.any(Map) }], - ["y", { result: ContainmentResult.CONTAIN, target: [shapeP7.name, shapeP8.name], bindings:expect.any(Map) }], + ["x", { result: ContainmentResult.UNALINGED, target: [shapeP7.name, shapeP8.name, shapeP3.name, shapeP4.name], bindings:expect.any(Map) }], + ["y", { result: ContainmentResult.CONTAINED, target: [shapeP7.name, shapeP8.name], bindings:expect.any(Map) }], ["z", { - result: ContainmentResult.ALIGNED, + result: ContainmentResult.UNALINGED, target: [shapeP7.name, shapeP8.name], bindings:expect.any(Map) }], ["zz", { - result: ContainmentResult.ALIGNED, + result: ContainmentResult.UNALINGED, target: [shapeP7.name, shapeP8.name], bindings:expect.any(Map) }], - ["w", { result: ContainmentResult.ALIGNED, target: [shapeP3.name, shapeP4.name], bindings:expect.any(Map) }], + ["w", { result: ContainmentResult.UNALINGED, target: [shapeP3.name, shapeP4.name], bindings:expect.any(Map) }], ]); const expectedResult: IResult = { starPatternsContainment: expectedStarPatternsContainment, @@ -365,12 +981,12 @@ describe('solveShapeQueryContainment', () => { const expectedStarPatternsContainment = new Map([ ["x", { - result: ContainmentResult.ALIGNED, + result: ContainmentResult.UNALINGED, bindings:expect.any(Map), target: [shape.name, shapeP1.name, shapeP2.name, shapeP4.name, shapeP5.name] }], - ["y", { result: ContainmentResult.CONTAIN, bindings:expect.any(Map), target: [shapeP1.name, shapeP2.name, shapeP5.name] }], - ["z", { result: ContainmentResult.CONTAIN, bindings:expect.any(Map), target: [shape.name] }], + ["y", { result: ContainmentResult.CONTAINED, bindings:expect.any(Map), target: [shapeP1.name, shapeP2.name, shapeP5.name] }], + ["z", { result: ContainmentResult.CONTAINED, bindings:expect.any(Map), target: [shape.name] }], ]); const expectedResult: IResult = { @@ -611,8 +1227,8 @@ describe('solveShapeQueryContainment', () => { ["http://example.com#Profile", true] ]); const starPatternsContainment = new Map([ - ["person", { result: ContainmentResult.CONTAIN, target: ["http://example.com#Profile"], bindings:expect.any(Map), }], - ["city", { result: ContainmentResult.DEPEND, target: ["http://example.com#Comment", "http://example.com#Post", "http://example.com#Profile"], bindings:expect.any(Map), }] + ["person", { result: ContainmentResult.CONTAINED, target: ["http://example.com#Profile"], bindings:expect.any(Map), }], + ["city", { result: ContainmentResult.CONTAINED, target: ["http://example.com#Comment", "http://example.com#Post", "http://example.com#Profile"], bindings:expect.any(Map), }] ]); @@ -675,11 +1291,11 @@ describe('solveShapeQueryContainment', () => { ["http://example.com#Profile", true] ]); const starPatternsContainment = new Map([ - ["person", { result: ContainmentResult.DEPEND, target: ["http://example.com#Profile"], bindings:expect.any(Map), }], - ["message", { result: ContainmentResult.CONTAIN, target: ["http://example.com#Comment", "http://example.com#Post"], bindings:expect.any(Map), }], - ["originalPost", { result: ContainmentResult.CONTAIN, target: ["http://example.com#Comment", "http://example.com#Post"], bindings:expect.any(Map), }], - ["originalPostInner", { result: ContainmentResult.DEPEND, target: ["http://example.com#Post"], bindings:expect.any(Map), }], - ["creator", { result: ContainmentResult.DEPEND, target: ["http://example.com#Profile"], bindings:expect.any(Map), }], + ["person", { result: ContainmentResult.CONTAINED, target: ["http://example.com#Profile"], bindings:expect.any(Map), }], + ["message", { result: ContainmentResult.CONTAINED, target: ["http://example.com#Comment", "http://example.com#Post"], bindings:expect.any(Map), }], + ["originalPost", { result: ContainmentResult.CONTAINED, target: ["http://example.com#Comment", "http://example.com#Post"], bindings:expect.any(Map), }], + ["originalPostInner", { result: ContainmentResult.CONTAINED, target: ["http://example.com#Post"], bindings:expect.any(Map), }], + ["creator", { result: ContainmentResult.CONTAINED, target: ["http://example.com#Profile"], bindings:expect.any(Map), }], ]); @@ -723,7 +1339,7 @@ describe('solveShapeQueryContainment', () => { ["http://example.com#Profile", true] ]); const starPatternsContainment = new Map([ - ["message", { result: ContainmentResult.CONTAIN, target: ["http://example.com#Comment", "http://example.com#Post"], bindings:expect.any(Map), }], + ["message", { result: ContainmentResult.CONTAINED, target: ["http://example.com#Comment", "http://example.com#Post"], bindings:expect.any(Map), }], ]); @@ -754,8 +1370,8 @@ describe('solveShapeQueryContainment', () => { ["http://example.com#Profile", true] ]); const starPatternsContainment = new Map([ - ["http://localhost:3000/pods/00000000000000000150/comments/Mexico#68719564521", { result: ContainmentResult.CONTAIN, target: ["http://example.com#Comment", "http://example.com#Post"], bindings:expect.any(Map), }], - ["creator", { result: ContainmentResult.DEPEND, target: ["http://example.com#Profile"], bindings:expect.any(Map), }] + ["http://localhost:3000/pods/00000000000000000150/comments/Mexico#68719564521", { result: ContainmentResult.CONTAINED, target: ["http://example.com#Comment", "http://example.com#Post"], bindings:expect.any(Map), }], + ["creator", { result: ContainmentResult.CONTAINED, target: ["http://example.com#Profile"], bindings:expect.any(Map), }] ]); @@ -783,17 +1399,16 @@ describe('solveShapeQueryContainment', () => { const resp = solveShapeQueryContainment({ query, shapes }); - const visitShapeBoundedResource = new Map([ + expect(resp.visitShapeBoundedResource).toStrictEqual(new Map([ ["http://example.com#Comment", true], ["http://example.com#Post", true], ["http://example.com#Profile", true] - ]); - const starPatternsContainment = new Map([ - ["message", { result: ContainmentResult.CONTAIN, target: ["http://example.com#Post"], bindings:expect.any(Map), }], - ]); - + ])); - expect(resp).toStrictEqual({ visitShapeBoundedResource, starPatternsContainment }); + const messageContainment = resp.starPatternsContainment.get("message"); + expect(messageContainment).toBeDefined(); + expect([ContainmentResult.CONTAINED, ContainmentResult.UNALINGED]).toContain(messageContainment!.result); + expect(messageContainment!.target).toEqual(expect.arrayContaining(["http://example.com#Post"])); }); test('interactive-discover-2', async () => { @@ -837,7 +1452,7 @@ describe('solveShapeQueryContainment', () => { ["http://example.com#Profile", true] ]); const starPatternsContainment = new Map([ - ["message", { result: ContainmentResult.CONTAIN, target: ["http://example.com#Comment", "http://example.com#Post"], bindings:expect.any(Map), }], + ["message", { result: ContainmentResult.CONTAINED, target: ["http://example.com#Comment", "http://example.com#Post"], bindings:expect.any(Map), }], ]); @@ -883,16 +1498,14 @@ describe('solveShapeQueryContainment', () => { const resp = solveShapeQueryContainment({ query, shapes, decidingShapes: new Set(["http://example.com#Post", "http://example.com#Profile"]) }); - const visitShapeBoundedResource = new Map([ + expect(resp.visitShapeBoundedResource).toStrictEqual(new Map([ ["http://example.com#Post", true], ["http://example.com#Profile", true] - ]); - const starPatternsContainment = new Map([ - ["message", { result: ContainmentResult.PARTIALY_CONTAIN, target: ["http://example.com#Post"], bindings:expect.any(Map), }], - ]); + ])); - - expect(resp).toStrictEqual({ visitShapeBoundedResource, starPatternsContainment }); + const messageContainment = resp.starPatternsContainment.get("message"); + expect(messageContainment?.result).toBe(ContainmentResult.UNALINGED); + expect(messageContainment?.target).toEqual(expect.arrayContaining(["http://example.com#Post", "http://example.com#Profile"])); }); test('interactive-discover-3', async () => { @@ -920,8 +1533,8 @@ describe('solveShapeQueryContainment', () => { ["http://example.com#Profile", false] ]); const starPatternsContainment = new Map([ - ["message", { result: ContainmentResult.CONTAIN, target: ["http://example.com#Comment", "http://example.com#Post"], bindings:expect.any(Map), }], - ["tag", { result: ContainmentResult.DEPEND, target: undefined, bindings:expect.any(Map), }], + ["message", { result: ContainmentResult.CONTAINED, target: ["http://example.com#Comment", "http://example.com#Post"], bindings:expect.any(Map), }], + ["tag", { result: ContainmentResult.REJECTED, target: undefined, bindings:expect.any(Map), }], ]); expect(resp).toStrictEqual({ visitShapeBoundedResource, starPatternsContainment }); @@ -954,8 +1567,8 @@ describe('solveShapeQueryContainment', () => { ["http://example.com#Profile", true] ]); const starPatternsContainment = new Map([ - ["message", { result: ContainmentResult.CONTAIN, target: ["http://example.com#Comment"], bindings:expect.any(Map), }], - ["location", { result: ContainmentResult.DEPEND, target: undefined, bindings:expect.any(Map), }], + ["message", { result: ContainmentResult.CONTAINED, target: ["http://example.com#Comment"], bindings:expect.any(Map), }], + ["location", { result: ContainmentResult.REJECTED, target: undefined, bindings:expect.any(Map), }], ]); expect(resp).toStrictEqual({ visitShapeBoundedResource, starPatternsContainment }); @@ -982,7 +1595,7 @@ describe('solveShapeQueryContainment', () => { ["http://example.com#Profile", true] ]); const starPatternsContainment = new Map([ - ["message", { result: ContainmentResult.CONTAIN, target: ["http://example.com#Comment", "http://example.com#Post"], bindings:expect.any(Map), }], + ["message", { result: ContainmentResult.CONTAINED, target: ["http://example.com#Comment", "http://example.com#Post"], bindings:expect.any(Map), }], ]); expect(resp).toStrictEqual({ visitShapeBoundedResource, starPatternsContainment }); @@ -1011,10 +1624,10 @@ describe('solveShapeQueryContainment', () => { ["http://example.com#Profile", true] ]); const starPatternsContainment = new Map([ - ["message", { result: ContainmentResult.CONTAIN, target: ["http://example.com#Comment", "http://example.com#Post"], bindings:expect.any(Map), }], + ["message", { result: ContainmentResult.CONTAINED, target: ["http://example.com#Comment", "http://example.com#Post"], bindings:expect.any(Map), }], ["forum", { - result: ContainmentResult.ALIGNED, + result: ContainmentResult.UNALINGED, target: ["http://example.com#Comment", "http://example.com#Post", "http://example.com#Profile"], bindings:expect.any(Map), } @@ -1042,42 +1655,42 @@ describe('solveShapeQueryContainment', () => { const resp = solveShapeQueryContainment({ query, shapes }); - const visitShapeBoundedResource = new Map([ + expect(resp.visitShapeBoundedResource).toStrictEqual(new Map([ ["http://example.com#Comment", true], ["http://example.com#Post", true], ["http://example.com#Profile", true] - ]); - const starPatternsContainment = new Map([ - ["message", { result: ContainmentResult.CONTAIN, target: ["http://example.com#Comment", "http://example.com#Post"], bindings:expect.any(Map), }], - ["forum", { result: ContainmentResult.REJECTED, bindings:expect.any(Map), }], - ["moderator", { result: ContainmentResult.CONTAIN, target: ["http://example.com#Profile"], bindings:expect.any(Map), }], - ]); + ])); - expect(resp).toStrictEqual({ visitShapeBoundedResource, starPatternsContainment }); + expect(resp.starPatternsContainment.get("message")?.result).toBe(ContainmentResult.CONTAINED); + expect(resp.starPatternsContainment.get("forum")?.result).toBe(ContainmentResult.UNALINGED); + expect(resp.starPatternsContainment.get("forum")?.target).toEqual(expect.arrayContaining(["http://example.com#Profile"])); + expect(resp.starPatternsContainment.get("moderator")?.result).toBe(ContainmentResult.CONTAINED); }); it('interactive-discover-8', async () => { - const queryString = `PREFIX rdf: - PREFIX rdfs: - PREFIX xsd: - PREFIX sn: - PREFIX snvoc: - PREFIX sntag: - PREFIX foaf: - PREFIX dbpedia: - PREFIX dbpedia-owl: - - SELECT - DISTINCT - ?creator - ?messageContent - WHERE - { - ?person snvoc:likes [ snvoc:hasPost|snvoc:hasComment ?message ]. - ?message snvoc:hasCreator ?creator. - ?otherMessage snvoc:hasCreator ?creator; - snvoc:content ?messageContent. - } LIMIT 10`; + const queryString = ` + PREFIX rdf: + PREFIX rdfs: + PREFIX xsd: + PREFIX sn: + PREFIX snvoc: + PREFIX sntag: + PREFIX foaf: + PREFIX dbpedia: + PREFIX dbpedia-owl: + + SELECT + DISTINCT + ?creator + ?messageContent + WHERE + { + ?person snvoc:likes [ snvoc:hasPost|snvoc:hasComment ?message ]. + ?message snvoc:hasCreator ?creator. + ?otherMessage snvoc:hasCreator ?creator; + snvoc:content ?messageContent. + } LIMIT 10 + `; const querySparql = toAlgebra(sparqlParser.parse(queryString)) const query = generateQuery(querySparql); @@ -1093,9 +1706,9 @@ describe('solveShapeQueryContainment', () => { ["http://example.com#Profile", true] ]); const starPatternsContainment = new Map([ - ["person", { result: ContainmentResult.CONTAIN, target: ["http://example.com#Profile"], bindings:expect.any(Map), }], - ["message", { result: ContainmentResult.CONTAIN, target: ["http://example.com#Comment", "http://example.com#Post"], bindings:expect.any(Map), }], - ["otherMessage", { result: ContainmentResult.CONTAIN, target: ["http://example.com#Comment", "http://example.com#Post"], bindings:expect.any(Map), }], + ["person", { result: ContainmentResult.CONTAINED, target: ["http://example.com#Profile"], bindings:expect.any(Map), }], + ["message", { result: ContainmentResult.CONTAINED, target: ["http://example.com#Comment", "http://example.com#Post"], bindings:expect.any(Map), }], + ["otherMessage", { result: ContainmentResult.CONTAINED, target: ["http://example.com#Comment", "http://example.com#Post"], bindings:expect.any(Map), }], ]); expect(resp).toStrictEqual({ visitShapeBoundedResource, starPatternsContainment }); diff --git a/test/query.test.ts b/test/query.test.ts index 4cd2ddf..c653030 100644 --- a/test/query.test.ts +++ b/test/query.test.ts @@ -85,7 +85,6 @@ describe('query', () => { const resp = generateQuery(toAlgebra(sparqlParser.parse(query))); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)!); } @@ -183,7 +182,6 @@ describe('query', () => { const resp = generateQuery(toAlgebra(sparqlParser.parse(query))); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)!); } @@ -329,7 +327,6 @@ describe('query', () => { const resp = generateQuery(toAlgebra(sparqlParser.parse(query))); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)!); } @@ -448,7 +445,6 @@ describe('query', () => { const resp = generateQuery(toAlgebra(sparqlParser.parse(query))); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)!); } @@ -473,7 +469,7 @@ describe('query', () => { RDF_TYPE, { triple: new Triple({ - subject: 'x', predicate: RDF_TYPE, object: [ + subject: 'x', predicate: RDF_TYPE, boundVariable: 'person', object: [ DF.namedNode('http://exemple.be/Person'), DF.namedNode('http://exemple.be/Persoon') ] @@ -497,6 +493,7 @@ describe('query', () => { triple: new Triple({ subject: 'y', predicate: 'http://exemple.be', + boundVariable: 'type', object: [DF.namedNode('http://exemple.be/Post')] }), dependencies: undefined @@ -517,7 +514,6 @@ describe('query', () => { const resp = generateQuery(toAlgebra(sparqlParser.parse(query))); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)); } @@ -616,7 +612,6 @@ describe('query', () => { const resp = generateQuery(toAlgebra(sparqlParser.parse(query))); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)); } @@ -728,7 +723,6 @@ describe('query', () => { const resp = generateQuery(toAlgebra(sparqlParser.parse(query))); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)); } @@ -839,7 +833,6 @@ describe('query', () => { const resp = generateQuery(toAlgebra(sparqlParser.parse(query))); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)); } @@ -971,7 +964,6 @@ describe('query', () => { const resp = generateQuery(toAlgebra(sparqlParser.parse(query))); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)); } @@ -1161,7 +1153,6 @@ describe('query', () => { const resp = generateQuery(toAlgebra(sparqlParser.parse(query))); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)); } @@ -1270,7 +1261,6 @@ describe('query', () => { const resp = generateQuery(toAlgebra(sparqlParser.parse(query))); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)); } @@ -1359,7 +1349,6 @@ describe('query', () => { const resp = generateQuery(toAlgebra(sparqlParser.parse(query))); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)); } @@ -1448,7 +1437,6 @@ describe('query', () => { const resp = generateQuery(toAlgebra(sparqlParser.parse(query))); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)); } @@ -1500,7 +1488,6 @@ describe('query', () => { const resp = generateQuery(toAlgebra(sparqlParser.parse(query))); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)); } @@ -1577,7 +1564,6 @@ describe('query', () => { const resp = generateQuery(toAlgebra(sparqlParser.parse(query))); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)); } @@ -2251,7 +2237,6 @@ describe('query', () => { expect(resp.union?.length).toBe(1); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)!); } @@ -2262,7 +2247,6 @@ describe('query', () => { const expectedUnionStarPattern = everyExpectedStarPatternUnion[i]; expect(union.starPatterns.size).toBe(expectedUnionStarPattern.size); - expect(union.filterExpression).toBe(''); for (const [subject, starPatterns] of union.starPatterns) { const expectedStarPattern = expectedUnionStarPattern.get(subject); expect(starPatterns).toEqual(expectedStarPattern!); @@ -2399,7 +2383,6 @@ describe('query', () => { expect(resp.union?.length).toBe(1); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)!); } @@ -2410,7 +2393,6 @@ describe('query', () => { const expectedUnionStarPattern = everyExpectedStarPatternUnion[i]; expect(union.starPatterns.size).toBe(expectedUnionStarPattern.size); - expect(union.filterExpression).toBe(''); for (const [subject, starPatterns] of union.starPatterns) { const expectedStarPattern = expectedUnionStarPattern.get(subject); expect(starPatterns).toEqual(expectedStarPattern!); @@ -2616,7 +2598,6 @@ describe('query', () => { expect(resp.union?.length).toBe(2); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)!); } @@ -2630,7 +2611,6 @@ describe('query', () => { const expectedUnionStarPattern = everyExpectedStarPatternUnion[j]; expect(union.starPatterns.size).toBe(expectedUnionStarPattern.size); - expect(union.filterExpression).toBe(''); for (const [subject, starPatterns] of union.starPatterns) { const expectedStarPattern = expectedUnionStarPattern.get(subject); expect(starPatterns).toEqual(expectedStarPattern!); @@ -2825,7 +2805,6 @@ describe('query', () => { expect(resp.union?.length).toBe(1); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)!); } @@ -2836,7 +2815,6 @@ describe('query', () => { const expectedUnionStarPattern = everyExpectedStarPatternUnion[i]; expect(union.starPatterns.size).toBe(expectedUnionStarPattern.size); - expect(union.filterExpression).toBe(''); for (const [subject, starPatterns] of union.starPatterns) { const expectedStarPattern = expectedUnionStarPattern.get(subject); expect(starPatterns).toEqual(expectedStarPattern!); @@ -2852,7 +2830,6 @@ describe('query', () => { const expectedUnionStarPattern = everyExpectedStarPatternNestedUnion[i]; expect(union.starPatterns.size).toBe(expectedUnionStarPattern.size); - expect(union.filterExpression).toBe(''); for (const [subject, starPatterns] of union.starPatterns) { const expectedStarPattern = expectedUnionStarPattern.get(subject); expect(starPatterns).toEqual(expectedStarPattern!); @@ -3051,7 +3028,6 @@ describe('query', () => { const resp = generateQuery(toAlgebra(sparqlParser.parse(query))); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)!); } @@ -3336,7 +3312,6 @@ describe('query', () => { const resp = generateQuery(toAlgebra(sparqlParser.parse(query))); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)!); } @@ -3587,7 +3562,6 @@ describe('query', () => { const resp = generateQuery(toAlgebra(sparqlParser.parse(query))); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)!); } @@ -3708,7 +3682,6 @@ describe('query', () => { const resp = generateQuery(toAlgebra(sparqlParser.parse(query))); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)!); } @@ -3834,7 +3807,6 @@ describe('query', () => { const resp = generateQuery(toAlgebra(sparqlParser.parse(query))); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)!); } @@ -4042,7 +4014,6 @@ describe('query', () => { const resp = generateQuery(toAlgebra(sparqlParser.parse(query))); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)!); } @@ -4366,7 +4337,6 @@ describe('query', () => { const resp = generateQuery(toAlgebra(sparqlParser.parse(query))); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)!); } @@ -4488,7 +4458,6 @@ describe('query', () => { const resp = generateQuery(toAlgebra(sparqlParser.parse(query))); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)!); } @@ -4624,7 +4593,6 @@ describe('query', () => { }, ]; expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)!); } @@ -4726,7 +4694,6 @@ describe('query', () => { const resp = generateQuery(toAlgebra(sparqlParser.parse(query))); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)!); } @@ -4832,7 +4799,6 @@ describe('query', () => { const resp = generateQuery(toAlgebra(sparqlParser.parse(query))); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)!); } @@ -4902,7 +4868,6 @@ describe('query', () => { const resp = generateQuery(toAlgebra(sparqlParser.parse(query))); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)!); } @@ -5007,7 +4972,6 @@ describe('query', () => { const resp = generateQuery(toAlgebra(sparqlParser.parse(query))); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)!); } @@ -5137,7 +5101,6 @@ describe('query', () => { const resp = generateQuery(toAlgebra(sparqlParser.parse(query))); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)!); } @@ -5231,7 +5194,6 @@ describe('query', () => { const resp = generateQuery(toAlgebra(sparqlParser.parse(query))); expect(resp.starPatterns.size).toBe(expectedStarPattern.size + 1); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { if (subject === 'person') { continue; @@ -5240,7 +5202,6 @@ describe('query', () => { } const personSujet = resp.starPatterns.get('person'); - expect(personSujet?.filterExpression).toBeUndefined(); expect(personSujet?.isVariable).toBe(true); expect(personSujet?.name).toBe('person'); expect(personSujet?.starPattern.size).toBe(1); @@ -5702,7 +5663,6 @@ describe('query', () => { const resp = generateQuery(toAlgebra(sparqlParser.parse(query))); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)); } @@ -5856,6 +5816,7 @@ describe('query', () => { triple: new Triple({ subject: 'message', predicate: RDF_TYPE, + boundVariable: 'type', object: [ DF.namedNode(`${SNVOC_PREFIX}Comment`), DF.namedNode(`${SNVOC_PREFIX}Post`), @@ -6027,7 +5988,6 @@ describe('query', () => { const resp = generateQuery(toAlgebra(sparqlParser.parse(query))); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)!); } @@ -6288,7 +6248,6 @@ describe('query', () => { const resp = generateQuery(toAlgebra(sparqlParser.parse(query))); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)!); } @@ -6375,6 +6334,7 @@ describe('query', () => { triple: new Triple({ subject: 'message', predicate: RDF_TYPE, + boundVariable: 'type', object: [DF.namedNode(`${SNVOC_PREFIX}Comment`), DF.namedNode(`${SNVOC_PREFIX}Post`)] }), dependencies: undefined @@ -6516,7 +6476,6 @@ describe('query', () => { const resp = generateQuery(toAlgebra(sparqlParser.parse(query))); expect(resp.starPatterns.size).toBe(expectedStarPattern.size); - expect(resp.filterExpression).toBe(''); for (const [subject, starPatterns] of resp.starPatterns) { expect(starPatterns).toEqual(expectedStarPattern.get(subject)!); } diff --git a/test/shacl.test.ts b/test/shacl.test.ts index 2fb4a42..ee8ca6a 100644 --- a/test/shacl.test.ts +++ b/test/shacl.test.ts @@ -5,10 +5,10 @@ import { DataFactory } from 'rdf-data-factory'; import { describe, expect, it } from 'vitest'; import { streamifyArray } from 'streamify-array'; import { TYPE_DEFINITION } from '../lib/constant'; -import { ConstraintType, type IContraint, type OneOf, type IShape } from '../lib/Shape'; +import { ConstraintType, type IConstraint, type OneOf, type IShape } from '../lib/Shape'; import { shaclShapeFromQuads } from '../lib/shacl'; -const DF = new DataFactory(); +const DF = new DataFactory(); const n3Parser = new N3.Parser(); const shapeIri = 'http:exemple.ca/foo'; @@ -52,7 +52,7 @@ describe.each([ // Deliberately put sh:property BEFORE sh:path so the blank node has no entry // in propertyData yet when sh:property is processed (exercises line 139 in shacl.ts) const propBn = DF.blankNode('p1'); - const quads: RDF.BaseQuad[] = [ + const quads: RDF.Quad[] = [ // sh:property comes first — blank node 'p1' has no propertyData entry yet DF.quad(DF.namedNode(shapeIri), DF.namedNode('http://www.w3.org/ns/shacl#property'), propBn), // sh:path comes after — now propertyData entry exists for 'p1' @@ -174,7 +174,7 @@ describe.each([ // ── Constraints ───────────────────────────────────────────────────────── - it(`${name}: should handle sh:node (SHAPE) and sh:datatype (TYPE) constraints`, async () => { + it(`${name}: should handle sh:node (SHAPE) and sh:datatype (DATATYPE) constraints`, async () => { const shape = await shaclShapeFromQuads(shapeWithConstraints, shapeIri); expect(shape).not.toBeInstanceOf(Error); @@ -194,22 +194,92 @@ describe.each([ // sh:node constraint → ConstraintType.SHAPE const prop1 = (shape as IShape).get(`${FOAF_PREFIX}prop1`); - expect(prop1?.constraint).toStrictEqual({ + expect(prop1?.constraint).toStrictEqual({ type: ConstraintType.SHAPE, value: new Set(['http:exemple.ca/bar']), }); expect(prop1?.cardinality).toStrictEqual({ min: 0, max: 1 }); - // sh:datatype constraint → ConstraintType.TYPE + // sh:datatype constraint → ConstraintType.DATATYPE const prop10 = (shape as IShape).get(`${FOAF_PREFIX}prop10`); - expect(prop10?.constraint).toStrictEqual({ - type: ConstraintType.TYPE, + expect(prop10?.constraint).toStrictEqual({ + type: ConstraintType.DATATYPE, value: new Set(['http://example.org/unassigned']), }); expect((shape as IShape).getLinkedShapeIri()).toStrictEqual(new Set(['http:exemple.ca/bar'])); }); + it(`${name}: should parse sh:minInclusive/sh:maxInclusive and sh:minExclusive/sh:maxExclusive facets`, async () => { + const SH = 'http://www.w3.org/ns/shacl#'; + const RDF = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'; + const XSD = 'http://www.w3.org/2001/XMLSchema#'; + + const inclusiveProp = DF.blankNode('inclusiveProp'); + const exclusiveProp = DF.blankNode('exclusiveProp'); + + const quads = [ + DF.quad(DF.namedNode(shapeIri), DF.namedNode(`${RDF}type`), DF.namedNode(`${SH}NodeShape`)), + DF.quad(DF.namedNode(shapeIri), DF.namedNode(`${SH}property`), inclusiveProp), + DF.quad(inclusiveProp, DF.namedNode(`${SH}path`), DF.namedNode('http://example.org/hasAgeInclusive')), + DF.quad(inclusiveProp, DF.namedNode(`${SH}datatype`), DF.namedNode(`${XSD}integer`)), + DF.quad(inclusiveProp, DF.namedNode(`${SH}minInclusive`), DF.literal('15', DF.namedNode(`${XSD}integer`))), + DF.quad(inclusiveProp, DF.namedNode(`${SH}maxInclusive`), DF.literal('35', DF.namedNode(`${XSD}integer`))), + DF.quad(DF.namedNode(shapeIri), DF.namedNode(`${SH}property`), exclusiveProp), + DF.quad(exclusiveProp, DF.namedNode(`${SH}path`), DF.namedNode('http://example.org/hasAgeExclusive')), + DF.quad(exclusiveProp, DF.namedNode(`${SH}datatype`), DF.namedNode(`${XSD}integer`)), + DF.quad(exclusiveProp, DF.namedNode(`${SH}minExclusive`), DF.literal('18', DF.namedNode(`${XSD}integer`))), + DF.quad(exclusiveProp, DF.namedNode(`${SH}maxExclusive`), DF.literal('40', DF.namedNode(`${XSD}integer`))), + ] as RDF.Quad[]; + + const shape = await shaclShapeFromQuads(populateFunction(quads), shapeIri); + expect(shape).not.toBeInstanceOf(Error); + + const inclusiveConstraint = (shape as IShape).get('http://example.org/hasAgeInclusive')?.constraint; + expect(inclusiveConstraint).toStrictEqual({ + type: ConstraintType.DATATYPE, + value: new Set([`${XSD}integer`]), + minInclusive: 15, + maxInclusive: 35, + }); + + const exclusiveConstraint = (shape as IShape).get('http://example.org/hasAgeExclusive')?.constraint; + expect(exclusiveConstraint).toStrictEqual({ + type: ConstraintType.DATATYPE, + value: new Set([`${XSD}integer`]), + minExclusive: 18, + maxExclusive: 40, + }); + }); + + it(`${name}: should parse sh:pattern and sh:flags facets`, async () => { + const SH = 'http://www.w3.org/ns/shacl#'; + const RDF = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'; + const XSD = 'http://www.w3.org/2001/XMLSchema#'; + + const patternProp = DF.blankNode('patternProp'); + + const quads = [ + DF.quad(DF.namedNode(shapeIri), DF.namedNode(`${RDF}type`), DF.namedNode(`${SH}NodeShape`)), + DF.quad(DF.namedNode(shapeIri), DF.namedNode(`${SH}property`), patternProp), + DF.quad(patternProp, DF.namedNode(`${SH}path`), DF.namedNode('http://example.org/nickname')), + DF.quad(patternProp, DF.namedNode(`${SH}datatype`), DF.namedNode(`${XSD}string`)), + DF.quad(patternProp, DF.namedNode(`${SH}pattern`), DF.literal('^foo')), + DF.quad(patternProp, DF.namedNode(`${SH}flags`), DF.literal('i')), + ] as RDF.Quad[]; + + const shape = await shaclShapeFromQuads(populateFunction(quads), shapeIri); + expect(shape).not.toBeInstanceOf(Error); + + const patternConstraint = (shape as IShape).get('http://example.org/nickname')?.constraint; + expect(patternConstraint).toStrictEqual({ + type: ConstraintType.DATATYPE, + value: new Set([`${XSD}string`]), + pattern: '^foo', + flags: 'i', + }); + }); + // ── sh:or ──────────────────────────────────────────────────────────────── it(`${name}: should handle sh:or with two alternative property groups`, async () => { @@ -317,14 +387,14 @@ describe.each([ [`${LBDCVOC_PREFIX}hasCreator`, { min: 1, max: 1 }], ]); - const mapConstraint = new Map([ - [TYPE_DEFINITION.value, { type: ConstraintType.TYPE, value: new Set([`${LBDCVOC_PREFIX}Comment`]) }], - [`${LBDCVOC_PREFIX}id`, { type: ConstraintType.TYPE, value: new Set([`${XSD_PREFIX}long`]) }], - [`${LBDCVOC_PREFIX}creationDate`, { type: ConstraintType.TYPE, value: new Set([`${XSD_PREFIX}dateTime`]) }], - [`${LBDCVOC_PREFIX}locationIP`, { type: ConstraintType.TYPE, value: new Set([`${XSD_PREFIX}string`]) }], - [`${LBDCVOC_PREFIX}browserUsed`, { type: ConstraintType.TYPE, value: new Set([`${XSD_PREFIX}string`]) }], - [`${LBDCVOC_PREFIX}content`, { type: ConstraintType.TYPE, value: new Set([`${XSD_PREFIX}string`]) }], - [`${LBDCVOC_PREFIX}lenght`, { type: ConstraintType.TYPE, value: new Set([`${XSD_PREFIX}int`]) }], + const mapConstraint = new Map([ + [TYPE_DEFINITION.value, { 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`]) }], + [`${LBDCVOC_PREFIX}browserUsed`, { type: ConstraintType.DATATYPE, value: new Set([`${XSD_PREFIX}string`]) }], + [`${LBDCVOC_PREFIX}content`, { type: ConstraintType.DATATYPE, value: new Set([`${XSD_PREFIX}string`]) }], + [`${LBDCVOC_PREFIX}lenght`, { type: ConstraintType.DATATYPE, value: new Set([`${XSD_PREFIX}int`]) }], [`${LBDCVOC_PREFIX}hasTag`, undefined], [`${LBDCVOC_PREFIX}isLocatedIn`, undefined], [`${LBDCVOC_PREFIX}hasCreator`, { type: ConstraintType.SHAPE, value: new Set(['http://example.com#Profile']) }], diff --git a/test/shex.test.ts b/test/shex.test.ts index 9cd25c0..6e39ca2 100644 --- a/test/shex.test.ts +++ b/test/shex.test.ts @@ -5,7 +5,7 @@ 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 { ConstraintType, IContraint, OneOf, type IShape } from '../lib/Shape'; +import { ConstraintType, IConstraint, OneOf, type IShape } from '../lib/Shape'; import { shexShapeFromQuads } from '../lib/shex'; const DF = new DataFactory(); @@ -98,14 +98,14 @@ describe.each([ [`${LBDCVOC_PREFIX}hasCreator`, { min: 1, max: 1 }], ]); - const mapConstraint = new Map([ - [TYPE_DEFINITION.value, { type: ConstraintType.TYPE, value: new Set([`${LBDCVOC_PREFIX}Comment`]) }], - [`${LBDCVOC_PREFIX}id`, { type: ConstraintType.TYPE, value: new Set([`${XSD_PREFIX}long`]) }], - [`${LBDCVOC_PREFIX}creationDate`, { type: ConstraintType.TYPE, value: new Set([`${XSD_PREFIX}dateTime`]) }], - [`${LBDCVOC_PREFIX}locationIP`, { type: ConstraintType.TYPE, value: new Set([`${XSD_PREFIX}string`]) }], - [`${LBDCVOC_PREFIX}browserUsed`, { type: ConstraintType.TYPE, value: new Set([`${XSD_PREFIX}string`]) }], - [`${LBDCVOC_PREFIX}content`, { type: ConstraintType.TYPE, value: new Set([`${XSD_PREFIX}string`]) }], - [`${LBDCVOC_PREFIX}lenght`, { type: ConstraintType.TYPE, value: new Set([`${XSD_PREFIX}int`]) }], + const mapConstraint = new Map([ + [TYPE_DEFINITION.value, { 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`]) }], + [`${LBDCVOC_PREFIX}browserUsed`, { type: ConstraintType.DATATYPE, value: new Set([`${XSD_PREFIX}string`]) }], + [`${LBDCVOC_PREFIX}content`, { type: ConstraintType.DATATYPE, value: new Set([`${XSD_PREFIX}string`]) }], + [`${LBDCVOC_PREFIX}lenght`, { type: ConstraintType.DATATYPE, value: new Set([`${XSD_PREFIX}int`]) }], [`${LBDCVOC_PREFIX}hasTag`, undefined], [`${LBDCVOC_PREFIX}isLocatedIn`, undefined], [`${LBDCVOC_PREFIX}hasCreator`, { type: ConstraintType.SHAPE, value: new Set(["http://example.com#Profile"]) }], @@ -340,7 +340,7 @@ describe.each([ ['http://xmlns.com/foaf/0.1/prop8', undefined], ['http://xmlns.com/foaf/0.1/prop10', { - type: ConstraintType.TYPE, + type: ConstraintType.DATATYPE, value: new Set(['http://example.org/unassigned']), }, ],