From de05a9e049834ca9c0c7da351cc15918eb3ce896 Mon Sep 17 00:00:00 2001 From: Tomasz Pluskiewicz Date: Fri, 14 Feb 2025 11:26:52 +0100 Subject: [PATCH] feat: separate member shapes --- .changeset/warm-foxes-argue.md | 5 ++ packages/hydra/index.ts | 2 + packages/hydra/lib/queryShapes.ts | 5 +- packages/hydra/lib/validation.ts | 6 ++- .../__snapshots__/collection.test.ts.snap | 6 +++ packages/hydra/test/collection.test.ts | 52 ++++++++++++++++++ packages/hydra/test/collection.test.ts.trig | 42 +++++++++++++++ .../__snapshots__/queryShapes.test.ts.snap | 54 +++++++++++++++++++ packages/hydra/test/lib/queryShapes.test.ts | 13 +++++ .../hydra/test/lib/queryShapes.test.ts.trig | 21 ++++++++ 10 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 .changeset/warm-foxes-argue.md diff --git a/.changeset/warm-foxes-argue.md b/.changeset/warm-foxes-argue.md new file mode 100644 index 00000000..227e7ac7 --- /dev/null +++ b/.changeset/warm-foxes-argue.md @@ -0,0 +1,5 @@ +--- +"@kopflos-cms/hydra": patch +--- + +Collections: support `kl-hydra:memberCreateShape` and `kl-hydra:memberQueryShape` which can be used instead of `kl-hydra:memberShape` to separate querying from validation diff --git a/packages/hydra/index.ts b/packages/hydra/index.ts index 26f9d563..61d191c9 100644 --- a/packages/hydra/index.ts +++ b/packages/hydra/index.ts @@ -11,6 +11,8 @@ import limitOffsetStrategy from './lib/partialCollection/limitOffsetStrategy.js' import pageIndexStrategy from './lib/partialCollection/pageIndexStrategy.js' type ExtendingTerms = 'hydra#memberShape' +| 'hydra#memberCreateShape' +| 'hydra#memberQueryShape' | 'hydra#MemberAssertionConstraintComponent' | 'hydra' | 'hydra#DefaultCollectionShape' diff --git a/packages/hydra/lib/queryShapes.ts b/packages/hydra/lib/queryShapes.ts index 5ed5b690..bd621dab 100644 --- a/packages/hydra/lib/queryShapes.ts +++ b/packages/hydra/lib/queryShapes.ts @@ -82,7 +82,10 @@ export function memberQueryShape({ env, collection: original, limit, offset }: M }) }) - let memberShape = collection.out(kl['hydra#memberShape']) + let memberShape = collection.out(kl['hydra#memberQueryShape']) + if (!isGraphPointer(memberShape)) { + memberShape = collection.out(kl['hydra#memberShape']) + } if (!isGraphPointer(memberShape)) { memberShape = collection.blankNode() } diff --git a/packages/hydra/lib/validation.ts b/packages/hydra/lib/validation.ts index 1bc75c61..0d217783 100644 --- a/packages/hydra/lib/validation.ts +++ b/packages/hydra/lib/validation.ts @@ -1,8 +1,12 @@ import type { HandlerArgs } from '@kopflos-cms/core' import type { MultiPointer } from 'clownface' +import { isGraphPointer } from 'is-graph-pointer' export default function ({ env, subject }: HandlerArgs): MultiPointer { - const memberShape = subject.out(env.ns.kl('hydra#memberShape')) + let memberShape = subject.out(env.ns.kl('hydra#memberCreateShape')) + if (!isGraphPointer(memberShape)) { + memberShape = subject.out(env.ns.kl('hydra#memberShape')) + } const collectionShape = subject .blankNode() diff --git a/packages/hydra/test/__snapshots__/collection.test.ts.snap b/packages/hydra/test/__snapshots__/collection.test.ts.snap index 050a6f44..58a2fcfb 100644 --- a/packages/hydra/test/__snapshots__/collection.test.ts.snap +++ b/packages/hydra/test/__snapshots__/collection.test.ts.snap @@ -5,3 +5,9 @@ exports[`@kopflos-cms/hydra hydra:Collection post when collection has validation . " `; + +exports[`@kopflos-cms/hydra hydra:Collection post when collection has validation when collection uses :hydraCreateShape creates a new resource 1`] = ` +" \\"Valid name\\" . + . +" +`; diff --git a/packages/hydra/test/collection.test.ts b/packages/hydra/test/collection.test.ts index 2231f492..69e55cec 100644 --- a/packages/hydra/test/collection.test.ts +++ b/packages/hydra/test/collection.test.ts @@ -516,6 +516,58 @@ describe('@kopflos-cms/hydra', () => { expect(res.status).to.equal(400) }) + context('when collection uses :hydraCreateShape', () => { + it('should return 400 when body is invalid', async function () { + // given + const kopflos = await startKopflos() + const collection = ex['municipalities/writable-with-create-shape'] + const newMember = ex('invalid-municipality') + const dataset = loadGraph(newMember) + $rdf.clownface({ dataset }) + .node(collection) + .addOut(ns.hydra.member, newMember) + + // when + const res = await kopflos.handleRequest({ + method: 'POST', + iri: collection, + headers: {}, + query: {}, + body: asBody(dataset, collection), + }) + + // then + expect(res.status).to.equal(400) + }) + + it('creates a new resource', async function () { + // given + const kopflos = await startKopflos() + const collection = ex['municipalities/writable-with-create-shape'] + const newMember = ex('valid-municipality') + const dataset = loadGraph(newMember) + $rdf.clownface({ dataset }) + .node(collection) + .addOut(ns.hydra.member, newMember) + + // when + const res = await kopflos.handleRequest({ + method: 'POST', + iri: collection, + headers: {}, + query: {}, + body: asBody(dataset, collection), + }) + + // then + expect(res.status).to.equal(201) + expect(res.headers?.Location).to.equal(`${ex('municipality/valid-name').value}`) + const newMemberDataset = await $rdf.dataset() + .import(clients.stream.store.get(ex('municipality/valid-name'))) + expect(newMemberDataset).canonical.toMatchSnapshot() + }) + }) + it('should return 400 when body does not include hydra:member triple', async function () { // given const kopflos = await startKopflos() diff --git a/packages/hydra/test/collection.test.ts.trig b/packages/hydra/test/collection.test.ts.trig index a9500453..9b819768 100644 --- a/packages/hydra/test/collection.test.ts.trig +++ b/packages/hydra/test/collection.test.ts.trig @@ -201,6 +201,48 @@ GRAPH { . } +GRAPH { + + a hydra:Collection ; + hydra:memberAssertion + [ + hydra:property rdf:type ; + hydra:object ; + ] ; + hydra:writable true ; + kl-hydra:memberUriTemplate + ( + "/municipality/" + [ + sparql:encode_for_uri + ( + [ + sparql:replace ( [ sparql:lcase ( [ sh:path schema:name ] ) ] " " "-" "g" ) + ] + ) + ] + ) ; + kl-hydra:memberShape + [ + sh:property + [ + sh:path schema:name ; + sh:dataType xsd:string ; + ] ; + ] ; + kl-hydra:memberCreateShape + [ + sh:property + [ + sh:path schema:name ; + sh:dataType xsd:string ; + sh:minCount 1 ; + sh:maxCount 1 ; + ] ; + ] ; + . +} + graph { schema:name "Already exists" . diff --git a/packages/hydra/test/lib/__snapshots__/queryShapes.test.ts.snap b/packages/hydra/test/lib/__snapshots__/queryShapes.test.ts.snap index dcb24b74..6bb83e37 100644 --- a/packages/hydra/test/lib/__snapshots__/queryShapes.test.ts.snap +++ b/packages/hydra/test/lib/__snapshots__/queryShapes.test.ts.snap @@ -265,6 +265,60 @@ _:t5 a sh:NodeShape ; " `; +exports[`@kopflos-cms/hydra/lib/queryShapes.js memberQueryShape using member query shape ignores :memberShape 1`] = ` +"@prefix sh: . +@prefix hydra: . +@prefix schema: . +@prefix rdf: . +@prefix kl: . + +_:t3 sh:property [ + sh:path schema:name ; + ] ; + sh:rule [ + rdf:type sh:TripleRule ; + sh:predicate hydra:member ; + sh:subject ; + sh:object sh:this ; + ], [ + rdf:type sh:TripleRule ; + sh:predicate rdf:type ; + sh:subject sh:this ; + sh:object ; + ] . + +_:t7 hydra:object ; + hydra:property rdf:type . + + a hydra:Collection ; + _:t3 ; + [ + sh:property [ + sh:path ; + ], [ + sh:path schema:name ; + ] ; + ] ; + hydra:memberAssertion _:t7 . + +_:t8 a sh:NodeShape ; + sh:target [ + rdf:type ; + sh:expression [ + sh:distinct [ + sh:filterShape [ + hydra:memberAssertion _:t7 ; + ] ; + ] ; + ] ; + ] ; + sh:and ( + _:t3 + ) . + +" +`; + exports[`@kopflos-cms/hydra/lib/queryShapes.js totalsQueryShape multiple member assertions returns correct total count 1`] = ` "@prefix sh: . @prefix hydra: . diff --git a/packages/hydra/test/lib/queryShapes.test.ts b/packages/hydra/test/lib/queryShapes.test.ts index 87f39edf..a0709aba 100644 --- a/packages/hydra/test/lib/queryShapes.test.ts +++ b/packages/hydra/test/lib/queryShapes.test.ts @@ -33,6 +33,19 @@ describe('@kopflos-cms/hydra/lib/queryShapes.js', () => { }) }) + context('using member query shape', () => { + it('ignores :memberShape', async function () { + // given + const collection = this.rdf.graph.namedNode(ex()) + + // when + const shape = memberQueryShape({ env, collection }) + + // then + expect(await serialize(shape.dataset)).toMatchSnapshot() + }) + }) + context('ordered collection', () => { it('returns shape with offset', async function () { // given diff --git a/packages/hydra/test/lib/queryShapes.test.ts.trig b/packages/hydra/test/lib/queryShapes.test.ts.trig index 9c6a51c0..929fcf2d 100644 --- a/packages/hydra/test/lib/queryShapes.test.ts.trig +++ b/packages/hydra/test/lib/queryShapes.test.ts.trig @@ -1,3 +1,4 @@ +PREFIX gn: PREFIX ex: PREFIX schema: PREFIX sh: @@ -21,6 +22,26 @@ GRAPH { . } +GRAPH { + ex: + a hydra:Collection ; + hydra:memberAssertion + [ + hydra:property rdf:type ; + hydra:object ; + ] ; + kl-hydra:memberQueryShape + [ + sh:property [ sh:path schema:name ] ; + ] ; + kl-hydra:memberShape + [ + sh:property [ sh:path schema:name ] ; + sh:property [ sh:path gn:featureCode ] ; + ] ; + . +} + GRAPH { ex: a hydra:Collection ;