Skip to content

Commit 4f0020c

Browse files
committed
fix(shaclgen): emit sh:minCount/maxCount 0 for zero cardinality values
The SHACL generator used Python truthiness checks for minimum_cardinality and maximum_cardinality: if s.minimum_cardinality: # 0 is falsy! if s.maximum_cardinality: # 0 is falsy! Since int(0) evaluates as False in Python, setting maximum_cardinality: 0 (which should produce sh:maxCount 0, meaning the property MUST NOT appear) silently emitted nothing. This patch changes both checks to explicit `is not None` comparisons, matching the pattern already used in the OWL generator (owlgen.py lines 627-640) for the same attributes. sh:maxCount 0 is valid per the W3C SHACL specification and means "this property must not exist on any conforming node". This is the standard mechanism for suppressing inherited properties on subclasses via slot_usage with maximum_cardinality: 0. Signed-off-by: Carlo van Driesten <carlo.van-driesten@bmw.de>
1 parent 2b95fc5 commit 4f0020c

3 files changed

Lines changed: 61 additions & 2 deletions

File tree

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ def prop_pv_literal(p, v):
169169
prop_pv_literal(SH.name, s.title)
170170
prop_pv_literal(SH.description, s.description)
171171
# minCount
172-
if s.minimum_cardinality:
172+
if s.minimum_cardinality is not None:
173173
prop_pv_literal(SH.minCount, s.minimum_cardinality)
174174
elif s.exact_cardinality:
175175
prop_pv_literal(SH.minCount, s.exact_cardinality)
@@ -179,7 +179,7 @@ def prop_pv_literal(p, v):
179179
elif s.required and not s.identifier:
180180
prop_pv_literal(SH.minCount, 1)
181181
# maxCount
182-
if s.maximum_cardinality:
182+
if s.maximum_cardinality is not None:
183183
prop_pv_literal(SH.maxCount, s.maximum_cardinality)
184184
elif s.exact_cardinality:
185185
prop_pv_literal(SH.maxCount, s.exact_cardinality)

tests/linkml/test_generators/input/shaclgen/cardinality.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,17 @@ classes:
1717
slots:
1818
- list_exact_size
1919

20+
ParentClass:
21+
slots:
22+
- inherited_slot
23+
- restricted_slot
24+
25+
ChildWithZeroMaxCard:
26+
is_a: ParentClass
27+
slot_usage:
28+
restricted_slot:
29+
maximum_cardinality: 0
30+
2031
slots:
2132
list_min_max_size:
2233
range: integer
@@ -28,3 +39,11 @@ slots:
2839
range: integer
2940
multivalued: true
3041
exact_cardinality: 3
42+
43+
inherited_slot:
44+
range: string
45+
multivalued: true
46+
47+
restricted_slot:
48+
range: string
49+
multivalued: true

tests/linkml/test_generators/test_shaclgen.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,46 @@ def test_multivalued_slot_exact_cardinality(input_path):
568568
) in g
569569

570570

571+
def test_zero_maximum_cardinality_emits_maxcount(input_path):
572+
"""Test that maximum_cardinality: 0 correctly emits sh:maxCount 0.
573+
574+
Regression test for the bug where Python truthiness check
575+
`if s.maximum_cardinality:` would skip the value 0 (falsy),
576+
failing to emit sh:maxCount 0 in the generated SHACL shape.
577+
The fix uses `if s.maximum_cardinality is not None:` instead.
578+
579+
This is the primary mechanism for suppressing inherited slots on
580+
subclasses via slot_usage (e.g., OWL maxCardinality 0 pattern).
581+
"""
582+
shacl = ShaclGenerator(input_path("shaclgen/cardinality.yaml"), mergeimports=True).serialize()
583+
584+
g = rdflib.Graph()
585+
g.parse(data=shacl)
586+
587+
# Find the ChildWithZeroMaxCard shape
588+
child_uri = URIRef("https://w3id.org/linkml/examples/cardinality/ChildWithZeroMaxCard")
589+
restricted_slot_uri = URIRef("https://w3id.org/linkml/examples/cardinality/restricted_slot")
590+
591+
# Get all property shapes for the child class
592+
prop_nodes = list(g.objects(child_uri, SH.property))
593+
assert prop_nodes, "ChildWithZeroMaxCard should have property shapes"
594+
595+
# Find the property shape for restricted_slot
596+
restricted_prop_node = None
597+
for pn in prop_nodes:
598+
if (pn, SH.path, restricted_slot_uri) in g:
599+
restricted_prop_node = pn
600+
break
601+
assert restricted_prop_node is not None, "Should have a property shape for restricted_slot"
602+
603+
# The critical assertion: sh:maxCount 0 must be emitted
604+
max_count_values = list(g.objects(restricted_prop_node, SH.maxCount))
605+
assert len(max_count_values) == 1, f"Expected exactly one sh:maxCount, got {max_count_values}"
606+
assert max_count_values[0] == rdflib.term.Literal(
607+
0, datatype=rdflib.term.URIRef("http://www.w3.org/2001/XMLSchema#integer")
608+
), f"sh:maxCount should be 0, got {max_count_values[0]}"
609+
610+
571611
def test_exclude_imports(input_path):
572612
shacl = ShaclGenerator(
573613
input_path("shaclgen/exclude_imports.yaml"), mergeimports=True, exclude_imports=True

0 commit comments

Comments
 (0)