Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

lists in result messages #129

Merged
merged 5 commits into from
May 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/eighty-snakes-hide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"rdf-validate-shacl": patch
---

Improved result messages with lists
47 changes: 39 additions & 8 deletions src/dataset-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,52 @@ import NodeSet from './node-set.js'
*
* @param {DatasetCore} dataset
* @param {Term} startNode
* @returns Array of quads
* @yields {Quad}
*/
export function extractStructure(dataset, startNode, visited = new TermSet()) {
export function * extractStructure(dataset, startNode, visited = new TermSet()) {
if (startNode.termType !== 'BlankNode' || visited.has(startNode)) {
return []
return
}

visited.add(startNode)
const quads = [...dataset.match(startNode, null, null)]
for (const quad of dataset.match(startNode, null, null)) {
yield quad
yield * extractStructure(dataset, quad.object, visited)
}
}

/**
* Extracts all the quads forming the structure under a blank shape node. Stops at
* non-blank nodes. Replaces sh:in with a comment if the list is too long.
*
* @param {Shape} shape
* @param {DatasetCore} dataset
* @param {Term} startNode
* @yields {Quad}
*/
export function * extractSourceShapeStructure(shape, dataset, startNode, visited = new TermSet()) {
if (startNode.termType !== 'BlankNode' || visited.has(startNode)) {
return
}

const children = quads.map((quad) => {
return extractStructure(dataset, quad.object, visited)
})
const { factory } = shape.context
const { sh, rdfs } = shape.context.ns

return quads.concat(...children)
const inListSize = term => {
const inConstraint = shape.constraints.find(x => x.paramValue.equals(term))
return inConstraint?.nodeSet.size
}

visited.add(startNode)
for (const quad of dataset.match(startNode, null, null)) {
if (quad.predicate.equals(sh.in) && inListSize(quad.object) > 3) {
const msg = `sh:in has ${inListSize(quad.object)} elements and has been removed from the report for brevity. Please refer the original shape`
yield factory.quad(quad.subject, rdfs.comment, factory.literal(msg))
} else {
yield quad
yield * extractSourceShapeStructure(shape, dataset, quad.object, visited)
}
}
}

/**
Expand Down
10 changes: 9 additions & 1 deletion src/shapes-graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import NodeSet from './node-set.js'
import ValidationFunction from './validation-function.js'
import validatorsRegistry from './validators-registry.js'
import { extractPropertyPath, getPathObjects } from './property-path.js'
import { getInstancesOf, isInstanceOf } from './dataset-utils.js'
import { getInstancesOf, isInstanceOf, rdfListToArray } from './dataset-utils.js'

class ShapesGraph {
constructor(context) {
Expand Down Expand Up @@ -124,6 +124,14 @@ class Constraint {
get componentMessages() {
return this.component.getMessages(this.shape)
}

get nodeSet() {
const { sh } = this.shape.context.ns
if (!this.inNodeSet) {
this.inNodeSet = new NodeSet(rdfListToArray(this.shapeNodePointer.out(sh.in)))
}
return this.inNodeSet
}
}

class ConstraintComponent {
Expand Down
38 changes: 32 additions & 6 deletions src/validation-engine.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import clownface from 'clownface'
import debug from 'debug'
import ValidationReport from './validation-report.js'
import { extractStructure } from './dataset-utils.js'
import { extractStructure, extractSourceShapeStructure } from './dataset-utils.js'

const error = debug('validation-enging::error')

Expand All @@ -17,10 +17,10 @@ class ValidationEngine {
this.nestedResults = {}
}

clone () {
clone() {
return new ValidationEngine(this.context, { maxErrors: this.maxErrors })
}

initReport() {
const { rdf, sh } = this.context.ns

Expand Down Expand Up @@ -241,7 +241,7 @@ class ValidationEngine {
.addOut(sh.sourceShape, sourceShape)
.addOut(sh.focusNode, focusNode)

this.copyNestedStructure(sourceShape, result)
this.copySourceShapeStructure(constraint.shape, result)
this.copyNestedStructure(focusNode, result)

const children = this.nestedResults[this.recordErrorsLevel + 1]
Expand All @@ -266,6 +266,13 @@ class ValidationEngine {
}
}

copySourceShapeStructure(shape, result) {
const structureQuads = extractSourceShapeStructure(shape, this.context.$shapes.dataset, shape.shapeNode)
for (const quad of structureQuads) {
result.dataset.add(quad)
}
}

/**
* Creates a result message from the validation result and the message pattern in the constraint
*/
Expand Down Expand Up @@ -320,7 +327,16 @@ function localName(uri) {
return uri.substring(index + 1)
}

function nodeLabel(node) {
function * take(n, iterable) {
let i = 0
for (const item of iterable) {
if (i++ === n) break
yield item
}
}

function nodeLabel(constraint, param) {
const node = constraint.getParameterValue(param)
if (!node) {
return 'NULL'
}
Expand All @@ -331,6 +347,16 @@ function nodeLabel(node) {
}

if (node.termType === 'BlankNode') {
if (constraint.nodeSet) {
const limit = 3
if (constraint.nodeSet.size > limit) {
const prefix = Array.from(take(limit, constraint.nodeSet)).map(x => x.value)
return prefix.join(', ') + ` ... (and ${constraint.nodeSet.size - limit} more)`
} else {
return Array.from(constraint.nodeSet).map(x => x.value).join(', ')
}
}

return 'Blank node ' + node.value
}

Expand All @@ -340,7 +366,7 @@ function nodeLabel(node) {
function withSubstitutions(messageTerm, constraint, factory) {
const message = constraint.component.parameters.reduce((message, param) => {
const paramName = localName(param.value)
const paramValue = nodeLabel(constraint.getParameterValue(param))
const paramValue = nodeLabel(constraint, param)
return message
.replace(`{$${paramName}}`, paramValue)
.replace(`{?${paramName}}`, paramValue)
Expand Down
2 changes: 1 addition & 1 deletion src/validators-registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export default {
[ns.sh.InConstraintComponent.value]: {
validator: {
func: validators.validateIn,
message: 'Value is not in {$in}',
message: 'Value is not one of the allowed values: {$in}',
},
},
[ns.sh.LanguageInConstraintComponent.value]: {
Expand Down
9 changes: 1 addition & 8 deletions src/validators.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,14 +131,7 @@ function validateHasValueProperty(context, focusNode, valueNode, constraint) {
}

function validateIn(context, focusNode, valueNode, constraint) {
const { sh } = context.ns
if (!constraint.nodeSet) {
const inNode = constraint.getParameterValue(sh.in)
constraint.nodeSet = new NodeSet(rdfListToArray(context.$shapes.node(inNode)))
}
const { nodeSet } = constraint

return nodeSet.has(valueNode)
return constraint.nodeSet.has(valueNode)
}

function validateLanguageIn(context, focusNode, valueNode, constraint) {
Expand Down
11 changes: 11 additions & 0 deletions test/data/validation-message/message-with-list.ttl
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .

<Alice> a <Person> ; <category> "f" .

<ShapeWithList> a sh:NodeShape ;
sh:targetClass <Person> ;
sh:property [
sh:path <category> ;
sh:in ("a" "b" "c" "d" "e") ;
] .
12 changes: 12 additions & 0 deletions test/data/validation-sourceShape/long-list.ttl
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix ex: <http://example.com/> .

ex:p1 a ex:Person ; ex:category 3 .
ex:p2 a ex:Person ; ex:category 30, 40 .

ex:shape sh:targetClass ex:Person ;
sh:property [
sh:path ex:category ;
sh:minCount 2 ;
sh:in (1 2 3 4 5) ;
] .
11 changes: 11 additions & 0 deletions test/data/validation-sourceShape/mandatory-list.ttl
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix ex: <http://example.com/> .

ex:p1 a ex:Person .

ex:shape sh:targetClass ex:Person ;
sh:property [
sh:path ex:category ;
sh:minCount 1 ;
sh:in (1 2 3) ;
] .
13 changes: 13 additions & 0 deletions test/validation_message_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,17 @@ describe('validation messages', () => {
{ value: 'Mon message de validation', language: 'fr' },
]))
})

it('Lists first items in message', async () => {
const dataPath = path.join(rootPath, 'message-with-list.ttl')
const data = await loadDataset(dataPath)
const shapes = data

const validator = new SHACLValidator(shapes)
const report = validator.validate(data)

assert.strictEqual(report.results.length, 1)
assert.strictEqual(report.results[0].message.length, 1)
assert.strictEqual(report.results[0].message[0].value, 'Value is not one of the allowed values: a, b, c ... (and 2 more)')
})
})
52 changes: 52 additions & 0 deletions test/validation_sourceShape_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/* eslint-env mocha */
import path from 'path'
import assert from 'assert'
import * as url from 'url'
import clownface from 'clownface'
import SHACLValidator from '../index.js'
import ns from '../src/namespaces.js'
import { rdfListToArray } from '../src/dataset-utils.js'
import { loadDataset } from './utils.js'

const { rdfs, sh } = ns

const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
const rootPath = path.join(__dirname, '/data/validation-sourceShape')

describe('validation source shapes', () => {
it('Includes source shape without long list', async () => {
const dataPath = path.join(rootPath, 'long-list.ttl')
const data = await loadDataset(dataPath)
const shapes = data

const validator = new SHACLValidator(shapes)
const report = validator.validate(data)

// three results for the same source shape
assert.strictEqual(report.results.length, 3)
const sourceShapes = new Set(report.results.map(result => result.sourceShape))
assert.strictEqual(sourceShapes.size, 1)
const [shape] = sourceShapes
// the shape has a comment instead of sh:in
assert.deepEqual([], [...report.dataset.match(shape, sh.in, null)])
const [comment] = report.dataset.match(shape, rdfs.comment, null)
assert.strictEqual(comment.object.value, 'sh:in has 5 elements and has been removed from the report for brevity. Please refer the original shape')
})
it('Includes source shape with list', async () => {
const dataPath = path.join(rootPath, 'mandatory-list.ttl')
const data = await loadDataset(dataPath)
const shapes = data

const validator = new SHACLValidator(shapes)
const report = validator.validate(data)

assert.strictEqual(report.results.length, 1)
const sourceShapes = new Set(report.results.map(result => result.sourceShape))
assert.strictEqual(sourceShapes.size, 1)
const [shape] = sourceShapes
// the shape has the lst of allowed values even though the violated constraint is sh:minCount and not sh:in
const list = clownface({ dataset: report.dataset, term: shape }).out(sh.in)
const actual = rdfListToArray(list).map(term => term.value)
assert.deepEqual(actual, ['1', '2', '3'])
})
})
Loading