diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e678610..653f65b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: - main pull_request: + workflow_dispatch: jobs: build-and-test: diff --git a/README.md b/README.md index 7f0f6e4..7c39409 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # query-shape-matching-algorithm +[![CI](https://github.com/DiSHACLed/query-shape-matching-algorithm/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/DiSHACLed/query-shape-matching-algorithm/actions/workflows/ci.yml) [![npm version](https://badge.fury.io/js/query-shape-matching.svg)](https://www.npmjs.com/package/query-shape-matching) Reference implementation for the query/shape matching algorithm described in the discovery specification. @@ -11,6 +12,9 @@ It is implemented as a Node.js library to calculate the containment (subsumption - **Star Pattern Decomposition**: Breaks down complex SPARQL queries into star patterns (groups of triple patterns sharing the same subject). - **Alignment Detection**: Identifies how closely a query matches the constraints defined in a shape. - **Dependency Tracking**: Handles links between shapes, detecting when a star pattern depends on another to be fully bounded. +- **Explicit Negation**: A predicate forbidden by SHACL `sh:not [ sh:path p ; sh:minCount 1 ]` or limited by `sh:maxCount 0` (or a ShEx negative triple constraint) never matches. Because such a shape states that the property must be absent, a query needing it is `REJECTED` even when the shape is open. A `sh:not` with any other parameter forbids nothing (`sh:not [ sh:path p ; sh:minCount 2 ]` allows one value), so it is ignored and reported as an `UNSUPPORTED_NOT` parser diagnostic. +- **Negated Triple Patterns**: A `FILTER NOT EXISTS` or `MINUS` over a single triple pattern `?s p ?o` (with `?s` bound by a required triple pattern and `?o` fresh) states that `?s` must not carry `p`. A shape that requires `p` (`sh:minCount` of at least 1) contradicts it, so the star pattern is `REJECTED` even when the shape is open. A `sh:not` of an input shape is translated the same way. +- **SHACL Targets**: `sh:targetNode`, `sh:targetClass` (including implicit class targets), `sh:targetSubjectsOf` and `sh:targetObjectsOf` say which nodes a shape describes. The targets of a source shape become patterns about its focus node; the targets of a candidate shape are matched, and limit which star patterns its closedness can reject. See [Targets](#targets). ## How it Works @@ -26,9 +30,9 @@ npm install query-shape-detection yarn add query-shape-detection ``` -## Example Code +## Code examples -### Simple SHACL Example +### SPARQL-to-SHACL Example ```ts import { @@ -69,17 +73,87 @@ const report = solveShapeQueryContainment({ shapes: [personShape], }); -console.log(report.starPatternsContainment.get("person")); +// 4. Read the global result for the complete query. +console.log(report.result); // ContainmentResult.CONTAINED + +// 5. Inspect how the global result was obtained by checking every query star pattern. +for (const [starPatternName, containment] of report.starPatternsContainment) { + console.log(starPatternName); // "person" + console.log(containment.result); // ContainmentResult.CONTAINED + console.log(containment.target); // ["http://example.org/PersonShape"] + console.log(containment.bindings); // Detailed predicate/dependency matches +} ``` -### Simple ShEx Example +`report.result` is the aggregate result for the complete query. It is +`CONTAINED` only when every star pattern is also `CONTAINED`; otherwise it reports the most restrictive result among the star patterns. + +### Shape-to-Shape Example + +You can compare a source shape against candidate target shapes directly using +`solveShapeShapeContainment`. Internally, this uses a shape-to-query translation and then reuses the existing query-to-shape containment engine. ```ts -import { shexShapeFromQuads } from 'query-shape-detection'; +import { + shaclShapeFromQuads, + solveShapeShapeContainment, +} from 'query-shape-detection'; +import * as N3 from 'n3'; + +const sourceShapeRaw = ` + @prefix sh: . + @prefix ex: . + @prefix xsd: . -// Parsing a ShEx shape follows the same pattern -const shexQuads = /* RDF quads from ShEx definition */; -const personShape = await shexShapeFromQuads(shexQuads, "http://example.org/PersonShape"); + ex:SourceShape a sh:NodeShape ; + sh:closed true ; + sh:property [ + sh:path ex:age ; + sh:datatype xsd:integer ; + sh:minInclusive 18 ; + sh:maxInclusive 35 + ] . +`; + +const targetShapeRaw = ` + @prefix sh: . + @prefix ex: . + @prefix xsd: . + + ex:TargetShape a sh:NodeShape ; + sh:closed true ; + sh:property [ + sh:path ex:age ; + sh:datatype xsd:integer ; + sh:minInclusive 10 ; + sh:maxInclusive 40 + ] . +`; + +const sourceShape = await shaclShapeFromQuads( + new N3.Parser().parse(sourceShapeRaw), + 'https://www.example.org/SourceShape', +); +const targetShape = await shaclShapeFromQuads( + new N3.Parser().parse(targetShapeRaw), + 'https://www.example.org/TargetShape', +); + +const report = solveShapeShapeContainment({ + sourceShape, + targetShapes: [targetShape], +}); + +// Read the global result for the complete source-shape/target-shape comparison. +console.log(report.result); // ContainmentResult.CONTAINED + +// Inspect the individual result for every star pattern generated from the source shape. +for (const [starPatternName, containment] of report.starPatternsContainment) { + console.log(starPatternName); + console.log(containment.result); // ContainmentResult.CONTAINED + console.log(containment.target); // ["https://www.example.org/TargetShape"] + console.log(containment.bindings); // Detailed predicate/dependency matches +} ``` ## Containment Results @@ -88,11 +162,87 @@ The library returns a report where each star pattern is assigned one of the foll | Result | Description | | :----------------- | :---------- | -| **`CONTAINED`** | All query star patterns, including nested ones, are matched by the shape. | +| **`CONTAINED`** | Every *required* triple pattern of every star pattern, including nested ones, is matched by the shape. Unmatched `OPTIONAL` patterns do not prevent this. | | **`ALIGNED`** | At least one triple pattern from the root star pattern matches on an open shape. | -| **`UNALINGED`** | Partial root star pattern match on a closed shape; or match on a nested star pattern while having no match on root star pattern. | -| **`WEAKLY_REJECTED`** | None of the triple patterns match on an open shape. | -| **`REJECTED`** | None of the triple patterns match on a closed shape. | +| **`UNALINGED`** | Partial root star pattern match on a closed shape; or match on a nested star pattern while having no match on root star pattern. A predicate the shape declares but whose `sh:node` dependency could not be established counts as such a partial match. | +| **`WEAKLY_REJECTED`** | No triple pattern matches, and at least one candidate shape that describes the pattern is open and does not forbid the predicates involved, or no candidate shape describes it (see [Targets](#targets)). | +| **`REJECTED`** | No triple pattern matches, and every shape that describes the pattern (at least one) either is closed or explicitly forbids a required predicate through `sh:not` or `sh:maxCount 0`. Also returned when there is no candidate shape. | + +### Nested dependencies + +A predicate constrained by `sh:node` is only fully bound when the nested star pattern is +bound by the referenced shape. When it is not, the outcome depends on *why*: + +- the referenced shape is **closed** and does not declare what the nested pattern asks + for, so it forbids it. The dependency is refuted, the predicate does not match, and the + root pattern can be `REJECTED`. This is what lets a closed shape graph prune. +- the referenced shape is **open** and merely silent about it. Nothing is refuted: data + conforming to that shape may well carry the predicate. The root predicate still matched + the referring shape, so the root pattern is a partial match — `UNALINGED` on a closed + referring shape, `ALIGNED` on an open one — and never `REJECTED`. + +A shape reached only through `sh:node` also governs the nested star pattern it resolves, +so that pattern is classified against it even when the shape is passed as a +`dependentShapes` entry rather than a candidate. Both placements give the same +classification. This applies regardless of `decidingShapes`: role filtering chooses which +shapes may decide a resource's relevance, not which shape governs a pattern inside the +shape graph. Only references reached from a candidate count, though: a shape excluded by +`decidingShapes` lends no evidence through `sh:node` references of its own. + +`OPTIONAL` triple patterns are never required for containment: a resource can answer +the query without them. The rule is the same for open and closed shapes, so adding +`sh:closed true` to a shape never raises its result. + +A source shape follows the cardinality semantics of its language. In SHACL, an absent +`sh:minCount` is 0 and an absent `sh:maxCount` is unbounded, so a property constraint +without `sh:minCount` becomes an optional triple pattern. In ShEx, a triple constraint +without a cardinality occurs exactly once, so it becomes a required one. + +### Targets + +The SHACL targets of a shape (`IShape.targets`: `nodes`, `classes`, `subjectsOf`, `objectsOf`) +say which nodes it describes when it is used on its own. A shape that is also an +`rdfs:Class` (or an instance of a class declared a subclass of `rdfs:Class` in the shapes +graph) targets its own instances. SHACL ignores targets when a shape is reached through +`sh:node`, and so does this library, on both sides. + +**Source shapes.** `shapeToQuery` translates the targets of the given shape into patterns +about its focus node: + +- a class target requires `rdf:type` with the class as value, and a subjects-of target + requires its predicate; +- node targets restrict the subject of the focus star pattern to the target nodes + (`IStarPatternWithDependencies.subjectValues`); +- an objects-of target `p` adds a star pattern `?s p ` that depends on the focus + star pattern; +- several class and subjects-of targets become the branches of a UNION, as the branches + of an `sh:or` do, so a resource contains the shape only when every alternative is + covered. + +Node and objects-of targets combined with other targets, or several objects-of targets, +cannot be represented: they are ignored and reported as `TARGETS` in `unsupported`, which +can only over-estimate relevance. A shape without constraints whose targets are node +targets asks for any triple about those nodes, and sets `matchesAnyTriple`. + +**Candidate shapes.** A target implies constraints that the shape does not declare: a +class target implies `rdf:type` with a target class as value (matched by IRI, without +subclass reasoning), and a subjects-of target implies its predicate. They match like +declared constraints, and a closed shape does not forbid them. Since the focus nodes are +the union of the nodes of the targets, an implied constraint is only required — for +negated triple patterns — when every focus node carries it: when all the targets are +class targets, or the only target is one subjects-of target. A closed shape also allows +its `sh:ignoredProperties`. A shape whose only targets are node targets describes those +nodes and nothing else, so it cannot contain a star pattern about another constant +subject. + +A shape without targets describes every star pattern, as before. A shape with targets +only constrains the nodes of its targets, so its closedness and negative constraints +count only for a star pattern whose matching nodes are all in a target: its subject is a +node target, it requires `rdf:type` with target classes only, it requires the predicate +of a subjects-of target, or it is reached through a required triple pattern whose +predicate is that of an objects-of target. For any other star pattern the shape neither +supports nor softens a rejection, and its matches count as on an open shape. A closed +shape targeting persons therefore does not reject a query about places. ### Examples of Containment Results @@ -152,9 +302,7 @@ Another `CONTAINED` case combines a FILTER expression with compatible shape cons ``` Containment decisions do not use runtime FILTER truth values directly. -Instead, FILTER expressions are only used when they can be checked against shape constraints. -For example, a numeric comparison like `?age > 18` is compatible with an `xsd:integer` constraint, -but can contradict a non-numeric datatype constraint. +Instead, FILTER expressions are only used when they can be checked against shape constraints. For example, a numeric comparison like `?age > 18` is compatible with an `xsd:integer` constraint, but can contradict a non-numeric datatype constraint. #### 2. `ALIGNED` @@ -256,17 +404,19 @@ Another `UNALINGED` case is when the root star pattern does not match, but a nes sh:property [ sh:path foaf:name ] . ``` -Another `UNALINGED` case with FILTER and constraints: +Another `UNALINGED` case occurs when the root pattern does not match, but an +optional nested star pattern reachable through a linked variable does. * **Query**: ```sparql - PREFIX ex: - PREFIX xsd: + PREFIX ex: + PREFIX foaf: SELECT * WHERE { - ?person ex:age ?age ; - ex:status ?status . - FILTER(?age > 18) + ?person ex:unknownLink ?friend . + OPTIONAL { + ?friend foaf:name ?friendName . + } } ``` @@ -274,17 +424,23 @@ Another `UNALINGED` case with FILTER and constraints: ```turtle @prefix sh: . - @prefix xsd: . + @prefix ex: . - a sh:NodeShape ; + ex:PersonShape a sh:NodeShape ; sh:closed true ; sh:property [ - sh:path ; - sh:datatype xsd:integer + sh:path ex:knows ; + sh:node ex:FriendShape ] . + + ex:FriendShape a sh:NodeShape ; + sh:closed true ; + sh:property [ sh:path foaf:name ] . ``` -On a closed shape, `ex:age` matches but `ex:status` does not, so containment is partial (`UNALINGED`) even though the FILTER is constraint-compatible. +The required root `ex:unknownLink` pattern does not match the shape, but the +optional nested `foaf:name` pattern matches `ex:FriendShape`. The root result is +therefore `UNALINGED` because only a nested star pattern is aligned. #### 4. `WEAKLY_REJECTED` @@ -306,15 +462,18 @@ No triple pattern matches and at least one candidate shape is open. sh:property [ sh:path foaf:name ] . ``` -Another `WEAKLY_REJECTED` case with FILTER and constraints: +Another `WEAKLY_REJECTED` case has only optional predicates, none of which are +defined by an open shape: * **Query**: ```sparql - PREFIX ex: + PREFIX ex: SELECT * WHERE { - ?person ex:age ?age . - FILTER(?age > 18) + OPTIONAL { + ?person ex:email ?email ; + ex:phone ?phone . + } } ``` @@ -322,20 +481,21 @@ Another `WEAKLY_REJECTED` case with FILTER and constraints: ```turtle @prefix sh: . - @prefix xsd: . + @prefix ex: . - a sh:NodeShape ; + ex:PersonShape a sh:NodeShape ; sh:property [ - sh:path ; - sh:datatype xsd:string + sh:path ex:name ] . ``` -The numeric FILTER contradicts the string datatype constraint. Because the shape is open, the result is `WEAKLY_REJECTED`. +Neither optional predicate is defined by the open shape, so no triple pattern +matches. Because the shape is open, the result is `WEAKLY_REJECTED` rather than +`REJECTED`. #### 5. `REJECTED` -The query uses `schema:birthDate`, but the shape only defines `foaf:name`. +The query uses `schema:birthDate`, but the closed shape only defines `foaf:name`. * **Query**: @@ -412,15 +572,50 @@ Another `REJECTED` case due to min/max value constraints: The FILTER range (`> 35`) conflicts with the shape range (`18..35`), so the result is `REJECTED`. +## Solver options and report fields + +`solveShapeQueryContainment` accepts, besides `query` and `shapes`: + +| Option | Meaning | +| :----- | :------ | +| `dependentShapes` | Shapes that are not candidates themselves but are referenced by `sh:node` from a candidate. They are needed to resolve those references: an unresolvable `sh:node` cannot be refuted and is treated as satisfied, so omitting them over-estimates relevance. | +| `decidingShapes` | Restricts which shapes in `shapes` are candidates. The others are treated as `dependentShapes`: they still resolve `sh:node` references from the candidates, but never classify a star pattern themselves, lend evidence through `sh:node` references of their own, or receive a visit indication. Use it to apply an external eligibility rule, such as the input/output role filtering defined by the discovery specification. | + +The report carries: + +| Field | Meaning | +| :---- | :------ | +| `result` | The aggregate result for the complete query (see above). | +| `starPatternsContainment` | Per star pattern: its result, the matching shape IRIs, and the predicate-level bindings. | +| `visitShapeBoundedResource` | Per shape: whether at least one triple pattern bound to it. Independent of `result`, and usable to decide whether to retrieve the data behind a shape. | +| `unsupported` | Constructs discarded during normalization, if any, including the targets of a source shape that could not be translated (`TARGETS`). | + ## SPARQL Limitations The detection logic is focused on **Triple Patterns** and **Star Patterns**. Currently, the following SPARQL features are not (yet) supported: - **Filter Expressions**: FILTERs are only used to detect contradictions with shape constraints (for example numeric comparisons against non-numeric datatype constraints). Expressions that cannot be safely compared to shape constraints are conservatively ignored for containment decisions. -- **Negative Patterns**: `MINUS` and `FILTER NOT EXISTS` are not used to determine containment. +- **Negative Patterns**: `MINUS` and `FILTER NOT EXISTS` are only supported over a single triple pattern, as negated triple patterns (see above). Any other form is discarded: its triple patterns describe solutions to exclude, not data the resource must hold, so collecting them as ordinary patterns would move the result in both directions. +- **Existence Tests**: Any other `EXISTS` or `NOT EXISTS` stays in its `FILTER` but is undecidable, so it never causes a rejection; the rest of the expression is still evaluated. It is reported as discarded. - **Complex Property Paths**: While simple paths are supported, complex or recursive property paths are not considered yet. - **Aggregates & Subqueries**: `GROUP BY`, `HAVING`, and subqueries are not processed. -- **Federated Queries**: `SERVICE` clauses are currently ignored. +- **Federated Queries**: `SERVICE` clauses are discarded, since they read data from another endpoint. + +Discarding a construct removes constraints, so the result can over-estimate relevance +but never ranks a relevant resource lower than it would otherwise be. Whatever was +discarded is listed on `query.unsupported` and echoed on `report.unsupported`. + +### Queries with no usable triple pattern + +An unrestricted triple pattern (`?s ?p ?o`) yields no star pattern, because shapes +constrain named predicates. It still asks for every triple of the resource, so such a +query is `CONTAINED` by any shape, every shape is marked visitable, and +`query.matchesAnyTriple` is set. + +A query with no triple pattern at all — an empty `WHERE`, a body of only `VALUES` or +`FILTER`, or one left empty after discarding — never asks the resource for data, so no +relevance degree applies. `solveShapeQueryContainment` throws `EmptyQueryError` rather +than reporting `CONTAINED` for it. ## License diff --git a/bun.lock b/bun.lock index 240223a..2fcb6f2 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@traqula/algebra-sparql-1-1": "^1.1.6", "@traqula/algebra-transformations-1-1": "^1.1.4", "@traqula/parser-sparql-1-1": "^1.1.6", + "@treecg/types": "^0.4.6", "rdf-data-factory": "^2.0.2", }, "devDependencies": { @@ -126,6 +127,8 @@ "@traqula/rules-sparql-1-1": ["@traqula/rules-sparql-1-1@1.1.4", "", { "dependencies": { "@traqula/chevrotain": "^1.1.0", "@traqula/core": "^1.1.4" } }, "sha512-E/IbmR7PM50yLDHlWP19amsAf2X0+KBb8lYI8lRQH92/rhVCP7QYEhSZksJ75+w6jm/l+OQvmQKolCZaMF7JHA=="], + "@treecg/types": ["@treecg/types@0.4.6", "", { "dependencies": { "@rdfjs/types": "*", "loglevel": "^1.8.1", "loglevel-plugin-prefix": "^0.8.4", "rdf-data-factory": "^1.1.0" } }, "sha512-iL8rBhAX4INvfuHZzJ1RELh1i/fspviylENZTk6D2p/xDazY1pTkd+y/hxKYVWCC0jHNolDWRsH7ozwaFGNQKA=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -322,6 +325,10 @@ "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "loglevel": ["loglevel@1.9.2", "", {}, "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg=="], + + "loglevel-plugin-prefix": ["loglevel-plugin-prefix@0.8.4", "", {}, "sha512-WpG9CcFAOjz/FtNht+QJeGpvVl/cdR6P0z6OcXSkr8wFJOsV2GRj2j10JLfjuA4aYkcKCNIEqRGCyTife9R8/g=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="], @@ -438,6 +445,8 @@ "@rdfjs/types/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], + "@treecg/types/rdf-data-factory": ["rdf-data-factory@1.1.3", "", { "dependencies": { "@rdfjs/types": "^1.0.0" } }, "sha512-ny6CI7m2bq4lfQQmDYvcb2l1F9KtGwz9chipX4oWu2aAtVoXjb7k3d8J1EsgAsEbMXnBipB/iuRen5H2fwRWWQ=="], + "@types/n3/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], "@types/streamify-array/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], @@ -458,6 +467,8 @@ "@rdfjs/types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "@treecg/types/rdf-data-factory/@rdfjs/types": ["@rdfjs/types@1.1.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-wqpOJK1QCbmsGNtyzYnojPU8gRDPid2JO0Q0kMtb4j65xhCK880cnKAfEOwC+dX85VJcCByQx5zOwyyfCjDJsg=="], + "@types/n3/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "@types/streamify-array/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], diff --git a/index.ts b/index.ts index e5fc001..fb10481 100644 --- a/index.ts +++ b/index.ts @@ -1,8 +1,12 @@ export * from './lib/constant'; export * from './lib/query'; +export * from './lib/shapeToQuery'; export * from './lib/shex'; export * from './lib/shacl'; export * from './lib/containment'; export * from './lib/Shape'; export * from './lib/Triple'; export * from './lib/Binding'; +export * from './lib/target'; +export * from './lib/parser-policy'; +export * from './lib/shape-parser'; diff --git a/lib/Binding.ts b/lib/Binding.ts index c403e9d..543379a 100644 --- a/lib/Binding.ts +++ b/lib/Binding.ts @@ -1,6 +1,7 @@ -import { ConstraintType, IConstraint, IPredicate, IShape, OneOfPathIndexed } from "./Shape"; +import { ConstraintType, IConstraint, IPredicate, IShape, OneOfPathIndexed, withoutTargets } from "./Shape"; import { IStarPatternWithDependencies, type ITriple, Triple } from "./Triple"; -import { TYPE_DEFINITION } from "./constant"; +import { RDF } from "./constant"; +import { impliedPredicateNames, impliedPredicates, isSubjectOutsideNodeTargets } from "./target"; /** * A binding from a query to a shape @@ -25,7 +26,35 @@ export interface IBindings { */ shouldVisitShape: () => boolean; /** - * + * Indicate that the shape explicitly forbids a predicate required by the star pattern + * (SHACL sh:not / ShEx negative triple constraint), or requires (minimum cardinality of at + * least one) a predicate the star pattern excludes (a negated triple pattern). This is + * definitive evidence of incompatibility, independently of whether the shape is closed. + * @returns {boolean} whether the shape contradicts the star pattern + */ + hasNegativeContradiction: () => boolean; + /** + * Indicate that an sh:node dependency of a matched predicate was neither established nor + * refuted, because the referenced shape is open and simply does not describe the nested + * pattern. The predicate itself matched this shape, so the star pattern is partially matched. + * @returns {boolean} whether a dependency was left undecided rather than refuted + */ + hasNonRefutedDependency: () => boolean; + /** + * Indicate that the root star pattern matched the shape: a triple pattern is bound, or its + * predicate matched while its sh:node dependency was left undecided (see hasNonRefutedDependency). + * @returns {boolean} whether the star pattern matched the shape at its root + */ + hasRootMatch: () => boolean; + /** + * Shapes that nested star patterns were resolved against while checking sh:node constraints. + * A referenced shape governs its nested star pattern even when it is not a candidate shape + * itself, so its open or closed status is evidence for that pattern's classification. + * @returns {IDependencyEvidence[]} one entry per nested star pattern and referenced shape + */ + getDependencyEvidence: () => IDependencyEvidence[]; + /** + * * Return the unbounded triples * @returns {ITriple[]} The binded triples */ @@ -64,6 +93,22 @@ export enum ConstraintResult { NOT_RESPECT } +/** + * A shape a nested star pattern was resolved against while checking an sh:node constraint, and + * what the nested pattern did against it. The referenced shape governs that pattern, so this is + * the evidence its classification has to be based on. + */ +export interface IDependencyEvidence { + starPatternName: string; + shapeName: string; + closed: boolean; + /** The nested pattern bound at least one triple against the referenced shape. */ + hasMatch: boolean; + /** The nested pattern is fully bound by the referenced shape. */ + contained: boolean; + bindings: IBindings; +} + export interface IContainmentType { result: ContainmentType; unContaineStarPattern?: IStarPatternWithDependencies[]; @@ -85,6 +130,11 @@ export class Bindings implements IBindings { private shapePredicateBind = new Map(); private strict: boolean; private allOptional = true; + private negativeContradiction = false; + // Predicates whose sh:node dependency was not established, but not refuted either. + private nonRefutedDependency = new Set(); + // Per nested star pattern, the shapes it was resolved against while checking sh:node. + private dependencyEvidence = new Map(); private typeOfContainment: IContainmentType = { result: ContainmentType.NONE, unContaineStarPattern: [] }; private alreadyTraversed: Map; public readonly starPattern: IStarPatternWithDependencies; @@ -101,6 +151,11 @@ export class Bindings implements IBindings { for (const predicate of shape.getAll()) { this.shapePredicateBind.set(predicate.name, false); } + for (const predicate of impliedPredicateNames(shape)) { + if (!this.shapePredicateBind.has(predicate)) { + this.shapePredicateBind.set(predicate, false); + } + } this.oneOfs = shape.oneOfIndexed.map((oneOfs: OneOfPathIndexed[]) => new OneOfBinding(oneOfs)); this.calculateBinding(shape, starPattern, linkedShape, unionStarPattern ?? []); @@ -119,7 +174,11 @@ export class Bindings implements IBindings { negatedTriples.push(triple); continue; } - const singlePredicate = shape.get(triple.predicate); + const declaredPredicate = shape.get(triple.predicate); + // A predicate declared under sh:not (ShEx: a negative triple constraint) states that + // conforming data must NOT carry it, so it can never satisfy a triple pattern. + const negatedByShape = declaredPredicate?.negative === true; + const singlePredicate = negatedByShape ? undefined : declaredPredicate; let predicates: IPredicate[] = []; // check if the triple match a disjunction for (const oneOfBinding of this.oneOfs) { @@ -128,11 +187,18 @@ export class Bindings implements IBindings { predicates = predicates.concat(predicatesOneOf); } } + // constraints the shape implies through its targets or sh:ignoredProperties + if (!negatedByShape) { + predicates = predicates.concat(impliedPredicates(shape, triple.predicate)); + } if (singlePredicate === undefined && predicates.length === 0) { - // Open shapes can ignore unknown predicates, but closed shapes keep - // tracking unmatched non-optional predicates. - if (this.closedShape && triple.isOptional !== true && !this.strict) { + // A shape that explicitly forbids the predicate contradicts the triple pattern + // whether or not it is closed; an open shape merely stays silent about unknown ones. + if (negatedByShape && triple.isOptional !== true && !this.strict) { + this.negativeContradiction = true; + } + if ((negatedByShape || this.closedShape) && triple.isOptional !== true && !this.strict) { this.unboundTriple.push(triple); } continue; @@ -146,6 +212,20 @@ export class Bindings implements IBindings { } + // A predicate the star pattern excludes but the shape requires in its body is carried by + // every conforming node, so no data conforming to the shape answers the star pattern. + if (!this.strict) { + for (const excludedPredicate of starPattern.excludedPredicates ?? []) { + const declaredPredicate = shape.get(excludedPredicate); + const required = [declaredPredicate, ...impliedPredicates(shape, excludedPredicate)] + .some(predicate => predicate !== undefined && predicate.negative !== true && (predicate.cardinality?.min ?? 0) >= 1); + if (declaredPredicate?.negative !== true && required) { + this.negativeContradiction = true; + } + } + } + // A shape about given nodes only cannot contain a star pattern about another constant subject. + const subjectOutsideTargets = isSubjectOutsideNodeTargets(shape, starPattern); // negative triple in a strict containment mean that the we can take any values // see paper https://link.springer.com/chapter/10.1007/978-3-319-25007-6_1 if (!this.strict) { @@ -165,41 +245,22 @@ export class Bindings implements IBindings { } } let boundedUnionFull = true; + let boundedUnion = true; const uncontainedUnionStarPatterns: IStarPatternWithDependencies[] = [] - - if (shape.closed === false) { - let boundedUnion = true; - for (const unionBinding of this.unionBindings) { - boundedUnion = ((unionBinding.hasOneContained && !this.strict) || - (this.strict && unionBinding.areAllContained)) && boundedUnion; - boundedUnionFull = boundedUnionFull && unionBinding.areAllContained; - if (!unionBinding.areAllContained) { - for (const binding of unionBinding.bindings) { - if (!binding.isFullyBounded()) { - uncontainedUnionStarPatterns.push(binding.starPattern); - } + 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) { - 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.unboundTriple.length === 0 && starPattern.starPattern.size !== 0 && boundedUnion; } + this.fullyBounded = this.isEveryRequiredTripleBound(starPattern) && boundedUnion && !this.negativeContradiction + && !subjectOutsideTargets; if (this.fullyBounded) { const cycle = new Set(); const rejectedValues = new Set(); @@ -239,6 +300,30 @@ export class Bindings implements IBindings { } } + /** + * A star pattern is fully bound when every *required* triple pattern binds. + * OPTIONAL triple patterns (and shape properties with sh:minCount 0) are not needed to answer + * the query, so they never block containment — this rule is the same for open and closed shapes. + * At least one triple must bind, so a star pattern made only of unmatched optional patterns + * is not considered contained. + */ + private isEveryRequiredTripleBound(starPattern: IStarPatternWithDependencies): boolean { + if (starPattern.starPattern.size === 0) { + // All the triple patterns of the star pattern are in UNION branches, which are checked separately + return this.unionBindings.length > 0; + } + let boundCount = 0; + for (const { triple } of starPattern.starPattern.values()) { + const isBound = this.bindings.get(triple.predicate) !== undefined; + if (isBound) { + boundCount++; + } else if (triple.isOptional !== true) { + return false; + } + } + return boundCount > 0; + } + private evaluateConstraint(predicates: IPredicate[], triple: ITriple, linkedShape: Map, @@ -329,7 +414,13 @@ export class Bindings implements IBindings { if (currentLinkedShape === undefined) { return ConstraintResult.RESPECT; } - const nestedBinding = new Bindings(currentLinkedShape, dependencies, linkedShape, [], this.strict); + const nestedBinding = new Bindings(withoutTargets(currentLinkedShape), dependencies, linkedShape, [], this.strict); + this.recordDependencyEvidence(dependencies.name, currentLinkedShape, nestedBinding); + for (const [starPatternName, evidence] of nestedBinding.dependencyEvidence) { + for (const entry of evidence) { + this.addDependencyEvidence(starPatternName, entry); + } + } if (nestedBinding.isFullyBounded()) { this.bindings.set(triple.predicate, triple); this.nestedContainedStarPatternNameShapesContained = new Map( @@ -344,9 +435,18 @@ export class Bindings implements IBindings { dependentShape.push(currentLinkedShape.name); } return ConstraintResult.RESPECT; - } else { - return ConstraintResult.NOT_RESPECT; } + // The dependency was not established. Distinguish a referenced shape that *refutes* the + // nested pattern — it leaves unbound triples, or forbids a predicate — from one that is + // merely silent about it because it is open. Only the former is evidence of + // incompatibility; the latter leaves the predicate of this triple matched by the + // current shape, which the classification records as a partial match. + const refutes = nestedBinding.getUnboundedTriple().length > 0 + || nestedBinding.hasNegativeContradiction(); + if (!refutes) { + this.nonRefutedDependency.add(triple.predicate); + } + return ConstraintResult.NOT_RESPECT; } return ConstraintResult.INAPPLICABLE; @@ -356,20 +456,20 @@ export class Bindings implements IBindings { constraint: IConstraint, triple: ITriple): ConstraintResult { if (constraint.type === ConstraintType.CLASS && - triple.predicate === TYPE_DEFINITION.value && + triple.predicate === RDF.type && !Array.isArray(triple.object) && triple.object.termType === "NamedNode" && constraint.value.has(triple.object.value)) { return ConstraintResult.RESPECT; } else if (constraint.type === ConstraintType.CLASS && - triple.predicate === TYPE_DEFINITION.value && + triple.predicate === RDF.type && !Array.isArray(triple.object) && triple.object.termType === "NamedNode" && !constraint.value.has(triple.object.value)) { return ConstraintResult.NOT_RESPECT; } else if (constraint.type === ConstraintType.CLASS && - triple.predicate === TYPE_DEFINITION.value && + triple.predicate === RDF.type && Array.isArray(triple.object)) { for (const object of triple.object) { if (constraint.value.has(object.value)) { @@ -377,7 +477,7 @@ export class Bindings implements IBindings { } } return ConstraintResult.NOT_RESPECT; - } else if (constraint.type === ConstraintType.CLASS && triple.predicate !== TYPE_DEFINITION.value) { + } else if (constraint.type === ConstraintType.CLASS && triple.predicate !== RDF.type) { return ConstraintResult.NOT_RESPECT; } @@ -495,6 +595,49 @@ export class Bindings implements IBindings { return this.getBoundTriple().length > 0; } + public hasNegativeContradiction(): boolean { + return this.negativeContradiction; + } + + public hasRootMatch(): boolean { + return this.getBoundTriple().length > 0 || this.hasNonRefutedDependency(); + } + + public hasNonRefutedDependency(): boolean { + return this.nonRefutedDependency.size > 0; + } + + public getDependencyEvidence(): IDependencyEvidence[] { + const resp: IDependencyEvidence[] = []; + for (const evidence of this.dependencyEvidence.values()) { + resp.push(...evidence); + } + return resp; + } + + private recordDependencyEvidence(starPatternName: string, shape: IShape, nested: Bindings): void { + this.addDependencyEvidence(starPatternName, { + starPatternName, + shapeName: shape.name, + closed: shape.closed, + hasMatch: nested.hasRootMatch(), + contained: nested.isFullyBounded() + && nested.containmentType().result === ContainmentType.FULL, + bindings: nested, + }); + } + + private addDependencyEvidence(starPatternName: string, entry: IDependencyEvidence): void { + const existing = this.dependencyEvidence.get(starPatternName); + if (existing === undefined) { + this.dependencyEvidence.set(starPatternName, [entry]); + return; + } + if (!existing.some(candidate => candidate.shapeName === entry.shapeName)) { + existing.push(entry); + } + } + public getNestedContainedStarPatternName(): IDependentStarPattern[] { return this.nestedContainedStarPatternName; } diff --git a/lib/Shape.ts b/lib/Shape.ts index 1ea1ea7..a268853 100644 --- a/lib/Shape.ts +++ b/lib/Shape.ts @@ -1,3 +1,5 @@ +import { RDF } from './constant'; + /** * A shape interface */ @@ -27,8 +29,12 @@ export interface IShape extends IShapeObj { * @returns {Set} - IRIs of the shape necessary for the constraint */ getLinkedShapeIri: () => Set; + // A shape always has the fields that its serialized form leaves out when they are empty + negativePredicates: string[]; oneOf: OneOf[]; oneOfIndexed: OneOfIndexed[]; + targets: IShapeTargets; + ignoredProperties: string[]; } /** @@ -54,11 +60,9 @@ export type IPredicateJson = Omit & { } /** - * A constraint + * The numeric and string facets that restrict the literal values of a datatype constraint */ -export interface IConstraint { - value: Set; - type: ConstraintType; +export interface IConstraintFacets { minInclusive?: number; maxInclusive?: number; minExclusive?: number; @@ -67,6 +71,23 @@ export interface IConstraint { flags?: string; } +const FACET_KEYS: (keyof IConstraintFacets)[] = [ + 'minInclusive', + 'maxInclusive', + 'minExclusive', + 'maxExclusive', + 'pattern', + 'flags', +]; + +/** + * A constraint + */ +export interface IConstraint extends IConstraintFacets { + value: Set; + type: ConstraintType; +} + function toJsonConstraint(constraint: IConstraint): IConstraintJson { return { ...constraint, @@ -103,6 +124,22 @@ export const enum ConstraintType { // Is bound by a literal datatype IRI DATATYPE, } +/** + * The SHACL targets of a shape: the nodes it describes when it is used on its own. SHACL ignores + * them when the shape is reached through `sh:node`. A shape with no target at all is treated as + * describing any node. + */ +export interface IShapeTargets { + // sh:targetNode values, as keys made by nodeTargetKey: an IRI is its value + nodes: string[]; + // sh:targetClass values, and the shape itself when it is also an rdfs:Class (implicit class target) + classes: string[]; + // sh:targetSubjectsOf values + subjectsOf: string[]; + // sh:targetObjectsOf values + objectsOf: string[]; +} + /** * A simple Shape object */ @@ -112,6 +149,10 @@ export interface IShapeObj { positivePredicates: string[]; negativePredicates?: string[]; oneOf?: OneOf[]; + // only present when the shape declares at least one target + targets?: IShapeTargets; + // sh:ignoredProperties of a closed shape; only present when there is at least one + ignoredProperties?: string[]; } /** @@ -124,6 +165,8 @@ export interface IShapeArgs { linkedShapeIri?: string[] closed?: boolean; oneOf?: OneOf[]; + targets?: Partial; + ignoredProperties?: string[]; } export type OneOf = OneOfPath[]; @@ -161,15 +204,24 @@ export class Shape implements IShape { private readonly predicates = new Map(); public readonly oneOf: OneOf[]; public readonly oneOfIndexed: OneOfIndexed[] = []; + public readonly targets: IShapeTargets; + public readonly ignoredProperties: string[]; /** * * @param {IShapeArgs} args - The argument to build a shape */ - public constructor({ name, positivePredicates, negativePredicates, closed, oneOf }: IShapeArgs) { + public constructor({ name, positivePredicates, negativePredicates, closed, oneOf, targets, ignoredProperties }: IShapeArgs) { this.name = name; this.closed = closed ?? false; this.oneOf = oneOf ? oneOf : []; + this.targets = { + nodes: [...targets?.nodes ?? []], + classes: [...targets?.classes ?? []], + subjectsOf: [...targets?.subjectsOf ?? []], + objectsOf: [...targets?.objectsOf ?? []], + }; + this.ignoredProperties = [...ignoredProperties ?? []]; const linkedShapeIri = new Set(); for (const oneOf of this.oneOf) { const currentOneOf: OneOfIndexed = []; @@ -247,6 +299,11 @@ export class Shape implements IShape { Object.freeze(this.closed); Object.freeze(this.oneOf); Object.freeze(this.oneOfIndexed); + for (const values of Object.values(this.targets)) { + Object.freeze(values); + } + Object.freeze(this.targets); + Object.freeze(this.ignoredProperties); Object.freeze(this); } @@ -266,13 +323,20 @@ export class Shape implements IShape { } public toObject(): IShapeObj { - return { + const obj: IShapeObj = { name: this.name, closed: this.closed, positivePredicates: this.positivePredicates, negativePredicates: this.negativePredicates, oneOf: this.oneOf }; + if (declaresTargets(this.targets)) { + obj.targets = this.targets; + } + if (this.ignoredProperties.length > 0) { + obj.ignoredProperties = this.ignoredProperties; + } + return obj; } public toJson(): IShapeJson{ @@ -322,6 +386,154 @@ export class Shape implements IShape { return this.linkedShapeIri; } } + +export interface IRdfListIndex { + firstByNode: Map; + restByNode: Map; +} + +export interface IRdfListWalkResult { + values: string[]; + malformed: boolean; + cycleDetected: boolean; +} + +/** + * Walk an RDF collection linked with rdf:first / rdf:rest. + * + * Returns all list member values found from `head` until rdf:nil. + * If the collection is malformed, traversal stops and marks `malformed`. + */ +export function walkRdfList( + head: string, + index: IRdfListIndex, +): IRdfListWalkResult { + const values: string[] = []; + const visited = new Set(); + + let malformed = false; + let cycleDetected = false; + let current: string | undefined = head; + + while (true) { + if (current === undefined || current === RDF.nil) { + break; + } + + if (visited.has(current)) { + cycleDetected = true; + malformed = true; + break; + } + visited.add(current); + + const value = index.firstByNode.get(current); + const next = index.restByNode.get(current); + if (value === undefined || next === undefined) { + malformed = true; + break; + } + + values.push(value); + current = next; + } + + return { + values, + malformed, + cycleDetected, + }; +} + +/** + * What a shapes graph declares about the values of a property: a class, a shape or a datatype, and + * the facets of the datatype. + */ +export interface IConstraintDeclaration extends IConstraintFacets { + classConstraint?: string; + shapeConstraint?: string; + datatypeConstraint?: string; +} + +/** + * Build the constraint of a property from its declaration. A class takes precedence over a shape, + * and a shape over a datatype. A datatype on rdf:type names a class. + */ +export function buildConstraint(declaration: IConstraintDeclaration, predicateName: string): IConstraint | undefined { + if (declaration.classConstraint !== undefined) { + return { value: new Set([declaration.classConstraint]), type: ConstraintType.CLASS }; + } + if (declaration.shapeConstraint !== undefined) { + return { value: new Set([declaration.shapeConstraint]), type: ConstraintType.SHAPE }; + } + if (declaration.datatypeConstraint === undefined) { + return undefined; + } + if (predicateName === RDF.type) { + return { value: new Set([declaration.datatypeConstraint]), type: ConstraintType.CLASS }; + } + return { value: new Set([declaration.datatypeConstraint]), type: ConstraintType.DATATYPE, ...definedFacets(declaration) }; +} + +function definedFacets(facets: IConstraintFacets): IConstraintFacets { + const defined: IConstraintFacets = {}; + for (const key of FACET_KEYS) { + if (facets[key] !== undefined) { + Object.assign(defined, { [key]: facets[key] }); + } + } + return defined; +} + +/** + * Build a shape, returning the error instead of throwing it when the shape is inconsistent. + */ +export function tryCreateShape(args: IShapeArgs): IShape | ShapeError { + try { + return new Shape(args); + } catch (error: unknown) { + if (error instanceof InconsistentPositiveAndNegativePredicateError) { + return error; + } + throw error; + } +} + +/** + * Whether there is at least one target. + */ +export function declaresTargets(targets: IShapeTargets): boolean { + return Object.values(targets).some(values => values.length > 0); +} + +const TARGETLESS_SHAPES = new WeakMap(); + +/** + * The shape as SHACL applies it when the shape is reached through sh:node: without its targets. + */ +export function withoutTargets(shape: IShape): IShape { + if (!declaresTargets(shape.targets)) { + return shape; + } + let targetless = TARGETLESS_SHAPES.get(shape); + if (targetless === undefined) { + targetless = new Shape({ + name: shape.name, + positivePredicates: shape.positivePredicates.map(predicate => shape.get(predicate) ?? predicate), + negativePredicates: shape.negativePredicates.map(predicate => shape.get(predicate) ?? predicate), + closed: shape.closed, + oneOf: shape.oneOf, + ignoredProperties: shape.ignoredProperties, + }); + TARGETLESS_SHAPES.set(shape, targetless); + } + return targetless; +} + +export function isNegativeCardinality(minCount?: number, maxCount?: number): boolean { + return minCount === 0 && maxCount === 0; +} + /** * An error to indicate that there is an inconsistency with the positive and negative properties */ diff --git a/lib/Triple.ts b/lib/Triple.ts index 28de581..58b6885 100644 --- a/lib/Triple.ts +++ b/lib/Triple.ts @@ -9,6 +9,17 @@ export interface IStarPatternWithDependencies { starPattern: Map; name: string; isVariable: boolean; + /** + * Predicates the subject must not carry: negated triple patterns from a `FILTER NOT EXISTS` or + * `MINUS` over a single triple pattern, or negative predicates of an input shape. + */ + excludedPredicates?: Set; + /** + * The constants the subject is one of, when the star pattern is about given nodes only: the node + * targets (sh:targetNode) of an input shape. A star pattern whose subject is a constant in the + * query does not need it; its name is that constant. + */ + subjectValues?: Set; } export interface ITripleWithDependencies { diff --git a/lib/constant.ts b/lib/constant.ts index d1032fd..e139a72 100644 --- a/lib/constant.ts +++ b/lib/constant.ts @@ -1,51 +1,98 @@ -import type { BaseQuad } from '@rdfjs/types'; -import { DataFactory } from 'rdf-data-factory'; - -export const DF = new DataFactory(); - -export const SHEX_PREDICATE = DF.namedNode('http://www.w3.org/ns/shex#predicate'); -export const SHEX_SHAPE_EXPRESSION = DF.namedNode('http://www.w3.org/ns/shex#shapeExpr'); -export const SHEX_EXPRESSION = DF.namedNode('http://www.w3.org/ns/shex#expression'); -export const SHEX_SHAPE = DF.namedNode('http://www.w3.org/ns/shex#Shape'); -export const SHEX_EXPRESSIONS = DF.namedNode('http://www.w3.org/ns/shex#expressions'); -export const SHEX_CLOSED_SHAPE = DF.namedNode('http://www.w3.org/ns/shex#closed'); -export const SHEX_VALUE_EXPR = DF.namedNode('http://www.w3.org/ns/shex#valueExpr'); -export const SHEX_NODE_KIND = DF.namedNode('http://www.w3.org/ns/shex#nodeKind'); -export const SHEX_MAX = DF.namedNode('http://www.w3.org/ns/shex#max'); -export const SHEX_MIN = DF.namedNode('http://www.w3.org/ns/shex#min'); -export const SHEX_DATA_TYPE = DF.namedNode('http://www.w3.org/ns/shex#datatype'); - -export const SHEX_LITERAL = DF.namedNode('http://www.w3.org/ns/shex#literal'); -export const SHEX_IRI = DF.namedNode('http://www.w3.org/ns/shex#iri'); -export const SHEX_BNODE = DF.namedNode('http://www.w3.org/ns/shex#bnode'); -export const SHEX_NON_LITERAL = DF.namedNode('http://www.w3.org/ns/shex#nonliteral'); -export const SHEX_VALUES = DF.namedNode('http://www.w3.org/ns/shex#values'); - -export const SHEX_EACH_OF = DF.namedNode("http://www.w3.org/ns/shex#EachOf"); -export const SHEX_ONE_OF = DF.namedNode("http://www.w3.org/ns/shex#OneOf"); - -export const TYPE_DEFINITION = DF.namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'); -export const IRI_FIRST_RDF_LIST = DF.namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#first'); -export const IRI_REST_RDF_LIST = DF.namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#rest'); -export const IRI_END_RDF_LIST = DF.namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#nil'); -export const XSD_BOOLEAN = DF.namedNode('http://www.w3.org/2001/XMLSchema#boolean'); -export const RDF_TRUE = DF.literal('true', XSD_BOOLEAN); - -export const SHACL_SH = 'http://www.w3.org/ns/shacl#'; -export const SHACL_PROPERTY = DF.namedNode(`${SHACL_SH}property`); -export const SHACL_PATH = DF.namedNode(`${SHACL_SH}path`); -export const SHACL_MIN_COUNT = DF.namedNode(`${SHACL_SH}minCount`); -export const SHACL_MAX_COUNT = DF.namedNode(`${SHACL_SH}maxCount`); -export const SHACL_MIN_INCLUSIVE = DF.namedNode(`${SHACL_SH}minInclusive`); -export const SHACL_MAX_INCLUSIVE = DF.namedNode(`${SHACL_SH}maxInclusive`); -export const SHACL_MIN_EXCLUSIVE = DF.namedNode(`${SHACL_SH}minExclusive`); -export const SHACL_MAX_EXCLUSIVE = DF.namedNode(`${SHACL_SH}maxExclusive`); -export const SHACL_PATTERN = DF.namedNode(`${SHACL_SH}pattern`); -export const SHACL_FLAGS = DF.namedNode(`${SHACL_SH}flags`); -export const SHACL_CLOSED = DF.namedNode(`${SHACL_SH}closed`); -export const SHACL_CLASS = DF.namedNode(`${SHACL_SH}class`); -export const SHACL_DATATYPE = DF.namedNode(`${SHACL_SH}datatype`); -export const SHACL_NODE = DF.namedNode(`${SHACL_SH}node`); -export const SHACL_OR = DF.namedNode(`${SHACL_SH}or`); -export const SHACL_XONE = DF.namedNode(`${SHACL_SH}xone`); -export const SHACL_NOT = DF.namedNode(`${SHACL_SH}not`); +import type { NamedNode } from '@rdfjs/types'; +import { createUriAndTermNamespace } from '@treecg/types'; + +type TermVocabulary = { + namespace: NamedNode; + custom: (input: string) => NamedNode; +} & Record>; + +type Vocabulary = { + namespace: string; + custom: (input: string) => string; + terms: TermVocabulary; +} & Record; + +function createVocabulary( + baseUri: string, + ...localNames: T[] +): Vocabulary { + return createUriAndTermNamespace(baseUri, ...localNames) as unknown as Vocabulary; +} + +export const SHACL = createVocabulary( + "http://www.w3.org/ns/shacl#", + "property", + "path", + "minCount", + "maxCount", + "minInclusive", + "maxInclusive", + "minExclusive", + "maxExclusive", + "pattern", + "flags", + "closed", + "class", + "datatype", + "node", + "or", + "xone", + "not", + "targetNode", + "targetClass", + "targetSubjectsOf", + "targetObjectsOf", + "ignoredProperties" +); + +export const SHEX = createVocabulary( + "http://www.w3.org/ns/shex#", + "predicate", + "shapeExpr", + "expression", + "Shape", + "expressions", + "closed", + "valueExpr", + "nodeKind", + "max", + "min", + "datatype", + "mininclusive", + "maxinclusive", + "minexclusive", + "maxexclusive", + "pattern", + "flags", + "literal", + "iri", + "bnode", + "nonliteral", + "values", + "EachOf", + "OneOf" +); + +export const RDF = createVocabulary( + "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "type", + "first", + "rest", + "nil" +); + +export const RDFS = createVocabulary( + "http://www.w3.org/2000/01/rdf-schema#", + "Class", + "subClassOf" +); + +export const XSD = createVocabulary( + "http://www.w3.org/2001/XMLSchema#", + "string", + "boolean", + "decimal", + "integer", + "float", + "double" +); diff --git a/lib/containment.ts b/lib/containment.ts index 969363a..558fd98 100644 --- a/lib/containment.ts +++ b/lib/containment.ts @@ -1,8 +1,10 @@ import { Bindings, ContainmentType, IBindings } from './Binding' -import { generateStarPatternUnion, type IQuery } from './query'; -import { ConstraintType, IConstraint, IShape } from './Shape'; +import { generateStarPatternUnion, type IQuery, type UnsupportedConstruct } from './query'; +import { shapeToQuery } from './shapeToQuery'; +import type { IShape } from './Shape'; import type { IStarPatternWithDependencies } from './Triple'; -import type { Term } from '@rdfjs/types'; +import { describesStarPattern, incomingRequiredPredicates } from './target'; +import { evaluateFiltersForShape, FilterTruth } from './filter-compatibility'; /** * Determine if a query is contained inside a shape. @@ -11,12 +13,37 @@ import type { Term } from '@rdfjs/types'; * @param param {IContainementArg} - the shape and the query to evaluate * @returns {IResult} result relative to the containement of the query inside of the shape */ -export function solveShapeQueryContainment({ query, shapes, decidingShapes }: IContainementArg): IResult { +export function solveShapeQueryContainment({ query, shapes, dependentShapes, decidingShapes }: IContainementArg): IResult { + if (decidingShapes !== undefined) { + // Role filtering selects the candidates; it does not hide shapes from sh:node resolution. A shape + // outside `decidingShapes` is therefore only a dependency: it still resolves the references of the + // candidates, but neither decides a star pattern, nor lends evidence through references of its own, + // nor receives a visit indication. + return solveShapeQueryContainment({ + query, + shapes: shapes.filter(shape => decidingShapes.has(shape.name)), + dependentShapes: (dependentShapes ?? []).concat(shapes.filter(shape => !decidingShapes.has(shape.name))), + }); + } + if (query.starPatterns.size === 0) { + // A query whose only triple patterns are unrestricted (?s ?p ?o) asks for every triple of the + // resource, so every resource is relevant and every shape is worth visiting. That is a real + // answer, not a failure: the query is maximally unselective, which is a property of the query. + if (query.matchesAnyTriple === true) { + return buildMatchesAnyTripleResult(shapes, query.unsupported); + } + // A query with no triple pattern at all (an empty WHERE, VALUES/FILTER only, or a body made + // solely of discarded constructs) never asks the resource for data, so relevance is undefined. + throw new EmptyQueryError( + 'the normalized query has no triple pattern, so no relevance degree can be assigned' + + (query.unsupported !== undefined ? `; discarded constructs: ${query.unsupported.join(', ')}` : ''), + ); + } const bindingResult = new Map>(); const starPatternsContainment = new Map(); const classificationStats = new Map(); - const groupedShapes = groupShapeBydependencies(shapes); + const groupedShapes = groupShapeBydependencies(shapes, dependentShapes); // Initialize per-star-pattern classification state. for (const [starPatternsName] of query.starPatterns) { @@ -24,6 +51,7 @@ export function solveShapeQueryContainment({ query, shapes, decidingShapes }: IC classificationStats.set(starPatternsName, { hasOpenShape: false, hasClosedShape: false, + hasUndescribedShape: false, rootTargetsOpen: new Set(), rootTargetsClosed: new Set(), containedTargets: new Set(), @@ -32,15 +60,51 @@ export function solveShapeQueryContainment({ query, shapes, decidingShapes }: IC } // Evaluate each star pattern against each candidate shape. + const incoming = incomingRequiredPredicates(query.starPatterns); for (const { shape, dependencies } of groupedShapes) { bindingResult.set(shape.name, new Map()); const bindingResultofShape = bindingResult.get(shape.name)!; for (const [starPatternName, starPattern] of query.starPatterns) { const starPatternUnion = generateStarPatternUnion(query.union ?? [], starPatternName); const bindings = new Bindings(shape, starPattern, dependencies, starPatternUnion); - const filterCompatibility = evaluateFiltersForShape(query.filters, starPattern, shape); bindingResultofShape.set(starPatternName, { result: bindings, shape }); - updateContainmentStats(classificationStats, starPatternName, shape, bindings, groupedShapes, decidingShapes, filterCompatibility !== FilterTruth.FALSE); + // A shape with targets only constrains the nodes of its targets. When the nodes matching the star + // pattern need not be among them, its closedness and negative constraints say nothing about those + // nodes: it neither supports nor softens a rejection, and its matches count as on an open shape. + const describes = describesStarPattern(shape, starPattern, incoming); + recordEvidence(classificationStats.get(starPatternName)!, { + shapeName: shape.name, + bindings, + describes, + closed: describes && shape.closed, + contradicts: describes && bindings.hasNegativeContradiction(), + filterCompatible: evaluateFiltersForShape(query.filters, starPattern, shape) !== FilterTruth.FALSE, + hasMatch: bindings.hasRootMatch(), + contained: isContainedByCandidate(bindings, groupedShapes, shape), + }); + } + } + + // A shape reached only as an sh:node target is not a candidate, but it is the shape that governs + // the nested star pattern it resolves. Classify that pattern against it, so a nested pattern is + // neither rejected on the strength of shapes that never described it, nor left rejected while + // the shape that does describe it binds the pattern completely. Only candidates have bindings here, + // so the evidence covers exactly the references reached from a candidate: a shape excluded by + // `decidingShapes` still governs the patterns a candidate resolves through it, but its own + // references lend no evidence. + for (const starPatternBindings of bindingResult.values()) { + for (const { result } of starPatternBindings.values()) { + for (const evidence of result.getDependencyEvidence()) { + const stats = classificationStats.get(evidence.starPatternName); + if (stats !== undefined) { + recordEvidence(stats, { + ...evidence, + describes: true, + contradicts: evidence.bindings.hasNegativeContradiction(), + filterCompatible: true, + }); + } + } } } @@ -63,7 +127,10 @@ export function solveShapeQueryContainment({ query, shapes, decidingShapes }: IC const bindings = rootTargetsClosed.length > 0 ? new Map(stats.bindings) : new Map(); currentResult = { result: ContainmentResult.UNALINGED, target: unalignedTargets, bindings }; } else { - const rejectedResult = stats.hasOpenShape ? ContainmentResult.WEAKLY_REJECTED : ContainmentResult.REJECTED; + // Rejection needs a shape that describes the star pattern and excludes it. Without any shape + // describing it, while the resource has shapes, nothing is known about the pattern. + const weaklyRejected = stats.hasOpenShape || (!stats.hasClosedShape && stats.hasUndescribedShape); + const rejectedResult = weaklyRejected ? ContainmentResult.WEAKLY_REJECTED : ContainmentResult.REJECTED; currentResult = { result: rejectedResult, bindings: new Map() }; } } @@ -71,73 +138,129 @@ export function solveShapeQueryContainment({ query, shapes, decidingShapes }: IC starPatternsContainment.set(starPatternName, currentResult); } + return buildResult( + getQueryContainmentResult(starPatternsContainment), + starPatternsContainment, + generateVisitStatus(bindingResult, shapes), + query.unsupported, + ); +} + +function buildResult( + result: ContainmentResult, + starPatternsContainment: Map, + visitShapeBoundedResource: Map, + unsupported: UnsupportedConstruct[] | undefined, +): IResult { return { + result, starPatternsContainment, - visitShapeBoundedResource: generateVisitStatus(bindingResult, shapes) + visitShapeBoundedResource, + ...(unsupported !== undefined ? { unsupported } : {}), }; +} +/** + * Report for a query that asks for every triple of a resource: contained by any shape, and every + * shape is visitable. Marking the shapes visitable matters — a CONTAINED result whose visit + * indications were all false would tell a client to fetch nothing for a query that wants everything. + */ +function buildMatchesAnyTripleResult(shapes: IShape[], unsupported?: UnsupportedConstruct[]): IResult { + return buildResult( + ContainmentResult.CONTAINED, + new Map(), + new Map(shapes.map(shape => [shape.name, true])), + unsupported, + ); } -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; - } +export interface IShapeContainmentArg { + sourceShape: IShape; + sourceLinkedShapes?: IShape[] | Map; + targetShapes: IShape[]; + decidingShapes?: Set; +} - const stats = classificationStats.get(starPatternName)!; - if (shape.closed) { +/** + * Convenience wrapper to compare a source shape against candidate target shapes. + * + * Internally this translates the source shape into a best-effort query and then + * delegates to the standard query-to-shape containment solver. + */ +export function solveShapeShapeContainment({ sourceShape, sourceLinkedShapes, targetShapes, decidingShapes }: IShapeContainmentArg): IResult { + const query = shapeToQuery(sourceShape, { linkedShapes: sourceLinkedShapes }); + return solveShapeQueryContainment({ query, shapes: targetShapes, decidingShapes }); +} + +/** + * What one shape says about one star pattern. + */ +interface IShapeEvidence { + shapeName: ShapeName; + bindings: IBindings; + // the shape describes the star pattern, see describesStarPattern + describes: boolean; + // the shape is closed and describes the star pattern + closed: boolean; + // the shape describes the star pattern and forbids what it requires, see IBindings.hasNegativeContradiction + contradicts: boolean; + // no FILTER of the query contradicts the shape + filterCompatible: boolean; + // the star pattern matched the shape at its root, see IBindings.hasRootMatch + hasMatch: boolean; + // the shape contains the star pattern + contained: boolean; +} + +/** + * Add the evidence of a shape to the classification state of a star pattern. A shape that + * contradicts the star pattern counts as closed, so it does not soften a rejection, and it + * contributes no match. + */ +function recordEvidence(stats: IContainmentStats, evidence: IShapeEvidence): void { + const { shapeName, bindings, closed, contradicts } = evidence; + if (!evidence.describes) { + stats.hasUndescribedShape = true; + } else if (closed || contradicts) { stats.hasClosedShape = true; } else { stats.hasOpenShape = true; } - if (!filterCompatible) { + if (!evidence.filterCompatible || contradicts) { return; } - - // 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); - } + // Root-level alignment is tracked independently from full containment. + if (evidence.hasMatch) { + stats.bindings.set(shapeName, bindings); + (closed ? stats.rootTargetsClosed : stats.rootTargetsOpen).add(shapeName); } - - // Full binding means this shape fully contains the star pattern. - if (bindings.isFullyBounded() && bindings.containmentType().result === ContainmentType.FULL) { - stats.bindings.set(shape.name, bindings); - stats.containedTargets.add(shape.name); + if (evidence.contained) { + stats.bindings.set(shapeName, bindings); + stats.containedTargets.add(shapeName); } +} - // Preserve previous behavior where a disjunction can be considered fully covered - // when unresolved alternatives are contained by another 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); - } +/** + * Whether a candidate shape contains the star pattern: it binds it fully, or it binds it fully but + * for disjunctive branches that another candidate shape contains. + */ +function isContainedByCandidate(bindings: IBindings, groupedShapes: IShapeWithDependencies[], shape: IShape): boolean { + if (!bindings.isFullyBounded()) { + return false; } + const containment = bindings.containmentType(); + return containment.result === ContainmentType.FULL || + (containment.result === ContainmentType.PARTIAL && + findDisjunctContainment(containment.unContaineStarPattern!, groupedShapes, shape)); } -function findDisjunctContainment(starPatterns: IStarPatternWithDependencies[], groupedShapes: IShapeWithDependencies[], shapeExcluded: IShape, decidingShapes?: Set): boolean { +function findDisjunctContainment(starPatterns: IStarPatternWithDependencies[], groupedShapes: IShapeWithDependencies[], shapeExcluded: IShape): boolean { // Search whether unresolved disjunctive branches can be absorbed by another shape. let haveContainment = false; for (const starPattern of starPatterns) { for (const { shape, dependencies } of groupedShapes) { - if (shape.name !== shapeExcluded.name && (decidingShapes === undefined || decidingShapes.has(shape.name))) { + if (shape.name !== shapeExcluded.name) { const bindings = new Bindings(shape, starPattern, dependencies); haveContainment = haveContainment || bindings.isFullyBounded(); } @@ -146,7 +269,7 @@ function findDisjunctContainment(starPatterns: IStarPatternWithDependencies[], g return haveContainment; } -function getNestedMatchingTargets(starPattern: IStarPatternWithDependencies, classificationStats: Map, visited: Set = new Set()): Set { +function getNestedMatchingTargets(starPattern: IStarPatternWithDependencies, classificationStats: Map, visited = new Set()): Set { // Include shapes that only become reachable through nested star-pattern dependencies. const nestedNames = getNestedDependencyNames(starPattern, visited); const nestedTargets = new Set(); @@ -189,672 +312,6 @@ function getNestedDependencyNames(starPattern: IStarPatternWithDependencies, vis return nestedNames; } -enum FilterTruth { - TRUE = 'true', - FALSE = 'false', - UNKNOWN = 'unknown', -} - -interface IFilterEvalContext { - starPattern: IStarPatternWithDependencies; - shape: IShape; -} - -interface INumericBounds { - minInclusive?: number; - maxInclusive?: number; - minExclusive?: number; - maxExclusive?: number; -} - -interface IVariableDatatypeConstraint { - datatypes: Set; - bounds?: INumericBounds; - pattern?: string; - flags?: string; -} - -function evaluateFiltersForShape( - filters: unknown[] | undefined, - starPattern: IStarPatternWithDependencies, - shape: IShape, -): FilterTruth { - // Filters are evaluated conservatively: we only reject on proven contradiction. - if (filters === undefined || filters.length === 0) { - return FilterTruth.UNKNOWN; - } - - const context: IFilterEvalContext = { starPattern, shape }; - let status = FilterTruth.UNKNOWN; - for (const filter of filters) { - const result = evaluateFilterCompatibility(filter, context); - if (result === FilterTruth.FALSE) { - return FilterTruth.FALSE; - } - if (result === FilterTruth.TRUE) { - status = FilterTruth.TRUE; - } - } - return status; -} - -function evaluateFilterCompatibility(expression: unknown, context: IFilterEvalContext): FilterTruth { - if (typeof expression !== 'object' || expression === null) { - return FilterTruth.UNKNOWN; - } - - const castExpression = expression as { subType?: string; operator?: string; args?: unknown[] }; - if (castExpression.subType === 'term') { - // Boolean literals like FILTER(true/false) are intentionally ignored for containment. - return FilterTruth.UNKNOWN; - } - if (castExpression.subType !== 'operator') { - return FilterTruth.UNKNOWN; - } - - const args = castExpression.args ?? []; - switch (castExpression.operator) { - case '&&': - return combineLogicalAnd(args, context); - case '||': - return combineLogicalOr(args, context); - case '!': { - // Negation does not provide a safe contradiction signal in this conservative check. - return FilterTruth.UNKNOWN; - } - case '=': - case '!=': - case '<': - case '<=': - case '>': - case '>=': - return evaluateComparisonCompatibility(castExpression.operator, args[0], args[1], context); - case 'regex': - return evaluateRegexCompatibility(args, context); - default: - return FilterTruth.UNKNOWN; - } -} - -function combineLogicalAnd(args: unknown[], context: IFilterEvalContext): FilterTruth { - // AND is false as soon as one branch is false; true only if at least one branch is provably true. - let hasTrue = false; - for (const arg of args) { - const current = evaluateFilterCompatibility(arg, context); - if (current === FilterTruth.FALSE) { - return FilterTruth.FALSE; - } - if (current === FilterTruth.TRUE) { - hasTrue = true; - } - } - return hasTrue ? FilterTruth.TRUE : FilterTruth.UNKNOWN; -} - -function combineLogicalOr(args: unknown[], context: IFilterEvalContext): FilterTruth { - // OR is false only if every branch is false. - let hasNonFalse = false; - for (const arg of args) { - const current = evaluateFilterCompatibility(arg, context); - if (current !== FilterTruth.FALSE) { - hasNonFalse = true; - } - } - return hasNonFalse ? FilterTruth.UNKNOWN : FilterTruth.FALSE; -} - -function evaluateComparisonCompatibility(operator: string, left: unknown, right: unknown, context: IFilterEvalContext): FilterTruth { - // First check datatype() comparisons, then numeric inequalities. - const datatypeCheck = evaluateDatatypeCompatibility(operator, left, right, context) - ?? evaluateDatatypeCompatibility(operator, right, left, context); - if (datatypeCheck !== undefined) { - return datatypeCheck; - } - - if (operator === '<' || operator === '<=' || operator === '>' || operator === '>=') { - const numericCheck = evaluateNumericComparisonCompatibility(operator, left, right, context, true) - ?? evaluateNumericComparisonCompatibility(operator, right, left, context, false); - if (numericCheck !== undefined) { - return numericCheck; - } - } - - return FilterTruth.UNKNOWN; -} - -function evaluateNumericComparisonCompatibility( - operator: string, - variableExpr: unknown, - otherExpr: unknown, - context: IFilterEvalContext, - variableOnLeft: boolean, -): FilterTruth | undefined { - // This branch only reasons about variable-vs-literal numeric constraints. - const variableName = extractVariableName(variableExpr); - if (variableName === undefined) { - return undefined; - } - - const literal = extractLiteral(otherExpr); - if (literal === undefined || getNumericLiteralValue(literal) === undefined) { - return FilterTruth.UNKNOWN; - } - - const variableConstraint = extractVariableDatatypeConstraint(variableName, context); - if (variableConstraint === undefined) { - return FilterTruth.UNKNOWN; - } - - for (const datatype of variableConstraint.datatypes) { - if (isNumericDatatype(datatype)) { - const filterBounds = boundsFromNumericFilter(operator, getNumericLiteralValue(literal)!, variableOnLeft); - const shapeBounds = variableConstraint.bounds; - if (shapeBounds === undefined || filterBounds === undefined) { - return FilterTruth.UNKNOWN; - } - - // Contradiction when filter bounds and shape bounds have an empty intersection. - const bounds = intersectBounds(shapeBounds, filterBounds); - if (bounds === undefined) { - return FilterTruth.FALSE; - } - - return FilterTruth.UNKNOWN; - } - } - return FilterTruth.FALSE; -} - -function evaluateDatatypeCompatibility(operator: string, datatypeExpr: unknown, otherExpr: unknown, context: IFilterEvalContext): FilterTruth | undefined { - // Handle FILTER(datatype(?v) = iri) and FILTER(datatype(?v) != iri) forms. - const variableName = extractDatatypeVariableName(datatypeExpr); - if (variableName === undefined) { - return undefined; - } - - const expectedDatatype = extractNamedNodeValue(otherExpr); - if (expectedDatatype === undefined) { - return FilterTruth.UNKNOWN; - } - - const datatypes = extractVariableDatatypes(variableName, context); - if (datatypes === undefined) { - return FilterTruth.UNKNOWN; - } - - if (operator === '=' && !datatypes.has(expectedDatatype)) { - return FilterTruth.FALSE; - } - if (operator === '!=' && datatypes.size === 1 && datatypes.has(expectedDatatype)) { - return FilterTruth.FALSE; - } - return FilterTruth.UNKNOWN; -} - -function extractVariableDatatypes(variableName: string, context: IFilterEvalContext): Set | undefined { - return extractVariableDatatypeConstraint(variableName, context)?.datatypes; -} - -function extractVariableDatatypeConstraint(variableName: string, context: IFilterEvalContext): IVariableDatatypeConstraint | undefined { - let collectedDatatypes: Set | undefined; - let collectedBounds: INumericBounds | undefined; - let collectedPattern: string | undefined; - let collectedFlags: string | undefined; - - for (const { triple } of context.starPattern.starPattern.values()) { - const matchesVariable = (!Array.isArray(triple.object) && triple.object.termType === 'Variable' && triple.object.value === variableName) - || (Array.isArray(triple.object) && triple.boundVariable === variableName); - if (!matchesVariable) { - continue; - } - const predicateConstraint = context.shape.get(triple.predicate)?.constraint; - if (predicateConstraint?.type !== ConstraintType.DATATYPE || predicateConstraint.value.size === 0) { - continue; - } - - // Multiple occurrences of the same variable are merged by intersection. - if (collectedDatatypes === undefined) { - collectedDatatypes = new Set(predicateConstraint.value); - } else { - collectedDatatypes = new Set(Array.from(collectedDatatypes).filter((value) => predicateConstraint.value.has(value))); - } - - const bounds = extractNumericBounds(predicateConstraint); - if (bounds !== undefined) { - if (collectedBounds === undefined) { - collectedBounds = bounds; - } else { - const intersected = intersectBounds(collectedBounds, bounds); - if (intersected === undefined) { - return { - datatypes: collectedDatatypes, - bounds: { - minInclusive: 1, - maxInclusive: 0, - }, - }; - } - collectedBounds = intersected; - } - } - - // Regex reasoning is only kept when all matching constraints agree on pattern/flags. - if (predicateConstraint.pattern !== undefined) { - if (collectedPattern === undefined) { - collectedPattern = predicateConstraint.pattern; - collectedFlags = predicateConstraint.flags; - } else if (collectedPattern !== predicateConstraint.pattern || collectedFlags !== predicateConstraint.flags) { - return { - datatypes: collectedDatatypes, - bounds: collectedBounds, - }; - } - } - } - - if (collectedDatatypes === undefined) { - return undefined; - } - - return { - datatypes: collectedDatatypes, - bounds: collectedBounds, - pattern: collectedPattern, - flags: collectedFlags, - }; -} - -function evaluateRegexCompatibility(args: unknown[], context: IFilterEvalContext): FilterTruth { - // Supported form: FILTER(regex(str(?v), pattern, flags?)). - const variableName = extractRegexVariableName(args[0]); - if (variableName === undefined) { - return FilterTruth.UNKNOWN; - } - - const regexPattern = extractStringLiteralValue(args[1]); - if (regexPattern === undefined) { - return FilterTruth.UNKNOWN; - } - - const regexFlags = extractStringLiteralValue(args[2]) ?? ''; - const variableConstraint = extractVariableDatatypeConstraint(variableName, context); - if (variableConstraint?.pattern === undefined) { - return FilterTruth.UNKNOWN; - } - - // Equality alone is not strong enough to prove containment in this conservative pass, - // so we keep it as UNKNOWN and only return TRUE/FALSE for explicit implication/conflict. - if (variableConstraint.pattern === regexPattern && (variableConstraint.flags ?? '') === regexFlags) { - return FilterTruth.UNKNOWN; - } - - const shapePatternTest = buildRegex(variableConstraint.pattern, variableConstraint.flags); - const filterPatternTest = buildRegex(regexPattern, regexFlags); - if (shapePatternTest === undefined || filterPatternTest === undefined) { - return FilterTruth.UNKNOWN; - } - - // TRUE when the shape regex is strictly stronger and therefore always satisfies the filter. - if (doesRegexConstraintImplyFilter(variableConstraint.pattern, variableConstraint.flags, regexPattern, regexFlags)) { - return FilterTruth.TRUE; - } - - // FALSE 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[] = []; @@ -900,8 +357,12 @@ interface IBindingStatus { } interface IContainmentStats { + // an open shape describes the star pattern hasOpenShape: boolean; + // a closed or contradicting shape describes the star pattern hasClosedShape: boolean; + // a shape with targets does not describe the star pattern + hasUndescribedShape: boolean; rootTargetsOpen: Set; rootTargetsClosed: Set; containedTargets: Set; @@ -922,7 +383,7 @@ export interface IContainementArg { query: IQuery; shapes: IShape[]; dependentShapes?: IShape[]; - // shapes to consider when making a decision + // names of the shapes in `shapes` that are candidates; the others only resolve sh:node references decidingShapes?: Set; } @@ -933,10 +394,29 @@ export type ShapeName = string; * The result of the alignment */ export interface IResult { + // The aggregate containment result for the complete query or source shape. + result: ContainmentResult; // The documents associated with a shape that can be followed visitShapeBoundedResource: Map; // The type of containment of each star patterns with there associated shapes starPatternsContainment: Map; + /** + * Constructs of the input that were discarded because they are outside the supported profile. + * When non-empty, the result was computed over a sub-query of the given query and therefore + * over-estimates relevance. + */ + unsupported?: UnsupportedConstruct[]; +} + +/** + * Raised when the normalized input has no star pattern, so there is no constraint to assess. + * Returning a relevance degree in that case would rank every resource on no evidence at all. + */ +export class EmptyQueryError extends Error { + public constructor(message: string) { + super(message); + this.name = 'EmptyQueryError'; + } } /** @@ -971,3 +451,15 @@ export enum ContainmentResult { // No triple matches and all candidate shapes are closed. REJECTED, } + +function getQueryContainmentResult( + starPatternsContainment: Map, +): ContainmentResult { + let queryResult = ContainmentResult.CONTAINED; + + for (const { result } of starPatternsContainment.values()) { + queryResult = Math.max(queryResult, result); + } + + return queryResult; +} diff --git a/lib/filter-compatibility.ts b/lib/filter-compatibility.ts new file mode 100644 index 0000000..16e844f --- /dev/null +++ b/lib/filter-compatibility.ts @@ -0,0 +1,697 @@ +import type { Term } from '@rdfjs/types'; +import { ConstraintType, type IConstraint, type IShape } from './Shape'; +import type { IStarPatternWithDependencies } from './Triple'; + +/** + * Whether the FILTER expressions of a query are compatible with what a shape says about the values + * of a star pattern. Filters are evaluated conservatively: FALSE only on a proven contradiction. + */ +export enum FilterTruth { + TRUE = 'true', + FALSE = 'false', + UNKNOWN = 'unknown', +} + +interface IFilterEvalContext { + starPattern: IStarPatternWithDependencies; + shape: IShape; +} + +interface INumericBounds { + minInclusive?: number; + maxInclusive?: number; + minExclusive?: number; + maxExclusive?: number; +} + +interface IVariableDatatypeConstraint { + datatypes: Set; + bounds?: INumericBounds; + pattern?: string; + flags?: string; +} + +export function evaluateFiltersForShape( + filters: unknown[] | undefined, + starPattern: IStarPatternWithDependencies, + shape: IShape, +): FilterTruth { + // Filters are evaluated conservatively: we only reject on proven contradiction. + if (filters === undefined || filters.length === 0) { + return FilterTruth.UNKNOWN; + } + + const context: IFilterEvalContext = { starPattern, shape }; + let status = FilterTruth.UNKNOWN; + for (const filter of filters) { + const result = evaluateFilterCompatibility(filter, context); + if (result === FilterTruth.FALSE) { + return FilterTruth.FALSE; + } + if (result === FilterTruth.TRUE) { + status = FilterTruth.TRUE; + } + } + return status; +} + +function evaluateFilterCompatibility(expression: unknown, context: IFilterEvalContext): FilterTruth { + if (typeof expression !== 'object' || expression === null) { + return FilterTruth.UNKNOWN; + } + + const castExpression = expression as { subType?: string; operator?: string; args?: unknown[] }; + if (castExpression.subType === 'term') { + // Boolean literals like FILTER(true/false) are intentionally ignored for containment. + return FilterTruth.UNKNOWN; + } + if (castExpression.subType !== 'operator') { + return FilterTruth.UNKNOWN; + } + + const args = castExpression.args ?? []; + switch (castExpression.operator) { + case '&&': + return combineLogicalAnd(args, context); + case '||': + return combineLogicalOr(args, context); + case '!': { + // Negation does not provide a safe contradiction signal in this conservative check. + return FilterTruth.UNKNOWN; + } + case '=': + case '!=': + case '<': + case '<=': + case '>': + case '>=': + return evaluateComparisonCompatibility(castExpression.operator, args[0], args[1], context); + case 'regex': + return evaluateRegexCompatibility(args, context); + default: + return FilterTruth.UNKNOWN; + } +} + +function combineLogicalAnd(args: unknown[], context: IFilterEvalContext): FilterTruth { + // AND is false as soon as one branch is false; true only if at least one branch is provably true. + let hasTrue = false; + for (const arg of args) { + const current = evaluateFilterCompatibility(arg, context); + if (current === FilterTruth.FALSE) { + return FilterTruth.FALSE; + } + if (current === FilterTruth.TRUE) { + hasTrue = true; + } + } + return hasTrue ? FilterTruth.TRUE : FilterTruth.UNKNOWN; +} + +function combineLogicalOr(args: unknown[], context: IFilterEvalContext): FilterTruth { + // OR is false only if every branch is false. + let hasNonFalse = false; + for (const arg of args) { + const current = evaluateFilterCompatibility(arg, context); + if (current !== FilterTruth.FALSE) { + hasNonFalse = true; + } + } + return hasNonFalse ? FilterTruth.UNKNOWN : FilterTruth.FALSE; +} + +function evaluateComparisonCompatibility(operator: string, left: unknown, right: unknown, context: IFilterEvalContext): FilterTruth { + // First check datatype() comparisons, then numeric inequalities. + const datatypeCheck = evaluateDatatypeCompatibility(operator, left, right, context) + ?? evaluateDatatypeCompatibility(operator, right, left, context); + if (datatypeCheck !== undefined) { + return datatypeCheck; + } + + if (operator === '<' || operator === '<=' || operator === '>' || operator === '>=') { + const numericCheck = evaluateNumericComparisonCompatibility(operator, left, right, context, true) + ?? evaluateNumericComparisonCompatibility(operator, right, left, context, false); + if (numericCheck !== undefined) { + return numericCheck; + } + } + + return FilterTruth.UNKNOWN; +} + +function evaluateNumericComparisonCompatibility( + operator: string, + variableExpr: unknown, + otherExpr: unknown, + context: IFilterEvalContext, + variableOnLeft: boolean, +): FilterTruth | undefined { + // This branch only reasons about variable-vs-literal numeric constraints. + const variableName = extractVariableName(variableExpr); + if (variableName === undefined) { + return undefined; + } + + const literal = extractLiteral(otherExpr); + if (literal === undefined || getNumericLiteralValue(literal) === undefined) { + return FilterTruth.UNKNOWN; + } + + const variableConstraint = extractVariableDatatypeConstraint(variableName, context); + if (variableConstraint === undefined) { + return FilterTruth.UNKNOWN; + } + + for (const datatype of variableConstraint.datatypes) { + if (isNumericDatatype(datatype)) { + const filterBounds = boundsFromNumericFilter(operator, getNumericLiteralValue(literal)!, variableOnLeft); + const shapeBounds = variableConstraint.bounds; + if (shapeBounds === undefined || filterBounds === undefined) { + return FilterTruth.UNKNOWN; + } + + // Contradiction when filter bounds and shape bounds have an empty intersection. + const bounds = intersectBounds(shapeBounds, filterBounds); + if (bounds === undefined) { + return FilterTruth.FALSE; + } + + return FilterTruth.UNKNOWN; + } + } + return FilterTruth.FALSE; +} + +function evaluateDatatypeCompatibility(operator: string, datatypeExpr: unknown, otherExpr: unknown, context: IFilterEvalContext): FilterTruth | undefined { + // Handle FILTER(datatype(?v) = iri) and FILTER(datatype(?v) != iri) forms. + const variableName = extractDatatypeVariableName(datatypeExpr); + if (variableName === undefined) { + return undefined; + } + + const expectedDatatype = extractNamedNodeValue(otherExpr); + if (expectedDatatype === undefined) { + return FilterTruth.UNKNOWN; + } + + const datatypes = extractVariableDatatypes(variableName, context); + if (datatypes === undefined) { + return FilterTruth.UNKNOWN; + } + + if (operator === '=' && !datatypes.has(expectedDatatype)) { + return FilterTruth.FALSE; + } + if (operator === '!=' && datatypes.size === 1 && datatypes.has(expectedDatatype)) { + return FilterTruth.FALSE; + } + return FilterTruth.UNKNOWN; +} + +function extractVariableDatatypes(variableName: string, context: IFilterEvalContext): Set | undefined { + return extractVariableDatatypeConstraint(variableName, context)?.datatypes; +} + +function extractVariableDatatypeConstraint(variableName: string, context: IFilterEvalContext): IVariableDatatypeConstraint | undefined { + let collectedDatatypes: Set | undefined; + let collectedBounds: INumericBounds | undefined; + let collectedPattern: string | undefined; + let collectedFlags: string | undefined; + + for (const { triple } of context.starPattern.starPattern.values()) { + const matchesVariable = (!Array.isArray(triple.object) && triple.object.termType === 'Variable' && triple.object.value === variableName) + || (Array.isArray(triple.object) && triple.boundVariable === variableName); + if (!matchesVariable) { + continue; + } + const predicateConstraint = context.shape.get(triple.predicate)?.constraint; + if (predicateConstraint?.type !== ConstraintType.DATATYPE || predicateConstraint.value.size === 0) { + continue; + } + + // Multiple occurrences of the same variable are merged by intersection. + if (collectedDatatypes === undefined) { + collectedDatatypes = new Set(predicateConstraint.value); + } else { + collectedDatatypes = new Set(Array.from(collectedDatatypes).filter((value) => predicateConstraint.value.has(value))); + } + + const bounds = extractNumericBounds(predicateConstraint); + if (bounds !== undefined) { + if (collectedBounds === undefined) { + collectedBounds = bounds; + } else { + const intersected = intersectBounds(collectedBounds, bounds); + if (intersected === undefined) { + return { + datatypes: collectedDatatypes, + bounds: { + minInclusive: 1, + maxInclusive: 0, + }, + }; + } + collectedBounds = intersected; + } + } + + // Regex reasoning is only kept when all matching constraints agree on pattern/flags. + if (predicateConstraint.pattern !== undefined) { + if (collectedPattern === undefined) { + collectedPattern = predicateConstraint.pattern; + collectedFlags = predicateConstraint.flags; + } else if (collectedPattern !== predicateConstraint.pattern || collectedFlags !== predicateConstraint.flags) { + return { + datatypes: collectedDatatypes, + bounds: collectedBounds, + }; + } + } + } + + if (collectedDatatypes === undefined) { + return undefined; + } + + return { + datatypes: collectedDatatypes, + bounds: collectedBounds, + pattern: collectedPattern, + flags: collectedFlags, + }; +} + +function evaluateRegexCompatibility(args: unknown[], context: IFilterEvalContext): FilterTruth { + // Supported form: FILTER(regex(str(?v), pattern, flags?)). + const variableName = extractRegexVariableName(args[0]); + if (variableName === undefined) { + return FilterTruth.UNKNOWN; + } + + const regexPattern = extractStringLiteralValue(args[1]); + if (regexPattern === undefined) { + return FilterTruth.UNKNOWN; + } + + const regexFlags = extractStringLiteralValue(args[2]) ?? ''; + const variableConstraint = extractVariableDatatypeConstraint(variableName, context); + if (variableConstraint?.pattern === undefined) { + return FilterTruth.UNKNOWN; + } + + // Equality alone is not strong enough to prove containment in this conservative pass, + // so we keep it as UNKNOWN and only return TRUE/FALSE for explicit implication/conflict. + if (variableConstraint.pattern === regexPattern && (variableConstraint.flags ?? '') === regexFlags) { + return FilterTruth.UNKNOWN; + } + + const shapePatternTest = buildRegex(variableConstraint.pattern, variableConstraint.flags); + const filterPatternTest = buildRegex(regexPattern, regexFlags); + if (shapePatternTest === undefined || filterPatternTest === undefined) { + return FilterTruth.UNKNOWN; + } + + // TRUE when the shape regex is strictly stronger and therefore always satisfies the filter. + if (doesRegexConstraintImplyFilter(variableConstraint.pattern, variableConstraint.flags, regexPattern, regexFlags)) { + return FilterTruth.TRUE; + } + + // FALSE only when no string can match both literal patterns. + if (hasRegexPrefixIncompatibility(variableConstraint.pattern, variableConstraint.flags, regexPattern, regexFlags)) { + return FilterTruth.FALSE; + } + + return FilterTruth.UNKNOWN; +} + +interface IAnchoredLiteralPrefix { + prefix: string; + anchoredEnd: boolean; +} + +function doesRegexConstraintImplyFilter( + shapePattern: string, + shapeFlags: string | undefined, + filterPattern: string, + filterFlags: string, +): boolean { + // We only reason about implication when both regexes run under the same flags. + const normalizedShapeFlags = shapeFlags ?? ''; + if (normalizedShapeFlags !== filterFlags) { + return false; + } + + const shapePrefix = extractAnchoredLiteralPrefix(shapePattern); + const filterPrefix = extractAnchoredLiteralPrefix(filterPattern); + if (shapePrefix === undefined || filterPrefix === undefined) { + return false; + } + + // If the filter is end-anchored (^x$), implication requires exact same anchored literal. + if (filterPrefix.anchoredEnd) { + return shapePrefix.anchoredEnd && shapePrefix.prefix === filterPrefix.prefix; + } + + // For simple start-anchored prefixes, ^foo implies ^f. + return shapePrefix.prefix.startsWith(filterPrefix.prefix); +} + +function extractRegexVariableName(expression: unknown): string | undefined { + // Accept both regex(?v, ...) and regex(str(?v), ...). + if (typeof expression !== 'object' || expression === null) { + return undefined; + } + + const castExpression = expression as { subType?: string; operator?: string; args?: unknown[] }; + if (castExpression.subType === 'operator' && castExpression.operator === 'str') { + return extractVariableName((castExpression.args ?? [])[0]); + } + + return extractVariableName(expression); +} + +function extractStringLiteralValue(expression: unknown): string | undefined { + const literal = extractLiteral(expression); + if (literal === undefined) { + return undefined; + } + return literal.value; +} + +function buildRegex(pattern: string, flags?: string): RegExp | undefined { + // Invalid regexes are ignored in conservative compatibility checks. + try { + return new RegExp(pattern, flags); + } catch { + return undefined; + } +} + +function hasRegexPrefixIncompatibility( + shapePattern: string, + shapeFlags: string | undefined, + filterPattern: string, + filterFlags: string, +): boolean { + // Flags change what a literal pattern matches (`i` folds case, `x` drops whitespace, `q` makes `^` and `$` literal, + // `m` anchors at every line), so a contradiction is only decided between patterns without flags. + if ((shapeFlags ?? '') !== '' || filterFlags !== '') { + return false; + } + + const shapePrefix = extractAnchoredLiteralPrefix(shapePattern); + const filterPrefix = extractAnchoredLiteralPrefix(filterPattern); + if (shapePrefix !== undefined && filterPrefix !== undefined) { + return !areAnchoredLiteralsCompatible(shapePrefix, filterPrefix); + } + + // A string can start with one literal and go on to contain another, so a prefix (^foo) contradicts no unanchored + // literal (bar), and neither do two unanchored literals. Only an exact literal (^foo$) excludes what it lacks. + const shapeContains = extractContainsLiteral(shapePattern); + const filterContains = extractContainsLiteral(filterPattern); + if (shapePrefix?.anchoredEnd === true && filterContains !== undefined) { + return !shapePrefix.prefix.includes(filterContains); + } + if (filterPrefix?.anchoredEnd === true && shapeContains !== undefined) { + return !filterPrefix.prefix.includes(shapeContains); + } + + return false; +} + +/** + * Whether some string matches both start-anchored literal patterns: ^foo and ^fo are compatible, ^foo and ^bar are not, + * and an end-anchored ^foo$ only admits prefixes of "foo". + */ +function areAnchoredLiteralsCompatible(first: IAnchoredLiteralPrefix, second: IAnchoredLiteralPrefix): boolean { + if (first.anchoredEnd && second.anchoredEnd) { + return first.prefix === second.prefix; + } + if (first.anchoredEnd) { + return first.prefix.startsWith(second.prefix); + } + if (second.anchoredEnd) { + return second.prefix.startsWith(first.prefix); + } + return first.prefix.startsWith(second.prefix) || second.prefix.startsWith(first.prefix); +} + +function extractAnchoredLiteralPrefix(pattern: string): IAnchoredLiteralPrefix | undefined { + const match = pattern.match(/^\^([A-Za-z0-9 _-]+)(\$)?$/); + if (match === null) { + return undefined; + } + + return { + prefix: match[1], + anchoredEnd: match[2] !== undefined, + }; +} + +function extractContainsLiteral(pattern: string): string | undefined { + const match = pattern.match(/^([A-Za-z0-9 _-]+)$/); + return match?.[1]; +} + +function extractNumericBounds(constraint: IConstraint): INumericBounds | undefined { + // Normalize optional SHACL numeric facets into a single interval object. + const bounds: INumericBounds = {}; + if (constraint.minInclusive !== undefined) { + bounds.minInclusive = constraint.minInclusive; + } + if (constraint.maxInclusive !== undefined) { + bounds.maxInclusive = constraint.maxInclusive; + } + if (constraint.minExclusive !== undefined) { + bounds.minExclusive = constraint.minExclusive; + } + if (constraint.maxExclusive !== undefined) { + bounds.maxExclusive = constraint.maxExclusive; + } + + return Object.keys(bounds).length === 0 ? undefined : bounds; +} + +function boundsFromNumericFilter(operator: string, literalValue: number, variableOnLeft: boolean): INumericBounds | undefined { + // Convert a comparison operator to an interval on the variable. + const normalizedOperator = normalizeComparisonOperator(operator, variableOnLeft); + switch (normalizedOperator) { + case '>': + return { minExclusive: literalValue }; + case '>=': + return { minInclusive: literalValue }; + case '<': + return { maxExclusive: literalValue }; + case '<=': + return { maxInclusive: literalValue }; + default: + return undefined; + } +} + +function normalizeComparisonOperator(operator: string, variableOnLeft: boolean): string { + // Re-orient operator so bounds are always computed as variable OP literal. + if (variableOnLeft) { + return operator; + } + if (operator === '>') { + return '<'; + } + if (operator === '>=') { + return '<='; + } + if (operator === '<') { + return '>'; + } + if (operator === '<=') { + return '>='; + } + return operator; +} + +function intersectBounds(left: INumericBounds, right: INumericBounds): INumericBounds | undefined { + // Compute interval intersection; undefined means empty intersection. + const lower = pickLowerBound( + mergeLowerBound(left.minInclusive, false, left.minExclusive, true), + mergeLowerBound(right.minInclusive, false, right.minExclusive, true), + ); + const upper = pickUpperBound( + mergeUpperBound(left.maxInclusive, false, left.maxExclusive, true), + mergeUpperBound(right.maxInclusive, false, right.maxExclusive, true), + ); + + if (lower !== undefined && upper !== undefined) { + if (lower.value > upper.value) { + return undefined; + } + if (lower.value === upper.value && (lower.exclusive || upper.exclusive)) { + return undefined; + } + } + + const bounds: INumericBounds = {}; + if (lower !== undefined) { + if (lower.exclusive) { + bounds.minExclusive = lower.value; + } else { + bounds.minInclusive = lower.value; + } + } + if (upper !== undefined) { + if (upper.exclusive) { + bounds.maxExclusive = upper.value; + } else { + bounds.maxInclusive = upper.value; + } + } + + return bounds; +} + +function mergeLowerBound(inclusive: number | undefined, inclusiveExclusive: boolean, exclusive: number | undefined, exclusiveExclusive: boolean): { value: number; exclusive: boolean } | undefined { + // Pick the tighter lower bound from inclusive/exclusive candidates. + const candidates: { 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: { value: number; exclusive: boolean }[] = []; + if (inclusive !== undefined) { + candidates.push({ value: inclusive, exclusive: inclusiveExclusive }); + } + if (exclusive !== undefined) { + candidates.push({ value: exclusive, exclusive: exclusiveExclusive }); + } + if (candidates.length === 0) { + return undefined; + } + return pickUpperBound(candidates[0], candidates[1]); +} + +function pickLowerBound(a?: { value: number; exclusive: boolean }, b?: { value: number; exclusive: boolean }): { value: number; exclusive: boolean } | undefined { + // Lower bound with the greatest value is the most restrictive. + if (a === undefined) { + return b; + } + if (b === undefined) { + return a; + } + if (a.value > b.value) { + return a; + } + if (b.value > a.value) { + return b; + } + return { value: a.value, exclusive: a.exclusive || b.exclusive }; +} + +function pickUpperBound(a?: { value: number; exclusive: boolean }, b?: { value: number; exclusive: boolean }): { value: number; exclusive: boolean } | undefined { + // Upper bound with the smallest value is the most restrictive. + if (a === undefined) { + return b; + } + if (b === undefined) { + return a; + } + if (a.value < b.value) { + return a; + } + if (b.value < a.value) { + return b; + } + return { value: a.value, exclusive: a.exclusive || b.exclusive }; +} + +function extractVariableName(expression: unknown): string | undefined { + // Extract a SPARQL variable from a term expression. + if (typeof expression !== 'object' || expression === null) { + return undefined; + } + const castExpression = expression as { subType?: string; term?: Term }; + if (castExpression.subType !== 'term' || castExpression.term === undefined || castExpression.term.termType !== 'Variable') { + return undefined; + } + return castExpression.term.value; +} + +function extractDatatypeVariableName(expression: unknown): string | undefined { + // Extract variable name from datatype(?v). + if (typeof expression !== 'object' || expression === null) { + return undefined; + } + const castExpression = expression as { subType?: string; operator?: string; args?: unknown[] }; + if (castExpression.subType !== 'operator' || castExpression.operator !== 'datatype') { + return undefined; + } + return extractVariableName((castExpression.args ?? [])[0]); +} + +function extractLiteral(expression: unknown): Term | undefined { + // Extract literal term from a term expression. + if (typeof expression !== 'object' || expression === null) { + return undefined; + } + const castExpression = expression as { subType?: string; term?: Term }; + if (castExpression.subType !== 'term' || castExpression.term === undefined || castExpression.term.termType !== 'Literal') { + return undefined; + } + return castExpression.term; +} + +function extractNamedNodeValue(expression: unknown): string | undefined { + // Extract named node IRI value from a term expression. + if (typeof expression !== 'object' || expression === null) { + return undefined; + } + const castExpression = expression as { subType?: string; term?: Term }; + if (castExpression.subType !== 'term' || castExpression.term === undefined || castExpression.term.termType !== 'NamedNode') { + return undefined; + } + return castExpression.term.value; +} + +function getNumericLiteralValue(term: Term): number | undefined { + // Parse numeric literal value only when datatype is numeric. + if (term.termType !== 'Literal') { + return undefined; + } + if (!isNumericDatatype(term.datatype.value)) { + return undefined; + } + + const value = Number(term.value); + return Number.isNaN(value) ? undefined : value; +} + +function isNumericDatatype(datatype: string): boolean { + // Accepted numeric XML Schema datatypes for numeric filter reasoning. + const numericDatatypes = new Set([ + 'http://www.w3.org/2001/XMLSchema#integer', + 'http://www.w3.org/2001/XMLSchema#decimal', + 'http://www.w3.org/2001/XMLSchema#double', + 'http://www.w3.org/2001/XMLSchema#float', + 'http://www.w3.org/2001/XMLSchema#nonNegativeInteger', + 'http://www.w3.org/2001/XMLSchema#nonPositiveInteger', + 'http://www.w3.org/2001/XMLSchema#positiveInteger', + 'http://www.w3.org/2001/XMLSchema#negativeInteger', + 'http://www.w3.org/2001/XMLSchema#long', + 'http://www.w3.org/2001/XMLSchema#int', + 'http://www.w3.org/2001/XMLSchema#short', + 'http://www.w3.org/2001/XMLSchema#byte', + 'http://www.w3.org/2001/XMLSchema#unsignedLong', + 'http://www.w3.org/2001/XMLSchema#unsignedInt', + 'http://www.w3.org/2001/XMLSchema#unsignedShort', + 'http://www.w3.org/2001/XMLSchema#unsignedByte', + ]); + return numericDatatypes.has(datatype); +} diff --git a/lib/negation.ts b/lib/negation.ts new file mode 100644 index 0000000..46b09f8 --- /dev/null +++ b/lib/negation.ts @@ -0,0 +1,108 @@ +import { Algebra } from '@traqula/algebra-transformations-1-1'; +import type { UnsupportedConstruct } from './query'; + +/** A negated triple pattern, waiting to be attached to the star pattern of its subject. */ +export interface INegatedPattern { subject: string, predicate: string, construct: 'MINUS' | 'NOT EXISTS' } + +/** + * Return the graph pattern of a `NOT EXISTS` (or `!EXISTS`) expression, or undefined when the + * expression is anything else. + */ +export function getNegatedExistenceInput(expression: Algebra.Expression): Algebra.Operation | undefined { + if (expression.subType === Algebra.ExpressionTypes.EXISTENCE) { + return expression.not ? expression.input : undefined; + } + if (expression.subType === Algebra.ExpressionTypes.OPERATOR && expression.operator === '!' && + expression.args.length === 1 && expression.args[0].subType === Algebra.ExpressionTypes.EXISTENCE && + !expression.args[0].not) { + return expression.args[0].input; + } + return undefined; +} + +/** + * Read a negated graph pattern as a negated triple pattern `?x p ?o`. The pattern must be a single + * triple pattern with a variable subject and an IRI predicate; its subject must be bound by a + * required triple pattern of `scope`, the group the negation applies to (the filtered group of a + * FILTER, the left operand of a MINUS); and its object must be a blank node or a variable that + * does not occur in `scope`. The negation then removes exactly the solutions whose subject + * carries `p`, so `MINUS` and `NOT EXISTS` coincide. Any other form returns undefined. + */ +export function toNegatedPattern(negated: Algebra.Operation, scope: Algebra.Operation): + Omit | undefined { + if (negated.type !== Algebra.Types.BGP || negated.patterns.length !== 1) { + return undefined; + } + const { subject, predicate, object, graph } = negated.patterns[0]; + if (subject.termType !== 'Variable' || predicate.termType !== 'NamedNode' || + graph.termType !== 'DefaultGraph') { + return undefined; + } + const freshObject = object.termType === 'BlankNode' || + (object.termType === 'Variable' && !occursIn(object.value, scope)); + if (!freshObject || !getRequiredSubjects(scope).has(subject.value)) { + return undefined; + } + return { subject: subject.value, predicate: predicate.value }; +} + +/** + * Subjects of the triple patterns with an IRI predicate that every solution of `operation` binds. + * The approximation is conservative: operations it does not know contribute no subject. + */ +function getRequiredSubjects(operation: Algebra.Operation): Set { + switch (operation.type) { + case Algebra.Types.BGP: + return new Set(operation.patterns + .filter(pattern => pattern.subject.termType === 'Variable' && pattern.predicate.termType === 'NamedNode') + .map(pattern => pattern.subject.value)); + case Algebra.Types.JOIN: + return new Set(operation.input.flatMap(input => Array.from(getRequiredSubjects(input)))); + case Algebra.Types.LEFT_JOIN: + case Algebra.Types.MINUS: + return getRequiredSubjects(operation.input[0]); + case Algebra.Types.FILTER: + case Algebra.Types.EXTEND: + case Algebra.Types.DISTINCT: + case Algebra.Types.REDUCED: + case Algebra.Types.ORDER_BY: + case Algebra.Types.GRAPH: + return getRequiredSubjects(operation.input); + case Algebra.Types.UNION: { + const [first, ...rest] = operation.input.map(getRequiredSubjects); + return new Set(Array.from(first ?? []).filter(subject => rest.every(branch => branch.has(subject)))); + } + default: + return new Set(); + } +} + +/** + * Whether the variable occurs anywhere in the operation, including its expressions. + */ +function occursIn(variable: string, operation: unknown): boolean { + if (typeof operation !== 'object' || operation === null) { + return false; + } + const node = operation as { termType?: unknown, value?: unknown }; + if (node.termType === 'Variable' && node.value === variable) { + return true; + } + return Object.values(operation).some(child => occursIn(variable, child)); +} + +/** + * Report every `EXISTS` and `NOT EXISTS` of an expression as a discarded construct. + */ +export function reportExistence(expression: Algebra.Expression, unsupported: Set, negated = false): void { + if (expression.subType === Algebra.ExpressionTypes.EXISTENCE) { + unsupported.add(expression.not !== negated ? 'NOT EXISTS' : 'EXISTS'); + return; + } + if (expression.subType === Algebra.ExpressionTypes.OPERATOR || expression.subType === Algebra.ExpressionTypes.NAMED) { + const negatesArgument = expression.subType === Algebra.ExpressionTypes.OPERATOR && expression.operator === '!'; + for (const argument of expression.args) { + reportExistence(argument, unsupported, negatesArgument); + } + } +} diff --git a/lib/parser-policy.ts b/lib/parser-policy.ts new file mode 100644 index 0000000..e4f3f70 --- /dev/null +++ b/lib/parser-policy.ts @@ -0,0 +1,93 @@ +import type * as RDF from '@rdfjs/types'; +import { type IRdfListIndex, PoorlyFormatedShapeError, type ShapeError, walkRdfList } from './Shape'; + +export type ShapeFormat = 'shacl' | 'shex'; + +export interface ParserDiagnostic { + level: 'warning' | 'error'; + code: string; + message: string; + shapeIri?: string; + nodeId?: string; +} + +export interface ShapeParserPolicy { + /** + * If true, malformed RDF lists are treated as parse errors. + * Defaults to true. + */ + strictRdfLists?: boolean; +} + +export interface IShapeParserOptions { + policy?: ShapeParserPolicy; + diagnostics?: ParserDiagnostic[]; +} + +export function getPolicy(options?: IShapeParserOptions): Required { + return { + strictRdfLists: options?.policy?.strictRdfLists ?? true, + }; +} + +export function addDiagnostic( + options: IShapeParserOptions | undefined, + diagnostic: ParserDiagnostic, +): void { + options?.diagnostics?.push(diagnostic); +} + +/** + * Where an RDF list is read, for its diagnostic. + */ +export interface IRdfListContext { + shapeIri: string; + // the construct the list belongs to, such as `sh:or` + construct: string; + options?: IShapeParserOptions; +} + +/** + * The members of an RDF list of a shapes graph. A malformed list is reported as a diagnostic; it is + * a parse error under the strict policy (the default), and otherwise its members up to the defect + * are used. + */ +export function readRdfList( + head: string, + index: IRdfListIndex, + context: IRdfListContext, +): string[] | PoorlyFormatedShapeError { + const walked = walkRdfList(head, index); + if (!walked.malformed) { + return walked.values; + } + const { strictRdfLists } = getPolicy(context.options); + addDiagnostic(context.options, { + level: strictRdfLists ? 'error' : 'warning', + code: 'MALFORMED_RDF_LIST', + message: `Malformed RDF list while resolving ${context.construct} for <${context.shapeIri}>`, + shapeIri: context.shapeIri, + nodeId: head, + }); + return strictRdfLists ? new PoorlyFormatedShapeError('An RDF list is poorly defined') : walked.values; +} + +/** + * Pass every quad of a shapes graph, given as an array or as a stream, to `onQuad`. The promise + * resolves when all the quads are passed, with the error of the stream if it fails: the parsers + * return their errors rather than throw them. + */ +export function forEachQuad( + quads: RDF.Stream | RDF.Quad[], + onQuad: (quad: RDF.Quad) => void, +): Promise { + if (Array.isArray(quads)) { + quads.forEach(onQuad); + return Promise.resolve(undefined); + } + return new Promise(resolve => { + quads.on('data', onQuad); + quads.on('error', (error: ShapeError) => resolve(error)); + quads.on('end', () => resolve(undefined)); + }); +} diff --git a/lib/query.ts b/lib/query.ts index e77913f..5c763bc 100644 --- a/lib/query.ts +++ b/lib/query.ts @@ -1,8 +1,11 @@ import type { Term } from '@rdfjs/types'; import { algebraUtils, Algebra } from '@traqula/algebra-transformations-1-1'; +import { DataFactory } from 'rdf-data-factory'; import { ITripleWithDependencies, Triple, type IStarPatternWithDependencies, ITriple } from './Triple'; -import { ICardinality } from './Shape'; -import { DF } from './constant'; +import type { ICardinality } from './Shape'; +import { getNegatedExistenceInput, type INegatedPattern, reportExistence, toNegatedPattern } from './negation'; + +const DF = new DataFactory(); // Constrained PropertyPathSymbol type for cardinality operations // These are the cardinality path types that contain a nested path with Link (iri property) @@ -22,6 +25,36 @@ type NpsPath = Omit & { interface IAccumulatedTriples { triples: Map, isVariable: boolean } +/** What normalizing a query branch accumulates while walking its algebra. */ +interface INormalizationState { + // triple patterns by subject + triples: Map; + // VALUES bindings by variable name + values: Map; + unions: IQuery[][]; + filters: Algebra.Expression[]; + unsupported: Set; + // an unrestricted triple pattern (a variable predicate) was found + matchesAnyTriple: boolean; + negatedPatterns: INegatedPattern[]; +} + +/** + * An empty normalization state. The branch built for one alternative of a property path shares the + * VALUES bindings of the query it belongs to. + */ +function createNormalizationState(values = new Map()): INormalizationState { + return { + triples: new Map(), + values, + unions: [], + filters: [], + unsupported: new Set(), + matchesAnyTriple: false, + negatedPatterns: [], + }; +} + /** * A query divided into star patterns */ @@ -33,8 +66,35 @@ export interface IQuery { filters?: Algebra.Expression[]; // VALUES bindings indexed by variable name values?: Map; + /** + * SPARQL constructs that were discarded during normalization because they are outside the + * supported profile. Discarding them can only over-estimate relevance, never discard a + * relevant resource, but it means the result approximates a sub-query of the given query. + */ + unsupported?: UnsupportedConstruct[]; + /** + * True when the query contained an unrestricted triple pattern (a variable predicate, as in + * `?s ?p ?o`). Such a pattern constrains nothing beyond "the resource holds triples", so it is + * not turned into a star pattern, but it is not a discarded construct either: it asks for every + * triple, which any resource can answer. + */ + matchesAnyTriple?: boolean; } +/** + * A SPARQL construct outside the supported profile, discarded during normalization. A `MINUS` or + * `NOT EXISTS` over a single triple pattern is supported as a negated triple pattern and is only + * reported here when it is not of that form. An `EXISTS` or `NOT EXISTS` expression that is + * discarded stays in its FILTER, where it is undecidable. + * + * `TARGETS` reports the targets of an input shape that could not be translated: node or objects-of + * targets combined with other targets, or several objects-of targets. They are ignored. + */ +export type UnsupportedConstruct = 'MINUS' | 'SERVICE' | 'EXISTS' | 'NOT EXISTS' | 'TARGETS'; + +/** + * Extract the star pattern with the requested subject from each UNION branch set. + */ export function generateStarPatternUnion(union: IQuery[][], starPatternName: string): IStarPatternWithDependencies[][] { const resp: IStarPatternWithDependencies[][] = []; for (const unionSet of union) { @@ -52,6 +112,16 @@ export function generateStarPatternUnion(union: IQuery[][], starPatternName: str return resp; } + +/** + * Whether a subject is a variable of the pattern. A blank node in a SPARQL pattern behaves as a + * variable that is not projected. + */ +function isVariableTerm(term: Term): boolean { + return term.termType === 'Variable' || term.termType === 'BlankNode'; +} + + /** * Divide a query into star patterns * @param {Algebra.Operation} algebraQuery - the algebra of a query @@ -59,25 +129,21 @@ export function generateStarPatternUnion(union: IQuery[][], starPatternName: str * @todo add support for the bind operator */ export function generateQuery(algebraQuery: Algebra.Operation, optional?: boolean): IQuery { - const accumulatedTriples = new Map(); - // the binding value to the value - const accumulatedValues = new Map(); - const accumulatedUnion: IQuery[][] = []; - const accumulatedFilters: Algebra.Expression[] = []; - - QueryHandler.collectFromAlgebra(algebraQuery, accumulatedTriples, accumulatedValues, accumulatedUnion, accumulatedFilters, optional); - - return buildQuery(accumulatedTriples, accumulatedValues, accumulatedUnion, accumulatedFilters); + const state = createNormalizationState(); + QueryHandler.collectFromAlgebra(algebraQuery, state, optional); + return buildQuery(state); } -function buildQuery( - tripleArgs: Map, - values: Map, - accumulatedUnion: IQuery[][], - filters: Algebra.Expression[] = [], -): IQuery { +/** + * Finalize the accumulated parsing state into the query structure used by containment. + */ +function buildQuery(state: INormalizationState): IQuery { + const { triples: tripleArgs, values, unions: accumulatedUnion, filters, unsupported } = state; const innerQuery = new Map(); const resp: IQuery = { starPatterns: innerQuery }; + if (state.matchesAnyTriple) { + resp.matchesAnyTriple = true; + } if (accumulatedUnion.length > 0) { resp.union = accumulatedUnion; } @@ -128,12 +194,32 @@ function buildQuery( addADependencyToStarPattern(tripleWithDependencies, innerQuery); } } + + // A negated triple pattern constrains the star pattern of its subject. Without a required triple + // pattern there, the subject need not be bound at all, so the negation is discarded instead. + for (const { subject, predicate, construct } of state.negatedPatterns) { + const starPattern = innerQuery.get(subject); + const hasRequiredTriple = starPattern !== undefined && + Array.from(starPattern.starPattern.values()).some(({ triple }) => triple.isOptional !== true); + if (!hasRequiredTriple) { + unsupported.add(construct); + continue; + } + starPattern.excludedPredicates ??= new Set(); + starPattern.excludedPredicates.add(predicate); + } + if (unsupported.size > 0) { + resp.unsupported = Array.from(unsupported); + } addUnionDependencies(resp) return resp; } // could be made so that when adding a union we make sure to find the dependency // function should be revisited +/** + * Resolve dependencies inside UNION branches back to root star patterns when possible. + */ function addUnionDependencies(query: IQuery): void { if (query.union === undefined) { return; @@ -185,6 +271,9 @@ function searchDependencyInUnion(unions: IQuery[][], currentBranch: number, trip } */ +/** + * Attach a star-pattern dependency to a triple when its object links to another known subject. + */ function addADependencyToStarPattern( tripleWithDependencies: ITripleWithDependencies, innerQuery: Map): void { @@ -201,106 +290,166 @@ function addADependencyToStarPattern( namespace QueryHandler { + /** + * Walk a SPARQL algebra tree and accumulate triples, values, filters, and unions by query branch. + */ export function collectFromAlgebra( rootAlgebra: Algebra.Operation, - accumulatedTriples: Map, - accumulatedValues: Map, - accumulatedUnion: IQuery[][], - accumulatedFilters: Algebra.Expression[], - optional?: boolean + state: INormalizationState, + optional?: boolean, ): void { algebraUtils.visitOperation( rootAlgebra, { [Algebra.Types.PATTERN]: { preVisitor: () => ({ continue: false }), - visitor: handlePattern(accumulatedTriples, optional), + visitor: handlePattern(state, optional), + }, + // MINUS removes solutions and SERVICE evaluates elsewhere, so neither describes data the + // candidate resource must hold. Their triple patterns must not be collected as required + // patterns: doing so both inflates and deflates relevance. A MINUS over a single triple + // pattern is kept as a negated triple pattern instead. + [Algebra.Types.MINUS]: { + preVisitor: () => ({ continue: false }), + visitor: handleMinus(state, optional), + }, + [Algebra.Types.SERVICE]: { + preVisitor: () => ({ continue: false }), + visitor: (): void => { state.unsupported.add('SERVICE'); }, }, [Algebra.Types.VALUES]: { preVisitor: () => ({ continue: false }), - visitor: handleValues(accumulatedValues), + visitor: handleValues(state.values), }, [Algebra.Types.UNION]: { preVisitor: () => ({ continue: false }), - visitor: handleUnion(accumulatedUnion, optional), + visitor: handleUnion(state, optional), }, [Algebra.Types.LEFT_JOIN]: { preVisitor: () => ({ continue: false }), - visitor: handleLeftJoin(accumulatedTriples, accumulatedValues, accumulatedFilters, accumulatedUnion), + visitor: handleLeftJoin(state), }, [Algebra.Types.PATH]: { preVisitor: () => ({ continue: false }), - visitor: handlePropertyPath(accumulatedTriples, accumulatedUnion, accumulatedValues, optional), + visitor: handlePropertyPath(state, optional), }, [Algebra.Types.FILTER]: { - visitor: handleFilter(accumulatedFilters), - // Ignore the expression subtree for now (e.g. FILTER NOT EXISTS) to avoid collecting - // inner patterns that belong to the filter condition, not the query body. + visitor: handleFilter(state, optional), + // Ignore the expression subtree (e.g. FILTER NOT EXISTS) to avoid collecting inner + // patterns that belong to the filter condition, not the query body. preVisitor: () => ({ ignoreKeys: new Set(['expression']) }), }, }, ); } - function handleLeftJoin(accumulatedTriples: Map, - accumulatedValues: Map, - accumulatedFilters: Algebra.Expression[], - accumulatedUnion: IQuery[][]): (element: Algebra.LeftJoin) => void { + /** + * Keep the left operand of a MINUS. The right one describes solutions to exclude, not data the + * candidate resource has to hold: it becomes a negated triple pattern when it is a single triple + * pattern of the supported form, and is discarded otherwise. + */ + function handleMinus(state: INormalizationState, optional?: boolean): (element: Algebra.Minus) => void { + return (element: Algebra.Minus): void => { + const [left, right] = element.input; + const negatedPattern = optional === true ? undefined : toNegatedPattern(right, left); + if (negatedPattern !== undefined) { + state.negatedPatterns.push({ ...negatedPattern, construct: 'MINUS' }); + } else { + state.unsupported.add('MINUS'); + } + collectFromAlgebra(left, state, optional); + } + } + + /** + * Split a LEFT JOIN into required and optional branches and collect both with the right optional flag. + */ + function handleLeftJoin(state: INormalizationState): (element: Algebra.LeftJoin) => void { return (element: Algebra.LeftJoin): void => { const joinElement = element.input; const requiredElements = joinElement[0]; const optionalElements = joinElement[1]; - collectFromAlgebra(requiredElements, accumulatedTriples, accumulatedValues, accumulatedUnion, accumulatedFilters); - collectFromAlgebra(optionalElements, accumulatedTriples, accumulatedValues, accumulatedUnion, accumulatedFilters, true); + // A filter of the OPTIONAL group only decides whether the optional part binds; it is not + // evaluated, but an EXISTS or NOT EXISTS inside it is still reported as discarded. + if (element.expression !== undefined) { + reportExistence(element.expression, state.unsupported); + } + collectFromAlgebra(requiredElements, state); + collectFromAlgebra(optionalElements, state, true); } } - function handleFilter(accumulatedFilters: Algebra.Expression[]): (element: Algebra.Filter) => void { + /** + * Record a top-level FILTER expression without traversing into its expression subtree. A + * `NOT EXISTS` over a single triple pattern of the supported form becomes a negated triple + * pattern; any other `EXISTS` or `NOT EXISTS` stays in the expression, where it is undecidable, + * and is reported as discarded. + */ + function handleFilter(state: INormalizationState, optional?: boolean): (element: Algebra.Filter) => void { return (element: Algebra.Filter): void => { - accumulatedFilters.push(element.expression); + const negatedInput = getNegatedExistenceInput(element.expression); + const negatedPattern = negatedInput === undefined || optional === true ? + undefined : + toNegatedPattern(negatedInput, element.input); + if (negatedPattern !== undefined) { + state.negatedPatterns.push({ ...negatedPattern, construct: 'NOT EXISTS' }); + return; + } + reportExistence(element.expression, state.unsupported); + state.filters.push(element.expression); } } - function handlePropertyPath( - accumulatedTriples: Map, - accumulatedUnion: IQuery[][], - accumulatedValues: Map, - optional?: boolean, - ): (element: Algebra.Path) => void { + + /** + * Dispatch property-path parsing to direct, cardinality, negated-set, or alternative handlers. + */ + function handlePropertyPath(state: INormalizationState, optional?: boolean): (element: Algebra.Path) => void { return (element: Algebra.Path): void => { const path = element.predicate.type; if (element.predicate.type === Algebra.Types.ALT) { - accumulatedUnion.push( + state.unions.push( handleAltPropertyPath( element.subject, element.predicate.input, element.object, - accumulatedValues, + state.values, optional ) ); } else if (isCardinalityPropertyPath(element.predicate)) { const triple = handleCardinalityPropertyPath(element as CardinalityPath, optional); - handleDirectPropertyPath(element, accumulatedTriples, triple); + handleDirectPropertyPath(element, state.triples, triple); } else if (path === Algebra.Types.NPS) { const triple = handleNegatedPropertySet(element as NpsPath, optional); - handleDirectPropertyPath(element, accumulatedTriples, triple); + handleDirectPropertyPath(element, state.triples, triple); } }; } - function handleUnion(accumulatedUnion: IQuery[][], optional?: boolean): (element: Algebra.Union) => void { + /** + * Convert a SPARQL UNION into a list of independently generated query branches. + */ + function handleUnion(state: INormalizationState, optional?: boolean): (element: Algebra.Union) => void { return (element: Algebra.Union): void => { const branches: Algebra.Operation[] = element.input; const currentUnion: IQuery[] = []; for (const branch of branches) { - currentUnion.push(generateQuery(branch, optional)) + const branchQuery = generateQuery(branch, optional); + // A construct discarded inside a branch is discarded from the query as a whole. + for (const construct of branchQuery.unsupported ?? []) { + state.unsupported.add(construct); + } + currentUnion.push(branchQuery); } - accumulatedUnion.push(currentUnion); + state.unions.push(currentUnion); }; } + /** + * Accumulate VALUES bindings by variable name so they can later replace variable objects. + */ function handleValues(accumulatedValues: Map): (element: Algebra.Values) => void { return (element: Algebra.Values): void => { const bindings: Record[] = element.bindings; @@ -317,32 +466,39 @@ namespace QueryHandler { } } - function handlePattern( - accumulatedTriples: Map, - optional?: boolean - ): (element: Algebra.Pattern) => void { + /** + * Convert a basic triple pattern into the internal Triple representation keyed by subject. + */ + function handlePattern(state: INormalizationState, optional?: boolean): (element: Algebra.Pattern) => void { return (quad: Algebra.Pattern): void => { const subject = quad.subject as Term; const predicate = quad.predicate as Term; const object = quad.object as Term; - if (predicate.termType === 'NamedNode') { - const startPattern = accumulatedTriples.get(subject.value); - const triple: ITriple = new Triple({ - subject: subject.value, - predicate: quad.predicate.value, - object, - isOptional: optional - }); - if (startPattern === undefined) { - accumulatedTriples.set(subject.value, - { triples: new Map([[triple.toString(), triple]]), isVariable: subject.termType === "Variable" }); - } else { - startPattern.triples.set(triple.toString(), triple); - } + if (predicate.termType !== 'NamedNode') { + // Shapes constrain named predicates, so an unrestricted pattern yields no star pattern. + // It still asks for every triple of the resource, which is recorded separately. + state.matchesAnyTriple = true; + return; + } + const startPattern = state.triples.get(subject.value); + const triple: ITriple = new Triple({ + subject: subject.value, + predicate: quad.predicate.value, + object, + isOptional: optional + }); + if (startPattern === undefined) { + state.triples.set(subject.value, + { triples: new Map([[triple.toString(), triple]]), isVariable: isVariableTerm(subject) }); + } else { + startPattern.triples.set(triple.toString(), triple); } }; } + /** + * Translate SPARQL cardinality property paths into triples carrying the corresponding cardinality range. + */ function handleCardinalityPropertyPath( element: CardinalityPath, optional?: boolean @@ -372,11 +528,14 @@ namespace QueryHandler { cardinality, isOptional: optional }), - isVariable: subject.termType === "Variable" + isVariable: isVariableTerm(subject) }; } } + /** + * Insert the triple generated from a direct/path handler into the subject's accumulated star pattern. + */ function handleDirectPropertyPath(element: Algebra.Path, accumulatedTriples: Map, triple: { triple: ITriple, isVariable: boolean } | undefined): void { @@ -398,6 +557,9 @@ namespace QueryHandler { } } + /** + * Reverse an inverse path so downstream alternative handling can treat it like a forward path. + */ function handleInvPath(element: Algebra.Inv, subject: Term, object: Term, @@ -410,6 +572,9 @@ namespace QueryHandler { } } + /** + * Expand an alternative property path into one query branch per alternative path expression. + */ function handleAltPropertyPath( subject: Term, predicates: Algebra.Operation[], @@ -436,34 +601,39 @@ namespace QueryHandler { return union; } + /** + * Flatten a sequence path into chained triples and nested unions when intermediate segments require them. + */ function handleSeqPath( element: Algebra.Seq, - accumulatedValues: Map, - accumulatedTriples: Map, - accumulatedUnion: IQuery[][], + state: INormalizationState, subject: Term, object: Term, optional?: boolean): void { const predicates = element.input; - let currentObject: Term = DF.blankNode(`${(predicates[0]).iri.value}_${subject.value}`); + let currentObject: Term = DF.blankNode(`${(predicates[0] as Algebra.Link).iri.value}_${subject.value}`); for (let i = 0; i < predicates.length; i++) { const path = predicates[i]; const currentSubject: Term = i === 0 ? subject : currentObject; currentObject = i === predicates.length - 1 ? - object : DF.blankNode(`${(predicates[0]).iri.value}_${subject.value}`); + object : DF.blankNode(`${(predicates[0] as Algebra.Link).iri.value}_${subject.value}`); if (path.type === Algebra.Types.LINK) { - handleLink(path, accumulatedTriples, currentSubject, currentObject, undefined, optional); + handleLink(path, state.triples, currentSubject, currentObject, undefined, optional); } else if (path.type === Algebra.Types.NPS) { - handleNegatedPropertyLink(path, accumulatedTriples, currentSubject, currentObject, optional) + handleNegatedPropertyLink(path, state.triples, currentSubject, currentObject, optional) } else if (path.type === Algebra.Types.ALT) { - accumulatedUnion.push(handleAltPropertyPath(currentSubject, path.input, currentObject, accumulatedValues, optional)); + state.unions.push(handleAltPropertyPath(currentSubject, path.input, currentObject, state.values, optional)); } } } + + /** + * Build a standalone query branch for one sequence path expression. + */ function handleSeqPathQuery( element: Algebra.Seq, accumulatedValues: Map, @@ -471,14 +641,14 @@ namespace QueryHandler { object: Term, optional?: boolean ): IQuery { - const accumulatedTriples = new Map(); - const accumulatedUnion: IQuery[][] = []; - - handleSeqPath(element, accumulatedValues, accumulatedTriples, accumulatedUnion, subject, object, optional); - - return buildQuery(accumulatedTriples, accumulatedValues, accumulatedUnion); + const branch = createNormalizationState(accumulatedValues); + handleSeqPath(element, branch, subject, object, optional); + return buildQuery(branch); } + /** + * Map algebra cardinality path operators to the internal min/max representation. + */ function getCardinality(nodeType: string): ICardinality | undefined { switch (nodeType) { case Algebra.Types.ZERO_OR_MORE_PATH: @@ -490,6 +660,9 @@ namespace QueryHandler { } } + /** + * Add a simple forward predicate link as a triple to the accumulated subject map. + */ function handleLink(element: Algebra.Link, accumulatedTriples: Map, subject: Term, @@ -508,10 +681,13 @@ namespace QueryHandler { accumulatedTriples.set(subject.value, { triples: new Map([[triple.toString(), triple]]), - isVariable: subject.termType === "Variable" + isVariable: isVariableTerm(subject) }); } + /** + * Build a standalone query branch for one direct link path. + */ function handleLinkQuery( element: Algebra.Link, accumulatedValues: Map, @@ -520,11 +696,14 @@ namespace QueryHandler { cardinality?: ICardinality, optional?: boolean, ): IQuery { - const accumulatedTriples = new Map(); - handleLink(element, accumulatedTriples, subject, object, cardinality, optional); - return buildQuery(accumulatedTriples, accumulatedValues, []); + const branch = createNormalizationState(accumulatedValues); + handleLink(element, branch.triples, subject, object, cardinality, optional); + return buildQuery(branch); } + /** + * Represent a negated property set path as a synthetic triple carrying the excluded predicate IRIs. + */ function handleNegatedPropertyLink( element: Algebra.Nps, accumulatedTriples: Map, @@ -549,11 +728,14 @@ namespace QueryHandler { accumulatedTriples.set(subject.value, { triples: new Map([[triple.toString(), triple]]), - isVariable: subject.termType === "Variable" + isVariable: isVariableTerm(subject) }); } + /** + * Build a standalone query branch for one negated property set path. + */ function handleNegatedPropertyQuery( element: Algebra.Nps, accumulatedValues: Map, @@ -561,11 +743,14 @@ namespace QueryHandler { object: Term, optional?: boolean, ): IQuery { - const accumulatedTriples = new Map(); - handleNegatedPropertyLink(element, accumulatedTriples, subject, object, optional); - return buildQuery(accumulatedTriples, accumulatedValues, []); + const branch = createNormalizationState(accumulatedValues); + handleNegatedPropertyLink(element, branch.triples, subject, object, optional); + return buildQuery(branch); } + /** + * Convert a top-level negated property set path into a triple result consumable by direct path insertion. + */ function handleNegatedPropertySet(element: NpsPath, optional?: boolean): { triple: ITriple, isVariable: boolean } | undefined { const subject = element.subject as Term; const object = element.object as Term; @@ -583,11 +768,13 @@ namespace QueryHandler { negatedSet: negatedSet, isOptional: optional }), - isVariable: subject.termType === "Variable" + isVariable: isVariableTerm(subject) }; } - // Type guard to check if an Operation is a cardinality property path + /** + * Narrow a generic algebra operation to the subset of path operators that encode cardinality. + */ function isCardinalityPropertyPath(operation: Algebra.Operation): operation is CardinalityPropertyPathSymbol { return operation.type === Algebra.Types.ZERO_OR_MORE_PATH || operation.type === Algebra.Types.ZERO_OR_ONE_PATH || diff --git a/lib/shacl.ts b/lib/shacl.ts index bdefdb5..937bb9b 100644 --- a/lib/shacl.ts +++ b/lib/shacl.ts @@ -1,53 +1,26 @@ import type * as RDF from '@rdfjs/types'; import { DataFactory } from 'rdf-data-factory'; -import type { IConstraint, OneOf, IShape, IPredicate } from './Shape'; -import { Shape, ConstraintType, PoorlyFormatedShapeError } from './Shape'; -import type { ShapeError } from './Shape'; - -import { - SHACL_PROPERTY, - SHACL_PATH, - SHACL_MIN_COUNT, - SHACL_MAX_COUNT, - SHACL_MIN_INCLUSIVE, - SHACL_MAX_INCLUSIVE, - SHACL_MIN_EXCLUSIVE, - SHACL_MAX_EXCLUSIVE, - SHACL_PATTERN, - SHACL_FLAGS, - SHACL_CLOSED, - SHACL_CLASS, - SHACL_DATATYPE, - SHACL_NODE, - SHACL_OR, - SHACL_XONE, - SHACL_NOT, - IRI_FIRST_RDF_LIST, - IRI_REST_RDF_LIST, - IRI_END_RDF_LIST, - RDF_TRUE, -} from './constant'; +import type { OneOf, IShape, IPredicate, IConstraintDeclaration } from './Shape'; +import { PoorlyFormatedShapeError, buildConstraint, tryCreateShape, isNegativeCardinality, declaresTargets } from './Shape'; +import type { ShapeError, IShapeTargets } from './Shape'; +import { SHACL, RDF as RDF_VOCAB, RDFS, XSD } from './constant'; +import { addDiagnostic, forEachQuad, type IRdfListContext, type IShapeParserOptions, readRdfList } from './parser-policy'; +import { nodeTargetKey } from './target'; const DF = new DataFactory(); +const RDF_TRUE = DF.literal('true', DF.namedNode(XSD.boolean)); // ── Internal parsing state ───────────────────────────────────────────────── /** * Raw quad data collected for a single SHACL property shape (blank node). */ -interface IPropertyShapeData { +interface IPropertyShapeData extends IConstraintDeclaration { path?: string; + /** false when sh:path is a blank node, that is, a complex path */ + pathIsIri?: boolean; minCount?: number; maxCount?: number; - minInclusive?: number; - maxInclusive?: number; - minExclusive?: number; - maxExclusive?: number; - pattern?: string; - flags?: string; - classConstraint?: string; // sh:class value - datatypeConstraint?: string; // sh:datatype value - nodeConstraint?: string; // sh:node value /** true when this property shape appears under sh:not */ isNegated: boolean; } @@ -62,18 +35,30 @@ interface IMapTripleShacl { propertyData: Map; /** shape IRI → closed flag */ closedShape: Map; - /** shape IRI / blank-node → list head blank-node (from sh:or / sh:xone) */ - orLists: Map; - /** shape IRI / blank-node → list head blank-node (from sh:xone) */ - xoneLists: Map; - /** shape IRI / blank-node → list head blank-node (from sh:not) */ - notLinks: Map; + /** shape IRI / blank-node → list head blank-nodes, one per sh:or value */ + orLists: Map>; + /** shape IRI / blank-node → list head blank-nodes, one per sh:xone value */ + xoneLists: Map>; + /** shape IRI / blank-node → negated shapes, one per sh:not value */ + notLinks: Map>; /** RDF list first: node → value */ listFirst: Map; /** RDF list rest: node → next node */ listRest: Map; + /** shape IRI → declared targets (sh:targetNode, sh:targetClass, sh:targetSubjectsOf, sh:targetObjectsOf) */ + targets: Map; + /** shape IRI → list head (from sh:ignoredProperties) */ + ignoredLists: Map; + /** node → rdf:type values, to find implicit class targets */ + types: Map>; + /** class → rdfs:subClassOf values, to find the subclasses of rdfs:Class */ + superClasses: Map>; + /** node → the SHACL predicates it is the subject of, to check the form of a sh:not value */ + shaclPredicates: Map>; } +type IShapeTargetSets = { [K in keyof IShapeTargets]: Set }; + function defaultMap(): IMapTripleShacl { return { shapeProperties: new Map(), @@ -84,6 +69,11 @@ function defaultMap(): IMapTripleShacl { notLinks: new Map(), listFirst: new Map(), listRest: new Map(), + targets: new Map(), + ignoredLists: new Map(), + types: new Map(), + superClasses: new Map(), + shaclPredicates: new Map(), }; } @@ -96,170 +86,106 @@ function defaultMap(): IMapTripleShacl { * @param {string} shapeIri - The IRI of the desired node shape * @returns {Promise} The parsed shape or an error */ -export function shaclShapeFromQuads( +export async function shaclShapeFromQuads( quads: RDF.Stream | RDF.Quad[], shapeIri: string, -): Promise { - if (Array.isArray(quads)) { - return new Promise(resolve => { - resolve(shapeFromQuadArray(quads, shapeIri)); - }); - } - return shapeFromQuadStream(quads, shapeIri); -} - -function shapeFromQuadStream( - quadStream: RDF.Stream, - shapeIri: string, + options?: IShapeParserOptions, ): Promise { const map = defaultMap(); - return new Promise(resolve => { - quadStream.on('data', (quad: RDF.Quad) => { parseQuad(quad, map); }); - quadStream.on('error', (error: any) => { resolve(error); }); - quadStream.on('end', () => { resolve(buildShape(map, shapeIri)); }); - }); -} - -function shapeFromQuadArray( - quads: RDF.Quad[], - shapeIri: string, -): IShape | ShapeError { - const map = defaultMap(); - for (const quad of quads) { - parseQuad(quad, map); - } - return buildShape(map, shapeIri); + const error = await forEachQuad(quads, quad => parseQuad(quad, map)); + return error ?? buildShape(map, shapeIri, options); } // ── Quad parsing ─────────────────────────────────────────────────────────── -function parseQuad(quad: RDF.Quad, map: IMapTripleShacl): void { - const s = quad.subject.value; - const o = quad.object.value; - - // sh:property → register property shape under the node shape - if (quad.predicate.equals(SHACL_PROPERTY)) { - let props = map.shapeProperties.get(s); - if (props === undefined) { - props = new Set(); - map.shapeProperties.set(s, props); - } - props.add(o); - // Ensure an entry exists so we can detect even empty property shapes - if (!map.propertyData.has(o)) { - map.propertyData.set(o, { isNegated: false }); - } - return; - } - - // sh:path → predicate IRI for this property shape - if (quad.predicate.equals(SHACL_PATH)) { - getOrCreatePropData(map, s).path = o; - return; - } - - // sh:minCount - if (quad.predicate.equals(SHACL_MIN_COUNT)) { - getOrCreatePropData(map, s).minCount = Number(o); - return; - } - - // sh:maxCount - if (quad.predicate.equals(SHACL_MAX_COUNT)) { - getOrCreatePropData(map, s).maxCount = Number(o); - 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; - } +type QuadHandler = (map: IMapTripleShacl, subject: string, object: RDF.Term) => void; - // 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)); - return; - } - - // sh:class → SHAPE constraint - if (quad.predicate.equals(SHACL_CLASS)) { - getOrCreatePropData(map, s).classConstraint = o; - return; - } - - // sh:datatype → TYPE constraint - if (quad.predicate.equals(SHACL_DATATYPE)) { - getOrCreatePropData(map, s).datatypeConstraint = o; - return; - } - - // sh:node → SHAPE constraint (reference to another shape) - if (quad.predicate.equals(SHACL_NODE)) { - getOrCreatePropData(map, s).nodeConstraint = o; - return; - } +/** Set a field of the property shape data of the subject from the object. */ +function setPropertyData( + key: K, + read: (object: RDF.Term) => IPropertyShapeData[K], +): QuadHandler { + return (map, subject, object): void => { + getOrCreatePropData(map, subject)[key] = read(object); + }; +} - // sh:or → alternatives list head - if (quad.predicate.equals(SHACL_OR)) { - map.orLists.set(s, o); - return; - } +const asNumber = (object: RDF.Term): number => Number(object.value); +const asValue = (object: RDF.Term): string => object.value; + +/** What each predicate of a shapes graph records, by predicate IRI. */ +const QUAD_HANDLERS = new Map([ + // sh:property registers a property shape under the node shape, even an empty one + [SHACL.property, (map, subject, object): void => { + addToSetMap(map.shapeProperties, subject, object.value); + getOrCreatePropData(map, object.value); + }], + [SHACL.path, (map, subject, object): void => { + const data = getOrCreatePropData(map, subject); + data.path = object.value; + data.pathIsIri = object.termType === 'NamedNode'; + }], + [SHACL.minCount, setPropertyData('minCount', asNumber)], + [SHACL.maxCount, setPropertyData('maxCount', asNumber)], + [SHACL.minInclusive, setPropertyData('minInclusive', asNumber)], + [SHACL.maxInclusive, setPropertyData('maxInclusive', asNumber)], + [SHACL.minExclusive, setPropertyData('minExclusive', asNumber)], + [SHACL.maxExclusive, setPropertyData('maxExclusive', asNumber)], + [SHACL.pattern, setPropertyData('pattern', asValue)], + [SHACL.flags, setPropertyData('flags', asValue)], + [SHACL.class, setPropertyData('classConstraint', asValue)], + [SHACL.datatype, setPropertyData('datatypeConstraint', asValue)], + // sh:node references another shape + [SHACL.node, setPropertyData('shapeConstraint', asValue)], + [SHACL.closed, (map, subject, object): void => { map.closedShape.set(subject, object.equals(RDF_TRUE)); }], + // sh:or and sh:xone are list heads of alternatives (sh:xone is treated as sh:or for query matching) + [SHACL.or, (map, subject, object): void => { addToSetMap(map.orLists, subject, object.value); }], + [SHACL.xone, (map, subject, object): void => { addToSetMap(map.xoneLists, subject, object.value); }], + // sh:not negates a property shape, which can become a negative predicate + [SHACL.not, (map, subject, object): void => { + addToSetMap(map.notLinks, subject, object.value); + getOrCreatePropData(map, object.value).isNegated = true; + }], + // Targets: the nodes the shape describes when it is used on its own + [SHACL.targetNode, (map, subject, object): void => { getOrCreateTargets(map, subject).nodes.add(nodeTargetKey(object)); }], + [SHACL.targetClass, (map, subject, object): void => { getOrCreateTargets(map, subject).classes.add(object.value); }], + [SHACL.targetSubjectsOf, (map, subject, object): void => { getOrCreateTargets(map, subject).subjectsOf.add(object.value); }], + [SHACL.targetObjectsOf, (map, subject, object): void => { getOrCreateTargets(map, subject).objectsOf.add(object.value); }], + // sh:ignoredProperties lists the properties a closed shape allows without declaring them + [SHACL.ignoredProperties, (map, subject, object): void => { map.ignoredLists.set(subject, object.value); }], + // rdf:type and rdfs:subClassOf, to recognize a shape that is also a class (implicit class target) + [RDF_VOCAB.type, (map, subject, object): void => { addToSetMap(map.types, subject, object.value); }], + [RDFS.subClassOf, (map, subject, object): void => { addToSetMap(map.superClasses, subject, object.value); }], + // rdf:first and rdf:rest of the RDF lists + [RDF_VOCAB.first, (map, subject, object): void => { map.listFirst.set(subject, object.value); }], + [RDF_VOCAB.rest, (map, subject, object): void => { map.listRest.set(subject, object.value); }], +]); - // sh:xone → exclusive-one-of list head (treated same as sh:or for query matching) - if (quad.predicate.equals(SHACL_XONE)) { - map.xoneLists.set(s, o); - return; +function parseQuad(quad: RDF.Quad, map: IMapTripleShacl): void { + const subject = quad.subject.value; + const predicate = quad.predicate.value; + if (predicate.startsWith(SHACL.namespace)) { + addToSetMap(map.shaclPredicates, subject, predicate); } + QUAD_HANDLERS.get(predicate)?.(map, subject, quad.object); +} - // sh:not → negation (blank node property shape that should become a negative predicate) - if (quad.predicate.equals(SHACL_NOT)) { - map.notLinks.set(s, o); - // Create entry for the negated shape's blank node - const negData = getOrCreatePropData(map, o); - negData.isNegated = true; - return; +function getOrCreateTargets(map: IMapTripleShacl, shapeIri: string): IShapeTargetSets { + let targets = map.targets.get(shapeIri); + if (targets === undefined) { + targets = { nodes: new Set(), classes: new Set(), subjectsOf: new Set(), objectsOf: new Set() }; + map.targets.set(shapeIri, targets); } + return targets; +} - // rdf:first / rdf:rest for RDF lists (used by sh:or / sh:xone) - if (quad.predicate.equals(IRI_FIRST_RDF_LIST)) { - map.listFirst.set(s, o); - return; - } - if (quad.predicate.equals(IRI_REST_RDF_LIST)) { - map.listRest.set(s, o); - return; +function addToSetMap(setMap: Map>, key: string, value: string): void { + let values = setMap.get(key); + if (values === undefined) { + values = new Set(); + setMap.set(key, values); } + values.add(value); } function getOrCreatePropData( @@ -279,16 +205,23 @@ function getOrCreatePropData( function buildShape( map: IMapTripleShacl, shapeIri: string, + options?: IShapeParserOptions, ): IShape | ShapeError { const propIds = map.shapeProperties.get(shapeIri); // Collect sh:not negated property shapes from under the target shape const negatedPropIds = collectNotProps(map, shapeIri); - const hasOrList = map.orLists.has(shapeIri) || map.xoneLists.has(shapeIri); + // A shape conforms to every one of its sh:or and sh:xone values, so each becomes a disjunction + const orHeads = [...map.orLists.get(shapeIri) ?? [], ...map.xoneLists.get(shapeIri) ?? []]; + const hasOrList = orHeads.length > 0; + const targets = collectTargets(map, shapeIri); + const hasTarget = declaresTargets(targets); - if ((propIds === undefined || propIds.size === 0) && !hasOrList && negatedPropIds.size === 0) { + // A shape that only declares targets still describes nodes: they exist and are of the target + // kind, even though the shape constrains none of their properties. + if ((propIds === undefined || propIds.size === 0) && !hasOrList && negatedPropIds.size === 0 && !hasTarget) { return new PoorlyFormatedShapeError( - `No property shapes, sh:or, or sh:not found for shape <${shapeIri}>`, + `No property shapes, sh:or, sh:not, or target found for shape <${shapeIri}>`, ); } @@ -303,140 +236,161 @@ function buildShape( if (isNeg) { negativePredicates.push(data.path); } else { - positivePredicates.push(buildPredicate(data)); + positivePredicates.push(buildPredicate(data.path, data)); } } // Negated property shapes from sh:not for (const negId of negatedPropIds) { - const data = map.propertyData.get(negId); - if (data?.path !== undefined) { - negativePredicates.push(data.path); + const forbidden = forbiddenPredicate(map, negId); + if (forbidden !== undefined) { + negativePredicates.push(forbidden); + } else { + addDiagnostic(options, { + level: 'warning', + code: 'UNSUPPORTED_NOT', + message: `sh:not of <${shapeIri}> does not forbid a single property and is ignored`, + shapeIri, + nodeId: negId, + }); } } // Resolve sh:or / sh:xone into oneOf branches const oneOfs: OneOf[] = []; - const orHead = map.orLists.get(shapeIri) ?? map.xoneLists.get(shapeIri); - if (orHead !== undefined) { - const branch = resolveOrList(orHead, map); + for (const orHead of orHeads) { + const members = readList(orHead, map, { shapeIri, construct: 'sh:or/sh:xone', options }); + if (members instanceof PoorlyFormatedShapeError) { + return members; + } + const branch = resolveOrList(members, map); if (branch.length > 0) { oneOfs.push(branch); } } const closed = map.closedShape.get(shapeIri) ?? false; + const ignoredHead = map.ignoredLists.get(shapeIri); + const ignoredProperties = ignoredHead === undefined + ? [] + : readList(ignoredHead, map, { shapeIri, construct: 'sh:ignoredProperties', options }); + if (ignoredProperties instanceof PoorlyFormatedShapeError) { + return ignoredProperties; + } + + return tryCreateShape({ + name: shapeIri, + positivePredicates, + negativePredicates, + closed, + oneOf: oneOfs, + targets, + ignoredProperties, + }); +} - try { - return new Shape({ - name: shapeIri, - positivePredicates, - negativePredicates, - closed, - oneOf: oneOfs, - }); - } catch (error: unknown) { - return error as ShapeError; +/** + * The targets declared for a shape. A shape that is also an rdfs:Class in the shapes graph targets + * its own instances (an implicit class target); as for any SHACL instance, the rdf:type may be a + * class declared, through rdfs:subClassOf, to be a subclass of rdfs:Class. + */ +function collectTargets(map: IMapTripleShacl, shapeIri: string): IShapeTargets { + const declared = map.targets.get(shapeIri); + const classes = new Set(declared?.classes ?? []); + const metaClasses = subClassesOf(map, RDFS.Class); + if (Array.from(map.types.get(shapeIri) ?? []).some(type => metaClasses.has(type))) { + classes.add(shapeIri); } + return { + nodes: Array.from(declared?.nodes ?? []), + classes: Array.from(classes), + subjectsOf: Array.from(declared?.subjectsOf ?? []), + objectsOf: Array.from(declared?.objectsOf ?? []), + }; } -/** Collect all property-shape blank nodes reachable via sh:not from a shape IRI. */ -function collectNotProps(map: IMapTripleShacl, shapeIri: string): Set { - const result = new Set(); - const notTarget = map.notLinks.get(shapeIri); - if (notTarget !== undefined) { - result.add(notTarget); +/** A class and every class declared, directly or transitively, to be a subclass of it. */ +function subClassesOf(map: IMapTripleShacl, superClass: string): Set { + const result = new Set([superClass]); + let added = true; + while (added) { + added = false; + for (const [subClass, superClasses] of map.superClasses) { + if (!result.has(subClass) && Array.from(superClasses).some(value => result.has(value))) { + result.add(subClass); + added = true; + } + } } return result; } +/** Collect the property shapes negated by the sh:not values of a shape. */ +function collectNotProps(map: IMapTripleShacl, shapeIri: string): Set { + return new Set(map.notLinks.get(shapeIri)); +} + +// The SHACL predicates that a sh:not value may carry besides sh:minCount and still forbid its path. The +// others are not parameters: they do not take part in validation. +const NEGATION_PREDICATES = new Set([ + 'path', 'minCount', 'name', 'description', 'order', 'group', 'defaultValue', 'message', 'severity', 'deactivated', +].map(name => SHACL.custom(name))); + +/** + * The predicate that a sh:not value forbids, if any. `sh:not [ sh:path p ; sh:minCount 1 ]`, with an IRI + * path and no other parameter, is the only form that a node conforms to exactly when it has no value for p. + * Any other form holds for some nodes that carry p, so it forbids nothing: `sh:not [ sh:path p ; sh:minCount 2 ]` + * allows one value, and `sh:not [ sh:path p ; sh:datatype xsd:string ]` requires a value that is not a string. + */ +function forbiddenPredicate(map: IMapTripleShacl, nodeId: string): string | undefined { + const data = map.propertyData.get(nodeId); + if (data?.path === undefined || data.pathIsIri !== true || data.minCount !== 1) { + return undefined; + } + const predicates = map.shaclPredicates.get(nodeId) ?? new Set(); + return Array.from(predicates).every(predicate => NEGATION_PREDICATES.has(predicate)) ? data.path : undefined; +} + +// SHACL bounds a cardinality only when it is declared: an absent sh:minCount is 0 and an absent +// sh:maxCount is unbounded (-1 in ICardinality). +const SHACL_DEFAULT_MIN_COUNT = 0; +const SHACL_DEFAULT_MAX_COUNT = -1; + /** Whether a property shape data entry represents a negative predicate (minCount=0, maxCount=0). */ function isNegatedData(data: IPropertyShapeData): boolean { - return data.minCount === 0 && data.maxCount === 0; + return isNegativeCardinality(data.minCount ?? SHACL_DEFAULT_MIN_COUNT, data.maxCount); } -/** Build an IPredicate from a property shape data entry. */ -function buildPredicate(data: IPropertyShapeData): IPredicate { - const constraint = resolveConstraint(data); - const min = data.minCount; - const max = data.maxCount; - const hasCardinality = min !== undefined || max !== undefined; +/** Build an IPredicate from a property shape data entry with the given path. */ +function buildPredicate(path: string, data: IPropertyShapeData): IPredicate { return { - name: data.path!, - constraint, - cardinality: hasCardinality - ? { min: min ?? 1, max: max ?? 1 } - : undefined, + name: path, + constraint: buildConstraint(data, path), + cardinality: { + min: data.minCount ?? SHACL_DEFAULT_MIN_COUNT, + max: data.maxCount ?? SHACL_DEFAULT_MAX_COUNT, + }, }; } -function resolveConstraint(data: IPropertyShapeData): IConstraint | undefined { - if (data.classConstraint !== undefined) { - // sh:class constrains the object to be an instance/class IRI. - return { - value: new Set([data.classConstraint]), - type: ConstraintType.CLASS, - }; - } - if (data.nodeConstraint !== undefined) { - // sh:node references another shape definition → SHAPE - return { - value: new Set([data.nodeConstraint]), - type: ConstraintType.SHAPE, - }; - } - if (data.datatypeConstraint !== undefined) { - const numericFacets: Partial = {}; - if (data.minInclusive !== undefined) { - numericFacets.minInclusive = data.minInclusive; - } - if (data.maxInclusive !== undefined) { - numericFacets.maxInclusive = data.maxInclusive; - } - if (data.minExclusive !== undefined) { - numericFacets.minExclusive = data.minExclusive; - } - if (data.maxExclusive !== undefined) { - numericFacets.maxExclusive = data.maxExclusive; - } - if (data.pattern !== undefined) { - numericFacets.pattern = data.pattern; - } - if (data.flags !== undefined) { - numericFacets.flags = data.flags; - } - return { - value: new Set([data.datatypeConstraint]), - type: ConstraintType.DATATYPE, - ...numericFacets, - }; - } - return undefined; +function readList(head: string, map: IMapTripleShacl, context: IRdfListContext): string[] | PoorlyFormatedShapeError { + return readRdfList(head, { firstByNode: map.listFirst, restByNode: map.listRest }, context); } /** - * Walk an RDF list headed at `head` and collect each member as an - * IPredicate[], returning them as a single OneOf (array of paths/branches). + * Collect the members of an sh:or (or sh:xone) list as a single OneOf: one branch per member. * * Each list member is itself a shape-like blank node that should have * sh:property children. We collect those into one OneOfPath per member. */ -function resolveOrList(head: string, map: IMapTripleShacl): OneOf { +function resolveOrList(members: string[], map: IMapTripleShacl): OneOf { const result: OneOf = []; - let current: string | undefined = head; - - while (current !== undefined && current !== IRI_END_RDF_LIST.value) { - const memberId = map.listFirst.get(current); - if (memberId !== undefined) { - const branch = resolveOrMember(memberId, map); - if (branch.length > 0) { - result.push(branch); - } + for (const memberId of members) { + const branch = resolveOrMember(memberId, map); + if (branch.length > 0) { + result.push(branch); } - const next = map.listRest.get(current); - current = next === IRI_END_RDF_LIST.value ? undefined : next; } - return result; } @@ -451,14 +405,14 @@ function resolveOrMember(memberId: string, map: IMapTripleShacl): IPredicate[] { for (const propId of propIds) { const data = map.propertyData.get(propId); if (data?.path !== undefined && !isNegatedData(data)) { - predicates.push(buildPredicate(data)); + predicates.push(buildPredicate(data.path, data)); } } } // A member might itself be a single property shape (sh:path lives directly on the member) const directData = map.propertyData.get(memberId); if (directData?.path !== undefined && predicates.length === 0) { - predicates.push(buildPredicate(directData)); + predicates.push(buildPredicate(directData.path, directData)); } return predicates; } \ No newline at end of file diff --git a/lib/shape-parser.ts b/lib/shape-parser.ts new file mode 100644 index 0000000..833855d --- /dev/null +++ b/lib/shape-parser.ts @@ -0,0 +1,77 @@ +import type * as RDFJS from '@rdfjs/types'; +import { SHACL, SHEX } from './constant'; +import { forEachQuad, type IShapeParserOptions, type ShapeFormat } from './parser-policy'; +import { type IShape, PoorlyFormatedShapeError, type ShapeError } from './Shape'; +import { shaclShapeFromQuads } from './shacl'; +import { shexShapeFromQuads } from './shex'; + +export interface IParseShapeFromQuadsOptions extends IShapeParserOptions { + format?: ShapeFormat | 'auto'; +} + +/** + * Unified parser entrypoint for SHACL and ShEx shape graphs. + */ +export async function parseShapeFromQuads( + quads: RDFJS.Stream | RDFJS.Quad[], + shapeIri: string, + options?: IParseShapeFromQuadsOptions, +): Promise { + // The format is detected from the whole graph, so a stream is collected first + const collected: RDFJS.Quad[] = []; + const error = await forEachQuad(quads, quad => collected.push(quad)); + if (error !== undefined) { + return error; + } + + const requestedFormat = options?.format ?? 'auto'; + const format = requestedFormat === 'auto' ? detectShapeFormat(collected) : requestedFormat; + if (format === undefined) { + return new PoorlyFormatedShapeError('Cannot detect shape format automatically. Please provide options.format.'); + } + return format === 'shacl' ? + shaclShapeFromQuads(collected, shapeIri, options) : + shexShapeFromQuads(collected, shapeIri, options); +} + +function detectShapeFormat(quads: RDFJS.Quad[]): ShapeFormat | undefined { + let shaclScore = 0; + let shexScore = 0; + + for (const quad of quads) { + if ( + quad.predicate.equals(SHACL.terms.property) + || quad.predicate.equals(SHACL.terms.path) + || quad.predicate.equals(SHACL.terms.or) + || quad.predicate.equals(SHACL.terms.xone) + || quad.predicate.equals(SHACL.terms.node) + || quad.predicate.equals(SHACL.terms.datatype) + || quad.predicate.equals(SHACL.terms.targetNode) + || quad.predicate.equals(SHACL.terms.targetClass) + || quad.predicate.equals(SHACL.terms.targetSubjectsOf) + || quad.predicate.equals(SHACL.terms.targetObjectsOf) + ) { + shaclScore++; + } + + if ( + quad.predicate.equals(SHEX.terms.expression) + || quad.predicate.equals(SHEX.terms.expressions) + || quad.predicate.equals(SHEX.terms.predicate) + || quad.predicate.equals(SHEX.terms.valueExpr) + || quad.object.equals(SHEX.terms.Shape) + || quad.object.equals(SHEX.terms.EachOf) + || quad.object.equals(SHEX.terms.OneOf) + ) { + shexScore++; + } + } + + if (shaclScore > shexScore) { + return 'shacl'; + } + if (shexScore > shaclScore) { + return 'shex'; + } + return undefined; +} diff --git a/lib/shapeToQuery.ts b/lib/shapeToQuery.ts new file mode 100644 index 0000000..489056c --- /dev/null +++ b/lib/shapeToQuery.ts @@ -0,0 +1,444 @@ +import type { Term } from '@rdfjs/types'; +import { type Algebra, AlgebraFactory } from '@traqula/algebra-transformations-1-1'; +import { DataFactory } from 'rdf-data-factory'; +import { type ITripleWithDependencies, Triple, type IStarPatternWithDependencies, type ITriple } from './Triple'; +import { ConstraintType, type IPredicate, type IShape } from './Shape'; +import { RDF, XSD } from './constant'; +import { focusNodes, type ITargetAlternative } from './target'; +import type { IQuery, UnsupportedConstruct } from './query'; + +const DF = new DataFactory(); +const AF = new AlgebraFactory(); + +export interface IShapeToQueryOptions { + linkedShapes?: IShape[] | Map; +} + +interface IShapeToQueryBuildContext { + shapeIndex: Map; + starPatterns: Map; + filters: Algebra.Expression[]; + unions: IQuery[][]; + visitedShapes: Set; + variableCounts: Map; + unsupported: Set; +} + +/** + * Translation from an IShape to an IQuery representation. + * + * Supported mapping: + * - Positive predicates -> triple patterns + * - Negative predicates -> negated triple patterns (excluded predicates of the star pattern) + * - Datatype + numeric/regex facets -> FILTER expressions + * - SHAPE constraints -> dependent star patterns (when linked shapes are provided) + * - oneOf/xone branches -> UNION branches + * - Targets of the given shape -> patterns about its focus node (see {@link addTargetsOfInputShape}) + * + * Not supported (yet): + * - closed/open world semantics + */ +export function shapeToQuery(shape: IShape, options?: IShapeToQueryOptions): IQuery { + const shapeIndex = normalizeShapeIndex(shape, options?.linkedShapes); + const context: IShapeToQueryBuildContext = { + shapeIndex, + starPatterns: new Map(), + filters: [], + unions: [], + visitedShapes: new Set(), + variableCounts: new Map(), + unsupported: new Set(), + }; + + buildStarPatternFromShape(shape, context); + addTargetsOfInputShape(shape, context); + const prunedSubjectRestriction = pruneEmptyStarPatterns(context); + + const query: IQuery = { starPatterns: context.starPatterns }; + if (context.filters.length > 0) { + query.filters = context.filters; + } + if (context.unions.length > 0) { + query.union = context.unions; + } + if (context.unsupported.size > 0) { + query.unsupported = Array.from(context.unsupported); + } + // A shape about given nodes that constrains none of their properties asks for any triple about them + if (context.starPatterns.size === 0 && prunedSubjectRestriction) { + query.matchesAnyTriple = true; + } + + return query; +} + +/** + * Translate the targets of the input shape into patterns about its focus node. Only the input + * shape's own targets are translated: SHACL ignores the targets of the shapes it reaches through + * sh:node. + * + * - A class target requires `rdf:type` with the class as value. + * - A subjects-of target requires the predicate. + * - Several class and subjects-of targets are alternatives, because the focus nodes of a shape are + * the union of the nodes of its targets: each target becomes a branch of a UNION, as the branches + * of an sh:or do. + * - Node targets restrict the subject of the focus star pattern to the target nodes. + * - An objects-of target adds a star pattern whose triple pattern has the focus node as object. + * + * Node and objects-of targets are not representable as alternatives of other targets. When they are + * combined with other targets, or when several objects-of targets are declared, the targets are + * ignored and reported, which can only over-estimate relevance. + */ +function addTargetsOfInputShape(shape: IShape, context: IShapeToQueryBuildContext): void { + const focus = context.starPatterns.get(shape.name); + const focusNodesOfShape = focusNodes(shape.targets); + if (focus === undefined) { + return; + } + + switch (focusNodesOfShape.kind) { + case 'any': + return; + case 'mixed': + context.unsupported.add('TARGETS'); + return; + case 'nodes': + focus.subjectValues = new Set(focusNodesOfShape.nodes); + return; + case 'objectsOf': { + const { predicate } = focusNodesOfShape; + const incomingName = `${shape.name}#targetObjectsOf`; + const triple = new Triple({ subject: incomingName, predicate, object: DF.namedNode(shape.name) }); + context.starPatterns.set(incomingName, { + starPattern: new Map([[ predicate, { triple, dependencies: focus } ]]), + name: incomingName, + isVariable: true, + }); + return; + } + case 'alternatives': + addTargetAlternatives(shape.name, focus, focusNodesOfShape.alternatives, context); + } +} + +/** + * Require the focus node to satisfy one of the target alternatives: each target class, or each + * subjects-of predicate, is a branch of a UNION, or a triple pattern of the focus star pattern when + * it is the only one. + */ +function addTargetAlternatives( + shapeName: string, + focus: IStarPatternWithDependencies, + targetAlternatives: ITargetAlternative[], + context: IShapeToQueryBuildContext, +): void { + const alternatives: ITripleWithDependencies[] = []; + for (const { predicate, classes } of targetAlternatives) { + if (classes !== undefined) { + for (const value of classes) { + alternatives.push({ triple: new Triple({ subject: shapeName, predicate, object: DF.namedNode(value) }) }); + } + continue; + } + // A declared property constraint on the predicate keeps its object and dependency, but is now required + const declared = focus.starPattern.get(predicate); + const object = declared?.triple.object ?? DF.variable(nextVariableName(shapeName, predicate, context.variableCounts)); + alternatives.push({ triple: new Triple({ subject: shapeName, predicate, object }), dependencies: declared?.dependencies }); + } + + if (alternatives.length === 1) { + focus.starPattern.set(alternatives[0].triple.predicate, alternatives[0]); + return; + } + context.unions.push(alternatives.map(alternative => ({ + starPatterns: new Map([[ shapeName, { + starPattern: new Map([[ alternative.triple.predicate, alternative ]]), + name: shapeName, + isVariable: true, + } ]]), + }))); +} + +/** + * Remove the star patterns that constrain nothing: no triple pattern, no excluded predicate and no + * UNION branch. They come from shapes without positive constraints, such as a shape that only + * declares targets, and would otherwise count as unmatched. A triple pattern that pointed to one of + * them is kept, and matches on its predicate alone. + * @returns whether a removed star pattern restricted its subject to given nodes + */ +function pruneEmptyStarPatterns(context: IShapeToQueryBuildContext): boolean { + const inUnion = new Set(); + for (const union of context.unions) { + for (const branch of union) { + for (const name of branch.starPatterns.keys()) { + inUnion.add(name); + } + } + } + const removed = new Set(); + let prunedSubjectRestriction = false; + for (const [ name, starPattern ] of context.starPatterns) { + if (starPattern.starPattern.size === 0 && (starPattern.excludedPredicates?.size ?? 0) === 0 && !inUnion.has(name)) { + removed.add(name); + prunedSubjectRestriction = prunedSubjectRestriction || starPattern.subjectValues !== undefined; + } + } + for (const name of removed) { + context.starPatterns.delete(name); + } + const remaining = [ + ...context.starPatterns.values(), + ...context.unions.flatMap(union => union.flatMap(branch => [ ...branch.starPatterns.values() ])), + ]; + for (const starPattern of remaining) { + for (const tripleWithDependencies of starPattern.starPattern.values()) { + if (tripleWithDependencies.dependencies !== undefined && removed.has(tripleWithDependencies.dependencies.name)) { + tripleWithDependencies.dependencies = undefined; + } + } + } + return prunedSubjectRestriction; +} + +/** + * Build a lookup table for the root shape and any linked shapes that may be referenced. + */ +function normalizeShapeIndex(rootShape: IShape, linkedShapes?: IShape[] | Map): Map { + const index = new Map(); + index.set(rootShape.name, rootShape); + + if (linkedShapes === undefined) { + return index; + } + + if (linkedShapes instanceof Map) { + for (const [name, shape] of linkedShapes) { + index.set(name, shape); + } + return index; + } + + for (const shape of linkedShapes) { + index.set(shape.name, shape); + } + + return index; +} + +/** + * Convert one shape into a root star pattern and recursively materialize reachable linked shapes. + */ +function buildStarPatternFromShape(shape: IShape, context: IShapeToQueryBuildContext): void { + if (context.visitedShapes.has(shape.name)) { + return; + } + context.visitedShapes.add(shape.name); + + const starPattern: IStarPatternWithDependencies = { + starPattern: new Map(), + name: shape.name, + isVariable: true, + }; + + for (const predicateName of shape.positivePredicates) { + const predicate = shape.get(predicateName); + if (predicate === undefined || predicate.negative === true) { + continue; + } + + const tripleWithDependencies = predicateToTripleWithDependencies(shape.name, predicate, context, context.filters); + starPattern.starPattern.set(predicate.name, tripleWithDependencies); + } + if (shape.negativePredicates.length > 0) { + starPattern.excludedPredicates = new Set(shape.negativePredicates); + } + + context.starPatterns.set(shape.name, starPattern); + + addOneOfAsUnion(shape, context); +} + +/** + * Translate shape alternatives into UNION branches attached to the generated query. + */ +function addOneOfAsUnion(shape: IShape, context: IShapeToQueryBuildContext): void { + for (const oneOf of shape.oneOf) { + const unionBranches: IQuery[] = []; + for (const branch of oneOf) { + const branchStar: IStarPatternWithDependencies = { + starPattern: new Map(), + name: shape.name, + isVariable: true, + }; + const branchFilters: Algebra.Expression[] = []; + + for (const predicate of branch) { + if (predicate.negative === true) { + continue; + } + const tripleWithDependencies = predicateToTripleWithDependencies(shape.name, predicate, context, branchFilters); + branchStar.starPattern.set(predicate.name, tripleWithDependencies); + } + + const branchQuery: IQuery = { + starPatterns: new Map([[shape.name, branchStar]]), + }; + if (branchFilters.length > 0) { + branchQuery.filters = branchFilters; + } + unionBranches.push(branchQuery); + } + + if (unionBranches.length > 0) { + context.unions.push(unionBranches); + } + } +} + +/** + * Turn one shape predicate into a triple pattern and any dependency/filter information it implies. + */ +function predicateToTripleWithDependencies( + shapeName: string, + predicate: IPredicate, + context: IShapeToQueryBuildContext, + filterCollector: Algebra.Expression[], +): ITripleWithDependencies { + const constraint = predicate.constraint; + let object: Term; + let dependencies: IStarPatternWithDependencies | undefined; + + if (constraint?.type === ConstraintType.SHAPE && constraint.value.size > 0) { + const linkedShapeName = Array.from(constraint.value)[0]; + object = DF.namedNode(linkedShapeName); + + const linkedShape = context.shapeIndex.get(linkedShapeName); + if (linkedShape !== undefined) { + buildStarPatternFromShape(linkedShape, context); + dependencies = context.starPatterns.get(linkedShapeName); + } + } else if ( + constraint?.type === ConstraintType.CLASS + && predicate.name === RDF.type + && constraint.value.size > 0 + ) { + object = DF.namedNode(Array.from(constraint.value)[0]); + } else { + object = DF.variable(nextVariableName(shapeName, predicate.name, context.variableCounts)); + addDatatypeFiltersFromConstraint(constraint, object, filterCollector); + } + + const triple: ITriple = new Triple({ + subject: shapeName, + predicate: predicate.name, + object, + cardinality: predicate.cardinality, + isOptional: predicate.optional, + }); + + return { triple, dependencies }; +} + +/** + * Produce a stable variable name for generated query objects, deduplicated per predicate occurrence. + */ +function nextVariableName(shapeName: string, predicate: string, variableCounts: Map): string { + const base = toVariableBase(`${shapeName}_${predicate}`); + const currentCount = variableCounts.get(base) ?? 0; + variableCounts.set(base, currentCount + 1); + return currentCount === 0 ? base : `${base}_${currentCount}`; +} + +/** + * Normalize arbitrary IRIs/shape names into SPARQL-safe variable base names. + */ +function toVariableBase(raw: string): string { + const normalized = raw.replace(/[^A-Za-z0-9_]/g, '_').replace(/^_+/, ''); + if (normalized.length === 0) { + return 'v'; + } + if (/^[0-9]/.test(normalized)) { + return `v_${normalized}`; + } + return normalized; +} + +/** + * Translate datatype, numeric, and regex shape facets into FILTER expressions on a generated variable. + */ +function addDatatypeFiltersFromConstraint( + constraint: IPredicate['constraint'], + object: Term, + collector: Algebra.Expression[], +): void { + if (constraint?.type !== ConstraintType.DATATYPE || object.termType !== 'Variable') { + return; + } + + const datatypeFilters = Array.from(constraint.value).map((datatypeIri) => + AF.createOperatorExpression('=', [ + AF.createOperatorExpression('datatype', [AF.createTermExpression(object)]), + AF.createTermExpression(DF.namedNode(datatypeIri)), + ]), + ); + + if (datatypeFilters.length === 1) { + collector.push(datatypeFilters[0]); + } else if (datatypeFilters.length > 1) { + collector.push(reduceOrExpressions(datatypeFilters)); + } + + if (constraint.minInclusive !== undefined) { + collector.push(AF.createOperatorExpression('>=', [ + AF.createTermExpression(object), + AF.createTermExpression(numberLiteral(constraint.minInclusive)), + ])); + } + if (constraint.maxInclusive !== undefined) { + collector.push(AF.createOperatorExpression('<=', [ + AF.createTermExpression(object), + AF.createTermExpression(numberLiteral(constraint.maxInclusive)), + ])); + } + if (constraint.minExclusive !== undefined) { + collector.push(AF.createOperatorExpression('>', [ + AF.createTermExpression(object), + AF.createTermExpression(numberLiteral(constraint.minExclusive)), + ])); + } + if (constraint.maxExclusive !== undefined) { + collector.push(AF.createOperatorExpression('<', [ + AF.createTermExpression(object), + AF.createTermExpression(numberLiteral(constraint.maxExclusive)), + ])); + } + + if (constraint.pattern !== undefined) { + const regexArgs: Algebra.Expression[] = [ + AF.createOperatorExpression('str', [AF.createTermExpression(object)]), + AF.createTermExpression(DF.literal(constraint.pattern)), + ]; + if (constraint.flags !== undefined) { + regexArgs.push(AF.createTermExpression(DF.literal(constraint.flags))); + } + collector.push(AF.createOperatorExpression('regex', regexArgs)); + } +} + +/** + * Fold multiple expressions into a left-associated logical OR tree. + */ +function reduceOrExpressions(expressions: Algebra.Expression[]): Algebra.Expression { + let current = expressions[0]; + for (let i = 1; i < expressions.length; i++) { + current = AF.createOperatorExpression('||', [current, expressions[i]]); + } + return current; +} + +/** + * Build an xsd:decimal literal for numeric facet comparisons. + */ +function numberLiteral(value: number): Term { + return DF.literal(String(value), DF.namedNode(XSD.decimal)); +} diff --git a/lib/shex.ts b/lib/shex.ts index d5089a7..bdc4c2a 100644 --- a/lib/shex.ts +++ b/lib/shex.ts @@ -1,29 +1,19 @@ import type * as RDF from '@rdfjs/types'; -import { - SHEX_PREDICATE, - SHEX_EXPRESSION, - TYPE_DEFINITION, - IRI_FIRST_RDF_LIST, - SHEX_EXPRESSIONS, - IRI_REST_RDF_LIST, - SHEX_CLOSED_SHAPE, - RDF_TRUE, - SHEX_SHAPE_EXPRESSION, - SHEX_MAX, - SHEX_MIN, - SHEX_VALUE_EXPR, - SHEX_DATA_TYPE, - SHEX_EACH_OF, - SHEX_ONE_OF, -} from './constant'; +import { DataFactory } from 'rdf-data-factory'; +import { SHEX, RDF as RDF_VOCAB, XSD } from './constant'; import type { - IConstraint, ShapeError, OneOf, IShape, - IPredicate + IPredicate, + IConstraint, + IConstraintFacets, } from './Shape'; -import { Shape, ConstraintType, PoorlyFormatedShapeError } from './Shape'; +import { PoorlyFormatedShapeError, tryCreateShape, buildConstraint, isNegativeCardinality } from './Shape'; +import { forEachQuad, type IShapeParserOptions, readRdfList } from './parser-policy'; + +const DF = new DataFactory(); +const RDF_TRUE = DF.literal('true', DF.namedNode(XSD.boolean)); /** * Parse a Shex shape from a set of quads @@ -32,66 +22,14 @@ import { Shape, ConstraintType, PoorlyFormatedShapeError } from './Shape'; * @returns {Promise} The shape * @todo support for `OR` statement */ -export function shexShapeFromQuads(quads: RDF.Stream | RDF.Quad[], shapeIri: string): Promise { - if (Array.isArray(quads)) { - return new Promise(resolve => { - resolve(shapeFromQuadArray(quads, shapeIri)); - }); - } - return shapeFromQuadStream(quads, shapeIri); -} - -/** - * Parse a Shex shape from a quad stream - * @param {RDF.Stream} quads - Quads representing a shape - * @param {string} shapeIri - The iri of the desired shape - * @returns {Promise} The shape - */ -function shapeFromQuadStream(quadSteam: RDF.Stream, shapeIri: string): Promise { - const mapTripleShex: IMapTripleShex = defaultMapTripleShex(); - - return new Promise(resolve => { - quadSteam.on('data', (quad: RDF.Quad) => { - parseShapeQuads( - quad, - mapTripleShex, - ); - }); - - quadSteam.on('error', (error: any) => { - resolve(error); - }); - quadSteam.on('end', () => { - const shape = concatShapeInfo( - mapTripleShex, - shapeIri, - ); - resolve(shape); - }); - }); -} - -/** - * Parse a Shex shape from an array of quad - * @param {RDF.Quad[]} quads - Quads representing a shape - * @param {string} shapeIri - The iri of the desired shape - * @returns {Promise} The shape - */ -function shapeFromQuadArray(quads: RDF.Quad[], shapeIri: string): IShape | ShapeError { - const mapTripleShex: IMapTripleShex = defaultMapTripleShex(); - - for (const quad of quads) { - parseShapeQuads( - quad, - mapTripleShex, - ); - } - - const shape = concatShapeInfo( - mapTripleShex, - shapeIri, - ); - return shape; +export async function shexShapeFromQuads( + quads: RDF.Stream | RDF.Quad[], + shapeIri: string, + options?: IShapeParserOptions, +): Promise { + const mapTripleShex = defaultMapTripleShex(); + const error = await forEachQuad(quads, quad => parseShapeQuads(quad, mapTripleShex)); + return error ?? concatShapeInfo(mapTripleShex, shapeIri, options); } /** @@ -103,15 +41,12 @@ function shapeFromQuadArray(quads: RDF.Quad[], shapeIri: string): IShape | Shape function concatShapeInfo( mapTripleShex: IMapTripleShex, shapeIri: string, + options?: IShapeParserOptions, ): IShape | ShapeError { const positivePredicates: IPredicate[] = []; const negativePredicates: string[] = []; const argsFunctionPredicate: IAppendPredicateArgs = { - mapIdPredicate: mapTripleShex.mapIdPredicate, - mapIriCardinalityMin: mapTripleShex.mapIriCardinalityMin, - mapIriCardinalityMax: mapTripleShex.mapIriCardinalityMax, - mapIriConstraint: mapTripleShex.mapIriConstraint, - mapIriDatatype: mapTripleShex.mapIriDatatype, + map: mapTripleShex, positivePredicates, negativePredicates, oneOf: new Map(), @@ -132,32 +67,37 @@ function concatShapeInfo( } else { expressions = mapTripleShex.mapLogicLinkIdExpressions.get(expression); } - let current; - let next; + const context: IShexParseContext = { map: mapTripleShex, shapeIri, options }; // If there is only one expression if (expressions === undefined) { - argsFunctionPredicate.current = expression; - const predicateAdded = appendPredicates((argsFunctionPredicate as Required)); + // an undefined expression without expressions has already returned an error above + const predicateAdded = expression !== undefined && appendPredicates(argsFunctionPredicate, expression); if (!predicateAdded) { return new PoorlyFormatedShapeError('there are no predicates in the shape'); } - } else if (expression !== undefined && mapTripleShex.setIriEachOf.has(expression)) { - current = mapTripleShex.mapPrevCurrentList.get(expressions); - next = mapTripleShex.mapPrevNextList.get(expressions); + } else if (expression === undefined || mapTripleShex.setIriEachOf.has(expression)) { + const members = readExpressionList(expressions, 'eachOf', context); + if (members instanceof PoorlyFormatedShapeError) { + return members; + } + const error = handleEachOf(members, argsFunctionPredicate, context); + if (error !== undefined) { + return error; + } + } else if (mapTripleShex.setIriOneOf.has(expression)) { + const error = handleOneOf(expression, expression, argsFunctionPredicate, mapTripleShex.setIriEachOf.has(expression), context); + if (error !== undefined) { + return error; + } } - const error = handleEachOf(current, next, mapTripleShex, argsFunctionPredicate); - if (error !== undefined) { - return error; - } let isClosed; if (shapeExpr === undefined) { isClosed = mapTripleShex.mapShapeExpressionClosedShape.get(shapeIri); } else { isClosed = mapTripleShex.mapShapeExpressionClosedShape.get(shapeExpr); } - try { - let oneOfs: OneOf[] = []; + let oneOfs: OneOf[] = []; for (const currentOneOf of argsFunctionPredicate.oneOf.values()) { const oneOf: OneOf = []; for (const currentPath of currentOneOf) { @@ -167,49 +107,39 @@ function concatShapeInfo( oneOfs.push(oneOf); } oneOfs = deleteIdenticalBranch(oneOfs) - return new Shape({ + return tryCreateShape({ name: shapeIri, - positivePredicates: positivePredicates, + positivePredicates, negativePredicates, closed: isClosed, oneOf: oneOfs, }); - } catch (error: unknown) { - return error as ShapeError; - } } /** * Interpret an RDF term of a constraint into an object * @param {RDF.Term | undefined} constraint - The constraint RDF term - * @param {Map} mapIriDatatype - A map of IRI and data type + * @param {IMapTripleShex} map - The ShEx shape information, for the datatype and facets of a node constraint + * @param {string} predicate - The predicate the constraint applies to * @returns {IConstraint | undefined} - The constraint or undefined if the constraint is not supported */ function interpretConstraint( constraint: RDF.Term | undefined, - mapIriDatatype: Map, - predicate?: string, + map: IMapTripleShex, + predicate: string, ): IConstraint | undefined { if (constraint === undefined) { return undefined; } if (constraint.termType === 'NamedNode') { - return { - value: new Set([constraint.value]), - type: ConstraintType.SHAPE, - }; + return buildConstraint({ shapeConstraint: constraint.value }, predicate); } if (constraint.termType === 'BlankNode') { - const dataType = mapIriDatatype.get(constraint.value); - if (dataType !== undefined) { - return { - value: new Set([dataType]), - type: predicate === TYPE_DEFINITION.value - ? ConstraintType.CLASS - : ConstraintType.DATATYPE, - }; + const datatypeConstraint = map.mapIriDatatype.get(constraint.value); + if (datatypeConstraint !== undefined) { + return buildConstraint({ datatypeConstraint, ...map.mapIriFacets.get(constraint.value) }, predicate); } } @@ -217,22 +147,23 @@ function interpretConstraint( } /** - * Add the predicate to the predicat lists - * @param {Required} args - Argument to build a predicate + * Add the predicate of a triple expression to the predicate lists + * @param {IAppendPredicateArgs} args - The lists to add to, and the shape information + * @param {string} current - The triple expression * @returns {boolean} - return true if the predicate was added */ function appendPredicates( - args: Required, + args: IAppendPredicateArgs, + current: string, ): boolean { - const predicate = args.mapIdPredicate.get(args.current); + const predicate = args.map.mapIdPredicate.get(current); if (predicate !== undefined) { - const min = args.mapIriCardinalityMin.get(args.current); - const max = args.mapIriCardinalityMax.get(args.current); - if (min === max && min === 0) { + const min = args.map.mapIriCardinalityMin.get(current); + const max = args.map.mapIriCardinalityMax.get(current); + if (isNegativeCardinality(min, max)) { args.negativePredicates.push(predicate); } else { - const constraintIri = args.mapIriConstraint.get(args.current); - const constraint = interpretConstraint(constraintIri, args.mapIriDatatype, predicate); + const constraint = interpretConstraint(args.map.mapIriConstraint.get(current), args.map, predicate); args.positivePredicates.push({ name: predicate, cardinality: { @@ -247,52 +178,63 @@ function appendPredicates( return false; } +/** + * What the ShEx list handlers need besides the lists they fill. + */ +interface IShexParseContext { + map: IMapTripleShex; + shapeIri: string; + options?: IShapeParserOptions; +} + +function readExpressionList(head: string, construct: string, context: IShexParseContext): string[] | PoorlyFormatedShapeError { + return readRdfList(head, { + firstByNode: context.map.mapPrevCurrentList, + restByNode: context.map.mapPrevNextList, + }, { shapeIri: context.shapeIri, construct, options: context.options }); +} + +/** + * Add the triple expressions of a oneOf (or of an eachOf inside a oneOf) as branches of the oneOf + * stored at `index` in `prevArgsFunctionPredicate`. Nested oneOf and eachOf expressions add their + * branches to the same oneOf. + */ function handleOneOf( iri: string, index: string, - mapTripleShex: IMapTripleShex, prevArgsFunctionPredicate: IAppendPredicateArgs, - eachOf: boolean): undefined | PoorlyFormatedShapeError { + eachOf: boolean, + context: IShexParseContext, +): undefined | PoorlyFormatedShapeError { + const { map } = context; const positivePredicates: IPredicate[] = []; const negativePredicates: string[] = []; const argsFunctionPredicate: IAppendPredicateArgs = { - mapIdPredicate: mapTripleShex.mapIdPredicate, - mapIriCardinalityMin: mapTripleShex.mapIriCardinalityMin, - mapIriCardinalityMax: mapTripleShex.mapIriCardinalityMax, - mapIriConstraint: mapTripleShex.mapIriConstraint, - mapIriDatatype: mapTripleShex.mapIriDatatype, + map, positivePredicates, negativePredicates, oneOf: new Map() }; - const expressions = mapTripleShex.mapLogicLinkIdExpressions.get(iri); + const expressions = map.mapLogicLinkIdExpressions.get(iri); // we don't really support validation of format /* istanbul ignore next */ if (expressions === undefined) { return new PoorlyFormatedShapeError('There are no expressions in a one of'); } - let current = mapTripleShex.mapPrevCurrentList.get(expressions); - let next = mapTripleShex.mapPrevNextList.get(expressions); - while (current !== undefined) { - if (!mapTripleShex.setIriOneOf.has(current) && !mapTripleShex.setIriEachOf.has(current)) { - const error = handleEachOf(current, next, mapTripleShex, argsFunctionPredicate); - // we don't really support validation of format - /* istanbul ignore next */ - if (error !== undefined) { - return error; - } - } else { - handleOneOf(current, iri, mapTripleShex, prevArgsFunctionPredicate, mapTripleShex.setIriEachOf.has(current)); + const members = readExpressionList(expressions, 'oneOf', context); + if (members instanceof PoorlyFormatedShapeError) { + return members; + } + for (const member of members) { + if (!map.setIriOneOf.has(member) && !map.setIriEachOf.has(member)) { + appendPredicates(argsFunctionPredicate, member); + continue; } - // we don't really support validation of format - /* istanbul ignore next */ - if (next === undefined) { - return new PoorlyFormatedShapeError('An RDF list is poorly defined'); + const error = handleOneOf(member, iri, prevArgsFunctionPredicate, map.setIriEachOf.has(member), context); + if (error !== undefined) { + return error; } - - current = mapTripleShex.mapPrevCurrentList.get(next); - next = mapTripleShex.mapPrevNextList.get(next); } const currentOneOf = prevArgsFunctionPredicate.oneOf.get(index); if (currentOneOf === undefined) { @@ -311,29 +253,29 @@ function handleOneOf( } } + + return undefined; } +/** + * Add the triple expressions of an eachOf to the predicate lists; a oneOf among them adds a oneOf. + */ function handleEachOf( - current: string | undefined, - next: string | undefined, - mapTripleShex: IMapTripleShex, - argsFunctionPredicate: IAppendPredicateArgs): undefined | PoorlyFormatedShapeError { - // Traverse the RDF list - while (current !== undefined) { - if (!mapTripleShex.setIriOneOf.has(current)) { - argsFunctionPredicate.current = current; - appendPredicates((argsFunctionPredicate as Required)); - } else { - handleOneOf(current, current, mapTripleShex, argsFunctionPredicate, mapTripleShex.setIriEachOf.has(current)); + members: string[], + argsFunctionPredicate: IAppendPredicateArgs, + context: IShexParseContext, +): undefined | PoorlyFormatedShapeError { + for (const member of members) { + if (!context.map.setIriOneOf.has(member)) { + appendPredicates(argsFunctionPredicate, member); + continue; } - - if (next === undefined) { - return new PoorlyFormatedShapeError('An RDF list is poorly defined'); + const error = handleOneOf(member, member, argsFunctionPredicate, context.map.setIriEachOf.has(member), context); + if (error !== undefined) { + return error; } - - current = mapTripleShex.mapPrevCurrentList.get(next); - next = mapTripleShex.mapPrevNextList.get(next); } + return undefined; } function deleteIdenticalBranch(oneOfs: OneOf[]): OneOf[] { @@ -371,47 +313,74 @@ function parseShapeQuads( quad: RDF.Quad, mapTripleShex: IMapTripleShex, ): void { - if (quad.predicate.equals(SHEX_PREDICATE)) { + if (quad.predicate.equals(SHEX.terms.predicate)) { mapTripleShex.mapIdPredicate.set(quad.subject.value, quad.object.value); } - if (quad.predicate.equals(IRI_FIRST_RDF_LIST)) { + if (quad.predicate.equals(RDF_VOCAB.terms.first)) { mapTripleShex.mapPrevCurrentList.set(quad.subject.value, quad.object.value); } - if (quad.predicate.equals(IRI_REST_RDF_LIST)) { + if (quad.predicate.equals(RDF_VOCAB.terms.rest)) { mapTripleShex.mapPrevNextList.set(quad.subject.value, quad.object.value); } - if (quad.predicate.equals(SHEX_EXPRESSIONS)) { + if (quad.predicate.equals(SHEX.terms.expressions)) { mapTripleShex.mapLogicLinkIdExpressions.set(quad.subject.value, quad.object.value); } - if (quad.predicate.equals(SHEX_EXPRESSION)) { + if (quad.predicate.equals(SHEX.terms.expression)) { mapTripleShex.mapShapeExpressionId.set(quad.subject.value, quad.object.value); } - if (quad.predicate.equals(SHEX_CLOSED_SHAPE)) { + if (quad.predicate.equals(SHEX.terms.closed)) { mapTripleShex.mapShapeExpressionClosedShape.set(quad.subject.value, quad.object.equals(RDF_TRUE)); } - if (quad.predicate.equals(SHEX_SHAPE_EXPRESSION)) { + if (quad.predicate.equals(SHEX.terms.shapeExpr)) { mapTripleShex.mapIriShapeExpression.set(quad.subject.value, quad.object.value); } - if (quad.predicate.equals(SHEX_MAX)) { + if (quad.predicate.equals(SHEX.terms.max)) { mapTripleShex.mapIriCardinalityMax.set(quad.subject.value, Number(quad.object.value)); } - if (quad.predicate.equals(SHEX_MIN)) { + if (quad.predicate.equals(SHEX.terms.min)) { mapTripleShex.mapIriCardinalityMin.set(quad.subject.value, Number(quad.object.value)); } - if (quad.predicate.equals(SHEX_VALUE_EXPR)) { + if (quad.predicate.equals(SHEX.terms.valueExpr)) { mapTripleShex.mapIriConstraint.set(quad.subject.value, quad.object); } - if (quad.predicate.equals(SHEX_DATA_TYPE)) { + if (quad.predicate.equals(SHEX.terms.datatype)) { mapTripleShex.mapIriDatatype.set(quad.subject.value, quad.object.value); } - if (quad.object.equals(SHEX_EACH_OF)) { + const numericFacet = NUMERIC_FACETS.get(quad.predicate.value); + if (numericFacet !== undefined) { + getOrCreateFacets(mapTripleShex, quad.subject.value)[numericFacet] = Number(quad.object.value); + } + if (quad.predicate.equals(SHEX.terms.pattern)) { + getOrCreateFacets(mapTripleShex, quad.subject.value).pattern = quad.object.value; + } + if (quad.predicate.equals(SHEX.terms.flags)) { + getOrCreateFacets(mapTripleShex, quad.subject.value).flags = quad.object.value; + } + if (quad.object.equals(SHEX.terms.EachOf)) { mapTripleShex.setIriEachOf.add(quad.subject.value); } - if (quad.object.equals(SHEX_ONE_OF)) { + if (quad.object.equals(SHEX.terms.OneOf)) { mapTripleShex.setIriOneOf.add(quad.subject.value); } } +// The ShEx numeric facets, by the facet they set +const NUMERIC_FACETS = new Map([ + [SHEX.mininclusive, 'minInclusive'], + [SHEX.maxinclusive, 'maxInclusive'], + [SHEX.minexclusive, 'minExclusive'], + [SHEX.maxexclusive, 'maxExclusive'], +]); + +function getOrCreateFacets(mapTripleShex: IMapTripleShex, nodeId: string): IConstraintFacets { + let facets = mapTripleShex.mapIriFacets.get(nodeId); + if (facets === undefined) { + facets = {}; + mapTripleShex.mapIriFacets.set(nodeId, facets); + } + return facets; +} + interface IMapTripleShex { mapIdPredicate: Map; mapPrevCurrentList: Map; @@ -424,20 +393,16 @@ interface IMapTripleShex { mapIriCardinalityMin: Map; mapIriConstraint: Map; mapIriDatatype: Map; + mapIriFacets: Map; setIriEachOf: Set; setIriOneOf: Set; } interface IAppendPredicateArgs { - mapIdPredicate: Map; - mapIriCardinalityMin: Map; - mapIriCardinalityMax: Map; - mapIriConstraint: Map; - mapIriDatatype: Map; + map: IMapTripleShex; positivePredicates: IPredicate[]; negativePredicates: string[]; oneOf: Map; - current?: string; } function defaultMapTripleShex(): IMapTripleShex { @@ -453,6 +418,7 @@ function defaultMapTripleShex(): IMapTripleShex { mapIriCardinalityMin: new Map(), mapIriConstraint: new Map(), mapIriDatatype: new Map(), + mapIriFacets: new Map(), setIriEachOf: new Set(), setIriOneOf: new Set() }; diff --git a/lib/target.ts b/lib/target.ts new file mode 100644 index 0000000..f1c7599 --- /dev/null +++ b/lib/target.ts @@ -0,0 +1,221 @@ +import type { Term } from '@rdfjs/types'; +import { ConstraintType, type IPredicate, type IShape, type IShapeTargets } from './Shape'; +import type { IStarPatternWithDependencies } from './Triple'; +import { RDF, XSD } from './constant'; + +/** + * The constants the subject of a star pattern is restricted to, or undefined when it is a variable. + */ +export function constantSubjects(starPattern: IStarPatternWithDependencies): Set | undefined { + if (starPattern.subjectValues !== undefined) { + return starPattern.subjectValues; + } + return starPattern.isVariable ? undefined : new Set([starPattern.name]); +} + +/** + * The focus nodes a shape selects through its targets when it is used on its own: + * + * - `any`: the shape declares no target and describes any node; + * - `nodes`: only node targets (sh:targetNode); + * - `objectsOf`: a single objects-of target (sh:targetObjectsOf) and no other target; + * - `alternatives`: only class and subjects-of targets, see {@link targetAlternatives}; + * - `mixed`: node or objects-of targets combined with other targets, or several objects-of targets. + */ +export type FocusNodes = + | { kind: 'any' } + | { kind: 'nodes', nodes: string[] } + | { kind: 'objectsOf', predicate: string } + | { kind: 'alternatives', alternatives: ITargetAlternative[] } + | { kind: 'mixed' }; + +/** + * A predicate that some focus nodes carry because of a target: `rdf:type` with one of the target + * classes, or the predicate of a subjects-of target. + */ +export interface ITargetAlternative { + predicate: string; + // the target classes, for the alternative of the class targets + classes?: string[]; +} + +export function focusNodes(targets: IShapeTargets): FocusNodes { + const { nodes, classes, subjectsOf, objectsOf } = targets; + const kinds = [ nodes, classes, subjectsOf, objectsOf ].filter(values => values.length > 0).length; + if (kinds === 0) { + return { kind: 'any' }; + } + if (nodes.length === 0 && objectsOf.length === 0) { + return { kind: 'alternatives', alternatives: targetAlternatives(targets) }; + } + if (kinds > 1 || objectsOf.length > 1) { + return { kind: 'mixed' }; + } + return nodes.length > 0 ? { kind: 'nodes', nodes } : { kind: 'objectsOf', predicate: objectsOf[0] }; +} + +/** + * The alternatives the class and subjects-of targets offer: one for all the class targets, which + * all imply `rdf:type`, and one per subjects-of target. The focus nodes of a shape are the union of + * the nodes of its targets, so an alternative holds for every focus node only when it is the only + * alternative and the shape has no other target. + */ +export function targetAlternatives({ classes, subjectsOf }: IShapeTargets): ITargetAlternative[] { + const alternatives: ITargetAlternative[] = classes.length > 0 ? [{ predicate: RDF.type, classes }] : []; + return alternatives.concat(subjectsOf.map(predicate => ({ predicate }))); +} + +/** + * The key of an RDF term as a node target. An IRI is its value, as the constant subject of a star + * pattern is; a literal or a blank node is its N-Triples form, so that it neither matches an IRI + * with the same text nor loses its datatype or language. + */ +export function nodeTargetKey(term: Term): string { + if (term.termType === 'BlankNode') { + return `_:${term.value}`; + } + if (term.termType !== 'Literal') { + return term.value; + } + const value = JSON.stringify(term.value); + if (term.language !== '') { + return `${value}@${term.language}`; + } + return term.datatype.value === XSD.string ? value : `${value}^^<${term.datatype.value}>`; +} + +/** + * The property constraints a shape implies without declaring them, as alternatives for a predicate. + * + * - A class target (sh:targetClass, or an implicit class target) makes every focus node an instance + * of a target class, so the shape implies `rdf:type` with one of those classes as value. + * - A subjects-of target (sh:targetSubjectsOf) makes every focus node carry the predicate. + * - A closed shape allows the properties listed by sh:ignoredProperties, without constraining them. + * + * A constraint implied by a target is only required (minimum 1) when every focus node carries it, + * see {@link targetAlternatives}. + * + * A shape reached through sh:node has no targets here: SHACL ignores them (see withoutTargets). + */ +export function impliedPredicates(shape: IShape, predicate: string): IPredicate[] { + const implied: IPredicate[] = []; + const alternatives = targetAlternatives(shape.targets); + const required = alternatives.length === 1 && focusNodes(shape.targets).kind === 'alternatives'; + for (const alternative of alternatives) { + if (alternative.predicate !== predicate) { + continue; + } + implied.push({ + name: predicate, + ...alternative.classes !== undefined ? + { constraint: { type: ConstraintType.CLASS, value: new Set(alternative.classes) } } : + {}, + cardinality: { min: required ? 1 : 0, max: -1 }, + }); + } + if (shape.closed && shape.ignoredProperties.includes(predicate)) { + implied.push({ name: predicate, cardinality: { min: 0, max: -1 }, optional: true }); + } + return implied; +} + +/** + * Every predicate for which {@link impliedPredicates} yields a constraint. + */ +export function impliedPredicateNames(shape: IShape): string[] { + const names = new Set(); + for (const { predicate } of targetAlternatives(shape.targets)) { + names.add(predicate); + } + if (shape.closed) { + for (const predicate of shape.ignoredProperties) { + names.add(predicate); + } + } + return Array.from(names); +} + +/** + * Whether a shape whose only targets are node targets is about other nodes than the star pattern. + * Such a shape describes the listed nodes and nothing else, so it cannot contain a star pattern whose + * subject is a constant it does not list. Any other target leaves the subject undecided. + */ +export function isSubjectOutsideNodeTargets(shape: IShape, starPattern: IStarPatternWithDependencies): boolean { + const focus = focusNodes(shape.targets); + const subjects = constantSubjects(starPattern); + if (focus.kind !== 'nodes' || subjects === undefined) { + return false; + } + return !Array.from(subjects).some(subject => focus.nodes.includes(subject)); +} + +/** + * Whether a shape describes a star pattern: the shape's closedness and negative property constraints + * apply to the nodes matching the pattern. A shape without targets describes every star pattern. A + * shape with targets only describes the nodes of its targets, so it describes a star pattern only + * when every node matching it is in a target: + * + * - its subject is a node target; + * - it requires an `rdf:type` whose values are all target classes; + * - it requires a predicate whose subjects are targets (sh:targetSubjectsOf); or + * - it is reached through a required triple pattern whose predicate has objects as targets + * (sh:targetObjectsOf). + * + * @param incoming the predicates of the required triple patterns through which each star pattern is reached + */ +export function describesStarPattern( + shape: IShape, + starPattern: IStarPatternWithDependencies, + incoming: Map>, +): boolean { + if (focusNodes(shape.targets).kind === 'any') { + return true; + } + const { nodes, classes, subjectsOf, objectsOf } = shape.targets; + + const subjects = constantSubjects(starPattern); + if (subjects !== undefined && subjects.size > 0 && Array.from(subjects).every(subject => nodes.includes(subject))) { + return true; + } + + for (const { triple } of starPattern.starPattern.values()) { + if (triple.isOptional === true) { + continue; + } + if (triple.predicate === RDF.type && classes.length > 0) { + const objects = Array.isArray(triple.object) ? triple.object : [ triple.object ]; + if (objects.length > 0 && objects.every(object => object.termType === 'NamedNode' && classes.includes(object.value))) { + return true; + } + } + if (subjectsOf.includes(triple.predicate)) { + return true; + } + } + + const incomingPredicates = incoming.get(starPattern.name); + return incomingPredicates !== undefined && objectsOf.some(predicate => incomingPredicates.has(predicate)); +} + +/** + * For each star pattern, the predicates of the required triple patterns whose object it is. + */ +export function incomingRequiredPredicates( + starPatterns: Map, +): Map> { + const incoming = new Map>(); + for (const starPattern of starPatterns.values()) { + for (const { triple, dependencies } of starPattern.starPattern.values()) { + if (dependencies === undefined || triple.isOptional === true) { + continue; + } + let predicates = incoming.get(dependencies.name); + if (predicates === undefined) { + predicates = new Set(); + incoming.set(dependencies.name, predicates); + } + predicates.add(triple.predicate); + } + } + return incoming; +} diff --git a/package-lock.json b/package-lock.json index 16d7cd8..ea6e209 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { - "name": "query-shape-detection", + "name": "query-shape-matching", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "query-shape-detection", + "name": "query-shape-matching", "version": "1.0.0", "license": "MIT", "dependencies": { "@traqula/algebra-sparql-1-1": "^1.1.6", "@traqula/algebra-transformations-1-1": "^1.1.4", "@traqula/parser-sparql-1-1": "^1.1.6", + "@treecg/types": "^0.4.6", "rdf-data-factory": "^2.0.2" }, "devDependencies": { @@ -713,6 +714,36 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@treecg/types": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/@treecg/types/-/types-0.4.6.tgz", + "integrity": "sha512-iL8rBhAX4INvfuHZzJ1RELh1i/fspviylENZTk6D2p/xDazY1pTkd+y/hxKYVWCC0jHNolDWRsH7ozwaFGNQKA==", + "license": "UNLICENSE", + "dependencies": { + "@rdfjs/types": "*", + "loglevel": "^1.8.1", + "loglevel-plugin-prefix": "^0.8.4", + "rdf-data-factory": "^1.1.0" + } + }, + "node_modules/@treecg/types/node_modules/@rdfjs/types": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rdfjs/types/-/types-1.1.2.tgz", + "integrity": "sha512-wqpOJK1QCbmsGNtyzYnojPU8gRDPid2JO0Q0kMtb4j65xhCK880cnKAfEOwC+dX85VJcCByQx5zOwyyfCjDJsg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@treecg/types/node_modules/rdf-data-factory": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rdf-data-factory/-/rdf-data-factory-1.1.3.tgz", + "integrity": "sha512-ny6CI7m2bq4lfQQmDYvcb2l1F9KtGwz9chipX4oWu2aAtVoXjb7k3d8J1EsgAsEbMXnBipB/iuRen5H2fwRWWQ==", + "license": "MIT", + "dependencies": { + "@rdfjs/types": "^1.0.0" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -1274,16 +1305,16 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/buffer": { @@ -2178,6 +2209,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, + "node_modules/loglevel-plugin-prefix": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/loglevel-plugin-prefix/-/loglevel-plugin-prefix-0.8.4.tgz", + "integrity": "sha512-WpG9CcFAOjz/FtNht+QJeGpvVl/cdR6P0z6OcXSkr8wFJOsV2GRj2j10JLfjuA4aYkcKCNIEqRGCyTife9R8/g==", + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2254,9 +2304,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -2391,9 +2441,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -2411,7 +2461,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/package.json b/package.json index d6721fa..9af713b 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@traqula/algebra-sparql-1-1": "^1.1.6", "@traqula/algebra-transformations-1-1": "^1.1.4", "@traqula/parser-sparql-1-1": "^1.1.6", + "@treecg/types": "^0.4.6", "rdf-data-factory": "^2.0.2" }, "devDependencies": { @@ -33,11 +34,11 @@ "@types/n3": "^1.26.1", "@types/node": "^26.1.0", "@types/streamify-array": "^1.0.3", + "@vitest/coverage-v8": "^4.1.9", "eslint": "^10.6.0", "globals": "^17.7.0", "n3": "^2.1.1", "streamify-array": "^2.0.0", - "@vitest/coverage-v8": "^4.1.9", "typescript": "^6.0.3", "typescript-eslint": "^8.62.1", "vitest": "^4.1.9" diff --git a/roadmap.md b/roadmap.md new file mode 100644 index 0000000..53e201a --- /dev/null +++ b/roadmap.md @@ -0,0 +1,86 @@ +1. [VALUES](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) / [FILTER IN](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) vs `sh:in` + +- SPARQL side: explicit finite allowed values. +- SHACL side: enumeration constraint. +- This is a very natural implication check: query allowed-set should be compatible with shape allowed-set. + +2. Constant object patterns vs `sh:hasValue` + +- SPARQL: [?s ex:p ex:Foo](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) or [?s ex:p "x"](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html). +- SHACL: property must contain a specific value. +- This is stricter than plain datatype/class matching and often easier to reason about. + +3. [FILTER(?x = ?y)](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) / `sameTerm` vs cross-property equality constraints + +- SHACL has [sh:equals](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html). +- This compares relational constraints between two properties, not just one property in isolation. + +4. [FILTER(?x != ?y)](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) or exclusion sets vs `sh:disjoint` + +- Similar idea, but for incompatibility between two properties. + +5. Numeric comparisons vs `sh:lessThan` / `sh:lessThanOrEquals` + +- These are cross-property numeric/order constraints. +- They matter when the query relates two variables rather than constraining one value against a constant. + +6. [FILTER isIRI / isLiteral / isBlank](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) vs `sh:nodeKind` + +- This is a clean semantic pairing: + - `isIRI` ↔ IRI node kind + - `isLiteral` ↔ literal node kind + - `isBlank` ↔ blank node kind +- This would strengthen class/datatype reasoning substantially. + +7. String functions vs string facets + +- SPARQL: `STRSTARTS`, `STRENDS`, `CONTAINS`, `LCASE/UCASE`, `STRLEN` +- SHACL: `sh:minLength`, `sh:maxLength`, [sh:pattern](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html), `sh:languageIn`, `sh:uniqueLang` +- Some implications are straightforward, others need conservative reasoning. + +8. Language filters vs `sh:languageIn` + +- SPARQL: `lang(?x) = "en"` or `langMatches(...)` +- SHACL: language whitelist. +- This is one of the clearest missing literal-comparison axes. + +9. Property-path structure vs SHACL path structure + +- SPARQL already has sequence, inverse, alternative, cardinality paths in [query.ts](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html). +- SHACL also has richer path forms. +- Comparing just the terminal predicate is weaker than comparing actual path semantics. + +10. `OPTIONAL` vs relaxed cardinality / optionality + +- A query optional triple is not the same as a shape optional property, but there is a real compatibility relation there. +- Especially useful when deciding contained vs merely aligned. + +11. [UNION](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) / branch structure vs [sh:or](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html), [sh:xone](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html), `sh:and` + +- [or](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) is already the obvious pairing. +- [xone](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) and `and` could be reasoned about more precisely than “best-effort branch compatibility”. + +12. Negation constructs vs negative shape constraints + +- SPARQL: [FILTER NOT EXISTS](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html), `MINUS`, negated property sets +- SHACL: [sh:not](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html), forbidden properties, disjointness-style constraints +- This is harder, but it is the real way to represent absence constraints rather than dropping them in [shapeToQuery](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html). + +13. Closed shape semantics vs query predicate completeness + +- SHACL [closed=true](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) says extra predicates are disallowed. +- On the query side, you can ask whether the query’s mentioned predicates are exhaustive or only partial. +- This matters for containment classification, not just local predicate matching. + +14. Conditional existence vs qualified constraints + +- SPARQL can express “if there exists X then ...” via `EXISTS`/`NOT EXISTS`. +- SHACL has `qualifiedValueShape`, `qualifiedMinCount`, `qualifiedMaxCount`. +- This is a strong next step for nested-shape reasoning. + +15. Count-like query structure vs cardinality families + +- Even without full aggregates, repeated triple patterns and path multiplicities can sometimes imply minimum multiplicity. +- SHACL cardinalities could be compared against that more aggressively. + +16. Add support for SHACL targets (sh:targetClass, sh:targetNode, sh:targetSubjectsOf, sh:targetObjectsOf) \ No newline at end of file diff --git a/test/Binding.test.ts b/test/Binding.test.ts index 08c1d50..642ab3b 100644 --- a/test/Binding.test.ts +++ b/test/Binding.test.ts @@ -12,7 +12,7 @@ import * as N3 from 'n3'; import { readFileSync } from 'fs'; import { streamifyArray } from 'streamify-array'; import { shexShapeFromQuads } from '../lib/shex'; -import { TYPE_DEFINITION } from '../lib/constant'; +import { RDF as RDF_VOCAB } from '../lib/constant'; const n3Parser = new N3.Parser(); const sparqlParser = new SPARQLParser(); @@ -1728,7 +1728,7 @@ describe('Bindings', () => { const shape: Shape = new Shape({ name: 'foo', positivePredicates: [ { - name: TYPE_DEFINITION.value, + name: RDF_VOCAB.type, constraint: { type: ConstraintType.CLASS, value: new Set(["t0"]) @@ -1740,13 +1740,13 @@ describe('Bindings', () => { const triple: Triple = new Triple({ subject: 'y', - predicate: TYPE_DEFINITION.value, + predicate: RDF_VOCAB.type, object: DF.namedNode('t0') }); const starPattern: IStarPatternWithDependencies = { starPattern: new Map([ [ - TYPE_DEFINITION.value, + RDF_VOCAB.type, { triple, dependencies: undefined @@ -1822,7 +1822,7 @@ describe('Bindings', () => { const shape: Shape = new Shape({ name: 'foo', positivePredicates: [ { - name: TYPE_DEFINITION.value, + name: RDF_VOCAB.type, constraint: { type: ConstraintType.CLASS, value: new Set(["t0"]) @@ -1848,7 +1848,7 @@ describe('Bindings', () => { const triple1: Triple = new Triple({ subject: 'y', - predicate: TYPE_DEFINITION.value, + predicate: RDF_VOCAB.type, object: [DF.namedNode('t0')] }); const triple2: Triple = new Triple({ diff --git a/test/Shape.test.ts b/test/Shape.test.ts index faa60de..419ff4e 100644 --- a/test/Shape.test.ts +++ b/test/Shape.test.ts @@ -277,7 +277,7 @@ describe('Shape', () => { const shape = new Shape({ name: 'foo', positivePredicates, negativePredicates }); it('should get all predicates', () => { - // eslint-disable-next-line @typescript-eslint/require-array-sort-compare + const expectedPredicates = [ { name: 'a' }, { @@ -295,7 +295,7 @@ describe('Shape', () => { { name: 'f', negative: true }, ].sort(); - // eslint-disable-next-line @typescript-eslint/require-array-sort-compare + expect(shape.getAll().sort()).toStrictEqual(expectedPredicates); }); diff --git a/test/containment.test.ts b/test/containment.test.ts index c5ccd58..f397aaa 100644 --- a/test/containment.test.ts +++ b/test/containment.test.ts @@ -3,11 +3,12 @@ import { toAlgebra } from '@traqula/algebra-sparql-1-1'; import { describe, expect, it, test } from 'vitest'; import { ConstraintType, IShape, Shape } from '../lib/Shape'; import { IStarPatternWithDependencies, Triple } from '../lib/Triple'; -import { ContainmentResult, IContainmentResult, IResult, StarPatternName, solveShapeQueryContainment } from '../lib/containment'; +import { ContainmentResult, EmptyQueryError, IContainmentResult, IResult, StarPatternName, solveShapeQueryContainment, solveShapeShapeContainment } from '../lib/containment'; import { DataFactory } from 'rdf-data-factory'; import { BaseQuad } from '@rdfjs/types'; import { IQuery, generateQuery } from '../lib/query'; -import { TYPE_DEFINITION } from '../lib/constant'; +import { shapeToQuery } from '../lib/shapeToQuery'; +import { RDF as RDF_VOCAB } from '../lib/constant'; import type * as RDF from '@rdfjs/types'; import * as N3 from 'n3'; import { readFileSync } from 'fs'; @@ -19,6 +20,14 @@ const DF = new DataFactory(); const n3Parser = new N3.Parser(); const sparqlParser = new SPARQLParser(); +async function parseShaclShape(quads: RDF.Quad[], name: string): Promise { + const parsed = await shaclShapeFromQuads(quads, name); + if (parsed instanceof Error) { + throw parsed; + } + return parsed; +} + describe('solveShapeQueryContainment', () => { describe('virtual use case', () => { @@ -110,7 +119,7 @@ describe('solveShapeQueryContainment', () => { const shapeP7: Shape = new Shape({ name: 'foo7', positivePredicates: [ { - name: TYPE_DEFINITION.value, + name: RDF_VOCAB.type, constraint: { value: new Set(['']), type: ConstraintType.SHAPE @@ -123,7 +132,7 @@ describe('solveShapeQueryContainment', () => { const shapeP8: Shape = new Shape({ name: 'foo8', positivePredicates: [ { - name: TYPE_DEFINITION.value, + name: RDF_VOCAB.type, constraint: { value: new Set(['']), type: ConstraintType.SHAPE @@ -137,32 +146,36 @@ describe('solveShapeQueryContainment', () => { * General tests cases * *******************************************************************/ - it('should return an empty result given an empty query and no shape', () => { + it('should throw given an empty query and no shape', () => { const query: IQuery = { starPatterns: new Map() }; const shapes: IShape[] = []; - const expectedResult: IResult = { - - starPatternsContainment: new Map(), - visitShapeBoundedResource: new Map() - }; - expect(solveShapeQueryContainment({ query, shapes })).toStrictEqual(expectedResult); + // An empty query states no constraint, so no relevance degree can be assigned. + // Reporting CONTAINED here would rank every resource maximally relevant on no evidence. + expect(() => solveShapeQueryContainment({ query, shapes })).toThrow(EmptyQueryError); }); - it('should reject every shape given an empty query', () => { + it('should throw given an empty query and candidate shapes', () => { const query: IQuery = { starPatterns: new Map() }; const shapes: IShape[] = [shape, shapeP1, shapeP2, shapeP3, shapeP4, shapeP5]; - const expectedResult: IResult = { - starPatternsContainment: new Map(), - visitShapeBoundedResource: new Map(shapes.map((shape) => [shape.name, false])) + expect(() => solveShapeQueryContainment({ query, shapes })).toThrow(EmptyQueryError); + }); + + it('should report the discarded constructs when a query falls outside the profile', () => { + const query: IQuery = { + starPatterns: new Map([ + ["x", generateZStarPattern()] + ]), + unsupported: ['MINUS'] }; + const shapes: IShape[] = [shape]; - expect(solveShapeQueryContainment({ query, shapes })).toStrictEqual(expectedResult); + expect(solveShapeQueryContainment({ query, shapes }).unsupported).toStrictEqual(['MINUS']); }); it('should return an empty result given no shape', () => { @@ -174,7 +187,7 @@ describe('solveShapeQueryContainment', () => { }; const shapes: IShape[] = []; const expectedResult: IResult = { - + result: ContainmentResult.REJECTED, starPatternsContainment: new Map([["x", { result: ContainmentResult.REJECTED, bindings:new Map() }]]), visitShapeBoundedResource: new Map() }; @@ -192,7 +205,7 @@ describe('solveShapeQueryContainment', () => { const shapes: IShape[] = [shape, shapeP1, shapeP2, shapeP3, shapeP4, shapeP5]; const expectedResult: IResult = { - + result: ContainmentResult.CONTAINED, starPatternsContainment: new Map([ ["x", { result: ContainmentResult.CONTAINED, target: [shape.name], bindings:expect.any(Map) }] ]), @@ -219,6 +232,7 @@ describe('solveShapeQueryContainment', () => { const shapes: IShape[] = [shape, shapeP1, shapeP2, shapeP3, shapeP4, shapeP5]; const expectedResult: IResult = { + result: ContainmentResult.REJECTED, starPatternsContainment: new Map([["z", { result: ContainmentResult.REJECTED, bindings: new Map() }]]), visitShapeBoundedResource: new Map([ [shape.name, false], @@ -243,6 +257,7 @@ describe('solveShapeQueryContainment', () => { const shapes: IShape[] = [shape, shapeP1, shapeP2, shapeP3, shapeP4, shapeP5]; const expectedResult: IResult = { + result: ContainmentResult.REJECTED, starPatternsContainment: new Map([["z", { result: ContainmentResult.REJECTED, bindings: new Map() }]]), visitShapeBoundedResource: new Map([ [shape.name, false], @@ -277,6 +292,7 @@ describe('solveShapeQueryContainment', () => { }); const expectedResult: IResult = { + result: ContainmentResult.WEAKLY_REJECTED, starPatternsContainment: new Map([ ["z", { result: ContainmentResult.WEAKLY_REJECTED, bindings: new Map() }] ]), @@ -304,6 +320,7 @@ describe('solveShapeQueryContainment', () => { }); const expectedResult: IResult = { + result: ContainmentResult.REJECTED, starPatternsContainment: new Map([ ["z", { result: ContainmentResult.REJECTED, bindings: new Map() }] ]), @@ -343,6 +360,7 @@ describe('solveShapeQueryContainment', () => { const shape = parsedShape as IShape; const expectedResult: IResult = { + result: ContainmentResult.CONTAINED, starPatternsContainment: new Map([ ["s", { result: ContainmentResult.CONTAINED, target: [shape.name], bindings: expect.any(Map) }], ]), @@ -383,6 +401,7 @@ describe('solveShapeQueryContainment', () => { const shape = parsedShape as IShape; const expectedResult: IResult = { + result: ContainmentResult.CONTAINED, starPatternsContainment: new Map([ ["s", { result: ContainmentResult.CONTAINED, target: [shape.name], bindings: expect.any(Map) }], ]), @@ -423,6 +442,7 @@ describe('solveShapeQueryContainment', () => { const shape = parsedShape as IShape; const expectedResult: IResult = { + result: ContainmentResult.REJECTED, starPatternsContainment: new Map([ ["s", { result: ContainmentResult.REJECTED, bindings: new Map() }], ]), @@ -434,6 +454,38 @@ describe('solveShapeQueryContainment', () => { expect(solveShapeQueryContainment({ query, shapes: [shape] })).toStrictEqual(expectedResult); }); + it.each([ + ['^fo', '^foo', '', ContainmentResult.CONTAINED], + ['^foo', 'bar', '', ContainmentResult.CONTAINED], + ['^foo', '^FOO', 'i', ContainmentResult.CONTAINED], + ['^foo$', 'oo', '', ContainmentResult.CONTAINED], + ['^foo$', 'bar', '', ContainmentResult.REJECTED], + ['^foo$', '^foobar', '', ContainmentResult.REJECTED], + ])('should reject sh:pattern "%s" against FILTER regex "%s" (flags "%s") only when no string matches both', async (shapePattern, filterPattern, flags, expected) => { + const shacl = ` + PREFIX ex: + PREFIX sh: + PREFIX xsd: + + ex:patternShape a sh:NodeShape ; + sh:closed true ; + sh:property [ + sh:path ex:nickname ; + sh:datatype xsd:string ; + sh:pattern "${shapePattern}" + ] . + `; + const shape = await shaclShapeFromQuads(n3Parser.parse(shacl), 'https://www.example.ca/patternShape') as IShape; + const query = generateQuery(toAlgebra(sparqlParser.parse(` + PREFIX ex: + SELECT * WHERE { + ?s ex:nickname ?nickname . + FILTER(regex(str(?nickname), "${filterPattern}", "${flags}")) + }`))); + + expect(solveShapeQueryContainment({ query, shapes: [shape] }).result).toBe(expected); + }); + it('should support FILTER datatype(?v) comparison with VALUES', () => { const queryString = ` PREFIX ex: @@ -447,6 +499,7 @@ describe('solveShapeQueryContainment', () => { const shapes: IShape[] = [shape]; const expectedResult: IResult = { + result: ContainmentResult.CONTAINED, starPatternsContainment: new Map([ ["x", { result: ContainmentResult.CONTAINED, target: [shape.name], bindings: expect.any(Map) }], ]), @@ -487,6 +540,7 @@ describe('solveShapeQueryContainment', () => { const shape = parsedShape as IShape; const expectedResult: IResult = { + result: ContainmentResult.CONTAINED, starPatternsContainment: new Map([ ["s", { result: ContainmentResult.CONTAINED, target: [shape.name], bindings: expect.any(Map) }], ]), @@ -528,6 +582,7 @@ describe('solveShapeQueryContainment', () => { const shape = parsedShape as IShape; const expectedResult: IResult = { + result: ContainmentResult.REJECTED, starPatternsContainment: new Map([ ["s", { result: ContainmentResult.REJECTED, bindings: new Map() }], ]), @@ -570,6 +625,7 @@ describe('solveShapeQueryContainment', () => { const shape = parsedShape as IShape; const expectedResult: IResult = { + result: ContainmentResult.CONTAINED, starPatternsContainment: new Map([ ["s", { result: ContainmentResult.CONTAINED, target: [shape.name], bindings: expect.any(Map) }], ]), @@ -612,6 +668,7 @@ describe('solveShapeQueryContainment', () => { const shape = parsedShape as IShape; const expectedResult: IResult = { + result: ContainmentResult.REJECTED, starPatternsContainment: new Map([ ["s", { result: ContainmentResult.REJECTED, bindings: new Map() }], ]), @@ -653,6 +710,7 @@ describe('solveShapeQueryContainment', () => { const shape = parsedShape as IShape; const expectedResult: IResult = { + result: ContainmentResult.REJECTED, starPatternsContainment: new Map([ ["s", { result: ContainmentResult.REJECTED, bindings: new Map() }], ]), @@ -692,6 +750,7 @@ describe('solveShapeQueryContainment', () => { const shape = parsedShape as IShape; const expectedResult: IResult = { + result: ContainmentResult.REJECTED, starPatternsContainment: new Map([ ["s", { result: ContainmentResult.REJECTED, bindings: new Map() }], ]), @@ -708,7 +767,7 @@ describe('solveShapeQueryContainment', () => { name: 'fooClassConstraint', positivePredicates: [ { - name: TYPE_DEFINITION.value, + name: RDF_VOCAB.type, constraint: { type: ConstraintType.CLASS, value: new Set(['https://www.example.ca/Person']) @@ -728,6 +787,7 @@ describe('solveShapeQueryContainment', () => { const query = generateQuery(querySparql); const expectedResult: IResult = { + result: ContainmentResult.CONTAINED, starPatternsContainment: new Map([ ["x", { result: ContainmentResult.CONTAINED, target: [constrainedShape.name], bindings: expect.any(Map) }], ]), @@ -764,6 +824,7 @@ describe('solveShapeQueryContainment', () => { const query = generateQuery(querySparql); const expectedResult: IResult = { + result: ContainmentResult.CONTAINED, starPatternsContainment: new Map([ ["x", { result: ContainmentResult.CONTAINED, target: [constrainedShape.name], bindings: expect.any(Map) }], ]), @@ -801,6 +862,7 @@ describe('solveShapeQueryContainment', () => { const query = generateQuery(querySparql); const expectedResult: IResult = { + result: ContainmentResult.CONTAINED, starPatternsContainment: new Map([ ["x", { result: ContainmentResult.CONTAINED, target: [constrainedShape.name], bindings: expect.any(Map) }], ]), @@ -838,6 +900,7 @@ describe('solveShapeQueryContainment', () => { const query = generateQuery(querySparql); const expectedResult: IResult = { + result: ContainmentResult.CONTAINED, starPatternsContainment: new Map([ ["x", { result: ContainmentResult.CONTAINED, target: [constrainedShape.name], bindings: expect.any(Map) }], ]), @@ -874,6 +937,7 @@ describe('solveShapeQueryContainment', () => { const query = generateQuery(querySparql); const expectedResult: IResult = { + result: ContainmentResult.REJECTED, starPatternsContainment: new Map([ ["x", { result: ContainmentResult.REJECTED, bindings: new Map() }], ]), @@ -896,6 +960,7 @@ describe('solveShapeQueryContainment', () => { expect(resp.starPatternsContainment.get("w")?.result).toBe(ContainmentResult.CONTAINED); expect(resp.starPatternsContainment.get("w1")?.result).toBe(ContainmentResult.CONTAINED); expect(resp.starPatternsContainment.get("w2")?.result).toBe(ContainmentResult.CONTAINED); + expect(resp.result).toBe(ContainmentResult.CONTAINED); expect(resp.visitShapeBoundedResource).toStrictEqual(new Map([ [shape.name, true], @@ -920,7 +985,7 @@ describe('solveShapeQueryContainment', () => { ["w", { result: ContainmentResult.UNALINGED, target: [shapeP3.name, shapeP4.name], bindings:expect.any(Map) }], ]); const expectedResult: IResult = { - + result: ContainmentResult.UNALINGED, starPatternsContainment: expectedStarPatternsContainment, visitShapeBoundedResource: new Map([ [shape.name, true], @@ -933,7 +998,9 @@ describe('solveShapeQueryContainment', () => { ]) }; - expect(solveShapeQueryContainment({ query, shapes })).toStrictEqual(expectedResult); + const result = solveShapeQueryContainment({ query, shapes }); + expect(result).toStrictEqual(expectedResult); + expect(result.result).toBe(ContainmentResult.UNALINGED); }); @@ -958,6 +1025,7 @@ describe('solveShapeQueryContainment', () => { ["w", { result: ContainmentResult.UNALINGED, target: [shapeP3.name, shapeP4.name], bindings:expect.any(Map) }], ]); const expectedResult: IResult = { + result: ContainmentResult.UNALINGED, starPatternsContainment: expectedStarPatternsContainment, visitShapeBoundedResource: new Map([ [shape.name, false], @@ -989,7 +1057,7 @@ describe('solveShapeQueryContainment', () => { ["z", { result: ContainmentResult.CONTAINED, bindings:expect.any(Map), target: [shape.name] }], ]); const expectedResult: IResult = { - + result: ContainmentResult.UNALINGED, starPatternsContainment: expectedStarPatternsContainment, visitShapeBoundedResource: new Map([ [shape.name, true], @@ -1005,6 +1073,131 @@ describe('solveShapeQueryContainment', () => { }); + it('should support shape-to-shape containment through shapeToQuery wrapper', () => { + const sourceShape: IShape = new Shape({ + name: 'https://www.example.ca/source', + positivePredicates: [ + { + name: RDF_VOCAB.type, + constraint: { + type: ConstraintType.CLASS, + value: new Set(['https://www.example.ca/Person']) + } + }, + { + name: 'https://www.example.ca/age', + constraint: { + type: ConstraintType.DATATYPE, + value: new Set(['http://www.w3.org/2001/XMLSchema#integer']), + minInclusive: 18, + maxInclusive: 35 + } + } + ], + closed: true, + }); + + const targetShape: IShape = new Shape({ + name: 'https://www.example.ca/target', + positivePredicates: [ + { + name: RDF_VOCAB.type, + constraint: { + type: ConstraintType.CLASS, + value: new Set(['https://www.example.ca/Person']) + } + }, + { + name: 'https://www.example.ca/age', + constraint: { + type: ConstraintType.DATATYPE, + value: new Set(['http://www.w3.org/2001/XMLSchema#integer']), + minInclusive: 10, + maxInclusive: 40 + } + } + ], + closed: true, + }); + + const result = solveShapeShapeContainment({ + sourceShape, + targetShapes: [targetShape], + }); + + expect(result.starPatternsContainment.get(sourceShape.name)?.result).toBe(ContainmentResult.CONTAINED); + expect(result.result).toBe(ContainmentResult.CONTAINED); + }); + + it('should support shape-to-shape containment with source linked shapes', () => { + const childSource: IShape = new Shape({ + name: 'https://www.example.ca/sourceChild', + positivePredicates: [ + { + name: 'https://www.example.ca/nickname', + constraint: { + type: ConstraintType.DATATYPE, + value: new Set(['http://www.w3.org/2001/XMLSchema#string']), + pattern: '^a' + } + } + ], + closed: true, + }); + + const sourceShape: IShape = new Shape({ + name: 'https://www.example.ca/sourceRoot', + positivePredicates: [ + { + name: 'https://www.example.ca/knows', + constraint: { + type: ConstraintType.SHAPE, + value: new Set([childSource.name]) + } + } + ], + closed: true, + }); + + const childTarget: IShape = new Shape({ + name: childSource.name, + positivePredicates: [ + { + name: 'https://www.example.ca/nickname', + constraint: { + type: ConstraintType.DATATYPE, + value: new Set(['http://www.w3.org/2001/XMLSchema#string']), + pattern: '^a' + } + } + ], + closed: true, + }); + + const targetShape: IShape = new Shape({ + name: 'https://www.example.ca/targetRoot', + positivePredicates: [ + { + name: 'https://www.example.ca/knows', + constraint: { + type: ConstraintType.SHAPE, + value: new Set([childTarget.name]) + } + } + ], + closed: true, + }); + + const result = solveShapeShapeContainment({ + sourceShape, + sourceLinkedShapes: [childSource], + targetShapes: [targetShape, childTarget], + }); + + expect(result.starPatternsContainment.get(sourceShape.name)?.result).toBe(ContainmentResult.CONTAINED); + expect(result.result).toBe(ContainmentResult.CONTAINED); + }); + function generateMatchingQuery(): IQuery { const queryString = ` SELECT * WHERE { @@ -1076,15 +1269,15 @@ describe('solveShapeQueryContainment', () => { ?x ?z . ?x ?w . - ?y <${TYPE_DEFINITION.value}> . + ?y <${RDF_VOCAB.type}> . - ?z <${TYPE_DEFINITION.value}> . + ?z <${RDF_VOCAB.type}> . ?z . ?w ?w1 . ?w ?w1 . - ?zz <${TYPE_DEFINITION.value}> . + ?zz <${RDF_VOCAB.type}> . ?zz . } `; @@ -1232,7 +1425,7 @@ describe('solveShapeQueryContainment', () => { ]); - expect(resp).toStrictEqual({ visitShapeBoundedResource, starPatternsContainment }); + expect(resp).toStrictEqual({ result: ContainmentResult.CONTAINED, visitShapeBoundedResource, starPatternsContainment }); }); test('interactive-short-2', async () => { @@ -1299,7 +1492,7 @@ describe('solveShapeQueryContainment', () => { ]); - expect(resp).toStrictEqual({ visitShapeBoundedResource, starPatternsContainment }); + expect(resp).toStrictEqual({ result: ContainmentResult.CONTAINED, visitShapeBoundedResource, starPatternsContainment }); }); test('interactive-short-4', async () => { @@ -1343,7 +1536,7 @@ describe('solveShapeQueryContainment', () => { ]); - expect(resp).toStrictEqual({ visitShapeBoundedResource, starPatternsContainment }); + expect(resp).toStrictEqual({ result: ContainmentResult.CONTAINED, visitShapeBoundedResource, starPatternsContainment }); }); test('interactive-short-5', async () => { @@ -1375,7 +1568,7 @@ describe('solveShapeQueryContainment', () => { ]); - expect(resp).toStrictEqual({ visitShapeBoundedResource, starPatternsContainment }); + expect(resp).toStrictEqual({ result: ContainmentResult.CONTAINED, visitShapeBoundedResource, starPatternsContainment }); }); }); @@ -1456,7 +1649,7 @@ describe('solveShapeQueryContainment', () => { ]); - expect(resp).toStrictEqual({ visitShapeBoundedResource, starPatternsContainment }); + expect(resp).toStrictEqual({ result: ContainmentResult.CONTAINED, visitShapeBoundedResource, starPatternsContainment }); }); test('interactive-discover-2 comment missing', async () => { @@ -1537,7 +1730,7 @@ describe('solveShapeQueryContainment', () => { ["tag", { result: ContainmentResult.REJECTED, target: undefined, bindings:expect.any(Map), }], ]); - expect(resp).toStrictEqual({ visitShapeBoundedResource, starPatternsContainment }); + expect(resp).toStrictEqual({ result: ContainmentResult.REJECTED, visitShapeBoundedResource, starPatternsContainment }); }); test('interactive-discover-4', async () => { @@ -1571,7 +1764,7 @@ describe('solveShapeQueryContainment', () => { ["location", { result: ContainmentResult.REJECTED, target: undefined, bindings:expect.any(Map), }], ]); - expect(resp).toStrictEqual({ visitShapeBoundedResource, starPatternsContainment }); + expect(resp).toStrictEqual({ result: ContainmentResult.REJECTED, visitShapeBoundedResource, starPatternsContainment }); }); test('interactive-discover-5', async () => { @@ -1598,7 +1791,7 @@ describe('solveShapeQueryContainment', () => { ["message", { result: ContainmentResult.CONTAINED, target: ["http://example.com#Comment", "http://example.com#Post"], bindings:expect.any(Map), }], ]); - expect(resp).toStrictEqual({ visitShapeBoundedResource, starPatternsContainment }); + expect(resp).toStrictEqual({ result: ContainmentResult.CONTAINED, visitShapeBoundedResource, starPatternsContainment }); }); it('interactive-discover-6', async () => { @@ -1634,7 +1827,7 @@ describe('solveShapeQueryContainment', () => { ], ]); - expect(resp).toStrictEqual({ visitShapeBoundedResource, starPatternsContainment }); + expect(resp).toStrictEqual({ result: ContainmentResult.UNALINGED, visitShapeBoundedResource, starPatternsContainment }); }); it('interactive-discover-7', async () => { @@ -1711,7 +1904,7 @@ describe('solveShapeQueryContainment', () => { ["otherMessage", { result: ContainmentResult.CONTAINED, target: ["http://example.com#Comment", "http://example.com#Post"], bindings:expect.any(Map), }], ]); - expect(resp).toStrictEqual({ visitShapeBoundedResource, starPatternsContainment }); + expect(resp).toStrictEqual({ result: ContainmentResult.CONTAINED, visitShapeBoundedResource, starPatternsContainment }); }); }); @@ -1746,3 +1939,629 @@ describe('solveShapeQueryContainment', () => { }) }); + +describe('supported profile and explicit negation', () => { + const P = `PREFIX foaf: + PREFIX ex: `; + + async function classify(rawQuery: string, rawShape: string): Promise { + const query = generateQuery(toAlgebra(new SPARQLParser().parse(`${P} ${rawQuery}`))); + const candidate = await parseShaclShape(new N3.Parser().parse(rawShape), 'http://example.org/S'); + return solveShapeQueryContainment({ query, shapes: [candidate] }).result; + } + + const SHAPE_PREFIXES = `@prefix sh: . + @prefix foaf: . + @prefix ex: .`; + + const openNameShape = `${SHAPE_PREFIXES} + a sh:NodeShape ; + sh:property [ sh:path foaf:name ] .`; + + const closedNameShape = `${SHAPE_PREFIXES} + a sh:NodeShape ; + sh:closed true ; + sh:property [ sh:path foaf:name ] .`; + + describe('OPTIONAL patterns', () => { + const query = 'SELECT * WHERE { ?s foaf:name ?n . OPTIONAL { ?s ex:age ?a } }'; + + it('should not let an unmatched OPTIONAL pattern block containment on an open shape', async () => { + expect(await classify(query, openNameShape)).toBe(ContainmentResult.CONTAINED); + }); + + it('should classify the same query identically on the closed counterpart of the shape', async () => { + expect(await classify(query, closedNameShape)).toBe(ContainmentResult.CONTAINED); + }); + + it('should not consider a star pattern of only unmatched OPTIONAL patterns contained', async () => { + expect(await classify('SELECT * WHERE { OPTIONAL { ?s ex:email ?e } }', openNameShape)) + .toBe(ContainmentResult.WEAKLY_REJECTED); + }); + }); + + describe('discarded constructs', () => { + it('should not let a MINUS block lower the result', async () => { + expect(await classify('SELECT * WHERE { ?s foaf:name ?n . MINUS { ?s ex:age ?a . ?s ex:email ?e } }', closedNameShape)) + .toBe(ContainmentResult.CONTAINED); + }); + + it('should not let a MINUS block raise the result', async () => { + expect(await classify('SELECT * WHERE { ?s ex:unknown ?x . MINUS { ?s foaf:name ?n . ?s ex:email ?e } }', closedNameShape)) + .toBe(ContainmentResult.REJECTED); + }); + + it('should not let a SERVICE clause raise the result', async () => { + expect(await classify('SELECT * WHERE { ?s ex:unknown ?x . SERVICE { ?s foaf:name ?n } }', closedNameShape)) + .toBe(ContainmentResult.REJECTED); + }); + + it('should record the discarded constructs on the query', () => { + const query = generateQuery(toAlgebra(new SPARQLParser().parse( + `${P} SELECT * WHERE { ?s foaf:name ?n . MINUS { ?s ex:age ?a . ?s ex:email ?e } }`))); + expect(query.unsupported).toStrictEqual(['MINUS']); + }); + + it('should record a construct discarded inside a UNION branch on the query', () => { + const query = generateQuery(toAlgebra(new SPARQLParser().parse( + `${P} SELECT * WHERE { { ?s foaf:name ?n . SERVICE { ?s ex:age ?a } } UNION { ?s ex:email ?e } }`))); + expect(query.unsupported).toStrictEqual(['SERVICE']); + }); + + it.each([ + ['FILTER EXISTS { ?s ex:age ?a }', ['EXISTS']], + ['FILTER(?n = "a" || NOT EXISTS { ?s ex:age ?a })', ['NOT EXISTS']], + ['FILTER(!EXISTS { ?s ex:age ?a . ?s ex:email ?e })', ['NOT EXISTS']], + ['OPTIONAL { ?s ex:email ?e FILTER NOT EXISTS { ?s ex:age ?a } }', ['NOT EXISTS']], + ])('should record a discarded existence test in %s', (pattern, unsupported) => { + const query = generateQuery(toAlgebra(new SPARQLParser().parse( + `${P} SELECT * WHERE { ?s foaf:name ?n . ${pattern} }`))); + expect(query.unsupported).toStrictEqual(unsupported); + }); + + it('should keep the decidable part of a FILTER that contains a discarded existence test', async () => { + const shape = `${SHAPE_PREFIXES} @prefix xsd: . + a sh:NodeShape ; + sh:property [ sh:path ex:age ; sh:datatype xsd:integer ; sh:minInclusive 0 ] .`; + expect(await classify('SELECT * WHERE { ?s ex:age ?a . FILTER(?a < 0 && NOT EXISTS { ?s ex:x ?y . ?s ex:z ?w }) }', shape)) + .toBe(ContainmentResult.WEAKLY_REJECTED); + }); + + it('should throw when every triple pattern came from a discarded construct', () => { + const query = generateQuery(toAlgebra(new SPARQLParser().parse( + `${P} SELECT * WHERE { SERVICE { ?s foaf:name ?n } }`))); + expect(query.starPatterns.size).toBe(0); + expect(query.unsupported).toStrictEqual(['SERVICE']); + expect(() => solveShapeQueryContainment({ query, shapes: [] })).toThrow(EmptyQueryError); + }); + }); + + describe('negated triple patterns', () => { + const requiresPwShape = `${SHAPE_PREFIXES} + a sh:NodeShape ; + sh:property [ sh:path foaf:name ], [ sh:path ex:pw ; sh:minCount 1 ] .`; + const optionalPwShape = `${SHAPE_PREFIXES} + a sh:NodeShape ; + sh:property [ sh:path foaf:name ], [ sh:path ex:pw ] .`; + const forbidsPwShape = `${SHAPE_PREFIXES} + a sh:NodeShape ; + sh:property [ sh:path foaf:name ] ; + sh:not [ sh:path ex:pw ; sh:minCount 1 ] .`; + + function normalize(pattern: string): IQuery { + return generateQuery(toAlgebra(new SPARQLParser().parse( + `${P} SELECT * WHERE { ?s foaf:name ?n . ${pattern} }`))); + } + + describe.each([ + 'FILTER NOT EXISTS { ?s ex:pw ?p }', + 'FILTER(!EXISTS { ?s ex:pw ?p })', + 'MINUS { ?s ex:pw ?p }', + 'MINUS { ?s ex:pw [] }', + ])('%s', (pattern) => { + const query = `SELECT * WHERE { ?s foaf:name ?n . ${pattern} }`; + + it('should become an excluded predicate of the star pattern of its subject', () => { + const normalized = normalize(pattern); + expect(normalized.starPatterns.get('s')!.excludedPredicates).toStrictEqual(new Set(['http://example.org/pw'])); + expect(normalized.unsupported).toBeUndefined(); + expect(normalized.filters).toBeUndefined(); + }); + + it('should be rejected by a shape that requires the predicate, although the shape is open', async () => { + expect(await classify(query, requiresPwShape)).toBe(ContainmentResult.REJECTED); + }); + + it.each([ + ['declares the predicate optional', optionalPwShape], + ['forbids the predicate', forbidsPwShape], + ['is closed without declaring the predicate', closedNameShape], + ])('should not prevent containment by a shape that %s', async (_, shape) => { + expect(await classify(query, shape)).toBe(ContainmentResult.CONTAINED); + }); + }); + + it.each([ + ['an object bound by the group', 'FILTER NOT EXISTS { ?s ex:pw ?n }', 'NOT EXISTS'], + ['a constant object', 'FILTER NOT EXISTS { ?s ex:pw ex:secret }', 'NOT EXISTS'], + ['a subject the group does not bind', 'FILTER NOT EXISTS { ?x ex:pw ?p }', 'NOT EXISTS'], + ['more than one triple pattern', 'FILTER NOT EXISTS { ?s ex:pw ?p . ?p ex:kind ?k }', 'NOT EXISTS'], + ['no variable shared with the left operand', 'MINUS { ?x ex:pw ?p }', 'MINUS'], + ['an OPTIONAL group as its scope', 'OPTIONAL { ?s ex:email ?e MINUS { ?s ex:pw ?p } }', 'MINUS'], + ])('should discard a negation with %s', async (_, pattern, construct) => { + const normalized = normalize(pattern); + expect(normalized.unsupported).toStrictEqual([construct]); + expect(normalized.starPatterns.get('s')!.excludedPredicates).toBeUndefined(); + expect(await classify(`SELECT * WHERE { ?s foaf:name ?n . ${pattern} }`, requiresPwShape)) + .toBe(ContainmentResult.CONTAINED); + }); + + it('should discard a negation whose subject is bound only by an OPTIONAL pattern', () => { + const query = generateQuery(toAlgebra(new SPARQLParser().parse( + `${P} SELECT * WHERE { ?y foaf:name ?n . OPTIONAL { ?s ex:email ?e } FILTER NOT EXISTS { ?s ex:pw ?p } }`))); + expect(query.unsupported).toStrictEqual(['NOT EXISTS']); + expect(query.starPatterns.get('s')!.excludedPredicates).toBeUndefined(); + }); + + describe('on a nested star pattern', () => { + const shapes = `${SHAPE_PREFIXES} + a sh:NodeShape ; + sh:property [ sh:path ex:address ; sh:node ] . + a sh:NodeShape ; + sh:property [ sh:path ex:city ], [ sh:path ex:zip ; sh:minCount 1 ] .`; + + async function report(rawQuery: string): Promise { + const query = generateQuery(toAlgebra(new SPARQLParser().parse(`${P} ${rawQuery}`))); + const quads = new N3.Parser().parse(shapes); + const person = await parseShaclShape(quads, 'http://example.org/PersonShape'); + const address = await parseShaclShape(quads, 'http://example.org/AddressShape'); + return solveShapeQueryContainment({ query, shapes: [person], dependentShapes: [address] }); + } + + it('should be contained without the negation', async () => { + expect((await report('SELECT * WHERE { ?p ex:address ?a . ?a ex:city ?c }')).result) + .toBe(ContainmentResult.CONTAINED); + }); + + it('should refute the sh:node dependency when the referenced shape requires the predicate', async () => { + const result = await report('SELECT * WHERE { ?p ex:address ?a . ?a ex:city ?c . FILTER NOT EXISTS { ?a ex:zip ?z } }'); + expect(result.starPatternsContainment.get('p')!.result).toBe(ContainmentResult.WEAKLY_REJECTED); + expect(result.starPatternsContainment.get('a')!.result).toBe(ContainmentResult.WEAKLY_REJECTED); + expect(result.result).toBe(ContainmentResult.WEAKLY_REJECTED); + }); + }); + + describe('from a negative predicate of an input shape', () => { + const input = `${SHAPE_PREFIXES} + a sh:NodeShape ; + sh:property [ sh:path foaf:name ; sh:minCount 1 ] ; + sh:not [ sh:path ex:pw ; sh:minCount 1 ] .`; + + async function compare(rawTarget: string): Promise { + const sourceShape = await parseShaclShape(new N3.Parser().parse(input), 'http://example.org/In'); + const target = await parseShaclShape(new N3.Parser().parse(rawTarget), 'http://example.org/S'); + return solveShapeShapeContainment({ sourceShape, targetShapes: [target] }).result; + } + + it('should become an excluded predicate of the translated star pattern', async () => { + const sourceShape = await parseShaclShape(new N3.Parser().parse(input), 'http://example.org/In'); + expect(shapeToQuery(sourceShape).starPatterns.get('http://example.org/In')!.excludedPredicates) + .toStrictEqual(new Set(['http://example.org/pw'])); + }); + + it('should be rejected by a target shape that requires the predicate', async () => { + expect(await compare(requiresPwShape)).toBe(ContainmentResult.REJECTED); + }); + + it('should be contained by a target shape that does not require the predicate', async () => { + expect(await compare(optionalPwShape)).toBe(ContainmentResult.CONTAINED); + }); + }); + }); + + describe('queries that ask for every triple', () => { + // `?s ?p ?o` is maximally unselective rather than unanswerable: it asks for every triple of + // the resource, so any resource is relevant and the honest result is CONTAINED. + async function report(rawQuery: string): Promise<{ query: IQuery; result: IResult }> { + const query = generateQuery(toAlgebra(new SPARQLParser().parse(`${P} ${rawQuery}`))); + const candidate = await parseShaclShape(new N3.Parser().parse(closedNameShape), 'http://example.org/S'); + return { query, result: solveShapeQueryContainment({ query, shapes: [candidate] }) }; + } + + it('should report CONTAINED for an unrestricted triple pattern', async () => { + const { query, result } = await report('SELECT * WHERE { ?s ?p ?o }'); + expect(query.matchesAnyTriple).toBe(true); + expect(query.unsupported).toBeUndefined(); + expect(result.result).toBe(ContainmentResult.CONTAINED); + }); + + it('should report CONTAINED for an unrestricted OPTIONAL triple pattern', async () => { + const { result } = await report('SELECT * WHERE { OPTIONAL { ?s ?p ?o } }'); + expect(result.result).toBe(ContainmentResult.CONTAINED); + }); + + it('should mark every candidate shape visitable, since the query wants all their triples', async () => { + const { result } = await report('SELECT * WHERE { ?s ?p ?o }'); + expect([...result.visitShapeBoundedResource.values()]).toStrictEqual([true]); + }); + + it('should still throw when there is no triple pattern at all', () => { + const query = generateQuery(toAlgebra(new SPARQLParser().parse('SELECT ?x WHERE { VALUES ?x { 1 2 } }'))); + expect(query.matchesAnyTriple).toBeUndefined(); + expect(() => solveShapeQueryContainment({ query, shapes: [] })).toThrow(EmptyQueryError); + }); + }); + + describe('several disjunctions', () => { + const shape = `${SHAPE_PREFIXES} + a sh:NodeShape ; + sh:closed true ; + sh:property [ sh:path foaf:name ] ; + sh:or ( [ sh:path ex:email ] [ sh:path ex:fax ] ) ; + sh:xone ( [ sh:path ex:phone ] [ sh:path ex:mobile ] ) .`; + + it.each([ + ['the first', 'ex:email'], + ['the second', 'ex:phone'], + ])('should match a predicate of %s disjunction of a closed shape', async (_, predicate) => { + expect(await classify(`SELECT * WHERE { ?s foaf:name ?n ; ${predicate} ?x }`, shape)) + .toBe(ContainmentResult.CONTAINED); + }); + + it('should match predicates of both disjunctions together', async () => { + expect(await classify('SELECT * WHERE { ?s ex:fax ?f ; ex:mobile ?m }', shape)) + .toBe(ContainmentResult.CONTAINED); + }); + }); + + describe('sh:not', () => { + const notShape = `${SHAPE_PREFIXES} + a sh:NodeShape ; + sh:property [ sh:path foaf:name ] ; + sh:not [ sh:path ex:secret ; sh:minCount 1 ] .`; + + it('should reject a predicate the shape explicitly forbids, even on an open shape', async () => { + expect(await classify('SELECT * WHERE { ?s ex:secret ?x }', notShape)) + .toBe(ContainmentResult.REJECTED); + }); + + it('should still match the positive predicates of the same shape', async () => { + expect(await classify('SELECT * WHERE { ?s foaf:name ?n }', notShape)) + .toBe(ContainmentResult.CONTAINED); + }); + + it('should reject a predicate forbidden by any of several sh:not values', async () => { + const shape = `${SHAPE_PREFIXES} + a sh:NodeShape ; + sh:property [ sh:path foaf:name ] ; + sh:not [ sh:path ex:secret ; sh:minCount 1 ], [ sh:path ex:pin ; sh:minCount 1 ] .`; + expect(await classify('SELECT * WHERE { ?s ex:secret ?x }', shape)).toBe(ContainmentResult.REJECTED); + expect(await classify('SELECT * WHERE { ?s ex:pin ?x }', shape)).toBe(ContainmentResult.REJECTED); + }); + + // Each of these negations holds for some nodes that carry ex:secret, so the open shape does not exclude it. + it.each([ + ['allows one value', '[ sh:path ex:secret ; sh:minCount 2 ]'], + ['requires a value that is not a string', '[ sh:path ex:secret ; sh:datatype xsd:string ]'], + ['only excludes the value ex:x', '[ sh:path ex:secret ; sh:minCount 1 ; sh:hasValue ex:x ]'], + ])('should not reject a predicate under a sh:not that %s', async (_, negation) => { + const shape = `${SHAPE_PREFIXES} @prefix xsd: . + a sh:NodeShape ; + sh:property [ sh:path foaf:name ] ; + sh:not ${negation} .`; + expect(await classify('SELECT * WHERE { ?s ex:secret ?x }', shape)) + .toBe(ContainmentResult.WEAKLY_REJECTED); + }); + }); +}); + +describe('dependency-only shapes', () => { + // `dependentShapes` carries shapes that are not candidates themselves but are referenced by + // sh:node from a candidate. They must be resolvable, otherwise the constraint is treated as + // satisfied (Bindings.handleShapeConstraint returns RESPECT for an unknown linked shape) and + // the root star pattern binds on a dependency that was never checked. + const shapesTtl = ` + @prefix sh: . + @prefix foaf: . + @prefix ex: . + + ex:A a sh:NodeShape ; sh:closed true ; + sh:property [ sh:path ex:knows ; sh:node ex:B ] . + + ex:B a sh:NodeShape ; sh:closed true ; + sh:property [ sh:path foaf:name ] .`; + + // Same shapes with ex:A open. Resolving sh:node only asks whether the nested star pattern is + // fully bound by ex:B, so ex:B's own openness never changes the outcome; ex:A's does, because + // it decides between the two rejection degrees once the root triple fails to bind. + const openRootShapesTtl = shapesTtl.replace('ex:A a sh:NodeShape ; sh:closed true ;', 'ex:A a sh:NodeShape ;'); + + const PREFIXES = `PREFIX foaf: PREFIX ex: `; + + async function rootResultFor(rawQuery: string, useDependentShapes: boolean, ttl: string = shapesTtl): Promise { + const query = generateQuery(toAlgebra(new SPARQLParser().parse(`${PREFIXES} ${rawQuery}`))); + const quads = new N3.Parser().parse(ttl); + const candidate = await parseShaclShape(quads, 'http://example.org/A'); + const linked = await parseShaclShape(quads, 'http://example.org/B'); + const report = solveShapeQueryContainment({ + query, + shapes: [candidate], + ...(useDependentShapes ? { dependentShapes: [linked] } : {}), + }); + return report.starPatternsContainment.get('p')!.result; + } + + const nonFitting = 'SELECT * WHERE { ?p ex:knows ?f . ?f ex:somethingElse ?x }'; + const fitting = 'SELECT * WHERE { ?p ex:knows ?f . ?f foaf:name ?n }'; + + it('should check the dependency when the referenced shape is supplied', async () => { + expect(await rootResultFor(nonFitting, true)).toBe(ContainmentResult.REJECTED); + }); + + it('should bind the root when the nested pattern fits the referenced shape', async () => { + expect(await rootResultFor(fitting, true)).toBe(ContainmentResult.CONTAINED); + }); + + it('should weakly reject rather than reject when the referring shape is open', async () => { + expect(await rootResultFor(nonFitting, true, openRootShapesTtl)).toBe(ContainmentResult.WEAKLY_REJECTED); + }); + + it('should bind the root on a fitting nested pattern whatever the referring shape is', async () => { + expect(await rootResultFor(fitting, true, openRootShapesTtl)).toBe(ContainmentResult.CONTAINED); + }); + + it('should over-estimate when the referenced shape is unavailable', async () => { + // Documents the remaining approximation: with no way to resolve ex:B the sh:node + // constraint cannot be refuted, so the root binds even though the nested pattern does not fit. + expect(await rootResultFor(nonFitting, false)).toBe(ContainmentResult.CONTAINED); + }); +}); + +describe('nested dependencies and partial matches', () => { + // ex:PersonShape is closed and declares foaf:knows, constrained by sh:node ex:FriendShape. + // Whether the nested pattern is refuted depends on ex:FriendShape: closed, it forbids anything + // it does not declare; open, it is merely silent. + const shapesTtl = (friendClosed: boolean): string => ` + @prefix sh: . + @prefix foaf: . + @prefix ex: . + + ex:PersonShape a sh:NodeShape ; sh:closed true ; + sh:property [ sh:path foaf:knows ; sh:node ex:FriendShape ] . + + ex:FriendShape a sh:NodeShape ; ${friendClosed ? 'sh:closed true ;' : ''} + sh:property [ sh:path foaf:name ] .`; + + const PREFIXES = `PREFIX foaf: PREFIX ex: `; + // foaf:mbox is not declared by ex:FriendShape; foaf:name is. + const NOT_DECLARED = 'SELECT * WHERE { ?p foaf:knows ?f . ?f foaf:mbox ?m }'; + const DECLARED = 'SELECT * WHERE { ?p foaf:knows ?f . ?f foaf:name ?n }'; + + async function classify( + rawQuery: string, + friendClosed: boolean, + asDependency: boolean, + ): Promise<{ root: ContainmentResult; nested: ContainmentResult; aggregate: ContainmentResult }> { + const query = generateQuery(toAlgebra(new SPARQLParser().parse(`${PREFIXES} ${rawQuery}`))); + const quads = new N3.Parser().parse(shapesTtl(friendClosed)); + const person = await parseShaclShape(quads, 'http://example.org/PersonShape'); + const friend = await parseShaclShape(quads, 'http://example.org/FriendShape'); + const report = asDependency + ? solveShapeQueryContainment({ query, shapes: [person], dependentShapes: [friend] }) + : solveShapeQueryContainment({ query, shapes: [person, friend] }); + return { + root: report.starPatternsContainment.get('p')!.result, + nested: report.starPatternsContainment.get('f')!.result, + aggregate: report.result, + }; + } + + describe('a dependency that is undecided rather than refuted', () => { + it('should leave the root partially matched on the closed referring shape', async () => { + // foaf:knows is declared by the closed ex:PersonShape, so the root pattern did match it. + // Only the nested pattern is unresolved, and open ex:FriendShape does not forbid it. + for (const asDependency of [true, false]) { + const { root } = await classify(NOT_DECLARED, false, asDependency); + expect(root).toBe(ContainmentResult.UNALINGED); + } + }); + + it('should not make the resource omittable', async () => { + for (const asDependency of [true, false]) { + const { aggregate } = await classify(NOT_DECLARED, false, asDependency); + expect(aggregate).toBe(ContainmentResult.WEAKLY_REJECTED); + expect(aggregate).not.toBe(ContainmentResult.REJECTED); + } + }); + }); + + describe('a dependency that is genuinely refuted', () => { + it('should stay rejected, so a closed shape graph can still prune', async () => { + for (const asDependency of [true, false]) { + const { root, aggregate } = await classify(NOT_DECLARED, true, asDependency); + expect(root).toBe(ContainmentResult.REJECTED); + expect(aggregate).toBe(ContainmentResult.REJECTED); + } + }); + }); + + describe('a nested pattern governed by a dependency-only shape', () => { + it('should be classified against the shape that governs it, not only the candidates', async () => { + // Without this the nested pattern is judged against ex:PersonShape alone, which never + // describes it, and a fully satisfiable query comes out REJECTED. + for (const friendClosed of [false, true]) { + const { nested, aggregate } = await classify(DECLARED, friendClosed, true); + expect(nested).toBe(ContainmentResult.CONTAINED); + expect(aggregate).toBe(ContainmentResult.CONTAINED); + } + }); + + it('should classify identically however the referenced shape is passed', async () => { + for (const query of [NOT_DECLARED, DECLARED]) { + for (const friendClosed of [false, true]) { + expect(await classify(query, friendClosed, true)) + .toStrictEqual(await classify(query, friendClosed, false)); + } + } + }); + }); +}); + +describe('role filtering with decidingShapes', () => { + // The service relates ex:Response with role disc:OutputShape, and ex:Request and ex:Address with + // role disc:InputShape. The user's shapes have role disc:InputShape, so only ex:Response may + // decide; the client applies that through decidingShapes. ex:Request and ex:Address are excluded, + // but remain part of the shape graph that ex:Response refers to. + const serviceTtl = ` + @prefix sh: . + @prefix ex: . + + ex:Response a sh:NodeShape ; sh:closed true ; + sh:property [ sh:path ex:request ; sh:node ex:Request ] . + + ex:Request a sh:NodeShape ; sh:closed true ; + sh:property [ sh:path ex:query ] ; + sh:property [ sh:path ex:address ; sh:node ex:Address ] . + + ex:Address a sh:NodeShape ; sh:closed true ; + sh:property [ sh:path ex:street ] .`; + + const userTtl = ` + @prefix sh: . + @prefix ex: . + @prefix user: . + + # Reachable from ex:Response, directly and through ex:Request. + user:EchoedQuery a sh:NodeShape ; + sh:property [ sh:path ex:request ; sh:node user:QueryRequest ] . + user:QueryRequest a sh:NodeShape ; + sh:property [ sh:path ex:query ] . + + user:EchoedAddress a sh:NodeShape ; + sh:property [ sh:path ex:request ; sh:node user:AddressRequest ] . + user:AddressRequest a sh:NodeShape ; + sh:property [ sh:path ex:address ; sh:node user:StreetAddress ] . + + # Described only by the excluded input shapes. + user:Addressed a sh:NodeShape ; + sh:property [ sh:path ex:address ; sh:node user:StreetAddress ] . + user:StreetAddress a sh:NodeShape ; + sh:property [ sh:path ex:street ] .`; + + const RESPONSE = 'http://example.org/Response'; + const SERVICE_SHAPES = [RESPONSE, 'http://example.org/Request', 'http://example.org/Address']; + const USER = 'http://example.org/user/'; + const USER_SHAPES = ['EchoedQuery', 'QueryRequest', 'EchoedAddress', 'AddressRequest', 'Addressed', 'StreetAddress'] + .map(name => `${USER}${name}`); + const ECHOED_QUERY = `${USER}EchoedQuery`; + const ECHOED_ADDRESS = `${USER}EchoedAddress`; + const ADDRESSED = `${USER}Addressed`; + + async function parseShapes(ttl: string, names: string[]): Promise { + const quads = new N3.Parser().parse(ttl); + const shapes: IShape[] = []; + for (const name of names) { + shapes.push(await parseShaclShape(quads, name)); + } + return shapes; + } + + async function solve(userShape: string, placement: 'deciding' | 'unfiltered' | 'dependent' | 'dropped'): Promise { + const [response, request, address] = await parseShapes(serviceTtl, SERVICE_SHAPES); + const userShapes = await parseShapes(userTtl, USER_SHAPES); + const sourceShape = userShapes.find(shape => shape.name === userShape)!; + switch (placement) { + case 'deciding': + return solveShapeShapeContainment({ sourceShape, sourceLinkedShapes: userShapes, targetShapes: [response, request, address], decidingShapes: new Set([RESPONSE]) }); + case 'unfiltered': + return solveShapeShapeContainment({ sourceShape, sourceLinkedShapes: userShapes, targetShapes: [response, request, address] }); + case 'dependent': + // solveShapeShapeContainment takes no dependentShapes, so translate the shape as it does. + return solveShapeQueryContainment({ query: shapeToQuery(sourceShape, { linkedShapes: userShapes }), shapes: [response], dependentShapes: [request, address] }); + case 'dropped': + return solveShapeShapeContainment({ sourceShape, sourceLinkedShapes: userShapes, targetShapes: [response] }); + } + } + + function summarize(report: IResult): { aggregate: ContainmentResult; starPatterns: Map; visit: Map } { + return { + aggregate: report.result, + starPatterns: new Map(Array.from(report.starPatternsContainment, ([name, { result }]) => [name, result])), + visit: report.visitShapeBoundedResource, + }; + } + + it('should resolve references to excluded shapes, directly and transitively', async () => { + for (const userShape of [ECHOED_QUERY, ECHOED_ADDRESS]) { + expect((await solve(userShape, 'deciding')).result).toBe(ContainmentResult.CONTAINED); + } + }); + + it('should lose that match when the excluded shapes are dropped instead', async () => { + // The unresolvable reference leaves the nested pattern to ex:Response alone, which is closed + // and never describes it. + expect((await solve(ECHOED_QUERY, 'dropped')).result).toBe(ContainmentResult.REJECTED); + }); + + it('should not let an excluded shape lend evidence through its own references', async () => { + // ex:Request binds the root pattern and resolves the nested one against ex:Address, but neither + // may decide. Counting that evidence would lift the resource to UNALINGED on the strength of + // its input shapes alone. + const report = await solve(ADDRESSED, 'deciding'); + expect(report.result).toBe(ContainmentResult.REJECTED); + expect(report.starPatternsContainment.get(`${USER}StreetAddress`)!.result).toBe(ContainmentResult.REJECTED); + }); + + it('should only give visit indications to the deciding shapes', async () => { + const report = await solve(ADDRESSED, 'deciding'); + expect(report.visitShapeBoundedResource).toStrictEqual(new Map([[RESPONSE, false]])); + }); + + it('should classify exactly as passing the excluded shapes as dependentShapes', async () => { + for (const userShape of [ECHOED_QUERY, ECHOED_ADDRESS, ADDRESSED]) { + expect(summarize(await solve(userShape, 'deciding'))) + .toStrictEqual(summarize(await solve(userShape, 'dependent'))); + } + }); + + it('should contain the same request when no role applies', async () => { + // A role only qualifies an input shape. Without one, or for a SPARQL query, every service shape + // is a candidate: ex:Request covers the root pattern and ex:Address the nested one. + expect((await solve(ADDRESSED, 'unfiltered')).result).toBe(ContainmentResult.CONTAINED); + const query = generateQuery(toAlgebra(new SPARQLParser().parse( + 'PREFIX ex: SELECT * WHERE { ?q ex:address ?a . ?a ex:street ?s }'))); + const shapes = await parseShapes(serviceTtl, SERVICE_SHAPES); + expect(solveShapeQueryContainment({ query, shapes }).result).toBe(ContainmentResult.CONTAINED); + }); +}); + +describe('SHACL cardinality defaults in a source shape', () => { + // As in SHACL, a property constraint without sh:minCount sets no lower bound: its triple pattern is + // optional and never prevents containment. ex:Target is open and only declares ex:age. + const shapesTtl = (nameCardinality: string): string => ` + @prefix sh: . + @prefix ex: . + + ex:Source a sh:NodeShape ; + sh:property [ sh:path ex:name ${nameCardinality} ] ; + sh:property [ sh:path ex:age ; sh:minCount 1 ] . + + ex:Target a sh:NodeShape ; + sh:property [ sh:path ex:age ] .`; + + async function solve(nameCardinality: string): Promise { + const quads = new N3.Parser().parse(shapesTtl(nameCardinality)); + const source = await parseShaclShape(quads, 'http://example.org/Source'); + const target = await parseShaclShape(quads, 'http://example.org/Target'); + return solveShapeShapeContainment({ sourceShape: source, targetShapes: [target] }).result; + } + + it('should treat a property constraint without sh:minCount as optional', async () => { + expect(await solve('')).toBe(ContainmentResult.CONTAINED); + expect(await solve('; sh:maxCount 1')).toBe(ContainmentResult.CONTAINED); + }); + + it('should keep a property constraint with a positive sh:minCount required', async () => { + expect(await solve('; sh:minCount 1')).toBe(ContainmentResult.ALIGNED); + }); +}); diff --git a/test/query.test.ts b/test/query.test.ts index c653030..a37da85 100644 --- a/test/query.test.ts +++ b/test/query.test.ts @@ -3,7 +3,7 @@ import { DataFactory } from 'rdf-data-factory'; import { describe, expect, it, test } from 'vitest'; import { Parser as SPARQLParser } from '@traqula/parser-sparql-1-1'; import { toAlgebra } from '@traqula/algebra-sparql-1-1'; -import { TYPE_DEFINITION } from '../lib/constant'; +import { RDF as RDF_VOCAB } from '../lib/constant'; import { generateQuery, generateStarPatternUnion, IQuery } from '../lib/query'; import { type IStarPatternWithDependencies, Triple } from '../lib/Triple'; @@ -13,7 +13,7 @@ const sparqlParser = new SPARQLParser(); const RDF_STRING = DF.namedNode('http://www.w3.org/2001/XMLSchema#string'); const SNVOC_PREFIX = 'http://localhost:3000/www.ldbc.eu/ldbc_socialnet/1.0/vocabulary/'; const FOAF_PREFIX = 'http://xmlns.com/foaf/0.1/'; -const RDF_TYPE = TYPE_DEFINITION.value; +const RDF_TYPE = RDF_VOCAB.type; describe('query', () => { describe('generateQuery', () => { @@ -26,7 +26,6 @@ describe('query', () => { const x = resp.starPatterns.get('x')!.starPattern; expect(x).toBeDefined(); const element = x.get('http://exemple.ca')!; - element.triple expect(element.triple.predicate).toBe('http://exemple.ca'); expect((element.triple.object as any).termType).toBe('Variable'); expect((element.triple.object as any).value).toBe('z'); @@ -1623,7 +1622,7 @@ describe('query', () => { ], ]), name: test1seq1.value, - isVariable: false, + isVariable: true, } ]; @@ -1727,7 +1726,7 @@ describe('query', () => { ], ]), name: test1seq1.value, - isVariable: false, + isVariable: true, } ]; @@ -4285,7 +4284,7 @@ describe('query', () => { ] ]), name: knowFirstBranchInter.value, - isVariable: false + isVariable: true } ]; @@ -4330,7 +4329,7 @@ describe('query', () => { ] ]), name: knowSecondBranchInter.value, - isVariable: false + isVariable: true } ]; @@ -5220,7 +5219,7 @@ describe('query', () => { expect(union[0].starPatterns.size).toBe(1); for (const starPattern of union[0].starPatterns.values() ?? []) { expect(starPattern.name).toBeDefined(); - expect(starPattern.isVariable).toBe(false); + expect(starPattern.isVariable).toBe(true); for (const pattern of starPattern.starPattern.values() ?? []) { expect(pattern.dependencies).toBeDefined(); @@ -5234,7 +5233,7 @@ describe('query', () => { expect(union[1].starPatterns.size).toBe(1); for (const starPattern of union[1].starPatterns.values() ?? []) { expect(starPattern.name).toBeDefined(); - expect(starPattern.isVariable).toBe(false); + expect(starPattern.isVariable).toBe(true); for (const pattern of starPattern.starPattern.values() ?? []) { expect(pattern.dependencies).toBeDefined(); @@ -5897,7 +5896,7 @@ describe('query', () => { ] ]), name: `${SNVOC_PREFIX}knows_rootPerson`, - isVariable: false, + isVariable: true, }, ]; @@ -5939,7 +5938,7 @@ describe('query', () => { ] ]), name: `${SNVOC_PREFIX}knows_fr`, - isVariable: false, + isVariable: true, }, ]; @@ -6199,7 +6198,7 @@ describe('query', () => { ] ]), name: `${SNVOC_PREFIX}knows_rootPerson`, - isVariable: false, + isVariable: true, }, ]; @@ -6241,7 +6240,7 @@ describe('query', () => { ] ]), name: `${SNVOC_PREFIX}knows_fr`, - isVariable: false, + isVariable: true, }, ]; diff --git a/test/shacl.test.ts b/test/shacl.test.ts index ee8ca6a..c2373ab 100644 --- a/test/shacl.test.ts +++ b/test/shacl.test.ts @@ -4,9 +4,10 @@ import * as N3 from 'n3'; import { DataFactory } from 'rdf-data-factory'; import { describe, expect, it } from 'vitest'; import { streamifyArray } from 'streamify-array'; -import { TYPE_DEFINITION } from '../lib/constant'; +import { RDF as RDF_VOCAB } from '../lib/constant'; import { ConstraintType, type IConstraint, type OneOf, type IShape } from '../lib/Shape'; import { shaclShapeFromQuads } from '../lib/shacl'; +import type { ParserDiagnostic } from '../lib/parser-policy'; const DF = new DataFactory(); const n3Parser = new N3.Parser(); @@ -31,6 +32,7 @@ describe.each([ const closedShapeMultipleProperties = populateFunction('./test/shape/shacl_shape_closed_multiple_properties.ttl'); const twoShapes = populateFunction('./test/shape/shacl_two_shapes.ttl'); const shapeMultipleCardinality = populateFunction('./test/shape/shacl_shape_multiple_cardinality.ttl'); + const shapeDefaultCardinality = populateFunction('./test/shape/shacl_shape_default_cardinality.ttl'); const shapeWithConstraints = populateFunction('./test/shape/shacl_shape_with_constraints.ttl'); const shapeWithOrStatement = populateFunction('./test/shape/shacl_shape_or_statement.ttl'); const shapeWithXoneStatement = populateFunction('./test/shape/shacl_shape_xone_statement.ttl'); @@ -172,6 +174,30 @@ describe.each([ } }); + it(`${name}: should apply the SHACL defaults for an absent sh:minCount or sh:maxCount`, async () => { + const shape = await shaclShapeFromQuads(shapeDefaultCardinality, shapeIri); + expect(shape).not.toBeInstanceOf(Error); + + expect(new Set((shape as IShape).positivePredicates)).toStrictEqual(new Set([ + `${FOAF_PREFIX}prop1`, + `${FOAF_PREFIX}prop2`, + `${FOAF_PREFIX}prop3`, + ])); + // sh:maxCount 0 forbids the property, since the absent sh:minCount is 0 + expect((shape as IShape).negativePredicates).toStrictEqual([`${FOAF_PREFIX}prop4`]); + + // An absent sh:minCount is 0, so the property is optional; an absent sh:maxCount is unbounded (-1) + const expected = new Map([ + [`${FOAF_PREFIX}prop1`, { cardinality: { min: 0, max: -1 }, optional: true }], + [`${FOAF_PREFIX}prop2`, { cardinality: { min: 1, max: -1 }, optional: false }], + [`${FOAF_PREFIX}prop3`, { cardinality: { min: 0, max: 2 }, optional: true }], + ]); + for (const [pred, { cardinality, optional }] of expected) { + expect((shape as IShape).get(pred)?.cardinality).toStrictEqual(cardinality); + expect((shape as IShape).get(pred)?.optional).toBe(optional); + } + }); + // ── Constraints ───────────────────────────────────────────────────────── it(`${name}: should handle sh:node (SHAPE) and sh:datatype (DATATYPE) constraints`, async () => { @@ -338,6 +364,24 @@ describe.each([ ])); }); + it(`${name}: should keep one disjunction for each sh:or and sh:xone value`, async () => { + const shape = await shaclShapeFromQuads(populateFunction(n3Parser.parse(` + @prefix sh: . + @prefix foaf: . + <${shapeIri}> a sh:NodeShape ; + sh:property [ sh:path foaf:name ] ; + sh:or ( [ sh:path foaf:mbox ] [ sh:path foaf:phone ] ) ; + sh:or ( [ sh:path foaf:homepage ] [ sh:path foaf:weblog ] ) ; + sh:xone ( [ sh:path foaf:age ] [ sh:path foaf:birthday ] ) .`)), shapeIri); + expect(shape).not.toBeInstanceOf(Error); + const disjunctions = (shape as IShape).oneOf.map(oneOf => oneOf.map(branch => branch.map(p => p.name))); + expect(new Set(disjunctions.map(oneOf => JSON.stringify(oneOf)))).toStrictEqual(new Set([ + [[`${FOAF_PREFIX}mbox`], [`${FOAF_PREFIX}phone`]], + [[`${FOAF_PREFIX}homepage`], [`${FOAF_PREFIX}weblog`]], + [[`${FOAF_PREFIX}age`], [`${FOAF_PREFIX}birthday`]], + ].map(oneOf => JSON.stringify(oneOf)))); + }); + // ── sh:not ─────────────────────────────────────────────────────────────── it(`${name}: should handle sh:not as a negative predicate`, async () => { @@ -347,6 +391,48 @@ describe.each([ expect((shape as IShape).negativePredicates).toStrictEqual([`${FOAF_PREFIX}prop2`]); }); + function shapeWithNegation(negation: string): any { + return populateFunction(n3Parser.parse(` + @prefix sh: . + @prefix foaf: . + @prefix xsd: . + <${shapeIri}> a sh:NodeShape ; + sh:property [ sh:path foaf:prop1 ] ; + sh:not ${negation} .`)); + } + + it(`${name}: should keep a negative predicate for each sh:not value`, async () => { + const shape = await shaclShapeFromQuads( + shapeWithNegation('[ sh:path foaf:prop2 ; sh:minCount 1 ], [ sh:path foaf:prop3 ; sh:minCount 1 ]'), + shapeIri, + ); + expect(new Set((shape as IShape).negativePredicates)).toStrictEqual(new Set([`${FOAF_PREFIX}prop2`, `${FOAF_PREFIX}prop3`])); + }); + + it(`${name}: should keep sh:not as a negative predicate when it also carries non-validating properties`, async () => { + const shape = await shaclShapeFromQuads( + shapeWithNegation('[ a sh:PropertyShape ; sh:path foaf:prop2 ; sh:minCount 1 ; sh:name "no prop2" ; sh:message "forbidden" ]'), + shapeIri, + ); + expect((shape as IShape).negativePredicates).toStrictEqual([`${FOAF_PREFIX}prop2`]); + }); + + it.each([ + ['without sh:minCount', '[ sh:path foaf:prop2 ]'], + ['with sh:minCount 2', '[ sh:path foaf:prop2 ; sh:minCount 2 ]'], + ['with another constraint', '[ sh:path foaf:prop2 ; sh:minCount 1 ; sh:datatype xsd:string ]'], + ['with sh:maxCount', '[ sh:path foaf:prop2 ; sh:minCount 1 ; sh:maxCount 3 ]'], + ['over a complex path', '[ sh:path [ sh:inversePath foaf:prop2 ] ; sh:minCount 1 ]'], + ['over a node shape', '[ sh:property [ sh:path foaf:prop2 ; sh:minCount 1 ] ]'], + ])(`${name}: should ignore a sh:not that does not forbid its property (%s) and report it`, async (_, negation) => { + const diagnostics: ParserDiagnostic[] = []; + const shape = await shaclShapeFromQuads(shapeWithNegation(negation), shapeIri, { diagnostics }); + expect(shape).not.toBeInstanceOf(Error); + expect((shape as IShape).positivePredicates).toStrictEqual([`${FOAF_PREFIX}prop1`]); + expect((shape as IShape).negativePredicates).toStrictEqual([]); + expect(diagnostics.map(d => d.code)).toStrictEqual(['UNSUPPORTED_NOT']); + }); + // ── Error: inconsistent predicates ──────────────────────────────────────── it(`${name}: should return an error for inconsistent positive and negative predicates`, async () => { @@ -362,7 +448,7 @@ describe.each([ expect(shape).not.toBeInstanceOf(Error); const expectedPredicates = new Set([ - TYPE_DEFINITION.value, + RDF_VOCAB.type, `${LBDCVOC_PREFIX}id`, `${LBDCVOC_PREFIX}creationDate`, `${LBDCVOC_PREFIX}locationIP`, @@ -375,7 +461,7 @@ describe.each([ ]); const mapCardinality = new Map([ - [TYPE_DEFINITION.value, { min: 0, max: 1 }], + [RDF_VOCAB.type, { min: 0, max: 1 }], [`${LBDCVOC_PREFIX}id`, { min: 1, max: 1 }], [`${LBDCVOC_PREFIX}creationDate`, { min: 1, max: 1 }], [`${LBDCVOC_PREFIX}locationIP`, { min: 1, max: 1 }], @@ -388,7 +474,7 @@ describe.each([ ]); const mapConstraint = new Map([ - [TYPE_DEFINITION.value, { type: ConstraintType.CLASS, value: new Set([`${LBDCVOC_PREFIX}Comment`]) }], + [RDF_VOCAB.type, { type: ConstraintType.CLASS, value: new Set([`${LBDCVOC_PREFIX}Comment`]) }], [`${LBDCVOC_PREFIX}id`, { type: ConstraintType.DATATYPE, value: new Set([`${XSD_PREFIX}long`]) }], [`${LBDCVOC_PREFIX}creationDate`, { type: ConstraintType.DATATYPE, value: new Set([`${XSD_PREFIX}dateTime`]) }], [`${LBDCVOC_PREFIX}locationIP`, { type: ConstraintType.DATATYPE, value: new Set([`${XSD_PREFIX}string`]) }], diff --git a/test/shape-parser.test.ts b/test/shape-parser.test.ts new file mode 100644 index 0000000..640ea84 --- /dev/null +++ b/test/shape-parser.test.ts @@ -0,0 +1,131 @@ +import { readFileSync } from 'fs'; +import type * as RDF from '@rdfjs/types'; +import * as N3 from 'n3'; +import { describe, expect, it } from 'vitest'; +import { parseShapeFromQuads } from '../lib/shape-parser'; +import type { IShape } from '../lib/Shape'; +import type { ParserDiagnostic } from '../lib/parser-policy'; + +const n3Parser = new N3.Parser(); +const shapeIri = 'http:exemple.ca/foo'; + +describe('parseShapeFromQuads', () => { + it('parses SHACL explicitly', async () => { + const quads = getShape('./test/shape/shacl_shape_one_property.ttl'); + const shape = await parseShapeFromQuads(quads, shapeIri, { format: 'shacl' }); + + expect(shape).not.toBeInstanceOf(Error); + expect((shape as IShape).positivePredicates).toStrictEqual(['http://example.org/state']); + }); + + it('parses ShEx explicitly', async () => { + const quads = getShape('./test/shape/shex_shape_one_property.ttl'); + const shape = await parseShapeFromQuads(quads, shapeIri, { format: 'shex' }); + + expect(shape).not.toBeInstanceOf(Error); + expect((shape as IShape).positivePredicates).toStrictEqual(['http://example.org/state']); + }); + + it('auto-detects SHACL format', async () => { + const quads = getShape('./test/shape/shacl_shape_one_property.ttl'); + const shape = await parseShapeFromQuads(quads, shapeIri, { format: 'auto' }); + + expect(shape).not.toBeInstanceOf(Error); + expect((shape as IShape).positivePredicates).toStrictEqual(['http://example.org/state']); + }); + + it('auto-detects ShEx format', async () => { + const quads = getShape('./test/shape/shex_shape_one_property.ttl'); + const shape = await parseShapeFromQuads(quads, shapeIri, { format: 'auto' }); + + expect(shape).not.toBeInstanceOf(Error); + expect((shape as IShape).positivePredicates).toStrictEqual(['http://example.org/state']); + }); + + it('produces parser parity for equivalent SHACL and ShEx fixtures', async () => { + const shaclQuads = getShape('./test/shape/shacl_shape_multiple_cardinality.ttl'); + const shexQuads = getShape('./test/shape/shex_shape_multiple_cardinality.ttl'); + + const shaclShape = await parseShapeFromQuads(shaclQuads, shapeIri, { format: 'shacl' }); + const shexShape = await parseShapeFromQuads(shexQuads, shapeIri, { format: 'shex' }); + + expect(shaclShape).not.toBeInstanceOf(Error); + expect(shexShape).not.toBeInstanceOf(Error); + + const left = canonical((shaclShape as IShape)); + const right = canonical((shexShape as IShape)); + + expect(left).toStrictEqual(right); + }); + + it('keeps malformed RDF list strict by default for ShEx', async () => { + const quads = getShape('./test/shape/shex_invalid_shape_incomplete_rdf_list.ttl'); + const shape = await parseShapeFromQuads(quads, shapeIri, { format: 'shex' }); + expect(shape).toBeInstanceOf(Error); + }); + + it('collects diagnostics when RDF list policy is non-strict', async () => { + const diagnostics: ParserDiagnostic[] = []; + const quads = getShape('./test/shape/shex_invalid_shape_incomplete_rdf_list.ttl'); + + await parseShapeFromQuads(quads, shapeIri, { + format: 'shex', + diagnostics, + policy: { strictRdfLists: false }, + }); + + expect(diagnostics.some(d => d.code === 'MALFORMED_RDF_LIST')).toBe(true); + }); + + describe('a malformed SHACL list', () => { + // The second cell of the sh:or list has no rdf:rest + const malformedOr = ` + @prefix sh: . + @prefix rdf: . + @prefix ex: . + <${shapeIri}> a sh:NodeShape ; + sh:property [ sh:path ex:name ] ; + sh:or _:list . + _:list rdf:first [ sh:path ex:label ] ; rdf:rest _:rest . + _:rest rdf:first [ sh:path ex:title ] .`; + + it('is a parse error by default', async () => { + const diagnostics: ParserDiagnostic[] = []; + const shape = await parseShapeFromQuads(n3Parser.parse(malformedOr), shapeIri, { format: 'shacl', diagnostics }); + + expect(shape).toBeInstanceOf(Error); + expect(diagnostics.map(d => [ d.code, d.level ])).toStrictEqual([[ 'MALFORMED_RDF_LIST', 'error' ]]); + }); + + it('keeps its members up to the defect when the policy is non-strict', async () => { + const diagnostics: ParserDiagnostic[] = []; + const shape = await parseShapeFromQuads(n3Parser.parse(malformedOr), shapeIri, { + format: 'shacl', + diagnostics, + policy: { strictRdfLists: false }, + }); + + expect(shape).not.toBeInstanceOf(Error); + expect((shape as IShape).oneOf.map(oneOf => oneOf.map(path => path.map(predicate => predicate.name)))) + .toStrictEqual([[[ 'http://example.org/label' ]]]); + expect(diagnostics.map(d => [ d.code, d.level ])).toStrictEqual([[ 'MALFORMED_RDF_LIST', 'warning' ]]); + }); + }); +}); + +function canonical(shape: IShape): { positive: string[]; negative: string[]; cards: Record } { + const cards: Record = {}; + for (const predicate of shape.positivePredicates) { + cards[predicate] = shape.get(predicate)?.cardinality; + } + + return { + positive: [...shape.positivePredicates].sort(), + negative: [...(shape.negativePredicates ?? [])].sort(), + cards, + }; +} + +function getShape(path: string): RDF.Quad[] { + return n3Parser.parse(readFileSync(path).toString()) as RDF.Quad[]; +} diff --git a/test/shape/shacl_shape_default_cardinality.ttl b/test/shape/shacl_shape_default_cardinality.ttl new file mode 100644 index 0000000..d707a67 --- /dev/null +++ b/test/shape/shacl_shape_default_cardinality.ttl @@ -0,0 +1,20 @@ +@prefix sh: . +@prefix foaf: . + + + a sh:NodeShape ; + sh:property [ + sh:path foaf:prop1 ; + ] ; + sh:property [ + sh:path foaf:prop2 ; + sh:minCount 1 ; + ] ; + sh:property [ + sh:path foaf:prop3 ; + sh:maxCount 2 ; + ] ; + sh:property [ + sh:path foaf:prop4 ; + sh:maxCount 0 ; + ] . diff --git a/test/shape/shacl_shape_with_not.ttl b/test/shape/shacl_shape_with_not.ttl index 39d1064..76642e6 100644 --- a/test/shape/shacl_shape_with_not.ttl +++ b/test/shape/shacl_shape_with_not.ttl @@ -1,7 +1,7 @@ @prefix sh: . @prefix foaf: . -# sh:not – shape negation: the property in sh:not's inner shape becomes a negative predicate +# sh:not – shape negation: `sh:not [ sh:path p ; sh:minCount 1 ]` forbids p, so p becomes a negative predicate a sh:NodeShape ; sh:property [ @@ -11,4 +11,5 @@ ] ; sh:not [ sh:path foaf:prop2 ; + sh:minCount 1 ; ] . diff --git a/test/shapeToQuery.test.ts b/test/shapeToQuery.test.ts new file mode 100644 index 0000000..33fab0d --- /dev/null +++ b/test/shapeToQuery.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest'; +import { ConstraintType, type IShape, Shape } from '../lib/Shape'; +import { shapeToQuery } from '../lib/shapeToQuery'; +import { solveShapeQueryContainment, ContainmentResult } from '../lib/containment'; +import { RDF } from '../lib/constant'; + +function collectOperators(expression: unknown, output: string[]): void { + if (typeof expression !== 'object' || expression === null) { + return; + } + + const castExpression = expression as { subType?: string; operator?: string; args?: unknown[] }; + if (castExpression.subType === 'operator' && castExpression.operator !== undefined) { + output.push(castExpression.operator); + for (const arg of castExpression.args ?? []) { + collectOperators(arg, output); + } + } +} + +describe('shapeToQuery', () => { + it('should translate constraints, dependencies and oneOf branches', () => { + const childShape: IShape = new Shape({ + name: 'https://www.example.ca/ChildShape', + positivePredicates: [ + { + name: 'https://www.example.ca/nickname', + constraint: { + type: ConstraintType.DATATYPE, + value: new Set(['http://www.w3.org/2001/XMLSchema#string']), + pattern: '^a', + }, + }, + ], + closed: true, + }); + + const rootShape: IShape = new Shape({ + name: 'https://www.example.ca/RootShape', + positivePredicates: [ + { + name: RDF.type, + constraint: { + type: ConstraintType.CLASS, + value: new Set(['https://www.example.ca/Person']), + }, + }, + { + name: 'https://www.example.ca/age', + constraint: { + type: ConstraintType.DATATYPE, + value: new Set(['http://www.w3.org/2001/XMLSchema#integer']), + minInclusive: 18, + maxInclusive: 35, + }, + }, + { + name: 'https://www.example.ca/knows', + constraint: { + type: ConstraintType.SHAPE, + value: new Set([childShape.name]), + }, + }, + ], + oneOf: [ + [ + [{ name: 'https://www.example.ca/email' }], + [{ name: 'https://www.example.ca/phone' }], + ], + ], + closed: true, + }); + + const query = shapeToQuery(rootShape, { linkedShapes: [childShape] }); + + expect(query.starPatterns.has(rootShape.name)).toBe(true); + expect(query.starPatterns.has(childShape.name)).toBe(true); + + const rootStarPattern = query.starPatterns.get(rootShape.name)!; + const typeTriple = rootStarPattern.starPattern.get(RDF.type)!.triple; + expect(Array.isArray(typeTriple.object)).toBe(false); + expect((typeTriple.object as any).termType).toBe('NamedNode'); + expect((typeTriple.object as any).value).toBe('https://www.example.ca/Person'); + + const knowsTriple = rootStarPattern.starPattern.get('https://www.example.ca/knows')!; + expect(knowsTriple.dependencies?.name).toBe(childShape.name); + + const operators: string[] = []; + for (const filter of query.filters ?? []) { + collectOperators(filter, operators); + } + expect(operators).toEqual(expect.arrayContaining(['datatype', '>=', '<='])); + + expect(query.union).toBeDefined(); + expect(query.union?.length).toBe(1); + expect(query.union?.[0].length).toBe(2); + }); + + it('should be usable directly with solveShapeQueryContainment', () => { + const sourceShape: IShape = new Shape({ + name: 'https://www.example.ca/SourceShape', + positivePredicates: [ + { + name: RDF.type, + constraint: { + type: ConstraintType.CLASS, + value: new Set(['https://www.example.ca/Person']), + }, + }, + { + name: 'https://www.example.ca/age', + constraint: { + type: ConstraintType.DATATYPE, + value: new Set(['http://www.w3.org/2001/XMLSchema#integer']), + minInclusive: 18, + maxInclusive: 35, + }, + }, + ], + closed: true, + }); + + const targetShape: IShape = new Shape({ + name: 'https://www.example.ca/TargetShape', + positivePredicates: [ + { + name: RDF.type, + constraint: { + type: ConstraintType.CLASS, + value: new Set(['https://www.example.ca/Person']), + }, + }, + { + name: 'https://www.example.ca/age', + constraint: { + type: ConstraintType.DATATYPE, + value: new Set(['http://www.w3.org/2001/XMLSchema#integer']), + minInclusive: 10, + maxInclusive: 50, + }, + }, + ], + closed: true, + }); + + const query = shapeToQuery(sourceShape); + const result = solveShapeQueryContainment({ query, shapes: [targetShape] }); + + expect(result.starPatternsContainment.get(sourceShape.name)?.result).toBe(ContainmentResult.CONTAINED); + }); +}); diff --git a/test/shex.test.ts b/test/shex.test.ts index 6e39ca2..692d183 100644 --- a/test/shex.test.ts +++ b/test/shex.test.ts @@ -4,7 +4,7 @@ import * as N3 from 'n3'; import { DataFactory } from 'rdf-data-factory'; import { describe, expect, it } from 'vitest'; import { streamifyArray } from 'streamify-array'; -import { SHEX_SHAPE, TYPE_DEFINITION, SHEX_PREDICATE } from '../lib/constant'; +import { RDF as RDF_VOCAB, SHEX } from '../lib/constant'; import { ConstraintType, IConstraint, OneOf, type IShape } from '../lib/Shape'; import { shexShapeFromQuads } from '../lib/shex'; @@ -34,12 +34,12 @@ describe.each([ ), DF.quad( DF.namedNode('a'), - TYPE_DEFINITION, - SHEX_SHAPE, + RDF_VOCAB.terms.type, + SHEX.terms.Shape, ), DF.quad( DF.namedNode('a'), - SHEX_PREDICATE, + SHEX.terms.predicate, DF.blankNode(), ), ] as RDF.Quad[]), @@ -73,7 +73,7 @@ describe.each([ it(`${name}: should handle Comment`, async () => { const shape = await shexShapeFromQuads(shapeSolidBenchComment, "http://example.com#Comment"); const expectedPredicates: string[] = [ - TYPE_DEFINITION.value, + RDF_VOCAB.type, `${LBDCVOC_PREFIX}id`, `${LBDCVOC_PREFIX}creationDate`, `${LBDCVOC_PREFIX}locationIP`, @@ -86,7 +86,7 @@ describe.each([ ]; const mapCardinality = new Map([ - [TYPE_DEFINITION.value, { min: 0, max: 1 }], + [RDF_VOCAB.type, { min: 0, max: 1 }], [`${LBDCVOC_PREFIX}id`, { min: 1, max: 1 }], [`${LBDCVOC_PREFIX}creationDate`, { min: 1, max: 1 }], [`${LBDCVOC_PREFIX}locationIP`, { min: 1, max: 1 }], @@ -99,7 +99,7 @@ describe.each([ ]); const mapConstraint = new Map([ - [TYPE_DEFINITION.value, { type: ConstraintType.CLASS, value: new Set([`${LBDCVOC_PREFIX}Comment`]) }], + [RDF_VOCAB.type, { type: ConstraintType.CLASS, value: new Set([`${LBDCVOC_PREFIX}Comment`]) }], [`${LBDCVOC_PREFIX}id`, { type: ConstraintType.DATATYPE, value: new Set([`${XSD_PREFIX}long`]) }], [`${LBDCVOC_PREFIX}creationDate`, { type: ConstraintType.DATATYPE, value: new Set([`${XSD_PREFIX}dateTime`]) }], [`${LBDCVOC_PREFIX}locationIP`, { type: ConstraintType.DATATYPE, value: new Set([`${XSD_PREFIX}string`]) }], @@ -167,6 +167,33 @@ describe.each([ expect((shape as IShape).name).toBe(shapeIri); }); + it(`${name}: should parse datatype numeric and regex facets into IConstraint`, async () => { + const shapeWithFacets: any = populateFunction([ + DF.quad(DF.namedNode(shapeIri), RDF_VOCAB.terms.type, SHEX.terms.Shape), + DF.quad(DF.namedNode(shapeIri), SHEX.terms.expression, DF.blankNode('expr1')), + DF.quad(DF.blankNode('expr1'), SHEX.terms.predicate, DF.namedNode('http://example.org/age')), + DF.quad(DF.blankNode('expr1'), SHEX.terms.valueExpr, DF.blankNode('constraint1')), + DF.quad(DF.blankNode('constraint1'), SHEX.terms.datatype, DF.namedNode('http://www.w3.org/2001/XMLSchema#integer')), + DF.quad(DF.blankNode('constraint1'), SHEX.terms.mininclusive, DF.literal('18', DF.namedNode('http://www.w3.org/2001/XMLSchema#integer'))), + DF.quad(DF.blankNode('constraint1'), SHEX.terms.maxexclusive, DF.literal('65', DF.namedNode('http://www.w3.org/2001/XMLSchema#integer'))), + DF.quad(DF.blankNode('constraint1'), SHEX.terms.pattern, DF.literal('^foo')), + DF.quad(DF.blankNode('constraint1'), SHEX.terms.flags, DF.literal('i')), + ] as RDF.Quad[]); + + const shape = await shexShapeFromQuads(shapeWithFacets, shapeIri); + expect(shape).not.toBeInstanceOf(Error); + + const predicate = (shape as IShape).get('http://example.org/age'); + expect(predicate?.constraint).toStrictEqual({ + type: ConstraintType.DATATYPE, + value: new Set(['http://www.w3.org/2001/XMLSchema#integer']), + minInclusive: 18, + maxExclusive: 65, + pattern: '^foo', + flags: 'i', + }); + }); + it(`${name}: should returns a closed Shape with multiple properties`, async () => { const shape = await shexShapeFromQuads(closedShapeWithMultipleProperties, shapeIri); const expectedPredicates: string[] = [ diff --git a/test/target.test.ts b/test/target.test.ts new file mode 100644 index 0000000..a0af0e1 --- /dev/null +++ b/test/target.test.ts @@ -0,0 +1,270 @@ +import { Parser as SPARQLParser } from '@traqula/parser-sparql-1-1'; +import { toAlgebra } from '@traqula/algebra-sparql-1-1'; +import * as N3 from 'n3'; +import { describe, expect, it } from 'vitest'; +import type { IShape } from '../lib/Shape'; +import { shaclShapeFromQuads } from '../lib/shacl'; +import { generateQuery, type IQuery } from '../lib/query'; +import { shapeToQuery } from '../lib/shapeToQuery'; +import { ContainmentResult, solveShapeQueryContainment, solveShapeShapeContainment } from '../lib/containment'; +import { RDF } from '../lib/constant'; + +const EX = 'http://example.org/'; +const PREFIXES = ` + @prefix sh: . + @prefix rdf: . + @prefix rdfs: . + @prefix owl: . + @prefix ex: . +`; + +async function parseShape(ttl: string, name: string): Promise { + const shape = await shaclShapeFromQuads(new N3.Parser().parse(PREFIXES + ttl), `${EX}${name}`); + if (shape instanceof Error) { + throw shape; + } + return shape; +} + +function parseQuery(body: string): IQuery { + return generateQuery(toAlgebra(new SPARQLParser().parse(`PREFIX ex: <${EX}> SELECT * WHERE { ${body} }`))); +} + +function classify(body: string, shapes: IShape[]): ContainmentResult { + return solveShapeQueryContainment({ query: parseQuery(body), shapes }).result; +} + +describe('SHACL targets', () => { + describe('parsing', () => { + it('should parse every kind of target, keeping a literal node target apart from an IRI', async () => { + const shape = await parseShape(` + ex:S a sh:NodeShape ; + sh:targetNode ex:Alice, "Alice" ; + sh:targetClass ex:Person ; + sh:targetSubjectsOf ex:knows ; + sh:targetObjectsOf ex:follows ; + sh:property [ sh:path ex:name ] .`, 'S'); + expect(shape.toObject().targets).toStrictEqual({ + nodes: [`${EX}Alice`, '"Alice"'], + classes: [`${EX}Person`], + subjectsOf: [`${EX}knows`], + objectsOf: [`${EX}follows`], + }); + }); + + it('should keep the datatype and language of a literal node target, and a blank node apart from an IRI', async () => { + const shape = await parseShape(` + @prefix xsd: . + ex:S a sh:NodeShape ; + sh:targetNode "1"^^xsd:integer, "1", "Alice"@en, _:alice ; + sh:property [ sh:path ex:name ] .`, 'S'); + const nodes = shape.targets.nodes; + expect(nodes.slice(0, 3)).toStrictEqual([ + '"1"^^', + '"1"', + '"Alice"@en', + ]); + expect(nodes[3]).toMatch(/^_:/); + }); + + it('should give a shape that is also a class an implicit class target', async () => { + const direct = await parseShape(`ex:Place a rdfs:Class, sh:NodeShape ; sh:property [ sh:path ex:name ] .`, 'Place'); + expect(direct.toObject().targets?.classes).toStrictEqual([`${EX}Place`]); + + // owl:Class is a SHACL instance of rdfs:Class only when the shapes graph says so + const owl = `ex:Place a owl:Class, sh:NodeShape ; sh:property [ sh:path ex:name ] .`; + expect((await parseShape(owl, 'Place')).toObject().targets).toBeUndefined(); + const owlSubClass = await parseShape(`owl:Class rdfs:subClassOf rdfs:Class . ${owl}`, 'Place'); + expect(owlSubClass.toObject().targets?.classes).toStrictEqual([`${EX}Place`]); + }); + + it('should accept a shape that only declares targets', async () => { + const shape = await parseShape(`ex:S a sh:NodeShape ; sh:targetClass ex:Place .`, 'S'); + expect(shape.positivePredicates).toStrictEqual([]); + expect(shape.toObject().targets?.classes).toStrictEqual([`${EX}Place`]); + }); + + it('should parse sh:ignoredProperties and leave the object of a shape without targets unchanged', async () => { + const shape = await parseShape(` + ex:S a sh:NodeShape ; sh:closed true ; sh:ignoredProperties ( rdf:type ex:label ) ; + sh:property [ sh:path ex:name ] .`, 'S'); + expect(shape.toObject().ignoredProperties).toStrictEqual([RDF.type, `${EX}label`]); + expect(Object.keys(shape.toObject())).not.toContain('targets'); + }); + }); + + describe('translation of an input shape', () => { + it('should require rdf:type for a class target and the predicate for a subjects-of target', async () => { + const classQuery = shapeToQuery(await parseShape(` + ex:In a sh:NodeShape ; sh:targetClass ex:Place ; sh:property [ sh:path ex:name ; sh:minCount 1 ] .`, 'In')); + const typeTriple = classQuery.starPatterns.get(`${EX}In`)!.starPattern.get(RDF.type)!.triple; + expect(typeTriple.isOptional).toBe(false); + expect(typeTriple.object).toEqual(expect.objectContaining({ termType: 'NamedNode', value: `${EX}Place` })); + + // an optional constraint on the predicate becomes required + const subjectsQuery = shapeToQuery(await parseShape(` + ex:In a sh:NodeShape ; sh:targetSubjectsOf ex:knows ; sh:property [ sh:path ex:knows ] .`, 'In')); + expect(subjectsQuery.starPatterns.get(`${EX}In`)!.starPattern.get(`${EX}knows`)!.triple.isOptional).toBe(false); + }); + + it('should restrict the subject for node targets and add an incoming pattern for an objects-of target', async () => { + const nodeQuery = shapeToQuery(await parseShape(` + ex:In a sh:NodeShape ; sh:targetNode ex:Alice, ex:Bob ; sh:property [ sh:path ex:name ; sh:minCount 1 ] .`, 'In')); + expect(nodeQuery.starPatterns.get(`${EX}In`)!.subjectValues).toStrictEqual(new Set([`${EX}Alice`, `${EX}Bob`])); + + const objectsQuery = shapeToQuery(await parseShape(` + ex:In a sh:NodeShape ; sh:targetObjectsOf ex:knows ; sh:property [ sh:path ex:name ; sh:minCount 1 ] .`, 'In')); + const incoming = objectsQuery.starPatterns.get(`${EX}In#targetObjectsOf`)!; + expect(incoming.starPattern.get(`${EX}knows`)!.dependencies?.name).toBe(`${EX}In`); + }); + + it('should turn several targets into UNION branches, and ignore combinations it cannot represent', async () => { + const union = shapeToQuery(await parseShape(` + ex:In a sh:NodeShape ; sh:targetClass ex:Place, ex:City ; sh:targetSubjectsOf ex:knows ; + sh:property [ sh:path ex:name ; sh:minCount 1 ] .`, 'In')); + expect(union.union).toHaveLength(1); + expect(union.union![0]).toHaveLength(3); + expect(union.unsupported).toBeUndefined(); + + const mixed = shapeToQuery(await parseShape(` + ex:In a sh:NodeShape ; sh:targetNode ex:Alice ; sh:targetClass ex:Place ; + sh:property [ sh:path ex:name ; sh:minCount 1 ] .`, 'In')); + expect(mixed.unsupported).toStrictEqual(['TARGETS']); + expect(mixed.starPatterns.get(`${EX}In`)!.starPattern.has(RDF.type)).toBe(false); + expect(mixed.starPatterns.get(`${EX}In`)!.subjectValues).toBeUndefined(); + }); + + it('should not translate the targets of a shape reached through sh:node', async () => { + const ttl = ` + ex:In a sh:NodeShape ; sh:property [ sh:path ex:address ; sh:node ex:Address ; sh:minCount 1 ] . + ex:Address a sh:NodeShape ; sh:targetClass ex:Address ; sh:property [ sh:path ex:city ; sh:minCount 1 ] .`; + const query = shapeToQuery(await parseShape(ttl, 'In'), { linkedShapes: [await parseShape(ttl, 'Address')] }); + expect(query.starPatterns.get(`${EX}Address`)!.starPattern.has(RDF.type)).toBe(false); + }); + + it('should ask for any triple about the target nodes of a shape without constraints', async () => { + const query = shapeToQuery(await parseShape(`ex:In a sh:NodeShape ; sh:targetNode ex:Alice .`, 'In')); + expect(query.starPatterns.size).toBe(0); + expect(query.matchesAnyTriple).toBe(true); + }); + }); + + describe('class targets of resource shapes', () => { + const typedQuery = '?x a ex:Place ; ex:name ?name .'; + + it('should match rdf:type against a class target, on open and closed shapes', async () => { + const open = await parseShape(`ex:R a sh:NodeShape ; sh:targetClass ex:Place ; sh:property [ sh:path ex:name ] .`, 'R'); + const closed = await parseShape(`ex:R a sh:NodeShape ; sh:targetClass ex:Place ; sh:closed true ; + sh:property [ sh:path ex:name ] .`, 'R'); + const ignoring = await parseShape(`ex:R a sh:NodeShape ; sh:targetClass ex:Place ; sh:closed true ; + sh:ignoredProperties ( rdf:type ) ; sh:property [ sh:path ex:name ] .`, 'R'); + for (const shape of [open, closed, ignoring]) { + expect(classify(typedQuery, [shape])).toBe(ContainmentResult.CONTAINED); + } + }); + + it('should not contain a request for another class', async () => { + const person = await parseShape(`ex:R a sh:NodeShape ; sh:targetClass ex:Person ; sh:property [ sh:path ex:name ] .`, 'R'); + expect(classify(typedQuery, [person])).toBe(ContainmentResult.ALIGNED); + }); + + it('should only reject the star patterns whose nodes are in the target of a closed shape', async () => { + const closedPerson = await parseShape(`ex:R a sh:NodeShape ; sh:targetClass ex:Person ; sh:closed true ; + sh:property [ sh:path ex:name ] .`, 'R'); + // places need not be persons, so the shape says nothing about them + expect(classify('?x a ex:Place ; ex:latitude ?l .', [closedPerson])).toBe(ContainmentResult.WEAKLY_REJECTED); + // a closed shape without targets still describes every node + const closedUntargeted = await parseShape(`ex:R a sh:NodeShape ; sh:closed true ; sh:property [ sh:path ex:name ] .`, 'R'); + expect(classify('?x a ex:Place ; ex:latitude ?l .', [closedUntargeted])).toBe(ContainmentResult.REJECTED); + }); + + it('should classify an input shape by its class target', async () => { + const input = await parseShape(`ex:In a sh:NodeShape ; sh:targetClass ex:Place ; + sh:property [ sh:path ex:name ; sh:minCount 1 ] .`, 'In'); + const place = await parseShape(`ex:R a sh:NodeShape ; sh:targetClass ex:Place ; sh:property [ sh:path ex:name ] .`, 'R'); + const person = await parseShape(`ex:R a sh:NodeShape ; sh:targetClass ex:Person ; sh:property [ sh:path ex:name ] .`, 'R'); + expect(solveShapeShapeContainment({ sourceShape: input, targetShapes: [place] }).result).toBe(ContainmentResult.CONTAINED); + expect(solveShapeShapeContainment({ sourceShape: input, targetShapes: [person] }).result).toBe(ContainmentResult.ALIGNED); + }); + + it('should require every target alternative of an input shape to be covered, as for sh:or', async () => { + const input = await parseShape(`ex:In a sh:NodeShape ; sh:targetClass ex:Place, ex:City ; + sh:property [ sh:path ex:name ; sh:minCount 1 ] .`, 'In'); + const city = await parseShape(`ex:C a sh:NodeShape ; sh:targetClass ex:City ; sh:property [ sh:path ex:name ] .`, 'C'); + const place = await parseShape(`ex:P a sh:NodeShape ; sh:targetClass ex:Place ; sh:property [ sh:path ex:name ] .`, 'P'); + expect(solveShapeShapeContainment({ sourceShape: input, targetShapes: [city] }).result).toBe(ContainmentResult.ALIGNED); + expect(solveShapeShapeContainment({ sourceShape: input, targetShapes: [city, place] }).result).toBe(ContainmentResult.CONTAINED); + }); + + it('should ignore the targets of a shape reached through sh:node', async () => { + const ttl = ` + ex:S1 a sh:NodeShape ; sh:property [ sh:path ex:address ; sh:node ex:S2 ] . + ex:S2 a sh:NodeShape ; sh:targetClass ex:Address ; sh:property [ sh:path ex:city ] .`; + const s1 = await parseShape(ttl, 'S1'); + const s2 = await parseShape(ttl, 'S2'); + const report = solveShapeQueryContainment({ query: parseQuery('?p ex:address ?a . ?a a ex:Address ; ex:city ?c .'), shapes: [s1, s2] }); + // ex:S2 used on its own describes addresses; through sh:node it does not type the value + expect(report.starPatternsContainment.get('a')!.result).toBe(ContainmentResult.CONTAINED); + expect(report.starPatternsContainment.get('p')!.result).toBe(ContainmentResult.ALIGNED); + }); + }); + + describe('node, subjects-of and objects-of targets of resource shapes', () => { + it('should only contain a constant subject listed by a shape whose targets are node targets', async () => { + const bob = await parseShape(`ex:R a sh:NodeShape ; sh:targetNode ex:Bob ; sh:property [ sh:path ex:name ] .`, 'R'); + const alice = await parseShape(`ex:R a sh:NodeShape ; sh:targetNode ex:Alice ; sh:property [ sh:path ex:name ] .`, 'R'); + const person = await parseShape(`ex:R a sh:NodeShape ; sh:targetClass ex:Person ; sh:property [ sh:path ex:name ] .`, 'R'); + expect(classify('ex:Alice ex:name ?n .', [bob])).toBe(ContainmentResult.ALIGNED); + expect(classify('ex:Alice ex:name ?n .', [alice])).toBe(ContainmentResult.CONTAINED); + // whether ex:Alice is a person cannot be decided, so it is not excluded + expect(classify('ex:Alice ex:name ?n .', [person])).toBe(ContainmentResult.CONTAINED); + // a blank node is a variable, not a constant + expect(classify('_:b ex:name ?n .', [bob])).toBe(ContainmentResult.CONTAINED); + }); + + it('should let a closed shape reject what it forbids for its node targets', async () => { + const alice = await parseShape(`ex:R a sh:NodeShape ; sh:targetNode ex:Alice ; sh:closed true ; + sh:property [ sh:path ex:name ] .`, 'R'); + expect(classify('ex:Alice ex:latitude ?l .', [alice])).toBe(ContainmentResult.REJECTED); + expect(classify('ex:Bob ex:latitude ?l .', [alice])).toBe(ContainmentResult.WEAKLY_REJECTED); + }); + + it('should imply the predicate of a subjects-of target', async () => { + const knower = await parseShape(`ex:R a sh:NodeShape ; sh:targetSubjectsOf ex:knows ; sh:property [ sh:path ex:name ] .`, 'R'); + expect(classify('?x ex:knows ?y ; ex:name ?n .', [knower])).toBe(ContainmentResult.CONTAINED); + const input = await parseShape(`ex:In a sh:NodeShape ; sh:targetSubjectsOf ex:knows ; + sh:property [ sh:path ex:name ; sh:minCount 1 ] .`, 'In'); + expect(solveShapeShapeContainment({ sourceShape: input, targetShapes: [knower] }).result).toBe(ContainmentResult.CONTAINED); + }); + + it('should only require an implied predicate that every focus node carries', async () => { + const query = '?x ex:name ?n . FILTER NOT EXISTS { ?x ex:knows ?k }'; + // every focus node knows someone, so none can satisfy the negation + const knowers = await parseShape(`ex:R a sh:NodeShape ; sh:targetSubjectsOf ex:knows ; sh:property [ sh:path ex:name ] .`, 'R'); + expect(classify(query, [knowers])).toBe(ContainmentResult.ALIGNED); + // a person need not know anyone + const knowersOrPersons = await parseShape(`ex:R a sh:NodeShape ; sh:targetSubjectsOf ex:knows ; sh:targetClass ex:Person ; + sh:property [ sh:path ex:name ] .`, 'R'); + expect(classify(query, [knowersOrPersons])).toBe(ContainmentResult.CONTAINED); + }); + + it('should describe the star patterns reached through the predicate of an objects-of target', async () => { + const knower = await parseShape(`ex:K a sh:NodeShape ; sh:targetSubjectsOf ex:knows ; sh:property [ sh:path ex:knows ] .`, 'K'); + const known = await parseShape(`ex:N a sh:NodeShape ; sh:targetObjectsOf ex:knows ; sh:closed true ; + sh:property [ sh:path ex:name ] .`, 'N'); + const report = solveShapeQueryContainment({ query: parseQuery('?x ex:knows ?y . ?y ex:latitude ?l .'), shapes: [knower, known] }); + // ex:K says nothing about ?y, while the closed ex:N forbids ex:latitude for it + expect(report.starPatternsContainment.get('y')!.result).toBe(ContainmentResult.REJECTED); + + const input = await parseShape(`ex:In a sh:NodeShape ; sh:targetObjectsOf ex:knows ; + sh:property [ sh:path ex:name ; sh:minCount 1 ] .`, 'In'); + expect(solveShapeShapeContainment({ sourceShape: input, targetShapes: [knower, known] }).result).toBe(ContainmentResult.CONTAINED); + }); + + it('should allow the ignored properties of a closed shape', async () => { + const shape = await parseShape(`ex:R a sh:NodeShape ; sh:closed true ; sh:ignoredProperties ( ex:label ) ; + sh:property [ sh:path ex:name ] .`, 'R'); + expect(classify('?x ex:label ?l ; ex:name ?n .', [shape])).toBe(ContainmentResult.CONTAINED); + }); + }); +});