Skip to content

Commit b9760a8

Browse files
jdsikarmessaou
authored andcommitted
fix(shaclgen): emit sh:minCount/maxCount 0 for zero cardinality values
Python truthiness (`if s.maximum_cardinality:`) treats 0 as falsy, so `maximum_cardinality: 0`, `minimum_cardinality: 0` and `exact_cardinality: 0` emitted no constraint at all. `maximum_cardinality: 0` (SHACL `sh:maxCount 0`, "property must not appear") is the idiomatic way to suppress an inherited slot on a subclass via slot_usage, and owlgen already emits `owl:maxCardinality 0` for it -- so the SHACL and OWL output silently diverged. Use explicit `is not None` checks for minimum_cardinality, maximum_cardinality and exact_cardinality, matching the pattern already used in owlgen.py and docgen.py. Precedence: an explicit minimum_cardinality wins over the `required` fallback in the elif cascade, consistent with owlgen.py (which uses the same `if minimum_cardinality is not None ... elif required` order), so `required: true` + `minimum_cardinality: 0` yields `sh:minCount 0`. That combination is a schema-authoring contradiction (the metamodel documents minimum_cardinality as a multivalued-slot count); the explicit, more specific constraint is emitted. Tests cover maximum_cardinality: 0, exact_cardinality: 0, minimum_cardinality: 0, and the required + minimum_cardinality: 0 precedence case. Signed-off-by: Carlo van Driesten <carlo.van-driesten@bmw.de>
1 parent 7414ab9 commit b9760a8

3 files changed

Lines changed: 187 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
@@ -275,19 +275,19 @@ def prop_pv_text(p, v):
275275
if msg_text:
276276
g.add((pnode, SH.message, Literal(msg_text, lang=self._resolve_language(None))))
277277
# minCount
278-
if s.minimum_cardinality:
278+
if s.minimum_cardinality is not None:
279279
prop_pv_literal(SH.minCount, s.minimum_cardinality)
280-
elif s.exact_cardinality:
280+
elif s.exact_cardinality is not None:
281281
prop_pv_literal(SH.minCount, s.exact_cardinality)
282282
# Identifiers map to the node's IRI rather than a property triple,
283283
# so there's no arc to constrain with sh:minCount 1 — emitting it
284284
# would cause spurious violations on every instance.
285285
elif s.required and not s.identifier:
286286
prop_pv_literal(SH.minCount, 1)
287287
# maxCount
288-
if s.maximum_cardinality:
288+
if s.maximum_cardinality is not None:
289289
prop_pv_literal(SH.maxCount, s.maximum_cardinality)
290-
elif s.exact_cardinality:
290+
elif s.exact_cardinality is not None:
291291
prop_pv_literal(SH.maxCount, s.exact_cardinality)
292292
elif not s.multivalued:
293293
prop_pv_literal(SH.maxCount, 1)

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,36 @@ 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+
37+
ChildWithZeroMinCard:
38+
is_a: ParentClass
39+
slot_usage:
40+
restricted_slot:
41+
minimum_cardinality: 0
42+
43+
ChildWithRequiredAndZeroMinCard:
44+
is_a: ParentClass
45+
slot_usage:
46+
restricted_slot:
47+
required: true
48+
minimum_cardinality: 0
49+
2050
slots:
2151
list_min_max_size:
2252
range: integer
@@ -28,3 +58,11 @@ slots:
2858
range: integer
2959
multivalued: true
3060
exact_cardinality: 3
61+
62+
inherited_slot:
63+
range: string
64+
multivalued: true
65+
66+
restricted_slot:
67+
range: string
68+
multivalued: true

tests/linkml/test_generators/test_shaclgen.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,151 @@ def test_multivalued_slot_exact_cardinality(input_path):
600600
) in g
601601

602602

