|
16 | 16 | from linkml.generators.shacl.shacl_ifabsent_processor import ShaclIfAbsentProcessor |
17 | 17 | from linkml.utils.generator import Generator, shared_arguments |
18 | 18 | 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 |
20 | 20 | from linkml_runtime.utils.formatutils import underscore |
21 | 21 | from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph |
22 | 22 | from linkml_runtime.utils.yamlutils import TypedNode, extended_float, extended_int, extended_str |
@@ -142,6 +142,22 @@ class ShaclGenerator(Generator): |
142 | 142 | ignores any per-slot ``in_language``. |
143 | 143 | """ |
144 | 144 |
|
| 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 | + """ |
145 | 161 | generatorname = os.path.basename(__file__) |
146 | 162 | generatorversion = "0.0.1" |
147 | 163 | valid_formats = ["ttl"] |
@@ -389,10 +405,228 @@ def st_node_pv(p, v): |
389 | 405 | if default_value: |
390 | 406 | prop_pv(SH.defaultValue, default_value) |
391 | 407 |
|
| 408 | + if self.emit_rules: |
| 409 | + self._add_rules(g, class_uri_with_suffix, c) |
| 410 | + |
392 | 411 | return g |
393 | 412 |
|
394 | 413 | LINKML_ANY_URI = "https://w3id.org/linkml/Any" |
395 | 414 |
|
| 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 | + |
396 | 630 | def _add_class(self, func: Callable, r: ElementName) -> None: |
397 | 631 | """Add an sh:class constraint for range class *r*. |
398 | 632 |
|
@@ -660,6 +894,17 @@ def add_simple_data_type(func: Callable, r: ElementName) -> None: |
660 | 894 | 'Example: "{name} ({class}): {description} [{comments}]"' |
661 | 895 | ), |
662 | 896 | ) |
| 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 | +) |
663 | 908 | @click.version_option(__version__, "-V", "--version") |
664 | 909 | def cli(yamlfile, **args): |
665 | 910 | """Generate SHACL turtle from a LinkML model""" |
|
0 commit comments