Skip to content

Commit 6630206

Browse files
committed
fix(shaclgen): emit sh:pattern for pattern constraints inside any_of
The SHACL generator translated any_of branches by dispatching solely on `any.range` (class, type, enum, or simple datatype). If a branch specified `pattern:` — either alone or combined with a range — the constraint was silently dropped, producing an empty blank node `[ ]` (trivially satisfied) instead of the intended `[ sh:pattern "..." ]`. This is a problem for schemas that use pattern alternatives in `any_of`, such as the SPDX license field where valid values are either members of a fixed enum (SPDX identifiers), IRIs, or custom identifiers matching the LicenseRef- pattern defined in SPDX Specification v2.3 Annex D (ABNF: license-ref = ["DocumentRef-"(idstring)":"]"LicenseRef-"(idstring)). The fix adds a single check after the range dispatch: if any.pattern: g.add((range_list[-1], SH.pattern, Literal(any.pattern))) This correctly handles: - Pattern-only branches (no range): node gets only sh:pattern - Range + pattern branches: node gets both sh:datatype and sh:pattern - Range-only branches (no pattern): unchanged behaviour The test suite now includes a dedicated schema exercising all three cases, with assertions on both the generated RDF triples and pyshacl validation of conforming/non-conforming data. Signed-off-by: Carlo van Driesten <carlo.van-driesten@bmw.de>
1 parent 2b95fc5 commit 6630206

3 files changed

Lines changed: 131 additions & 0 deletions

File tree

packages/linkml/src/linkml/generators/shaclgen.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,11 @@ def st_node_pv(p, v):
237237

238238
add_simple_data_type(st_node_pv, r)
239239
range_list.append(st_node)
240+
# Propagate pattern constraint to the branch node.
241+
# A branch may combine range + pattern (e.g. range: string
242+
# with pattern: "^...") or specify pattern alone (no range).
243+
if any.pattern:
244+
g.add((range_list[-1], SH.pattern, Literal(any.pattern)))
240245
Collection(g, or_node, range_list)
241246
else:
242247
prop_pv_literal(SH.hasValue, s.equals_number)
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
id: https://w3id.org/linkml/examples/any_of_pattern
2+
name: test_any_of_pattern
3+
description: >-
4+
Test schema for pattern constraints inside any_of branches.
5+
Exercises three cases: (1) pattern-only branch (no range),
6+
(2) range + pattern on the same branch, (3) mixed branches
7+
where some have pattern and some do not.
8+
prefixes:
9+
linkml: https://w3id.org/linkml/
10+
ex: https://w3id.org/linkml/examples/any_of_pattern/
11+
imports:
12+
- linkml:types
13+
default_range: string
14+
default_prefix: ex
15+
16+
enums:
17+
LicenseEnum:
18+
permissible_values:
19+
MIT:
20+
Apache-2.0:
21+
GPL-3.0-only:
22+
23+
classes:
24+
PatternOnlyBranch:
25+
description: >-
26+
A class where one any_of branch specifies only a pattern
27+
(no range). The generated SHACL sh:or should contain a
28+
node with sh:pattern but no sh:datatype or sh:class.
29+
attributes:
30+
license:
31+
any_of:
32+
- range: LicenseEnum
33+
- range: uri
34+
- pattern: "^LicenseRef-[a-zA-Z0-9\\-\\.]+$"
35+
36+
RangeWithPattern:
37+
description: >-
38+
A class where an any_of branch combines range + pattern.
39+
The generated SHACL sh:or node should have both sh:datatype
40+
and sh:pattern.
41+
attributes:
42+
identifier:
43+
any_of:
44+
- range: string
45+
pattern: "^[A-Z]{2}-[0-9]{4}$"
46+
- range: integer
47+
48+
MixedBranches:
49+
description: >-
50+
A class with three any_of branches: one with range only,
51+
one with pattern only, one with range + pattern. Ensures
52+
pattern is emitted only on branches that declare it.
53+
attributes:
54+
code:
55+
any_of:
56+
- range: integer
57+
- pattern: "^CUSTOM-.*$"
58+
- range: string
59+
pattern: "^STD-[0-9]+$"

