Skip to content

Commit

Permalink
feat: support NodeShape in member assertions
Browse files Browse the repository at this point in the history
  • Loading branch information
tpluscode committed Oct 14, 2022
1 parent a4434b1 commit 990a319
Show file tree
Hide file tree
Showing 18 changed files with 402 additions and 72 deletions.
5 changes: 5 additions & 0 deletions .changeset/chilly-jars-drive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hydrofoil/knossos": patch
---

Ignore member assertions where object is blank node
5 changes: 5 additions & 0 deletions .changeset/seven-buttons-cheat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hydrofoil/labyrinth": patch
---

Support shapes in member assertions
5 changes: 5 additions & 0 deletions .changeset/small-kangaroos-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@labyrinth/testing": patch
---

Helper to parse graph from turtle template
5 changes: 5 additions & 0 deletions .changeset/yellow-lamps-sit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hydrofoil/shape-to-query": minor
---

First version. Minimal support only for `sh:targetClass`
40 changes: 40 additions & 0 deletions docs/knossos/collections.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,42 @@ This will wrap such a member assertion in a `GRAPH ?member` pattern
> [!TIP]
> This technique is useful to exclude inferred terms from matching the query. Only the resources self-asserted properties will be matched.
## Advanced member assertions

A more advanced feature allows blank nodes to be used with member assertions. At the time of writing they can represent
[SHACL NodeShapes][node-shape], which will be used to add complex static filters to collections.

For example, the following collection will return `lexvo:Language` resources but only those which are used as objects
of `bibo:Book` resources.

```turtle
PREFIX bibo: <http://purl.org/ontology/bibo/>
PREFIX sh: <http://www.w3.org/ns/shacl#>
PREFIX dcterms: <http://purl.org/dc/terms/>
PREFIX lexvo: <http://lexvo.org/ontology#>
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
PREFIX hydra: <http://www.w3.org/ns/hydra/core#>
</book-langs>
a hydra:Collection ;
hydra:memberAssertion
[
hydra:property rdf:type ;
hydra:object lexvo:Language ;
],
[
hydra:property dcterms:language ;
hydra:subject
[
a sh:NodeShape ;
sh:targetClass bibo:Book ;
];
] ;
.
```

[node-shape]: https://www.w3.org/TR/shacl/#node-shapes

## Queries

Collections can also be queries dynamically using `GET` requests with query strings. The variables passed by the client need to be mapped to URI Template variables which gets reconstructed into an RDF graph of filters on the server. The filters are then transformed into SPARQL query patterns using JS code.
Expand Down Expand Up @@ -388,6 +424,10 @@ To enable this feature, the collection class has to support the `POST` operation

Member assertions which have `hydra:predicate` and `hydra:object` will be implicitly added to the newly created resource. Other member assertions will be ignored.

