Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions packages/linkml/src/linkml/generators/shaclgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,19 +169,19 @@ def prop_pv_literal(p, v):
prop_pv_literal(SH.name, s.title)
prop_pv_literal(SH.description, s.description)
# minCount
if s.minimum_cardinality:
if s.minimum_cardinality is not None:
prop_pv_literal(SH.minCount, s.minimum_cardinality)
elif s.exact_cardinality:
elif s.exact_cardinality is not None:
prop_pv_literal(SH.minCount, s.exact_cardinality)
# Identifiers map to the node's IRI rather than a property triple,
# so there's no arc to constrain with sh:minCount 1 — emitting it
# would cause spurious violations on every instance.
elif s.required and not s.identifier:
prop_pv_literal(SH.minCount, 1)
# maxCount
if s.maximum_cardinality:
if s.maximum_cardinality is not None:
prop_pv_literal(SH.maxCount, s.maximum_cardinality)
elif s.exact_cardinality:
elif s.exact_cardinality is not None:
prop_pv_literal(SH.maxCount, s.exact_cardinality)
elif not s.multivalued:
prop_pv_literal(SH.maxCount, 1)
Expand Down
25 changes: 25 additions & 0 deletions tests/linkml/test_generators/input/shaclgen/cardinality.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,23 @@ classes:
slots:
- list_exact_size

ParentClass:
slots:
- inherited_slot
- restricted_slot

ChildWithZeroMaxCard:
is_a: ParentClass
slot_usage:
restricted_slot:
maximum_cardinality: 0

ChildWithZeroExactCard:
is_a: ParentClass
slot_usage:
restricted_slot:
exact_cardinality: 0

slots:
list_min_max_size:
range: integer
Expand All @@ -28,3 +45,11 @@ slots:
range: integer
multivalued: true
exact_cardinality: 3

inherited_slot:
range: string
multivalued: true

restricted_slot:
range: string
multivalued: true
75 changes: 75 additions & 0 deletions tests/linkml/test_generators/test_shaclgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,81 @@ def test_multivalued_slot_exact_cardinality(input_path):
) in g


def test_zero_maximum_cardinality_emits_maxcount(input_path):
"""Test that maximum_cardinality: 0 correctly emits sh:maxCount 0.

Regression test for the bug where Python truthiness check
`if s.maximum_cardinality:` would skip the value 0 (falsy),
failing to emit sh:maxCount 0 in the generated SHACL shape.
The fix uses `if s.maximum_cardinality is not None:` instead.

This is the primary mechanism for suppressing inherited slots on
subclasses via slot_usage (e.g., OWL maxCardinality 0 pattern).
"""
shacl = ShaclGenerator(input_path("shaclgen/cardinality.yaml"), mergeimports=True).serialize()

g = rdflib.Graph()
g.parse(data=shacl)

# Find the ChildWithZeroMaxCard shape
child_uri = URIRef("https://w3id.org/linkml/examples/cardinality/ChildWithZeroMaxCard")
restricted_slot_uri = URIRef("https://w3id.org/linkml/examples/cardinality/restricted_slot")

# Get all property shapes for the child class
prop_nodes = list(g.objects(child_uri, SH.property))
assert prop_nodes, "ChildWithZeroMaxCard should have property shapes"

# Find the property shape for restricted_slot
restricted_prop_node = None
for pn in prop_nodes:
if (pn, SH.path, restricted_slot_uri) in g:
restricted_prop_node = pn
break
assert restricted_prop_node is not None, "Should have a property shape for restricted_slot"

# The critical assertion: sh:maxCount 0 must be emitted
max_count_values = list(g.objects(restricted_prop_node, SH.maxCount))
assert len(max_count_values) == 1, f"Expected exactly one sh:maxCount, got {max_count_values}"
assert max_count_values[0] == rdflib.term.Literal(
0, datatype=rdflib.term.URIRef("http://www.w3.org/2001/XMLSchema#integer")
), f"sh:maxCount should be 0, got {max_count_values[0]}"


def test_zero_exact_cardinality_emits_both_counts(input_path):
"""Test that exact_cardinality: 0 emits both sh:minCount 0 and sh:maxCount 0.

Same truthiness bug as maximum_cardinality: `if s.exact_cardinality:`
skips value 0 (falsy). The fix uses `is not None` instead.
"""
shacl = ShaclGenerator(input_path("shaclgen/cardinality.yaml"), mergeimports=True).serialize()

g = rdflib.Graph()
g.parse(data=shacl)

child_uri = URIRef("https://w3id.org/linkml/examples/cardinality/ChildWithZeroExactCard")
restricted_slot_uri = URIRef("https://w3id.org/linkml/examples/cardinality/restricted_slot")

prop_nodes = list(g.objects(child_uri, SH.property))
assert prop_nodes, "ChildWithZeroExactCard should have property shapes"

restricted_prop_node = None
for pn in prop_nodes:
if (pn, SH.path, restricted_slot_uri) in g:
restricted_prop_node = pn
break
assert restricted_prop_node is not None, "Should have a property shape for restricted_slot"

XSD_INT = rdflib.term.URIRef("http://www.w3.org/2001/XMLSchema#integer")

min_count_values = list(g.objects(restricted_prop_node, SH.minCount))
assert len(min_count_values) == 1, f"Expected exactly one sh:minCount, got {min_count_values}"
assert min_count_values[0] == rdflib.term.Literal(0, datatype=XSD_INT)

max_count_values = list(g.objects(restricted_prop_node, SH.maxCount))
assert len(max_count_values) == 1, f"Expected exactly one sh:maxCount, got {max_count_values}"
assert max_count_values[0] == rdflib.term.Literal(0, datatype=XSD_INT)


def test_exclude_imports(input_path):
shacl = ShaclGenerator(
input_path("shaclgen/exclude_imports.yaml"), mergeimports=True, exclude_imports=True
Expand Down
Loading