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
163 changes: 162 additions & 1 deletion packages/linkml/src/linkml/generators/shaclgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from linkml.generators.shacl.shacl_data_type import ShaclDataType
from linkml.generators.shacl.shacl_ifabsent_processor import ShaclIfAbsentProcessor
from linkml.utils.generator import Generator, shared_arguments
from linkml_runtime.linkml_model.meta import ClassDefinition, ElementName
from linkml_runtime.linkml_model.meta import ClassDefinition, ElementName, PresenceEnum
from linkml_runtime.utils.formatutils import underscore
from linkml_runtime.utils.yamlutils import TypedNode, extended_float, extended_int, extended_str

Expand Down Expand Up @@ -74,6 +74,19 @@ class ShaclGenerator(Generator):
"""
expand_subproperty_of: bool = True
"""If True, expand subproperty_of to sh:in constraints with slot descendants"""

emit_rules: bool = True
"""Emit ``sh:sparql`` constraints from LinkML ``rules:`` blocks.

When ``True`` (default), recognised rule patterns are translated into
SHACL-SPARQL constraints (``sh:SPARQLConstraint``) on the corresponding
``sh:NodeShape``. Currently the *boolean-guard* pattern is recognised:
a precondition with ``value_presence: PRESENT`` on a value slot and a
postcondition with ``equals_string: "true"`` on a boolean flag slot.

See `W3C SHACL §5 <https://www.w3.org/TR/shacl/#sparql-constraints>`_
and `linkml/linkml#2464 <https://github.com/linkml/linkml/issues/2464>`_.
"""
generatorname = os.path.basename(__file__)
generatorversion = "0.0.1"
valid_formats = ["ttl"]
Expand Down Expand Up @@ -283,10 +296,147 @@ def st_node_pv(p, v):
if default_value:
prop_pv(SH.defaultValue, default_value)

if self.emit_rules:
self._add_rules(g, class_uri_with_suffix, c)

return g

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

# -------------------------------------------------------------------
# Rules → sh:sparql
# -------------------------------------------------------------------

def _add_rules(self, g: Graph, shape_uri: URIRef, cls: ClassDefinition) -> None:
"""Emit ``sh:sparql`` constraints from LinkML ``rules:`` blocks.

Each recognised rule is converted into an ``sh:SPARQLConstraint``
attached to *shape_uri*. Unrecognised patterns are logged at
``DEBUG`` level and silently skipped.

Currently recognised patterns:

* **Boolean guard** — a *precondition* with
``value_presence: PRESENT`` on a value slot and a *postcondition*
with ``equals_string: "true"`` on a boolean flag slot.

See `W3C SHACL §5 <https://www.w3.org/TR/shacl/#sparql-constraints>`_.
"""
if not cls.rules:
return

sv = self.schemaview
for rule in cls.rules:
if getattr(rule, "deactivated", False):
continue

if getattr(rule, "bidirectional", False):
logger.warning(
"Rule in class %r has bidirectional=true; "
"SHACL-SPARQL generation does not yet support bidirectional rules. "
"Only the forward direction is emitted.",
cls.name,
)

if getattr(rule, "open_world", False):
logger.warning(
"Rule in class %r has open_world=true; "
"SHACL operates under closed-world assumption. "
"The constraint is emitted but may not match open-world semantics.",
cls.name,
)

sparql_query = self._rule_to_sparql(sv, cls, rule)
if sparql_query is None:
logger.debug(
"Skipping unsupported rule pattern in class %r: %s",
cls.name,
getattr(rule, "description", "(no description)"),
)
continue

constraint = BNode()
g.add((shape_uri, SH.sparql, constraint))
g.add((constraint, RDF.type, SH.SPARQLConstraint))

message = getattr(rule, "description", None)
if message:
g.add((constraint, SH.message, Literal(message)))

g.add((constraint, SH.select, Literal(sparql_query)))

def _rule_to_sparql(self, sv, cls: ClassDefinition, rule) -> str | None:
"""Convert a ``ClassRule`` to a SPARQL SELECT query string.

