Skip to content

feat(generators): enforce has_member alike in SHACL and JSON Schema - #34

Open
jdsika wants to merge 1 commit into
feat/shaclgen-inlined-range-sh-nodefrom
feat/shaclgen-has-member-qualified-value-shape
Open

jdsika wants to merge 1 commit into
feat/shaclgen-inlined-range-sh-nodefrom
feat/shaclgen-has-member-qualified-value-shape

Conversation

@jdsika

@jdsika jdsika commented Oct 2, 2026 •

Copy link
Copy Markdown

Mirror of linkml#4170, same head. Review there; this PR tracks the fork stack.

Proposed upstream description (2026-10-30), not yet applied to linkml#4170; it is copied there after review.

Merge order of this series: linkml#3716 → linkml#4168 → linkml#3993 → linkml#4144 → linkml#4171 → linkml#3989 → linkml#3990 → linkml#4156 → linkml#4157 → linkml#4158 → linkml#4028 → linkml#4169 → linkml#4170 → linkml#3446. A stack is rebased when its base lands.

Stacked on linkml#4028 and linkml#4169, which GitHub shows too; this PR's own diff is #34.

Read first: shaclgen.py (193), jsonschemagen.py (106) and class_expression.py (24); 323 lines of source; the rest is tests, snapshots and docs.

Summary

has_member asks for "at least one member satisfying the condition" of a multivalued slot. gen-shacl and gen-json-schema now read it the same way: a member is one value, which must satisfy the value operators, range and range_expression of the expression.

slots:
  tags:
    range: string
    multivalued: true
    has_member: {equals_string: reviewed}
