Skip to content

Commit d7da1f1

Browse files
committed
ExpressionConstraintComponent is implemented!
- Use your previously defined SHACL Functions to express complex constraints - Added DASH-tests for ExpressionConstraintComponent - Added advanced tests for ExpressionConstraintComponent, SHACLRules, and SHACLFunctions. - New Advanced features example, showcasing ExpressionConstraint and others features - Allow sh:message to be attached to an expression block, without breaking its functionality - A SHACL Function within a SHACL Expression now must be a list-valued property. - Refactored node-expression and path-expression methods to be common and reusable code - Re-black and isort all source files
1 parent 137d8a0 commit d7da1f1

File tree

17 files changed

+822
-288
lines changed

17 files changed

+822
-288
lines changed

CHANGELOG.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Python PEP 440 Versioning](https://www.python.org/dev/peps/pep-0440/).
66

7+
## [0.16.1] - 2021-08-20
8+
9+
### Added
10+
- [ExpressionConstraintComponent](https://www.w3.org/TR/shacl-af/#ExpressionConstraintComponent) is implemented!
11+
- Use your previously defined SHACL Functions to express complex constraints
12+
- Added DASH-tests for ExpressionConstraintComponent
13+
- Added advanced tests for ExpressionConstraintComponent, SHACLRules, and SHACLFunctions.
14+
- New Advanced features example, showcasing ExpressionConstraint and others features
15+
16+
### Changed
17+
- Allow sh:message to be attached to an expression block, without breaking its functionality
18+
- A SHACL Function within a SHACL Expression now must be a list-valued property.
19+
- Refactored node-expression and path-expression methods to be common and reusable code
20+
- Re-black and isort all source files
21+
22+
723
## [0.16.0] - 2021-08-19
824

925
### Changed
@@ -772,7 +788,8 @@ just leaves the files open. Now it is up to the command-line client to close the
772788

773789
- Initial version, limited functionality
774790

775-
[Unreleased]: https://github.com/RDFLib/pySHACL/compare/v0.16.0...HEAD
791+
[Unreleased]: https://github.com/RDFLib/pySHACL/compare/v0.16.1...HEAD
792+
[0.16.1]: https://github.com/RDFLib/pySHACL/compare/v0.16.0...v0.16.1
776793
[0.16.0]: https://github.com/RDFLib/pySHACL/compare/v0.15.0...v0.16.0
777794
[0.15.0]: https://github.com/RDFLib/pySHACL/compare/v0.14.5...v0.15.0
778795
[0.14.5]: https://github.com/RDFLib/pySHACL/compare/v0.14.4...v0.14.5

CITATION.cff

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ authors:
88
given-names: "Nicholas"
99
orcid: "http://orcid.org/0000-0002-8742-7730"
1010
title: "pySHACL"
11-
version: 0.16.0
11+
version: 0.16.1
1212
doi: 10.5281/zenodo.4750840
1313
license: Apache-2.0
1414
date-released: 2021-07-20

FEATURES.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@
148148
### [Expression Constraints][AFExpression]
149149
| Path | Link | Status | Comments |
150150
|:---------- |:------: |:-------------: |:------: |
151-
| `sh:ExpressionConstraintComponent` | [][AFExpression] | ![status-missing] | |
151+
| `sh:ExpressionConstraintComponent` | [][AFExpression] | ![status-complete] | |
152152

153153
### [SHACL Rules](https://www.w3.org/TR/shacl-af/#rules)
154154
| Parameter | Link | Status | Comments |

examples/advanced.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""\
2+
A cool test that combines a bunch of SHACL-AF features, including:
3+
SHACL Functions (implemented as SPARQL functions)
4+
SHACL Rules
5+
Node Expressions
6+
Expression Constraint
7+
"""
8+
9+
from pyshacl import validate
10+
from rdflib import Graph
11+
12+
shacl_file = '''\
13+
# prefix: ex
14+
15+
@prefix ex: <http://datashapes.org/shasf/tests/expression/advanced.test.shacl#> .
16+
@prefix exOnt: <http://datashapes.org/shasf/tests/expression/advanced.test.ont#> .
17+
@prefix exData: <http://datashapes.org/shasf/tests/expression/advanced.test.data#> .
18+
@prefix owl: <http://www.w3.org/2002/07/owl#> .
19+
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
20+
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
21+
@prefix sh: <http://www.w3.org/ns/shacl#> .
22+
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
23+
24+
<http://datashapes.org/shasf/tests/expression/advanced.test.shacl>
25+
rdf:type owl:Ontology ;
26+
rdfs:label "Test of advanced features" ;
27+
.
28+
29+
ex:concat
30+
a sh:SPARQLFunction ;
31+
rdfs:comment "Concatenates strings $op1 and $op2." ;
32+
sh:parameter [
33+
sh:path ex:op1 ;
34+
sh:datatype xsd:string ;
35+
sh:description "The first string" ;
36+
] ;
37+
sh:parameter [
38+
sh:path ex:op2 ;
39+
sh:datatype xsd:string ;
40+
sh:description "The second string" ;
41+
] ;
42+
sh:returnType xsd:string ;
43+
sh:select """
44+
SELECT ?result
45+
WHERE {
46+
BIND(CONCAT(STR(?op1),STR(?op2)) AS ?result) .
47+
}
48+
""" .
49+
50+
ex:strlen
51+
a sh:SPARQLFunction ;
52+
rdfs:comment "Returns length of the given string." ;
53+
sh:parameter [
54+
sh:path ex:op1 ;
55+
sh:datatype xsd:string ;
56+
sh:description "The string" ;
57+
] ;
58+
sh:returnType xsd:integer ;
59+
sh:select """
60+
SELECT ?result
61+
WHERE {
62+
BIND(STRLEN(?op1) AS ?result) .
63+
}
64+
""" .
65+
66+
ex:lessThan
67+
a sh:SPARQLFunction ;
68+
rdfs:comment "Returns True if op1 < op2." ;
69+
sh:parameter [
70+
sh:path ex:op1 ;
71+
sh:datatype xsd:integer ;
72+
sh:description "The first int" ;
73+
] ;
74+
sh:parameter [
75+
sh:path ex:op2 ;
76+
sh:datatype xsd:integer ;
77+
sh:description "The second int" ;
78+
] ;
79+
sh:returnType xsd:boolean ;
80+
sh:select """
81+
SELECT ?result
82+
WHERE {
83+
BIND(IF(?op1 < ?op2, true, false) AS ?result) .
84+
}
85+
""" .
86+
87+
ex:PersonExpressionShape
88+
a sh:NodeShape ;
89+
sh:targetClass exOnt:Person ;
90+
sh:expression [
91+
sh:message "Person's firstName and lastName together should be less than 35 chars long." ;
92+
ex:lessThan (
93+
[ ex:strlen (
94+
[ ex:concat ( [ sh:path exOnt:firstName] [ sh:path exOnt:lastName ] ) ] )
95+
]
96+
35 );
97+
] .
98+
99+
ex:PersonRuleShape
100+
a sh:NodeShape ;
101+
sh:targetClass exOnt:Administrator ;
102+
sh:message "An administrator is a person too." ;
103+
sh:rule [
104+
a sh:TripleRule ;
105+
sh:subject sh:this ;
106+
sh:predicate rdf:type ;
107+
sh:object exOnt:Person ;
108+
] .
109+
'''
110+
111+
data_graph = '''
112+
# prefix: ex
113+
114+
@prefix ex: <http://datashapes.org/shasf/tests/expression/advanced.test.data#> .
115+
@prefix exOnt: <http://datashapes.org/shasf/tests/expression/advanced.test.ont#> .
116+
@prefix owl: <http://www.w3.org/2002/07/owl#> .
117+
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
118+
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
119+
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
120+
121+
ex:Kate
122+
rdf:type exOnt:Person ;
123+
exOnt:firstName "Kate" ;
124+
exOnt:lastName "Jones" ;
125+
.
126+
127+
ex:Jenny
128+
rdf:type exOnt:Administrator ;
129+
exOnt:firstName "Jennifer" ;
130+
exOnt:lastName "Wolfeschlegelsteinhausenbergerdorff" ;
131+
.
132+
'''
133+
134+
if __name__ == "__main__":
135+
d = Graph().parse(data=data_graph, format="turtle")
136+
s = Graph().parse(data=shacl_file, format="turtle")
137+
conforms, report, message = validate(d, shacl_graph=s, advanced=True, debug=False)
138+
print(message)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
44

55
[tool.poetry]
66
name = "pyshacl"
7-
version = "0.16.0"
7+
version = "0.16.1"
88
# Don't forget to change the version number in __init__.py and CITATION.cff along with this one
99
description = "Python SHACL Validator"
1010
license = "Apache-2.0"

pyshacl/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77

88
# version compliant with https://www.python.org/dev/peps/pep-0440/
9-
__version__ = '0.16.0'
9+
__version__ = '0.16.1'
1010
# Don't forget to change the version number in pyproject.toml and CITATION.cff along with this one
1111

1212
__all__ = ['validate', 'Validator', '__version__', 'Shape', 'ShapesGraph']
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
"""
4+
SHACL-AF Advanced Constraints
5+
https://www.w3.org/TR/shacl-af/#ExpressionConstraintComponent
6+
"""
7+
import typing
8+
9+
from typing import Dict, List
10+
11+
from rdflib import Literal
12+
13+
from pyshacl.constraints.constraint_component import ConstraintComponent
14+
from pyshacl.consts import SH, SH_message
15+
from pyshacl.errors import ConstraintLoadError
16+
from pyshacl.helper.expression_helper import nodes_from_node_expression
17+
from pyshacl.pytypes import GraphLike
18+
19+
20+
SH_expression = SH.expression
21+
SH_ExpressionConstraintComponent = SH.ExpressionConstraintComponent
22+
23+
if typing.TYPE_CHECKING:
24+
from pyshacl.shape import Shape
25+
26+
27+
class ExpressionConstraint(ConstraintComponent):
28+
29+
shacl_constraint_component = SH_ExpressionConstraintComponent
30+
31+
def __init__(self, shape: 'Shape'):
32+
super(ExpressionConstraint, self).__init__(shape)
33+
self.expr_nodes = list(self.shape.objects(SH_expression))
34+
if len(self.expr_nodes) < 1:
35+
raise ConstraintLoadError(
36+
"ExpressionConstraintComponent must have at least one sh:expression predicate.",
37+
"https://www.w3.org/TR/shacl-af/#ExpressionConstraintComponent",
38+
)
39+
40+
@classmethod
41+
def constraint_parameters(cls):
42+
return [SH_expression]
43+
44+
@classmethod
45+
def constraint_name(cls):
46+
return "ExpressionConstraintComponent"
47+
48+
def make_generic_messages(self, datagraph: GraphLike, focus_node, value_node) -> List[Literal]:
49+
return [Literal("Expression evaluation generated constraint did not return true.")]
50+
51+
def evaluate(self, data_graph: GraphLike, focus_value_nodes: Dict, _evaluation_path: List):
52+
"""
53+
:type data_graph: rdflib.Graph
54+
:type focus_value_nodes: dict
55+
:type _evaluation_path: list
56+
"""
57+
reports = []
58+
non_conformant = False
59+
for n in self.expr_nodes:
60+
_n, _r = self._evaluate_expression(data_graph, focus_value_nodes, n)
61+
non_conformant = non_conformant or _n
62+
reports.extend(_r)
63+
return (not non_conformant), reports
64+
65+
def _evaluate_expression(self, data_graph, f_v_dict, expr):
66+
reports = []
67+
non_conformant = False
68+
messages = list(self.shape.sg.objects(expr, SH_message))
69+
if len(messages):
70+
messages = [next(iter(messages))]
71+
else:
72+
messages = None
73+
for f, value_nodes in f_v_dict.items():
74+
for v in value_nodes:
75+
try:
76+
n_set = nodes_from_node_expression(expr, v, data_graph, self.shape.sg)
77+
if (
78+
isinstance(n_set, (list, set))
79+
and len(n_set) == 1
80+
and next(iter(n_set)) in (Literal(True), True)
81+
):
82+
...
83+
else:
84+
non_conformant = non_conformant or True
85+
reports.append(
86+
self.make_v_result(
87+
data_graph, f, value_node=v, source_constraint=expr, extra_messages=messages
88+
)
89+
)
90+
except Exception as e:
91+
print(e)
92+
raise
93+
return non_conformant, reports

pyshacl/constraints/constraint_component.py

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@
99

1010
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Set, Tuple
1111

12-
import rdflib
13-
1412
from rdflib import BNode, Literal, URIRef
1513

1614
from pyshacl.consts import (
@@ -39,6 +37,8 @@
3937

4038

4139
if TYPE_CHECKING:
40+
from rdflib.term import Identifier
41+
4242
from pyshacl.shape import Shape
4343
from pyshacl.shapes_graph import ShapesGraph
4444

@@ -113,9 +113,9 @@ def recursion_triggers(self, _evaluation_path):
113113
def make_v_result_description(
114114
self,
115115
datagraph: GraphLike,
116-
focus_node: 'rdflib.term.Identifier',
116+
focus_node: 'Identifier',
117117
severity: URIRef,
118-
value_node: Optional['rdflib.term.Identifier'],
118+
value_node: Optional['Identifier'],
119119
messages: List[str],
120120
result_path=None,
121121
constraint_component=None,
@@ -195,27 +195,28 @@ def make_v_result_description(
195195
def make_v_result(
196196
self,
197197
datagraph: GraphLike,
198-
focus_node: 'rdflib.term.Identifier',
199-
value_node: Optional['rdflib.term.Identifier'] = None,
200-
result_path=None,
201-
constraint_component=None,
202-
source_constraint=None,
198+
focus_node: 'Identifier',
199+
value_node: Optional['Identifier'] = None,
200+
result_path: Optional['Identifier'] = None,
201+
constraint_component: Optional['Identifier'] = None,
202+
source_constraint: Optional['Identifier'] = None,
203203
extra_messages: Optional[Iterable] = None,
204204
bound_vars=None,
205205
):
206206
"""
207207
:param datagraph:
208208
:type datagraph: rdflib.Graph | rdflib.ConjunctiveGraph | rdflib.Dataset
209209
:param focus_node:
210-
:type focus_node: rdflib.term.Identifier
210+
:type focus_node: Identifier
211211
:param value_node:
212-
:type value_node: rdflib.term.Identifier | None
212+
:type value_node: Identifier | None
213213
:param result_path:
214-
:param bound_vars:
214+
:type result_path: Identifier | None
215215
:param constraint_component:
216216
:param source_constraint:
217217
:param extra_messages:
218218
:type extra_messages: collections.abc.Iterable | None
219+
:param bound_vars:
219220
:return:
220221
"""
221222
constraint_component = constraint_component or self.shacl_constraint_component
@@ -228,13 +229,13 @@ def make_v_result(
228229
r_triples.append((r_node, SH_sourceShape, (sg, self.shape.node)))
229230
r_triples.append((r_node, SH_resultSeverity, severity))
230231
r_triples.append((r_node, SH_focusNode, (datagraph or sg, focus_node)))
231-
if value_node:
232+
if value_node is not None:
232233
r_triples.append((r_node, SH_value, (datagraph, value_node)))
233234
if result_path is None and self.shape.is_property_shape:
234235
result_path = self.shape.path()
235-
if result_path:
236+
if result_path is not None:
236237
r_triples.append((r_node, SH_resultPath, (sg, result_path)))
237-
if source_constraint:
238+
if source_constraint is not None:
238239
r_triples.append((r_node, SH_sourceConstraint, (sg, source_constraint)))
239240
messages = list(self.shape.message)
240241
if extra_messages:

0 commit comments

Comments
 (0)