tests/linkml/test_generators/test_shaclgen.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1160,3 +1160,70 @@ def test_nodeidentifier_range_produces_blank_node_or_iri():
11601160
uri_ref = props["https://example.org/uriRef"]
11611161
uri_kinds = list(g.objects(uri_ref, SH.nodeKind))
11621162
assert SH.IRI in uri_kinds, f"Expected sh:IRI for uri, got {uri_kinds}"
1163+
1164+
1165+
def test_any_of_with_pattern(input_path):
1166+
"""Test that pattern constraints inside any_of branches emit sh:pattern.
1167+
1168+
Exercises three cases:
1169+
1. PatternOnlyBranch: any_of with a pattern-only branch (no range)
1170+
2. RangeWithPattern: any_of with range + pattern on the same branch
1171+
3. MixedBranches: combination of range-only, pattern-only, and range+pattern
1172+
"""
1173+
shacl = ShaclGenerator(input_path("shaclgen/any_of_pattern.yaml"), mergeimports=True).serialize()
1174+
g = rdflib.Graph()
1175+
g.parse(data=shacl)
1176+
1177+
def get_or_branch_nodes(class_uri: str, slot_local: str) -> list[rdflib.BNode]:
1178+
"""Return the list of BNodes inside sh:or for a given class property."""
1179+
class_ref = URIRef(class_uri)
1180+
for prop_node in g.objects(class_ref, SH.property):
1181+
paths = list(g.objects(prop_node, SH.path))
1182+
if any(slot_local in str(p) for p in paths):
1183+
for or_head in g.objects(prop_node, SH["or"]):
1184+
return list(Collection(g, or_head))
1185+
return []
1186+
1187+
prefix = "https://w3id.org/linkml/examples/any_of_pattern/"
1188+
1189+
# Case 1: PatternOnlyBranch — license slot has 3 branches:
1190+
# [enum sh:in], [sh:nodeKind sh:IRI], [sh:pattern "^LicenseRef-..."]
1191+
branches = get_or_branch_nodes(f"{prefix}PatternOnlyBranch", "license")
1192+
assert len(branches) == 3, f"Expected 3 branches, got {len(branches)}"
1193+
# Find the branch with sh:pattern
1194+
pattern_branches = [b for b in branches if list(g.objects(b, SH.pattern))]
1195+
assert len(pattern_branches) == 1, f"Expected 1 pattern branch, got {len(pattern_branches)}"
1196+
pattern_val = str(list(g.objects(pattern_branches[0], SH.pattern))[0])
1197+
assert pattern_val == "^LicenseRef-[a-zA-Z0-9\\-\\.]+$"
1198+
# The pattern-only branch should NOT have sh:datatype or sh:class
1199+
assert list(g.objects(pattern_branches[0], SH.datatype)) == []
1200+
assert list(g.objects(pattern_branches[0], SH["class"])) == []
1201+
1202+
# Case 2: RangeWithPattern — identifier slot has 2 branches:
1203+
# [sh:datatype xsd:string + sh:pattern "^[A-Z]{2}-[0-9]{4}$"], [sh:datatype xsd:integer]
1204+
branches = get_or_branch_nodes(f"{prefix}RangeWithPattern", "identifier")
1205+
assert len(branches) == 2, f"Expected 2 branches, got {len(branches)}"
1206+
# Find branch with both datatype and pattern
1207+
combo_branches = [
1208+
b for b in branches if list(g.objects(b, SH.datatype)) and list(g.objects(b, SH.pattern))
1209+
]
1210+
assert len(combo_branches) == 1, f"Expected 1 combo branch, got {len(combo_branches)}"
1211+
assert str(list(g.objects(combo_branches[0], SH.pattern))[0]) == "^[A-Z]{2}-[0-9]{4}$"
1212+
# The other branch (integer) should NOT have sh:pattern
1213+
int_branches = [b for b in branches if b not in combo_branches]
1214+
assert list(g.objects(int_branches[0], SH.pattern)) == []
1215+
1216+
# Case 3: MixedBranches — code slot has 3 branches:
1217+
# [sh:datatype xsd:integer], [sh:pattern "^CUSTOM-.*$"], [sh:datatype xsd:string + sh:pattern "^STD-[0-9]+$"]
1218+
branches = get_or_branch_nodes(f"{prefix}MixedBranches", "code")
1219+
assert len(branches) == 3, f"Expected 3 branches, got {len(branches)}"
1220+
# Exactly 2 branches should have sh:pattern
1221+
pattern_branches = [b for b in branches if list(g.objects(b, SH.pattern))]
1222+
assert len(pattern_branches) == 2, f"Expected 2 pattern branches, got {len(pattern_branches)}"
1223+
# Collect the patterns
1224+
patterns = sorted(str(list(g.objects(b, SH.pattern))[0]) for b in pattern_branches)
1225+
assert patterns == ["^CUSTOM-.*$", "^STD-[0-9]+$"]
1226+
# The integer-only branch should have no pattern
1227+
no_pattern = [b for b in branches if not list(g.objects(b, SH.pattern))]
1228+
assert len(no_pattern) == 1
1229+
assert list(g.objects(no_pattern[0], SH.datatype)) == [URIRef("http://www.w3.org/2001/XMLSchema#integer")]

0 commit comments

Comments
 (0)