603+
def test_zero_maximum_cardinality_emits_maxcount(input_path):
604+
"""Test that maximum_cardinality: 0 correctly emits sh:maxCount 0.
605+
606+
Regression test for the bug where Python truthiness check
607+
`if s.maximum_cardinality:` would skip the value 0 (falsy),
608+
failing to emit sh:maxCount 0 in the generated SHACL shape.
609+
The fix uses `if s.maximum_cardinality is not None:` instead.
610+
611+
This is the primary mechanism for suppressing inherited slots on
612+
subclasses via slot_usage (e.g., OWL maxCardinality 0 pattern).
613+
"""
614+
shacl = ShaclGenerator(input_path("shaclgen/cardinality.yaml"), mergeimports=True).serialize()
615+
616+
g = rdflib.Graph()
617+
g.parse(data=shacl)
618+
619+
# Find the ChildWithZeroMaxCard shape
620+
child_uri = URIRef("https://w3id.org/linkml/examples/cardinality/ChildWithZeroMaxCard")
621+
restricted_slot_uri = URIRef("https://w3id.org/linkml/examples/cardinality/restricted_slot")
622+
623+
# Get all property shapes for the child class
624+
prop_nodes = list(g.objects(child_uri, SH.property))
625+
assert prop_nodes, "ChildWithZeroMaxCard should have property shapes"
626+
627+
# Find the property shape for restricted_slot
628+
restricted_prop_node = None
629+
for pn in prop_nodes:
630+
if (pn, SH.path, restricted_slot_uri) in g:
631+
restricted_prop_node = pn
632+
break
633+
assert restricted_prop_node is not None, "Should have a property shape for restricted_slot"
634+
635+
# The critical assertion: sh:maxCount 0 must be emitted
636+
max_count_values = list(g.objects(restricted_prop_node, SH.maxCount))
637+
assert len(max_count_values) == 1, f"Expected exactly one sh:maxCount, got {max_count_values}"
638+
assert max_count_values[0] == rdflib.term.Literal(
639+
0, datatype=rdflib.term.URIRef("http://www.w3.org/2001/XMLSchema#integer")
640+
), f"sh:maxCount should be 0, got {max_count_values[0]}"
641+
642+
643+
def test_zero_exact_cardinality_emits_both_counts(input_path):
644+
"""Test that exact_cardinality: 0 emits both sh:minCount 0 and sh:maxCount 0.
645+
646+
Same truthiness bug as maximum_cardinality: `if s.exact_cardinality:`
647+
skips value 0 (falsy). The fix uses `is not None` instead.
648+
"""
649+
shacl = ShaclGenerator(input_path("shaclgen/cardinality.yaml"), mergeimports=True).serialize()
650+
651+
g = rdflib.Graph()
652+
g.parse(data=shacl)
653+
654+
child_uri = URIRef("https://w3id.org/linkml/examples/cardinality/ChildWithZeroExactCard")
655+
restricted_slot_uri = URIRef("https://w3id.org/linkml/examples/cardinality/restricted_slot")
656+
657+
prop_nodes = list(g.objects(child_uri, SH.property))
658+
assert prop_nodes, "ChildWithZeroExactCard should have property shapes"
659+
660+
restricted_prop_node = None
661+
for pn in prop_nodes:
662+
if (pn, SH.path, restricted_slot_uri) in g:
663+
restricted_prop_node = pn
664+
break
665+
assert restricted_prop_node is not None, "Should have a property shape for restricted_slot"
666+
667+
XSD_INT = rdflib.term.URIRef("http://www.w3.org/2001/XMLSchema#integer")
668+
669+
min_count_values = list(g.objects(restricted_prop_node, SH.minCount))
670+
assert len(min_count_values) == 1, f"Expected exactly one sh:minCount, got {min_count_values}"
671+
assert min_count_values[0] == rdflib.term.Literal(0, datatype=XSD_INT)
672+
673+
max_count_values = list(g.objects(restricted_prop_node, SH.maxCount))
674+
assert len(max_count_values) == 1, f"Expected exactly one sh:maxCount, got {max_count_values}"
675+
assert max_count_values[0] == rdflib.term.Literal(0, datatype=XSD_INT)
676+
677+
678+
def test_zero_minimum_cardinality_emits_mincount(input_path):
679+
"""Test that minimum_cardinality: 0 emits sh:minCount 0.
680+
681+
Same truthiness bug as maximum_cardinality: `if s.minimum_cardinality:`
682+
skips value 0 (falsy). The fix uses `is not None` instead. sh:minCount 0
683+
is vacuously satisfied (W3C SHACL 4.2.2) but is emitted for consistency
684+
with owlgen (owl:minCardinality 0) and to faithfully reflect the schema.
685+
"""
686+
shacl = ShaclGenerator(input_path("shaclgen/cardinality.yaml"), mergeimports=True).serialize()
687+
688+
g = rdflib.Graph()
689+
g.parse(data=shacl)
690+
691+
child_uri = URIRef("https://w3id.org/linkml/examples/cardinality/ChildWithZeroMinCard")
692+
restricted_slot_uri = URIRef("https://w3id.org/linkml/examples/cardinality/restricted_slot")
693+
694+
prop_nodes = list(g.objects(child_uri, SH.property))
695+
assert prop_nodes, "ChildWithZeroMinCard should have property shapes"
696+
697+
restricted_prop_node = None
698+
for pn in prop_nodes:
699+
if (pn, SH.path, restricted_slot_uri) in g:
700+
restricted_prop_node = pn
701+
break
702+
assert restricted_prop_node is not None, "Should have a property shape for restricted_slot"
703+
704+
XSD_INT = rdflib.term.URIRef("http://www.w3.org/2001/XMLSchema#integer")
705+
706+
min_count_values = list(g.objects(restricted_prop_node, SH.minCount))
707+
assert len(min_count_values) == 1, f"Expected exactly one sh:minCount, got {min_count_values}"
708+
assert min_count_values[0] == rdflib.term.Literal(0, datatype=XSD_INT)
709+
710+
711+
def test_explicit_minimum_cardinality_overrides_required(input_path):
712+
"""An explicit minimum_cardinality: 0 takes precedence over required: true.
713+
714+
The generator resolves min-count with an ``elif`` cascade in which an
715+
explicit ``minimum_cardinality`` wins and ``required`` is only the fallback.
716+
This mirrors owlgen.py (``if slot.minimum_cardinality is not None ... elif
717+
slot.required``), so ``required: true`` + ``minimum_cardinality: 0`` yields
718+
``sh:minCount 0`` (not 1). The combination is a schema contradiction; the
719+
explicit, more specific constraint is emitted.
720+
"""
721+
shacl = ShaclGenerator(input_path("shaclgen/cardinality.yaml"), mergeimports=True).serialize()
722+
723+
g = rdflib.Graph()
724+
g.parse(data=shacl)
725+
726+
child_uri = URIRef("https://w3id.org/linkml/examples/cardinality/ChildWithRequiredAndZeroMinCard")
727+
restricted_slot_uri = URIRef("https://w3id.org/linkml/examples/cardinality/restricted_slot")
728+
729+
prop_nodes = list(g.objects(child_uri, SH.property))
730+
assert prop_nodes, "ChildWithRequiredAndZeroMinCard should have property shapes"
731+
732+
restricted_prop_node = None
733+
for pn in prop_nodes:
734+
if (pn, SH.path, restricted_slot_uri) in g:
735+
restricted_prop_node = pn
736+
break
737+
assert restricted_prop_node is not None, "Should have a property shape for restricted_slot"
738+
739+
XSD_INT = rdflib.term.URIRef("http://www.w3.org/2001/XMLSchema#integer")
740+
741+
min_count_values = list(g.objects(restricted_prop_node, SH.minCount))
742+
assert len(min_count_values) == 1, f"Expected exactly one sh:minCount, got {min_count_values}"
743+
assert min_count_values[0] == rdflib.term.Literal(0, datatype=XSD_INT), (
744+
f"explicit minimum_cardinality: 0 should override required: true (minCount 0, not 1), got {min_count_values[0]}"
745+
)
746+
747+
603748
def test_exclude_imports(input_path):
604749
shacl = ShaclGenerator(
605750
input_path("shaclgen/exclude_imports.yaml"), mergeimports=True, exclude_imports=True

0 commit comments

Comments
 (0)