tags main: SHACL main: JSON Schema this PR: both
absent valid valid invalid
[] valid invalid invalid
[pending] valid invalid invalid
[reviewed, pending] valid valid valid
  • gen-shacl ignored has_member (SHACL generator: has_member is ignored linkml/linkml#2465). It now emits sh:qualifiedValueShape with sh:qualifiedMinCount 1 (SHACL §4.7.3), on slots and in slot conditions of class-level expressions.
  • gen-json-schema applied only the member's value operators, accepted an absent slot, and ignored has_member in class-level expressions. It now emits contains on a required slot, and for a slot inlined as a dict not: {additionalProperties: {not: <member>}}, since contains applies only to arrays.
  • In a slot condition has_member decides presence, and the member takes the form of the condition.

Decisions

  • A slot without a value has no member, so an absent slot and [] both fail: the metamodel says "at least one member", value_presence treats an empty list as absent, the compliance suite rejects [], and gen-owl emits owl:someValuesFrom. gen-json-schema accepted an absent slot but rejected [], a distinction RDF can't express.
  • This changes gen-json-schema: an optional slot with has_member is now required.
  • has_member on a single-valued slot contradicts the metamodel ("the value of the slot is multivalued"); both generators ignore it with a warning (gen-json-schema ignored it silently).
  • gen-shacl skips, with a warning, a member that uses presence or slot-level operators, and equals_string on a reference.

Other generators

gen-owl keeps owl:someValuesFrom; ShEx, Python and Pydantic don't support has_member. linkml#3495 asks for a slot constraint that maps to JSON Schema contains: has_member is that constraint, and now holds for absent slots and in class-level expressions too.

How was this tested?

Areas of uncertainty

  • The behaviour change for gen-json-schema users who rely on an absent slot passing has_member.
  • Found, not addressed: an optional multivalued slot with minimum_cardinality: 1 and no value is valid for gen-json-schema (minItems without required) and invalid for gen-shacl (sh:minCount 1), on main too.

Fixes linkml#2465. Refs linkml#3495.

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.

Fork review (kept from the earlier mirror description)

2026-10-23, fourth push: re-stacked on the re-stacked #30 (baff32593); no conflict, no code change. test_has_member.py + test_range_expression.py + test_shaclgen.py + test_jsonschemagen.py + test_boolean_slot_compliance.py + test_validation_context.py: 4191 passed, 631 skipped, 2 xfailed.

2026-10-23, third push (adversarial review): has_member on a slot that isn't multivalued is now ignored with a warning by gen-json-schema too (it was silent; gen-shacl already warned), with test_has_member_on_a_single_valued_slot_is_ignored_with_a_warning; test_has_member_is_inherited covers slot_usage on a parent class for both generators; linkml#3495 and the pre-existing minimum_cardinality divergence recorded below. test_has_member.py: 55 passed; test_shaclgen.py + test_jsonschemagen.py + test_boolean_slot_compliance.py + test_validation_context.py + test_range_expression.py + test_has_member.py: 4146 passed, 663 skipped, 2 xfailed, 264 subtests passed.

2026-10-23, second push: rebased onto the reviewed #30 (c1527ebe7); the only conflict was the json-schema.rst paragraph both PRs extend, both sentences kept. No code change. test_shaclgen.py + test_jsonschemagen.py + test_boolean_slot_compliance.py + test_validation_context.py + test_has_member.py + test_range_expression.py: 4139 passed, 663 skipped, 2 xfailed.

2026-10-23: rebased onto the rebased #30 (38285fa89); no conflict, no code change. test_shaclgen.py + test_jsonschemagen.py + test_boolean_slot_compliance.py + test_validation_context.py: 3973 passed, 264 subtests passed. test_range_expression.py + test_has_member.py: 166 passed.

2026-10-30: rebased with #29 and #30 onto upstream main (ae3df7a45); no conflict, no code change. tests/linkml: 12,824 passed.

Stacked on #30. Covers upstream linkml#2465 (gen-shacl ignores has_member) and linkml#3495, which asks for a slot constraint that maps to JSON Schema contains: has_member is that constraint, and with this PR it holds for absent and empty slots and in class-level expressions too.

Change

has_member asks for "at least one member satisfying the condition" of a multivalued slot. Both generators now read it the same way: a member is one value, which must satisfy the value operators, range and range_expression of the expression.

slots:
  tags:
    range: string
    multivalued: true
    has_member: {equals_string: reviewed}
tags before: SHACL before: JSON Schema now: both
absent valid valid invalid
[] valid invalid invalid
[pending] valid invalid invalid
[reviewed, pending] valid valid valid
  • gen-shacl ignored has_member. It now emits sh:qualifiedValueShape with sh:qualifiedMinCount 1 (SHACL §4.7.3), on slots and in slot conditions of class-level expressions.
  • gen-json-schema applied only the member's value operators, accepted an absent slot, and ignored has_member in class-level expressions. It now emits contains on a required slot. For a slot inlined as a dict it emits not: {additionalProperties: {not: <member>}}, because contains doesn't apply to objects.
  • In a slot condition, has_member decides presence (an absent slot fails it), and the member takes the form of the condition: "definitely true" under none_of.

Decisions

Other generators

Validation

  • test_has_member.py: 48 cases. They validate the same instance with both generated artifacts (RDF through the generated JSON-LD context), plus SHACL-only and JSON-only cases. 34 fail on fix(generators): enforce range_expression on each value alike in SHACL and JSON Schema #30.
  • Out-of-tree check during development: 480 random schemas and 5,760 instances (has_member on slots and in class-level conditions, with member range_expression). gen-shacl, gen-json-schema and an independent three-valued model agreed on all of them. That harness is not part of this PR.
  • Mutation: 21 mutants of the new code, all killed; one by the doctest of states_presence.
  • Sweep: across 387 schemas in the repository, SHACL and JSON Schema output is unchanged, with identical errors and warnings.
  • Compliance: test_membership now expects SHACL to implement has_member, except on references.
  • Tests: the agreement harness of fix(generators): enforce range_expression on each value alike in SHACL and JSON Schema #30's tests moves to tests/linkml/utils/generator_agreement.py, shared with these tests.
  • CI: see the checks on the current head.

Found, not addressed here (it predates this PR): an optional multivalued slot with minimum_cardinality: 1 and no value is valid for gen-json-schema (minItems without required) and invalid for gen-shacl (sh:minCount 1), on main too. The same reasoning as for has_member would make the slot required in JSON Schema.

@jdsika jdsika self-assigned this Oct 2, 2026
@jdsika
jdsika force-pushed the feat/shaclgen-has-member-qualified-value-shape branch from 21e4983 to a0b2c05 Compare October 2, 2026 15:08
@jdsika
jdsika changed the base branch from feat/shaclgen-class-boolean-expressions to feat/shaclgen-inlined-range-sh-node October 2, 2026 15:08
@jdsika
jdsika force-pushed the feat/shaclgen-inlined-range-sh-node branch from 5db175a to 111a453 Compare October 2, 2026 15:13
@jdsika
jdsika force-pushed the feat/shaclgen-has-member-qualified-value-shape branch from a0b2c05 to 883509a Compare October 2, 2026 15:13
@jdsika
jdsika force-pushed the feat/shaclgen-inlined-range-sh-node branch 3 times, most recently from 937b986 to 3f14cf9 Compare October 5, 2026 09:52
@jdsika
jdsika marked this pull request as draft October 5, 2026 11:26
@jdsika jdsika changed the title feat(gen-shacl): translate has_member to sh:qualifiedValueShape feat(generators): enforce existential collection membership Oct 5, 2026
@jdsika
jdsika force-pushed the feat/shaclgen-has-member-qualified-value-shape branch from 883509a to c94f379 Compare October 5, 2026 11:47
@jdsika
jdsika marked this pull request as ready for review October 5, 2026 12:01
@jdsika
jdsika force-pushed the feat/shaclgen-inlined-range-sh-node branch 2 times, most recently from 68430f4 to 46c2ab1 Compare October 7, 2026 20:03
@jdsika
jdsika force-pushed the feat/shaclgen-has-member-qualified-value-shape branch from c94f379 to 23553b9 Compare October 7, 2026 21:09
@jdsika jdsika changed the title feat(generators): enforce existential collection membership feat(generators): enforce has_member alike in SHACL and JSON Schema Oct 7, 2026
@jdsika
jdsika force-pushed the feat/shaclgen-inlined-range-sh-node branch from 46c2ab1 to 3f2a2ba Compare October 14, 2026 05:44
@jdsika
jdsika force-pushed the feat/shaclgen-has-member-qualified-value-shape branch from 23553b9 to a7becc8 Compare October 14, 2026 06:00
@jdsika
jdsika force-pushed the feat/shaclgen-inlined-range-sh-node branch from 3f2a2ba to 38285fa Compare October 23, 2026 08:49
@jdsika
jdsika force-pushed the feat/shaclgen-has-member-qualified-value-shape branch from a7becc8 to d8a6e0d Compare October 23, 2026 08:49
@jdsika
jdsika force-pushed the feat/shaclgen-inlined-range-sh-node branch from 38285fa to c1527eb Compare October 23, 2026 09:06
@jdsika
jdsika force-pushed the feat/shaclgen-has-member-qualified-value-shape branch 2 times, most recently from 56d21ca to 4af1c7f Compare October 23, 2026 09:23
@jdsika
jdsika force-pushed the feat/shaclgen-inlined-range-sh-node branch 2 times, most recently from 19b8129 to baff325 Compare October 23, 2026 10:58
@jdsika
jdsika force-pushed the feat/shaclgen-has-member-qualified-value-shape branch from 4af1c7f to 796fbfe Compare October 23, 2026 10:58
has_member asks for at least one value of a multivalued slot satisfying
an expression. gen-shacl ignored it. gen-json-schema translated it as
contains, which an absent slot passes but an empty one fails, applied
only the value operators of the expression, and ignored it in
class-level expressions.

Both generators now read a member as one value, which must satisfy the
value operators, range and range_expression of the expression. A slot
without a value has no member, so has_member decides presence. In SHACL
it is an sh:qualifiedValueShape with sh:qualifiedMinCount 1; in JSON
Schema, contains on a required slot, or some value of a dict inlined by
key. In a slot condition the member takes the form of the condition.
SHACL skips a has_member it cannot express with a warning, and both
generators warn about one on a slot that is not multivalued.

Signed-off-by: jdsika <carlo.van-driesten@vdl.digital>
@jdsika
jdsika force-pushed the feat/shaclgen-inlined-range-sh-node branch from baff325 to d047d2c Compare October 30, 2026 07:57
@jdsika
jdsika force-pushed the feat/shaclgen-has-member-qualified-value-shape branch from 796fbfe to d3c35ba Compare October 30, 2026 08:13
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