Skip to content

test(gen-shacl): cover nested range_expression postconditions - #35

Open
jdsika wants to merge 3 commits into
feat/shaclgen-compositional-rule-fallbackfrom
feat/shaclgen-postcondition-range-expression
Open

jdsika wants to merge 3 commits into
feat/shaclgen-compositional-rule-fallbackfrom
feat/shaclgen-postcondition-range-expression

Conversation

@jdsika

@jdsika jdsika commented Oct 2, 2026 •

Copy link
Copy Markdown

Test-only follow-up to #20 (mirror of linkml#3990). #20's composed translation handles a range_expression postcondition with the same code it uses for preconditions, but its tests covered nested postconditions only in a closed world. This PR pins the nested-postcondition cases:

Case Expected Checked against
open_world, wrapper omitted or empty conforms JSON Schema
open_world, value doesn't match violation JSON Schema
closed world, empty wrapper conforms (the inner condition holds for an absent inner slot) JSON Schema
multivalued wrapper, one member fails violation (every member must satisfy it) JSON Schema
enum value with a meaning compared as that IRI, resolved on the range class pyshacl, with explicit expectations, because the JSON-LD context renders this enum as strings (#40)
condition on the range class's identifier rule skipped with a warning the warning

The JSON Schema cases run for the declaring class and for a subclass. Four mutants of the shared code are each killed by the new cases: the inner presence default flipped, open_world ignored, the identifier check removed, and enum meaning ignored. No code changes.

History: this PR first implemented the translation itself, using an existential query form, so it had to skip multivalued wrappers and optional or multivalued inner slots. #20 now translates those cases. On this PR's original schemas, SHACL and JSON Schema agree on all 15 instances checked. The original commit is replaced by this test-only commit.

Fork stack: #19 → #20 → #35 (tests); #23 (docs) is also based on #20.

@jdsika
jdsika marked this pull request as draft October 5, 2026 12:01
@jdsika
jdsika force-pushed the feat/shaclgen-postcondition-range-expression branch from 58b7817 to cf5a2df Compare October 5, 2026 12:11
@jdsika
jdsika marked this pull request as ready for review October 5, 2026 12:25
rmessaou and others added 2 commits October 6, 2026 15:13
The rules-to-SHACL-SPARQL converter recognised two named patterns.  This
adds the presence-implies-value pattern: a precondition asserting
`value_presence: PRESENT` on one slot, and a postcondition constraining
another slot with `equals_string` or `equals_string_in`.  It reads as
"if the guard slot is present, the target slot must be present and hold
one of the allowed values", and generalises the boolean guard to
arbitrary enum values.  The boolean guard becomes the case
`equals_string: "true"` of this pattern on an xsd:boolean-typed flag.

`equals_string` / `equals_string_in` compare strings.  The metamodel
defines them for slots of range string; enums and types with datatype
xsd:string are treated alike, and a slot without a range takes the
schema's default_range.  On an xsd:boolean-typed slot each value must be
a lexical form of xsd:boolean (XML Schema 1.1 Part 2 section 3.3.2.2:
true, false, 1, 0) and denotes that boolean.  A rule applying them to
any other range is skipped with a warning: its RDF values are typed
literals or IRIs that equal no string.  Built-in type names resolve
without importing linkml:types, as in the main slot loop; the built-in
name of `curie` was misspelt there and is corrected.

Values are compared with `=`, spelled out as the disjunction SPARQL 1.1
section 17.4.1.9 defines `IN` to be, so the RDF 1.1-identical plain and
xsd:string literal forms both match and booleans compare by value;
rdflib evaluates `IN` and triple-pattern constants by term identity.
COALESCE turns the type error that RDFterm-equal raises for
incomparable literals into false, so a spec-conformant engine reports
such a value instead of dropping the row.  Queries are DISTINCT and
project ?path (and ?value where there is one), so each result names the
property and the offending value.  A value containing a backslash
followed by "u" or "U" is split with CONCAT, since codepoint escapes are
replaced before a query is parsed.

The exclusive-value pattern accepts the precondition
`has_member: {equals_string: V}`, which states "V is one of the values".
A bare `equals_string: V` is read the same way, as before, now with a
warning on a multivalued slot: the specification applies a slot
constraint to all members of a collection.  The pattern gets the same
range check and value comparison, and its maximum_cardinality > 1 form
tests HAVING on the COUNT aggregate: expressions projected by SELECT are
not visible to HAVING (SPARQL 1.1 section 18.2.4.2), so
`HAVING (?count > N)` never held and the constraint never fired.

A rule applies to all members of its class, so a class shape now
carries the rules of its ancestors and mixins, as the JSON Schema
generator applies them.  Each rule is translated in the inheriting
class's context, where slot_usage may refine the slots it references.
Classes sharing a class_uri share one shape, which carries each rule
once.  With `open_world: true` "the postconditions may be omitted in
instance data", so an absent target is no violation.

A rule whose conditions carry any operator beyond what a pattern
translates, or name an unknown or identifier slot, is skipped rather
than partially translated.  Each problem with a rule is logged once as a
warning naming the declaring class, the rule's position and the class
shapes it affects.  The metadata fields the operator accounting ignores
are derived from the metamodel.  Slot resolution goes through induced
slots, so slot_usage overrides, slot_uri overrides and alias-form keys
resolve to the IRI sh:path emits.

Behaviour changes for existing schemas: the boolean guard rejects a
string "true" on an xsd:boolean flag and skips a flag whose type has
another datatype IRI (main compared with str()); exclusive-value rules
with maximum_cardinality > 1 now fire; inherited rules are now enforced
on subclass shapes; problems with rules are logged at WARNING instead of
DEBUG; rule results carry sh:resultPath and sh:value; sh:message follows
the rule's in_language or default_language.

Co-authored-by: jdsika <[email protected]>
…ersion

A rule that no named pattern matches is now composed from the operators
it uses: each precondition becomes SPARQL filters on $this, and the
single postcondition slot the union of the ways to violate it, binding
the offending value to ?value (SHACL sections 5.3.1 and 5.3.2).

A condition may use value_presence, required, equals_string,
equals_string_in, minimum_value, maximum_value, a range_expression on
the slot's class (nested to any depth) and has_member.  The semantics
follow the JSON Schema generator's if/then:

- presence: value_presence decides, then required, then a default: a
  precondition requires its slot, a postcondition unless the rule is
  open_world, an inner condition of a nested expression never;
- value operators and range_expression apply to every value of the slot
  (05validation.md: "all members"), has_member to some value;
- equals_string(_in) only on string, enum and boolean slots (the named
  patterns' range check), bounds only on the numeric datatypes of SPARQL
  1.1 section 17.1, guarded with isNumeric, and only finite numbers.

Any other operator, a rule with empty pre- or postconditions or several
postcondition slots, skips the rule with one warning, so a rule is never
partially translated.  A rule skipped part-way no longer reports the
notes of the abandoned translation.

Co-authored-by: jdsika <[email protected]>
@jdsika

jdsika commented Oct 7, 2026 •

Copy link
Copy Markdown
Author

Update: reopened as a test-only follow-up on #20 (see the description).

#20 (mirror of linkml#3990, head c89faac) supersedes this PR's original implementation. Its composed translation handles nested range_expression postconditions in general:

@jdsika jdsika closed this Oct 7, 2026
@jdsika jdsika reopened this Oct 7, 2026
@jdsika
jdsika changed the base branch from docs/shaclgen-rule-constraints to feat/shaclgen-compositional-rule-fallback October 7, 2026 08:18
The composed rule translation handles a range_expression in a
postcondition with the same code as in a precondition, but its tests
exercised nested postconditions only in a closed world.  Pin the cases
specific to nested postconditions:

- open_world: an omitted or empty wrapper satisfies the rule, and a
  present non-matching value violates it;
- closed world: an empty wrapper satisfies the inner value condition,
  and every member of a multivalued wrapper must satisfy it;
- an enum value with a meaning compares as that IRI, resolved on the
  range class;
- a condition on the range class's identifier skips the rule.

Signed-off-by: jdsika <[email protected]>
@jdsika
jdsika force-pushed the feat/shaclgen-postcondition-range-expression branch from cf5a2df to fbc5a66 Compare October 7, 2026 08:20
@jdsika jdsika changed the title feat(gen-shacl): translate a nested range_expression postcondition test(gen-shacl): cover nested range_expression postconditions Oct 7, 2026
@jdsika
jdsika force-pushed the feat/shaclgen-compositional-rule-fallback branch from c89faac to a71a8f9 Compare October 13, 2026 20:11
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.

2 participants