Skip to content

Commit 69f833a

Browse files
committed
fix(shaclgen): emit sh:minCount/maxCount 0 for zero cardinality values
Python truthiness check `if s.maximum_cardinality:` evaluates to False when the value is 0 (an integer), silently skipping sh:maxCount 0 emission. The same bug affected minimum_cardinality and exact_cardinality. Replace all three truthiness checks with explicit `is not None` guards: - `if s.minimum_cardinality is not None:` - `if s.maximum_cardinality is not None:` - `elif s.exact_cardinality is not None:` (two occurrences) Add regression tests: - test_zero_maximum_cardinality_emits_maxcount - test_zero_exact_cardinality_emits_both_counts This is the primary mechanism for suppressing inherited slots on subclasses via slot_usage (OWL maxCardinality 0 pattern). Signed-off-by: Carlo van Driesten <carlo.van-driesten@bmw.de>
1 parent e6bf483 commit 69f833a

3 files changed

Lines changed: 104 additions & 4 deletions

File tree

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -170,19 +170,19 @@ def prop_pv_literal(p, v):
170170
prop_pv_literal(SH.name, s.title)
171171
prop_pv_literal(SH.description, s.description)
172172
# minCount
173-
if s.minimum_cardinality:
173+
if s.minimum_cardinality is not None:
174174
prop_pv_literal(SH.minCount, s.minimum_cardinality)
175-
elif s.exact_cardinality:
175+
elif s.exact_cardinality is not None:
176176
prop_pv_literal(SH.minCount, s.exact_cardinality)
177177
# Identifiers map to the node's IRI rather than a property triple,
178178
# so there's no arc to constrain with sh:minCount 1 — emitting it
179179
# would cause spurious violations on every instance.
180180
elif s.required and not s.identifier:
181181
prop_pv_literal(SH.minCount, 1)
182182
# maxCount
183-
if s.maximum_cardinality:
183+
if s.maximum_cardinality is not None:
184184
prop_pv_literal(SH.maxCount, s.maximum_cardinality)
185-
elif s.exact_cardinality:
185+
elif s.exact_cardinality is not None:
186186
prop_pv_literal(SH.maxCount, s.exact_cardinality)
187187
elif not s.multivalued:
188188
prop_pv_literal(SH.maxCount, 1)

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,23 @@ 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+
31+
ChildWithZeroExactCard:
32+
is_a: ParentClass
33+
slot_usage:
34+
restricted_slot:
35+
exact_cardinality: 0
36+
2037
slots:
2138
list_min_max_size:
2239
range: integer
@@ -28,3 +45,11 @@ slots:
2845
range: integer
2946
multivalued: true
3047
exact_cardinality: 3
48+
49+
inherited_slot:
50+
range: string
51+
multivalued: true
52+
53+
restricted_slot:
54+
range: string
55+
multivalued: true

tests/linkml/test_generators/test_shaclgen.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,81 @@ def test_multivalued_slot_exact_cardinality(input_path):
577577
) in g
578578

579579

580+
def test_zero_maximum_cardinality_emits_maxcount(input_path):
581+
"""Test that maximum_cardinality: 0 correctly emits sh:maxCount 0.
582+
583+
Regression test for the bug where Python truthiness check
584+
`if s.maximum_cardinality:` would skip the value 0 (falsy),
585+
failing to emit sh:maxCount 0 in the generated SHACL shape.
586+
The fix uses `if s.maximum_cardinality is not None:` instead.
587+
588+
This is the primary mechanism for suppressing inherited slots on
589+
subclasses via slot_usage (e.g., OWL maxCardinality 0 pattern).
590+
"""
591+
shacl = ShaclGenerator(input_path("shaclgen/cardinality.yaml"), mergeimports=True).serialize()
592+
593+
g = rdflib.Graph()
594+
g.parse(data=shacl)
595+
596+
# Find the ChildWithZeroMaxCard shape
597+
child_uri = URIRef("https://w3id.org/linkml/examples/cardinality/ChildWithZeroMaxCard")
598+
restricted_slot_uri = URIRef("https://w3id.org/linkml/examples/cardinality/restricted_slot")
599+
600+
# Get all property shapes for the child class
601+
prop_nodes = list(g.objects(child_uri, SH.property))
602+
assert prop_nodes, "ChildWithZeroMaxCard should have property shapes"
603+
604+
# Find the property shape for restricted_slot
605+
restricted_prop_node = None
606+
for pn in prop_nodes:
607+
if (pn, SH.path, restricted_slot_uri) in g:
608+
restricted_prop_node = pn
609+
break
610+
assert restricted_prop_node is not None, "Should have a property shape for restricted_slot"
611+
612+
# The critical assertion: sh:maxCount 0 must be emitted
613+
max_count_values = list(g.objects(restricted_prop_node, SH.maxCount))
614+
assert len(max_count_values) == 1, f"Expected exactly one sh:maxCount, got {max_count_values}"
615+
assert max_count_values[0] == rdflib.term.Literal(
616+
0, datatype=rdflib.term.URIRef("http://www.w3.org/2001/XMLSchema#integer")
617+
), f"sh:maxCount should be 0, got {max_count_values[0]}"
618+
619+
620+
def test_zero_exact_cardinality_emits_both_counts(input_path):
621+
"""Test that exact_cardinality: 0 emits both sh:minCount 0 and sh:maxCount 0.
622+
623+
Same truthiness bug as maximum_cardinality: `if s.exact_cardinality:`
624+
skips value 0 (falsy). The fix uses `is not None` instead.
625+
"""
626+
shacl = ShaclGenerator(input_path("shaclgen/cardinality.yaml"), mergeimports=True).serialize()
627+
628+
g = rdflib.Graph()
629+
g.parse(data=shacl)
630+
631+
child_uri = URIRef("https://w3id.org/linkml/examples/cardinality/ChildWithZeroExactCard")
632+
restricted_slot_uri = URIRef("https://w3id.org/linkml/examples/cardinality/restricted_slot")
633+
634+
prop_nodes = list(g.objects(child_uri, SH.property))
635+
assert prop_nodes, "ChildWithZeroExactCard should have property shapes"
636+
637+
restricted_prop_node = None
638+
for pn in prop_nodes:
639+
if (pn, SH.path, restricted_slot_uri) in g:
640+
restricted_prop_node = pn
641+
break
642+
assert restricted_prop_node is not None, "Should have a property shape for restricted_slot"
643+
644+
XSD_INT = rdflib.term.URIRef("http://www.w3.org/2001/XMLSchema#integer")
645+
646+
min_count_values = list(g.objects(restricted_prop_node, SH.minCount))
647+
assert len(min_count_values) == 1, f"Expected exactly one sh:minCount, got {min_count_values}"
648+
assert min_count_values[0] == rdflib.term.Literal(0, datatype=XSD_INT)
649+
650+
max_count_values = list(g.objects(restricted_prop_node, SH.maxCount))
651+
assert len(max_count_values) == 1, f"Expected exactly one sh:maxCount, got {max_count_values}"
652+
assert max_count_values[0] == rdflib.term.Literal(0, datatype=XSD_INT)
653+
654+
580655
def test_exclude_imports(input_path):
581656
shacl = ShaclGenerator(
582657
input_path("shaclgen/exclude_imports.yaml"), mergeimports=True, exclude_imports=True

0 commit comments

Comments
 (0)