Returns ``None`` when the rule does not match any supported pattern.
"""
pre = getattr(rule, "preconditions", None)
post = getattr(rule, "postconditions", None)
if not pre or not post:
return None

pre_slots = getattr(pre, "slot_conditions", None) or {}
post_slots = getattr(post, "slot_conditions", None) or {}

# Pattern: boolean guard
# preconditions: exactly one slot with value_presence PRESENT
# postconditions: exactly one slot with equals_string "true"
if len(pre_slots) == 1 and len(post_slots) == 1:
value_slot_name = next(iter(pre_slots))
flag_slot_name = next(iter(post_slots))

value_cond = pre_slots[value_slot_name]
flag_cond = post_slots[flag_slot_name]

is_value_present = (
getattr(value_cond, "value_presence", None) == PresenceEnum(PresenceEnum.PRESENT)
)
is_flag_true = getattr(flag_cond, "equals_string", None) == "true"

if is_value_present and is_flag_true:
return self._build_boolean_guard_sparql(sv, cls, flag_slot_name, value_slot_name)

return None

def _build_boolean_guard_sparql(
self, sv, cls: ClassDefinition, flag_slot_name: str, value_slot_name: str
) -> str:
"""Build a SPARQL SELECT query for the boolean-guard pattern.

The query detects violations where the value property is present
but the boolean flag is absent or not ``true``.

Conforms to `SHACL §5.3.1
<https://www.w3.org/TR/shacl/#sparql-constraints-prebound>`_:
``$this`` is pre-bound to each focus node.
"""
flag_uri = self._slot_uri(sv, flag_slot_name, cls)
value_uri = self._slot_uri(sv, value_slot_name, cls)

return (
f"SELECT $this WHERE {{\n"
f" OPTIONAL {{ $this <{flag_uri}> ?flag . }}\n"
f" OPTIONAL {{ $this <{value_uri}> ?value . }}\n"
f" FILTER (\n"
f" ( !BOUND(?flag) || ?flag != true ) &&\n"
f" BOUND(?value)\n"
f" )\n"
f"}}"
)

def _slot_uri(self, sv, slot_name: str, cls: ClassDefinition) -> str:
"""Resolve a slot name to a full IRI string for use in SPARQL queries.

Mirrors the resolution logic used for ``sh:path`` in the main slot loop:
prefer ``sv.get_uri()`` for slots registered in the schema map, fall
back to ``default_prefix:underscored_name``.
"""
slot = sv.get_slot(slot_name)
if slot and slot_name in sv.element_by_schema_map():
return sv.get_uri(slot, expand=True)
pfx = sv.schema.default_prefix
return sv.expand_curie(f"{pfx}:{underscore(slot_name)}")

def _add_class(self, func: Callable, r: ElementName) -> None:
"""Add an sh:class constraint for range class *r*.

Expand Down Expand Up @@ -526,6 +676,17 @@ def add_simple_data_type(func: Callable, r: ElementName) -> None:
help="If --expand-subproperty-of (default), slots with subproperty_of will generate sh:in constraints "
"containing all slot descendants. Use --no-expand-subproperty-of to disable this behavior.",
)
@click.option(
"--emit-rules/--no-emit-rules",
default=True,
show_default=True,
help=(
"Emit sh:sparql constraints from LinkML rules: blocks. "
"When enabled (default), recognised rule patterns (e.g. boolean-guard) "
"are translated into SHACL-SPARQL constraints on the corresponding "
"sh:NodeShape. Use --no-emit-rules to suppress rule generation."
),
)
@click.version_option(__version__, "-V", "--version")
def cli(yamlfile, **args):
"""Generate SHACL turtle from a LinkML model"""
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
id: https://example.org/boolean-guards
name: boolean_guard_rules
description: >-
Test schema for SHACL generation of sh:sparql constraints from LinkML rules.
Models the boolean-guard pattern where a boolean flag must be true if a
corresponding value property is present.

prefixes:
linkml: https://w3id.org/linkml/
ex: https://example.org/boolean-guards/

imports:
- linkml:types

default_prefix: ex
default_range: string

slots:
WeatherWind:
description: Whether wind conditions are present.
range: boolean
slot_uri: ex:WeatherWind
weatherWindValue:
description: Wind speed value.
range: decimal
slot_uri: ex:weatherWindValue
WeatherRain:
description: Whether rain conditions are present.
range: boolean
slot_uri: ex:WeatherRain
weatherRainValue:
description: Rain intensity value.
range: decimal
slot_uri: ex:weatherRainValue
Temperature:
description: Ambient temperature.
range: decimal
slot_uri: ex:Temperature

classes:
Environment:
description: Environmental conditions.
class_uri: ex:Environment
slots:
- WeatherWind
- weatherWindValue
- WeatherRain
- weatherRainValue
- Temperature
rules:
- description: >-
If weatherWindValue is provided, WeatherWind must be true.
preconditions:
slot_conditions:
weatherWindValue:
value_presence: PRESENT
postconditions:
slot_conditions:
WeatherWind:
equals_string: "true"
- description: >-
If weatherRainValue is provided, WeatherRain must be true.
preconditions:
slot_conditions:
weatherRainValue:
value_presence: PRESENT
postconditions:
slot_conditions:
WeatherRain:
equals_string: "true"
Loading
Loading