> [!NOTE]
> For a member assertion to be applied to a new member, the `hydra:property` MUST be an IRI and `hydra:object` MUST be
> an IRI or Literal
```turtle
prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#>
prefix schema: <http://schema.org/>
Expand Down
7 changes: 4 additions & 3 deletions packages/knossos/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import * as rdfRequest from 'express-rdf-request'
import { preprocessMiddleware, sendResponse } from '@hydrofoil/labyrinth/lib/middleware'
import { getPayload } from '@hydrofoil/labyrinth/lib/request'
import TermSet from '@rdfjs/term-set'
import { isBlankNode, isNamedNode } from 'is-graph-pointer'
import { payloadTypes, shaclValidate } from './shacl'
import { save } from './lib/resource'
import { applyTransformations, hasAllRequiredVariables } from './lib/template'
Expand Down Expand Up @@ -64,9 +65,9 @@ const assertMemberAssertions = asyncMiddleware(async (req, res: CreateMemberResp
const member = res.locals.member!

for (const assertion of res.locals.memberAssertions!.toArray()) {
const predicate = assertion.out(hydra.property).term
const object = assertion.out(hydra.object).term
if (predicate && object) {
const predicate = assertion.out(hydra.property)
const object = assertion.out(hydra.object)
if (isNamedNode(predicate) && !isBlankNode(object)) {
member.addOut(predicate, object)
}
}
Expand Down
66 changes: 66 additions & 0 deletions packages/knossos/test/collection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,72 @@ describe('@hydrofoil/knossos/collection', () => {
}))
})

it('ignores member assertions with blank nodes', async () => {
// given
app.use(async (req, res, next) => {
const collection = await req.hydra.resource.clownface()
collection.addOut(rdf.type, ex.Collection)
clownface(req.hydra.api)
.node(ex.Collection)
.addOut(hydra.memberAssertion, assert => {
assert.addOut(hydra.property, rdf.type)
assert.addOut(hydra.object, foaf.Person)
})
collection.addOut(hydra.memberAssertion, assert => {
assert.addOut(hydra.property, foaf.knows)
assert.addOut(hydra.object, collection.blankNode())
})
next()
})
app.post('/collection', CreateMember)

// when
await request(app)
.post('/collection')
.send(turtle`<> ${schema.name} "john" .`.toString())
.set('content-type', 'text/turtle')
.set('host', 'example.com')

// then
expect(knossos.store.save).to.have.been.calledWith(sinon.match((value: GraphPointer) => {
expect(value.out(foaf.knows).terms).to.be.empty
return true
}))
})

it('asserts member assertions with literal object', async () => {
// given
app.use(async (req, res, next) => {
const collection = await req.hydra.resource.clownface()
collection.addOut(rdf.type, ex.Collection)
clownface(req.hydra.api)
.node(ex.Collection)
.addOut(hydra.memberAssertion, assert => {
assert.addOut(hydra.property, rdf.type)
assert.addOut(hydra.object, foaf.Person)
})
collection.addOut(hydra.memberAssertion, assert => {
assert.addOut(hydra.property, foaf.gender)
assert.addOut(hydra.object, 'M')
})
next()
})
app.post('/collection', CreateMember)

// when
await request(app)
.post('/collection')
.send(turtle`<> ${schema.name} "john" .`.toString())
.set('content-type', 'text/turtle')
.set('host', 'example.com')

// then
expect(knossos.store.save).to.have.been.calledWith(sinon.match((value: GraphPointer) => {
expect(value.out(foaf.gender).term).to.deep.eq($rdf.literal('M'))
return true
}))
})

it('does not mistake self reference in member assertion for new item id', async () => {
// given
app.use(async (req, res, next) => {
Expand Down
57 changes: 7 additions & 50 deletions packages/labyrinth/lib/query/dynamicCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,16 @@ import cf, { AnyPointer, GraphPointer, MultiPointer } from 'clownface'
import { sparql, SparqlTemplateResult } from '@tpluscode/rdf-string'
import { IriTemplate, IriTemplateMapping } from '@rdfine/hydra'
import { Api } from 'hydra-box/Api'
import { hyper_query, knossos } from '@hydrofoil/vocabularies/builders'
import { hyper_query } from '@hydrofoil/vocabularies/builders'
import type { StreamClient } from 'sparql-http-client/StreamClient'
import toArray from 'stream-to-array'
import { toSparql } from 'clownface-shacl-path'
import { toRdf } from 'rdf-literal'
import TermSet from '@rdfjs/term-set'
import { loadImplementations } from '../code'
import { exactMatch } from '../query/filters'
import { Filter } from '../query'
import { log, warn } from '../logger'
import { loadResourceWithLinks } from '../query/eagerLinks'
import { exactMatch } from './filters'
import { loadResourceWithLinks } from './eagerLinks'
import { memberAssertionPatterns } from './memberAssertion'
import { Filter } from '.'

function createTemplateVariablePatterns(subject: Variable, queryPointer: AnyPointer, api: Api) {
return async (mapping: IriTemplateMapping, index: number): Promise<string | SparqlTemplateResult> => {
Expand Down Expand Up @@ -64,48 +63,6 @@ function createTemplateVariablePatterns(subject: Variable, queryPointer: AnyPoin
}
}

function * createPatterns(subs: Term[], preds: Term[], objs: Term[], { graph }: { graph?: Variable }) {
for (const subject of subs) {
for (const predicate of preds) {
for (const object of objs) {
const pattern = sparql`${subject} ${predicate} ${object} .`

yield graph ? sparql`GRAPH ${graph} { ${pattern} }` : pattern
}
}
}
}

function toSparqlPattern(member: Variable) {
const seen = new TermSet()

return function (previous: SparqlTemplateResult[], memberAssertion: GraphPointer): SparqlTemplateResult[] {
if (seen.has(memberAssertion.term)) {
return previous
}

seen.add(memberAssertion.term)
const subject = memberAssertion.out(hydra.subject).terms
const predicate = memberAssertion.out(hydra.property).terms
const object = memberAssertion.out(hydra.object).terms
const graph = memberAssertion.out(knossos.ownGraphOnly).term?.equals(toRdf(true)) ? member : undefined

if (subject.length && predicate.length && !object.length) {
return [...previous, ...createPatterns(subject, predicate, [member], { graph })]
}
if (subject.length && object.length && !predicate.length) {
return [...previous, ...createPatterns(subject, [member], object, { graph })]
}
if (predicate.length && object.length && !subject.length) {
return [...previous, ...createPatterns([member], predicate, object, { graph })]
}

log('Skipping invalid member assertion')

return previous
}
}

type SelectBuilder = ReturnType<typeof SELECT>

function createOrdering(collectionTypes: MultiPointer, collection: GraphPointer, subject: Variable): { patterns: SparqlTemplateResult; addClauses(q: SelectBuilder): SelectBuilder } {
Expand Down Expand Up @@ -170,8 +127,8 @@ export default async function ({ api, collection, client, pageSize, query, varia
const collectionTypes = apiPointer.node(collection.out(rdf.type))

const memberAssertions = [
...collectionTypes.out(memberAssertionPredicates).toArray().reduce(toSparqlPattern(subject), []),
...collection.out(memberAssertionPredicates).toArray().reduce(toSparqlPattern(subject), []),
...memberAssertionPatterns(collectionTypes.out(memberAssertionPredicates), subject),
...memberAssertionPatterns(collection.out(memberAssertionPredicates), subject),
]

const managesBlockPatterns = memberAssertions.reduce((combined, next) => sparql`${combined}\n${next}`, sparql``)
Expand Down
83 changes: 83 additions & 0 deletions packages/labyrinth/lib/query/memberAssertion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { Term, Variable } from 'rdf-js'
import { sparql, SparqlTemplateResult } from '@tpluscode/rdf-string'
import TermSet from '@rdfjs/term-set'
import { GraphPointer, MultiPointer } from 'clownface'
import { hydra, rdf, sh } from '@tpluscode/rdf-ns-builders'
import { knossos } from '@hydrofoil/vocabularies/builders'
import { toRdf } from 'rdf-literal'
import { isBlankNode } from 'is-graph-pointer'
import { shapeToPatterns } from '@hydrofoil/shape-to-query'
import $rdf from 'rdf-ext'
import { log } from '../../lib/logger'

export function memberAssertionPatterns(memberAssertions: MultiPointer, subject: Variable): SparqlTemplateResult[] {
return memberAssertions.toArray().reduce(toSparqlPattern(subject), [])
}

function toSparqlPattern(member: Variable) {
const seen = new TermSet()

return function (previous: SparqlTemplateResult[], memberAssertion: GraphPointer): SparqlTemplateResult[] {
if (seen.has(memberAssertion.term)) {
return previous
}

seen.add(memberAssertion.term)
const subject = memberAssertion.out(hydra.subject)
const predicate = memberAssertion.out(hydra.property)
const object = memberAssertion.out(hydra.object)
const graph = memberAssertion.out(knossos.ownGraphOnly).term?.equals(toRdf(true)) ? member : undefined
const memberPointer = memberAssertion.node(member)

if (subject.values.length && predicate.values.length && !object.values.length) {
return [...previous, ...createPatterns(subject, predicate, memberPointer, { graph })]
}
if (subject.values.length && object.values.length && !predicate.values.length) {
return [...previous, ...createPatterns(subject, memberPointer, object, { graph })]
}
if (predicate.values.length && object.values.length && !subject.values.length) {
return [...previous, ...createPatterns(memberPointer, predicate, object, { graph })]
}

log('Skipping invalid member assertion')

return previous
}
}

function * createPatterns(subs: MultiPointer, preds: MultiPointer, objs: MultiPointer, { graph }: { graph?: Variable }) {
for (const [subject, subjectPatterns] of subs.map(createPatternValue('ma_s'))) {
for (const [predicate, predicatePatterns] of preds.map(createPatternValue('ma_p'))) {
for (const [object, objectPatterns] of objs.map(createPatternValue('ma_o'))) {
const patterns = sparql`
${subject} ${predicate} ${object} .
${subjectPatterns}
${predicatePatterns}
${objectPatterns}
`

yield graph ? sparql`GRAPH ${graph} { ${patterns} }` : patterns
}
}
}
}

function createPatternValue(variable: string) {
return (ptr: GraphPointer, index: number): [Term | null, SparqlTemplateResult] | [Term | null] => {
if (isBlankNode(ptr)) {
if (isNodeShape(ptr)) {
const variableName = `${variable}${index}`

return [$rdf.variable(variableName), shapeToPatterns(ptr, variableName)]
}

return [null]
}

return [ptr.term]
}
}

function isNodeShape(pointer: GraphPointer) {
return pointer.has(rdf.type, sh.NodeShape).terms.length > 0
}
1 change: 1 addition & 0 deletions packages/labyrinth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"directory": "packages/labyrinth"
},
"dependencies": {
"@hydrofoil/shape-to-query": "^0.0.0",
"@hydrofoil/vocabularies": "^0.3.2",
"@rdfine/hydra": "^0.8.4",
"@rdfjs/data-model": "^1.2",
Expand Down
Loading

0 comments on commit 990a319

Please sign in to comment.