Skip to content

fix(shaclgen): emit sh:maxCount 0 for zero maximum_cardinality#12

Open
jdsika wants to merge 1 commit into
mainfrom
fix/shaclgen-maxcount-zero
Open

fix(shaclgen): emit sh:maxCount 0 for zero maximum_cardinality#12
jdsika wants to merge 1 commit into
mainfrom
fix/shaclgen-maxcount-zero

Conversation

@jdsika

@jdsika jdsika commented May 2, 2026

Copy link
Copy Markdown

Summary

Fix a Python truthiness bug in the SHACL generator that prevents sh:maxCount 0 and sh:minCount 0 from being emitted when maximum_cardinality: 0 or minimum_cardinality: 0 is set in a LinkML schema.

Problem

In shaclgen.py, the cardinality checks use Python truthiness:

if s.minimum_cardinality:    # 0 is falsy in Python!
    prop_pv_literal(SH.minCount, s.minimum_cardinality)
...
if s.maximum_cardinality:    # 0 is falsy in Python!
    prop_pv_literal(SH.maxCount, s.maximum_cardinality)

Since 0 evaluates as False in Python, setting maximum_cardinality: 0 (which should emit sh:maxCount 0 meaning "this property MUST NOT appear") produces no output at all.

Root Cause

The condition if s.maximum_cardinality: fails when the value is 0 because Python treats 0 as falsy. The correct check is if s.maximum_cardinality is not None: which distinguishes "not set" from "explicitly set to zero".

Fix

Changed both checks to use explicit is not None comparisons:

if s.minimum_cardinality is not None:
    prop_pv_literal(SH.minCount, s.minimum_cardinality)
...
if s.maximum_cardinality is not None:
    prop_pv_literal(SH.maxCount, s.maximum_cardinality)

This matches the pattern already used in the OWL generator (owlgen.py lines 627-640) for the same attributes.

Verification

  • W3C SHACL spec explicitly allows sh:maxCount 0 (means "property must not exist on any conforming node")
  • OWL generator already correctly uses is not None and emits owl:maxCardinality 0
  • docgen.py also uses is not None for the same field (line 693)
  • Added regression test that verifies sh:maxCount 0 appears in generated output

Use Case

This is needed for modeling class hierarchies where subclasses restrict inherited properties. For example, slot_usage with maximum_cardinality: 0 is the idiomatic way in LinkML to express "this inherited slot is not applicable on this subclass" --- but without this fix, the SHACL output silently omits the constraint.

How was this tested?

  • Added ChildWithZeroMaxCard class to tests/linkml/test_generators/input/shaclgen/cardinality.yaml
  • Added test_zero_maximum_cardinality_emits_maxcount regression test to test_shaclgen.py
  • Existing tests continue to pass (non-zero cardinalities unaffected by is not None check)

Note on exact_cardinality

The elif s.exact_cardinality: branches (lines 174, 184) have the same truthiness issue for the value 0. However, exact_cardinality: 0 is semantically degenerate (a list with exactly zero items is the same as a forbidden property) and extremely unlikely in practice. This fix focuses on the common and semantically meaningful case. A follow-up can address exact_cardinality if needed.

Areas of uncertainty

  • See "Note on exact_cardinality" above — aligning exact_cardinality: 0 handling is deliberately out of scope here and may deserve an upstream issue of its own.

Checklist

  • My code follows the contributor guidelines
  • I have added tests that prove my fix/feature works
  • Existing tests pass locally with my changes

AI Assistance

If you used AI tools while preparing this PR, you are still the author and responsible for understanding, verifying, and defending your submission. Please engage with reviewers personally rather than through your agent during feedback and revisions. See our AI Covenant for details.

jdsika added a commit that referenced this pull request May 2, 2026
Apply same fix as fix/shaclgen-maxcount-zero branch to develop.
Change truthiness checks to explicit `is not None` comparisons
for minimum_cardinality and maximum_cardinality in SHACL generator.

See: #12
jdsika added a commit that referenced this pull request May 2, 2026
Restore shaclgen.py (accidentally emptied) and apply the
is-not-None fix for minimum/maximum_cardinality checks.

See: #12
@jdsika jdsika force-pushed the fix/shaclgen-maxcount-zero branch 3 times, most recently from abe3f1c to 4f0020c Compare May 3, 2026 08:35
@jdsika jdsika force-pushed the fix/shaclgen-maxcount-zero branch 6 times, most recently from ae4b34a to 5544abc Compare May 12, 2026 09:39
@jdsika jdsika force-pushed the fix/shaclgen-maxcount-zero branch from 5544abc to 69f833a Compare June 9, 2026 15:20
@jdsika jdsika force-pushed the fix/shaclgen-maxcount-zero branch from ce9d0f8 to 5657d56 Compare July 3, 2026 08:10
@rmessaou rmessaou force-pushed the fix/shaclgen-maxcount-zero branch from 5657d56 to b9760a8 Compare July 8, 2026 08:19
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant