Skip to content

Commit 0e10b85

Browse files
jdsikarmessaou
authored andcommitted
feat(gen-shacl): generate sh:sparql constraints from LinkML rules
Implement SHACL-SPARQL constraint generation for the boolean-guard pattern commonly used in conditional validation rules. When a LinkML class has rules: blocks with preconditions (value_presence: PRESENT) and postconditions (equals_string: true), the generator now emits sh:SPARQLConstraint nodes on the corresponding sh:NodeShape. Features: - New _add_rules() method translates recognised rule patterns to SPARQL - Boolean-guard pattern: if value present then flag must be true - Rule description mapped to sh:message on the constraint - Deactivated rules are skipped - Warnings emitted for bidirectional/open_world rule flags - New --emit-rules/--no-emit-rules CLI flag (default: enabled) - Full URI references in SPARQL (no PREFIX declarations needed) The generated SPARQL follows W3C SHACL Section 5 and uses the pre-bound \ variable per Section 5.3.1. Constraints are validated by pyshacl with advanced=True. Refs: linkml#2464 Signed-off-by: Carlo van Driesten <carlo.van-driesten@bmw.de>
1 parent 7414ab9 commit 0e10b85

3 files changed

Lines changed: 1076 additions & 1 deletion

File tree

packages/linkml/src/linkml/generators/shaclgen.py

Lines changed: 246 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from linkml.generators.shacl.shacl_ifabsent_processor import ShaclIfAbsentProcessor
1717
from linkml.utils.generator import Generator, shared_arguments
1818
from linkml.utils.language_tags import LanguageTagResolver
19-
from linkml_runtime.linkml_model.meta import ClassDefinition, ElementName
19+
from linkml_runtime.linkml_model.meta import ClassDefinition, ElementName, PresenceEnum
2020
from linkml_runtime.utils.formatutils import underscore
2121
from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph
2222
from linkml_runtime.utils.yamlutils import TypedNode, extended_float, extended_int, extended_str
@@ -142,6 +142,22 @@ class ShaclGenerator(Generator):
142142
ignores any per-slot ``in_language``.
143143
"""
144144

145+
emit_rules: bool = True
146+
"""Emit ``sh:sparql`` constraints from LinkML ``rules:`` blocks.
147+
148+
When ``True`` (default), recognised rule patterns are translated into
149+
SHACL-SPARQL constraints (``sh:SPARQLConstraint``) on the corresponding
150+
``sh:NodeShape``. Currently two patterns are recognised:
151+
152+
* *Boolean guard* — a precondition with ``value_presence: PRESENT`` on a
153+
value slot and a postcondition with ``equals_string: "true"`` on a
154+
boolean flag slot.
155+
* *Exclusive value* — a precondition with ``equals_string`` on a slot and
156+
a postcondition with ``maximum_cardinality`` on the *same* slot.
157+
158+
See `W3C SHACL §5 <https://www.w3.org/TR/shacl/#sparql-constraints>`_
159+
and `linkml/linkml#2464 <https://github.com/linkml/linkml/issues/2464>`_.
160+
"""
145161
generatorname = os.path.basename(__file__)
146162
generatorversion = "0.0.1"
147163
valid_formats = ["ttl"]
@@ -389,10 +405,228 @@ def st_node_pv(p, v):
389405
if default_value:
390406
prop_pv(SH.defaultValue, default_value)
391407

408+
if self.emit_rules:
409+
self._add_rules(g, class_uri_with_suffix, c)
410+
392411
return g
393412

394413
LINKML_ANY_URI = "https://w3id.org/linkml/Any"
395414

415+
# -------------------------------------------------------------------
416+
# Rules → sh:sparql
417+
# -------------------------------------------------------------------
418+
419+
def _add_rules(self, g: Graph, shape_uri: URIRef, cls: ClassDefinition) -> None:
420+
"""Emit ``sh:sparql`` constraints from LinkML ``rules:`` blocks.
421+
422+
Each recognised rule is converted into an ``sh:SPARQLConstraint``
423+
attached to *shape_uri*. Unrecognised patterns are logged at
424+
``DEBUG`` level and silently skipped.
425+
426+
Currently recognised patterns:
427+
428+
* **Boolean guard** — a *precondition* with
429+
``value_presence: PRESENT`` on a value slot and a *postcondition*
430+
with ``equals_string: "true"`` on a boolean flag slot.
431+
432+
* **Exclusive value** — a *precondition* with ``equals_string`` on
433+
a slot and a *postcondition* with ``maximum_cardinality`` on the
434+
*same* slot. Enforces that when a specific value is present in a
435+
multivalued slot, the total number of values must not exceed the
436+
given cardinality (typically 1 for mutual exclusion).
437+
438+
See `W3C SHACL §5 <https://www.w3.org/TR/shacl/#sparql-constraints>`_.
439+
"""
440+
if not cls.rules:
441+
return
442+
443+
sv = self.schemaview
444+
for rule in cls.rules:
445+
if getattr(rule, "deactivated", False):
446+
continue
447+
448+
if getattr(rule, "bidirectional", False):
449+
logger.warning(
450+
"Rule in class %r has bidirectional=true; "
451+
"SHACL-SPARQL generation does not yet support bidirectional rules. "
452+
"Only the forward direction is emitted.",
453+
cls.name,
454+
)
455+
456+
if getattr(rule, "open_world", False):
457+
logger.warning(
458+
"Rule in class %r has open_world=true; "
459+
"SHACL operates under closed-world assumption. "
460+
"The constraint is emitted but may not match open-world semantics.",
461+
cls.name,
462+
)
463+
464+
sparql_query = self._rule_to_sparql(sv, cls, rule)
465+
if sparql_query is None:
466+
logger.debug(
467+
"Skipping unsupported rule pattern in class %r: %s",
468+
cls.name,
469+
getattr(rule, "description", "(no description)"),
470+
)
471+
continue
472+
473+
constraint = BNode()
474+
g.add((shape_uri, SH.sparql, constraint))
475+
g.add((constraint, RDF.type, SH.SPARQLConstraint))
476+
477+
message = getattr(rule, "description", None)
478+
if message:
479+
g.add((constraint, SH.message, Literal(message)))
480+
481+
g.add((constraint, SH.select, Literal(sparql_query)))
482+
483+
def _rule_to_sparql(self, sv, cls: ClassDefinition, rule) -> str | None:
484+
"""Convert a ``ClassRule`` to a SPARQL SELECT query string.
485+
486+
Returns ``None`` when the rule does not match any supported pattern.
487+
"""
488+
pre = getattr(rule, "preconditions", None)
489+
post = getattr(rule, "postconditions", None)
490+
if not pre or not post:
491+
return None
492+
493+
pre_slots = getattr(pre, "slot_conditions", None) or {}
494+
post_slots = getattr(post, "slot_conditions", None) or {}
495+
496+
# Pattern: boolean guard
497+
# preconditions: exactly one slot with value_presence PRESENT
498+
# postconditions: exactly one slot with equals_string "true"
499+
if len(pre_slots) == 1 and len(post_slots) == 1:
500+
pre_slot_name = next(iter(pre_slots))
501+
post_slot_name = next(iter(post_slots))
502+
503+
pre_cond = pre_slots[pre_slot_name]
504+
post_cond = post_slots[post_slot_name]
505+
506+
is_value_present = getattr(pre_cond, "value_presence", None) == PresenceEnum(PresenceEnum.PRESENT)
507+
is_flag_true = getattr(post_cond, "equals_string", None) == "true"
508+
509+
if is_value_present and is_flag_true:
510+
return self._build_boolean_guard_sparql(sv, cls, post_slot_name, pre_slot_name)
511+
512+
# Pattern: exclusive value
513+
# preconditions: slot X has equals_string (a specific enum value)
514+
# postconditions: same slot X has maximum_cardinality N
515+
# Semantics: "If value V is present in slot X, then X has at most N values."
516+
pre_equals = getattr(pre_cond, "equals_string", None)
517+
post_max_card = getattr(post_cond, "maximum_cardinality", None)
518+
519+
if pre_equals is not None and post_max_card is not None and pre_slot_name == post_slot_name:
520+
return self._build_exclusive_value_sparql(sv, cls, pre_slot_name, pre_equals, int(post_max_card))
521+
522+
return None
523+
524+
def _build_boolean_guard_sparql(self, sv, cls: ClassDefinition, flag_slot_name: str, value_slot_name: str) -> str:
525+
"""Build a SPARQL SELECT query for the boolean-guard pattern.
526+
527+
The query detects violations where the value property is present
528+
but the boolean flag is absent or not ``true``.
529+
530+
Conforms to `SHACL §5.3.1
531+
<https://www.w3.org/TR/shacl/#sparql-constraints-prebound>`_:
532+
``$this`` is pre-bound to each focus node.
533+
"""
534+
flag_uri = self._slot_uri(sv, flag_slot_name, cls)
535+
value_uri = self._slot_uri(sv, value_slot_name, cls)
536+
537+
return (
538+
f"SELECT $this WHERE {{\n"
539+
f" OPTIONAL {{ $this <{flag_uri}> ?flag . }}\n"
540+
f" OPTIONAL {{ $this <{value_uri}> ?value . }}\n"
541+
f" FILTER (\n"
542+
f" ( !BOUND(?flag) || ?flag != true ) &&\n"
543+
f" BOUND(?value)\n"
544+
f" )\n"
545+
f"}}"
546+
)
547+
548+
def _build_exclusive_value_sparql(
549+
self,
550+
sv,
551+
cls: ClassDefinition,
552+
slot_name: str,
553+
value_name: str,
554+
max_card: int,
555+
) -> str | None:
556+
"""Build a SPARQL SELECT query for the exclusive-value pattern.
557+
558+
Detects violations where a specific value is present in a multivalued
559+
slot but the total number of values exceeds *max_card*.
560+
561+
For the common case ``max_card == 1``, the query checks whether the
562+
exclusive value coexists with any other value (simple existence test).
563+
For ``max_card > 1``, a subquery counts all values and checks against
564+
the limit.
565+
566+
The exclusive value is resolved to its full IRI via the slot's enum
567+
``meaning`` field. If the slot is not an enum or the value has no
568+
``meaning``, the value is compared as a plain literal.
569+
570+
Conforms to `SHACL §5.3.1
571+
<https://www.w3.org/TR/shacl/#sparql-constraints-prebound>`_:
572+
``$this`` is pre-bound to each focus node.
573+
"""
574+
slot_uri = self._slot_uri(sv, slot_name, cls)
575+
value_ref = self._resolve_enum_value_ref(sv, slot_name, value_name)
576+
577+
if max_card == 1:
578+
return (
579+
f"SELECT $this WHERE {{\n"
580+
f" $this <{slot_uri}> {value_ref} .\n"
581+
f" $this <{slot_uri}> ?other .\n"
582+
f" FILTER (?other != {value_ref})\n"
583+
f"}}"
584+
)
585+
586+
return (
587+
f"SELECT $this WHERE {{\n"
588+
f" $this <{slot_uri}> {value_ref} .\n"
589+
f" {{\n"
590+
f" SELECT $this (COUNT(?val) AS ?count)\n"
591+
f" WHERE {{ $this <{slot_uri}> ?val . }}\n"
592+
f" GROUP BY $this\n"
593+
f" HAVING (?count > {max_card})\n"
594+
f" }}\n"
595+
f"}}"
596+
)
597+
598+
def _resolve_enum_value_ref(self, sv, slot_name: str, value_name: str) -> str:
599+
"""Resolve an enum value name to a SPARQL term (IRI or literal).
600+
601+
Looks up the slot's range as an enum, finds the permissible value
602+
matching *value_name*, and returns its ``meaning`` as a full IRI
603+
wrapped in angle brackets. Falls back to a quoted literal if the
604+
slot is not an enum or the value lacks a ``meaning``.
605+
"""
606+
slot = sv.get_slot(slot_name)
607+
if slot:
608+
range_name = slot.range
609+
if range_name and range_name in sv.all_enums():
610+
enum = sv.get_enum(range_name)
611+
pv = enum.permissible_values.get(value_name)
612+
if pv and pv.meaning:
613+
iri = sv.expand_curie(pv.meaning)
614+
return f"<{iri}>"
615+
return f'"{value_name}"'
616+
617+
def _slot_uri(self, sv, slot_name: str, cls: ClassDefinition) -> str:
618+
"""Resolve a slot name to a full IRI string for use in SPARQL queries.
619+
620+
Mirrors the resolution logic used for ``sh:path`` in the main slot loop:
621+
prefer ``sv.get_uri()`` for slots registered in the schema map, fall
622+
back to ``default_prefix:underscored_name``.
623+
"""
624+
slot = sv.get_slot(slot_name)
625+
if slot and slot_name in sv.element_by_schema_map():
626+
return sv.get_uri(slot, expand=True)
627+
pfx = sv.schema.default_prefix
628+
return sv.expand_curie(f"{pfx}:{underscore(slot_name)}")
629+
396630
def _add_class(self, func: Callable, r: ElementName) -> None:
397631
"""Add an sh:class constraint for range class *r*.
398632
@@ -660,6 +894,17 @@ def add_simple_data_type(func: Callable, r: ElementName) -> None:
660894
'Example: "{name} ({class}): {description} [{comments}]"'
661895
),
662896
)
897+
@click.option(
898+
"--emit-rules/--no-emit-rules",
899+
default=True,
900+
show_default=True,
901+
help=(
902+
"Emit sh:sparql constraints from LinkML rules: blocks. "
903+
"When enabled (default), recognised rule patterns (e.g. boolean-guard) "
904+
"are translated into SHACL-SPARQL constraints on the corresponding "
905+
"sh:NodeShape. Use --no-emit-rules to suppress rule generation."
906+
),
907+
)
663908
@click.version_option(__version__, "-V", "--version")
664909
def cli(yamlfile, **args):
665910
"""Generate SHACL turtle from a LinkML model"""
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
id: https://example.org/boolean-guards
2+
name: boolean_guard_rules
3+
description: >-
4+
Test schema for SHACL generation of sh:sparql constraints from LinkML rules.
5+
Models the boolean-guard pattern where a boolean flag must be true if a
6+
corresponding value property is present.
7+
8+
prefixes:
9+
linkml: https://w3id.org/linkml/
10+
ex: https://example.org/boolean-guards/
11+
12+
imports:
13+
- linkml:types
14+
15+
default_prefix: ex
16+
default_range: string
17+
18+
slots:
19+
WeatherWind:
20+
description: Whether wind conditions are present.
21+
range: boolean
22+
slot_uri: ex:WeatherWind
23+
weatherWindValue:
24+
description: Wind speed value.
25+
range: decimal
26+
slot_uri: ex:weatherWindValue
27+
WeatherRain:
28+
description: Whether rain conditions are present.
29+
range: boolean
30+
slot_uri: ex:WeatherRain
31+
weatherRainValue:
32+
description: Rain intensity value.
33+
range: decimal
34+
slot_uri: ex:weatherRainValue
35+
Temperature:
36+
description: Ambient temperature.
37+
range: decimal
38+
slot_uri: ex:Temperature
39+
40+
classes:
41+
Environment:
42+
description: Environmental conditions.
43+
class_uri: ex:Environment
44+
slots:
45+
- WeatherWind
46+
- weatherWindValue
47+
- WeatherRain
48+
- weatherRainValue
49+
- Temperature
50+
rules:
51+
- description: >-
52+
If weatherWindValue is provided, WeatherWind must be true.
53+
preconditions:
54+
slot_conditions:
55+
weatherWindValue:
56+
value_presence: PRESENT
57+
postconditions:
58+
slot_conditions:
59+
WeatherWind:
60+
equals_string: "true"
61+
- description: >-
62+
If weatherRainValue is provided, WeatherRain must be true.
63+
preconditions:
64+
slot_conditions:
65+
weatherRainValue:
66+
value_presence: PRESENT
67+
postconditions:
68+
slot_conditions:
69+
WeatherRain:
70+
equals_string: "true"

0 commit comments

Comments
 (0)