Skip to content

Commit

Permalink
Merge pull request #129 from zazuko/list-in-message
Browse files Browse the repository at this point in the history
lists in result messages
  • Loading branch information
giacomociti committed May 7, 2024
2 parents 1bb0383 + f732f93 commit e01b85c
Show file tree
Hide file tree
Showing 11 changed files with 186 additions and 24 deletions.
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'])
})
})

0 comments on commit e01b85c

Please sign in to comment.