diff --git a/.changeset/curvy-ways-compare.md b/.changeset/curvy-ways-compare.md new file mode 100644 index 0000000..4ea4f40 --- /dev/null +++ b/.changeset/curvy-ways-compare.md @@ -0,0 +1,6 @@ +--- +"clownface-shacl-path": minor +--- + +Adds `NegatedPropertySet` class to support negated paths in `findNodes`, `toAlgebra` and `toAlgebra` +NOTE: negated paths are not supported in SHACL representation (see https://github.com/w3c/shacl/issues/20) diff --git a/.changeset/pretty-goats-grow.md b/.changeset/pretty-goats-grow.md new file mode 100644 index 0000000..3704d19 --- /dev/null +++ b/.changeset/pretty-goats-grow.md @@ -0,0 +1,5 @@ +--- +"clownface-shacl-path": minor +--- + +Allow calling `toSparql` with a path object diff --git a/package.json b/package.json index c3fabb9..72e30e2 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "release": "changeset publish" }, "dependencies": { + "@rdfjs/term-map": "^2.0.0", "@rdfjs/term-set": "^2.0.1", "@tpluscode/rdf-ns-builders": ">=3.0.2", "@tpluscode/rdf-string": "^1.3.1" diff --git a/src/index.ts b/src/index.ts index 6c05531..1c51b61 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,5 +11,6 @@ export { InversePath, SequencePath, ZeroOrOnePath, + NegatedPropertySet, fromNode, } from './lib/path.js' diff --git a/src/lib/findNodes.ts b/src/lib/findNodes.ts index da23742..10ccaa8 100644 --- a/src/lib/findNodes.ts +++ b/src/lib/findNodes.ts @@ -1,8 +1,9 @@ -import type { NamedNode, Term } from '@rdfjs/types' +/* eslint-disable camelcase */ +import type { NamedNode, Term, Quad_Predicate, Quad } from '@rdfjs/types' import type { MultiPointer } from 'clownface' import TermSet from '@rdfjs/term-set' +import TermMap from '@rdfjs/term-map' import * as Path from './path.js' -import { ShaclPropertyPath } from './path.js' interface Context { pointer: MultiPointer @@ -67,6 +68,41 @@ class FindNodesVisitor extends Path.PathVisitor { visitPredicatePath({ term }: Path.PredicatePath, { pointer }: Context): Term[] { return pointer.out(term).terms } + + visitNegatedPropertySet({ paths }: Path.NegatedPropertySet, { pointer }: Context): Term[] { + const outLinks = [...pointer.dataset.match(pointer.term)] + .reduce(toPredicateMap('object'), new TermMap()) + + const inLinks = [...pointer.dataset.match(null, null, pointer.term)] + .reduce(toPredicateMap('subject'), new TermMap()) + let includeInverse = false + + for (const path of paths) { + if (path instanceof Path.PredicatePath) { + outLinks.delete(path.term) + } else { + includeInverse = true + inLinks.delete(path.path.term) + } + } + + if (includeInverse) { + return [...new TermSet([...outLinks.values(), ...inLinks.values()].flatMap(v => [...v]))] + } + + return [...outLinks.values()].flatMap(v => [...v]) + } +} + +function toPredicateMap(so: 'subject' | 'object') { + return (map: TermMap, quad: Quad) => { + if (!map.has(quad.predicate)) { + map.set(quad.predicate, new TermSet()) + } + + map.get(quad.predicate)!.add(quad[so]) + return map + } } /** @@ -75,11 +111,9 @@ class FindNodesVisitor extends Path.PathVisitor { * @param pointer starting node * @param shPath SHACL Property Path */ -export function findNodes(pointer: MultiPointer, shPath: MultiPointer | NamedNode | ShaclPropertyPath): MultiPointer { - let path: ShaclPropertyPath - if ('termType' in shPath) { - path = Path.fromNode(pointer.node(shPath)) - } else if ('value' in shPath) { +export function findNodes(pointer: MultiPointer, shPath: MultiPointer | NamedNode | Path.ShaclPropertyPath): MultiPointer { + let path: Path.ShaclPropertyPath + if ('termType' in shPath || 'value' in shPath) { path = Path.fromNode(shPath) } else { path = shPath diff --git a/src/lib/path.ts b/src/lib/path.ts index aa07228..d9d2001 100644 --- a/src/lib/path.ts +++ b/src/lib/path.ts @@ -25,6 +25,9 @@ export abstract class PathVisitor { if (path instanceof ZeroOrOnePath) { return this.visitZeroOrOnePath(path, arg) } + if (path instanceof NegatedPropertySet) { + return this.visitNegatedPropertySet(path, arg) + } throw new Error('Unexpected path') } @@ -36,6 +39,7 @@ export abstract class PathVisitor { abstract visitZeroOrMorePath(path: ZeroOrMorePath, arg?: TArg): R abstract visitOneOrMorePath(path: OneOrMorePath, arg?: TArg): R abstract visitZeroOrOnePath(path: ZeroOrOnePath, arg?: TArg): R + abstract visitNegatedPropertySet(path: NegatedPropertySet, arg?: TArg): R } export abstract class ShaclPropertyPath { @@ -72,8 +76,8 @@ export class AlternativePath extends ShaclPropertyPath { } } -export class InversePath extends ShaclPropertyPath { - constructor(public path: ShaclPropertyPath) { +export class InversePath

extends ShaclPropertyPath { + constructor(public path: P) { super() } @@ -112,6 +116,16 @@ export class ZeroOrOnePath extends ShaclPropertyPath { } } +export class NegatedPropertySet extends ShaclPropertyPath { + constructor(public paths: Array>) { + super() + } + + accept(visitor: PathVisitor, arg: T) { + return visitor.visitNegatedPropertySet(this, arg) + } +} + interface Options { allowNamedNodeSequencePaths?: boolean } diff --git a/src/lib/toAlgebra.ts b/src/lib/toAlgebra.ts index 68bcd94..1655b49 100644 --- a/src/lib/toAlgebra.ts +++ b/src/lib/toAlgebra.ts @@ -55,14 +55,39 @@ class ToAlgebra extends Path.PathVisitor { items: [path.path.accept(this)], } } + + visitNegatedPropertySet({ paths }: Path.NegatedPropertySet): PropertyPath { + return { + type: 'path', + pathType: '!', + items: paths.map((path) => { + if (path instanceof Path.PredicatePath) { + return path.term + } + + return { + type: 'path', + pathType: '^', + items: [path.path.term], + } + }), + } + } } /** * Creates a sparqljs object which represents a SHACL path as Property Path * - * @param path SHACL Property Path + * @param shPath SHACL Property Path */ -export function toAlgebra(path: MultiPointer | NamedNode): PropertyPath | NamedNode { +export function toAlgebra(shPath: MultiPointer | NamedNode | Path.ShaclPropertyPath): PropertyPath | NamedNode { + let path: Path.ShaclPropertyPath + if ('termType' in shPath || 'value' in shPath) { + path = Path.fromNode(shPath) + } else { + path = shPath + } + const visitor = new ToAlgebra() - return visitor.visit(Path.fromNode(path)) + return visitor.visit(path) } diff --git a/src/lib/toSparql.ts b/src/lib/toSparql.ts index 6364e12..fd8ba2e 100644 --- a/src/lib/toSparql.ts +++ b/src/lib/toSparql.ts @@ -1,7 +1,7 @@ import type { NamedNode } from '@rdfjs/types' import { SparqlTemplateResult, sparql } from '@tpluscode/rdf-string' import { MultiPointer } from 'clownface' -import { assertWellFormedPath, fromNode, PathVisitor, ShaclPropertyPath } from './path.js' +import { assertWellFormedPath, fromNode, NegatedPropertySet, PathVisitor, ShaclPropertyPath } from './path.js' import * as Path from './path.js' class ToSparqlPropertyPath extends PathVisitor { @@ -45,6 +45,10 @@ class ToSparqlPropertyPath extends PathVisitor { if (index === 0) { diff --git a/test/index.test.ts b/test/index.test.ts index 8d9a822..ff05556 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -4,7 +4,16 @@ import { describe, it } from 'mocha' import { schema, sh, skos, foaf, rdf, owl } from '@tpluscode/rdf-ns-builders' import type { GraphPointer } from 'clownface' import RDF from '@zazuko/env-node' -import { findNodes, fromNode, toAlgebra, toSparql, PredicatePath } from '../src/index.js' +import { + findNodes, + fromNode, + toAlgebra, + toSparql, + PredicatePath, + NegatedPropertySet, + InversePath, + SequencePath, +} from '../src/index.js' import { any, blankNode, namedNode, parse } from './nodeFactory.js' const tbbt = RDF.namespace('http://example.com/') @@ -23,6 +32,7 @@ describe('clownface-shacl-path', () => { .addOut(skos.altLabel, 'Amy Farrah-Fowler') const penny = graph.namedNode(tbbt.Penny) + .addOut(skos.prefLabel, 'Penny') const leonard = graph.namedNode(tbbt.Leonard) .addOut(schema.spouse, tbbt.Penny) @@ -39,6 +49,92 @@ describe('clownface-shacl-path', () => { expect(nodes.terms).to.deep.contain.members([tbbt.Penny, tbbt.Howard, tbbt.Amy, tbbt.Leonard]) }) + context('negated path', () => { + it('follows simple negated path', () => { + // given + const path = new NegatedPropertySet([ + new PredicatePath(schema.knows), + ]) + + // when + const nodes = findNodes(sheldon, path) + + // then + expect(nodes.terms).to.deep.contain.members([tbbt.Amy]) + }) + + it('follows simple negated path, multiple matches', () => { + // given + const path = new NegatedPropertySet([ + new PredicatePath(schema.spouse), + ]) + + // when + const nodes = findNodes(sheldon, path) + + // then + expect(nodes.terms).to.deep.contain.members([tbbt.Penny, tbbt.Howard, tbbt.Leonard]) + }) + + it('follows multiple negated path', () => { + // given + const path = new NegatedPropertySet([ + new PredicatePath(skos.prefLabel), + new PredicatePath(skos.altLabel), + ]) + + // when + const nodes = findNodes(amy, path) + + // then + expect(nodes.terms).to.deep.contain.members([tbbt.Leonard]) + }) + + it('follows inverse negated path', () => { + // given + const path = new NegatedPropertySet([ + new InversePath(new PredicatePath(schema.spouse)), + ]) + + // when + const nodes = findNodes(penny, path) + + // then + expect(nodes.terms).to.deep.contain.members([tbbt.Sheldon]) + }) + + it('follows multiple inverse negated path', () => { + // given + const path = new NegatedPropertySet([ + new InversePath(new PredicatePath(schema.spouse)), + new InversePath(new PredicatePath(schema.knows)), + ]) + + // when + const nodes = findNodes(penny, path) + + // then + expect(nodes.terms).to.deep.contain.members([]) + }) + + it('follows mixed negated path', () => { + // given + const path = new NegatedPropertySet([ + new PredicatePath(skos.prefLabel), + new PredicatePath(skos.altLabel), + new InversePath(new PredicatePath(schema.spouse)), + ]) + + // when + const nodes = findNodes(penny, path) + + // then + expect(nodes.terms).to.deep.contain.members([ + tbbt.Sheldon, + ]) + }) + }) + it('follows direct path as pointer', () => { // given const path = schema.knows @@ -344,6 +440,50 @@ describe('clownface-shacl-path', () => { expect(sparql).to.eq('schema:knows') }) + context('converts negated property set', () => { + it('from single predicate', () => { + // given + const path = new NegatedPropertySet([ + new PredicatePath(schema.knows), + ]) + + // when + const sparql = toSparql(path).toString({ prologue: false }) + + // then + expect(sparql).to.eq('!(schema:knows)') + }) + + it('from multiple predicates', () => { + // given + const path = new NegatedPropertySet([ + new PredicatePath(schema.knows), + new PredicatePath(schema.spouse), + new PredicatePath(rdf.type), + ]) + + // when + const sparql = toSparql(path).toString({ prologue: false }) + + // then + expect(sparql).to.eq('!(schema:knows|schema:spouse|rdf:type)') + }) + + it('from multiple predicates', () => { + // given + const path = new NegatedPropertySet([ + new PredicatePath(rdf.type), + new InversePath(new PredicatePath(rdf.type)), + ]) + + // when + const sparql = toSparql(path).toString({ prologue: false }) + + // then + expect(sparql).to.eq('!(rdf:type|^rdf:type)') + }) + }) + it('converts simple inverse path', () => { // given const path = blankNode().addOut(sh.inversePath, schema.spouse) @@ -741,6 +881,116 @@ describe('clownface-shacl-path', () => { expect(algebra).to.deep.eq(schema.knows) }) + context('converts negated property set', () => { + it('from single predicate', () => { + // given + const path = new NegatedPropertySet([ + new PredicatePath(schema.knows), + ]) + + // when + const algebra = toAlgebra(path) + + // then + expect(algebra).to.deep.eq({ + type: 'path', + pathType: '!', + items: [schema.knows], + }) + }) + + it('combined with other paths', () => { + // given + const path = new SequencePath([ + new NegatedPropertySet([ + new PredicatePath(schema.knows), + ]), + new NegatedPropertySet([ + new PredicatePath(schema.name), + ]), + ]) + + // when + const algebra = toAlgebra(path) + + // then + expect(algebra).to.deep.eq({ + type: 'path', + pathType: '/', + items: [{ + type: 'path', + pathType: '!', + items: [schema.knows], + }, { + type: 'path', + pathType: '!', + items: [schema.name], + }], + }) + }) + + it('from multiple predicates', () => { + // given + const path = new NegatedPropertySet([ + new PredicatePath(schema.knows), + new PredicatePath(schema.spouse), + ]) + + // when + const algebra = toAlgebra(path) + + // then + expect(algebra).to.deep.eq({ + type: 'path', + pathType: '!', + items: [schema.knows, schema.spouse], + }) + }) + + it('from single inverse property', () => { + // given + const path = new NegatedPropertySet([ + new InversePath(new PredicatePath(schema.knows)), + ]) + + // when + const algebra = toAlgebra(path) + + // then + expect(algebra).to.deep.eq({ + type: 'path', + pathType: '!', + items: [{ + type: 'path', + pathType: '^', + items: [schema.knows], + }], + }) + }) + + it('from mixed properties', () => { + // given + const path = new NegatedPropertySet([ + new PredicatePath(schema.knows), + new InversePath(new PredicatePath(schema.knows)), + ]) + + // when + const algebra = toAlgebra(path) + + // then + expect(algebra).to.deep.eq({ + type: 'path', + pathType: '!', + items: [schema.knows, { + type: 'path', + pathType: '^', + items: [schema.knows], + }], + }) + }) + }) + it('converts simple inverse path', () => { // given const path = blankNode().addOut(sh.inversePath, schema.spouse)