Skip to content
Draft
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
66 changes: 66 additions & 0 deletions docs/generators/json-schema.rst
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,72 @@ will generate:
LinkML also supports `Structured patterns <https://w3id.org/linkml/structured_pattern>`_, these are
compiled down to patterns during JSON Schema generation.

Dictionary key constraints (propertyNames)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

A multivalued, inlined slot whose range class has an identifier slot is
compiled to a JSON object keyed by that identifier (see *Inlining* above).
When the identifier slot carries string-applicable constraints, they are
emitted as a `propertyNames <https://json-schema.org/understanding-json-schema/reference/object.html#property-names>`_
schema on the container object, so the *keys* of the dictionary are validated,
not just the values:

.. code-block:: yaml

slots:
tags:
range: Tag
multivalued: true
inlined: true
uid:
identifier: true
pattern: "^(0|[1-9][0-9]*)$"

generates on the container:

.. code-block:: json

"tags": {
"additionalProperties": {"$ref": "#/$defs/Tag"},
"propertyNames": {"pattern": "^(0|[1-9][0-9]*)$"},
"type": "object"
}

The constraints carried over from the key slot are the ones applicable to JSON
Schema strings, because object keys are always strings (`JSON Schema Core
2019-09, §9.3.2.5 <https://json-schema.org/draft/2019-09/json-schema-core.html#rfc.section.9.3.2.5>`_):

* ``pattern`` -- whether written directly on the slot, resolved from a
``structured_pattern``, or inherited from the slot's ``range`` type (for
example an identifier with ``range: ncname``, or a user-defined type that
declares a ``pattern``);
* ``equals_string_in``, emitted as ``enum``;
* a string ``equals_string``, emitted as ``const``.

The emitted key pattern is always the same one that applies to the identifier
*inside* the value object, so a key and a redundantly repeated in-object
identifier are now validated identically.

Numeric constraints -- ``minimum_value``/``maximum_value``, and the numeric
``const`` produced by ``equals_number`` -- are deliberately **not** carried
over: they cannot be satisfied by a string key, and a numeric ``const`` would
reject every key. The ``allOf`` produced by a ``range_expression``, and the
permissible values of an ``enum``-ranged identifier, are likewise out of scope.

``propertyNames`` composes conjunctively with ``additionalProperties``, so keys
and values are constrained independently. It is emitted only when the key slot
actually carries one of the constraints listed above; an unconstrained key slot
produces exactly the same output as before.

.. note::

Because type-level patterns are included, an identifier slot whose range is
``ncname`` (or another pattern-bearing type) gains a ``propertyNames``
entry even if the slot itself declares no constraint. The generated schema
becomes stricter, but only in ways the model already required: data whose
keys satisfy the declared identifier type is unaffected.


Rules
^^^^^

Expand Down
62 changes: 62 additions & 0 deletions docs/generators/owl.rst
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,26 @@ Mapping

.. note:: The current default settings for ``metaclasses`` and ``type-objects`` may change in the future

Prefix normalization
^^^^^^^^^^^^^^^^^^^^

Schemas sometimes declare non-standard aliases for well-known namespaces
(e.g. ``sh1:`` for the SHACL namespace, or a versioned alias for ``skos:``).
By default these aliases are carried through into the generated artifact.

Use ``--normalize-prefixes`` to remap declared prefixes whose namespace IRI
matches a well-known vocabulary to that vocabulary's conventional name in the
output (``owl``, ``rdf``, ``rdfs``, ``skos``, ``sh``, ``xsd``, ...):

.. code:: bash

gen-owl --normalize-prefixes schema.yaml

The mapping is a static, version-independent table; namespace IRIs that are
not in the table are left untouched. The option is also available on
``gen-shacl`` and ``gen-jsonld-context``.


Enums and PermissibleValues
^^^^^^^^^^^^^^^^^^^^^^^^^^^

Expand Down Expand Up @@ -311,6 +331,48 @@ Other examples
translation of Biolink schema to OWL


Deterministic output
^^^^^^^^^^^^^^^^^^^^

``gen-owl`` output is deterministic by default. The graph is canonicalized with
`RDFC-1.0 <https://www.w3.org/TR/rdf-canon/>`_ before serialization, so repeated
runs over the same schema -- and any two isomorphic graphs -- produce
byte-identical Turtle. No flag is needed, and checked-in artifacts do not churn
between runs.

RDFC-1.0 numbers blank nodes sequentially (``_:c14n0``, ``_:c14n1``, ...) in
canonical order. That is stable for a fixed graph, but inserting a single
statement can shift the numbering of every blank node ordered after it, so an
unrelated one-line schema edit may rewrite large parts of the file. Pass
``--diff-stable`` to derive each label from the node's own neighbourhood
instead, so that only the blank nodes an edit actually touches are renamed:

.. code:: bash

gen-owl --diff-stable schema.yaml

Both modes are deterministic and yield isomorphic graphs; only the choice of
label differs. ``--diff-stable`` is off by default because turning it on
relabels the blank nodes in existing output once.

The same ``--diff-stable/--no-diff-stable`` option is available on ``gen-rdf``,
``gen-shacl`` and ``gen-shex``.

Graphs that are not standard RDF -- literal predicates, as produced by
``gen-shacl`` in annotation mode, or relative IRIs such as the metamodel's
``bibo:status <testing>`` -- cannot be canonicalized under RDFC-1.0. Those fall
back to plain rdflib serialization, with blank-node labels canonicalized by
``rdflib.compare.to_canonical_graph``. Those labels are content-derived rather
than run-local, so the fallback remains reproducible across processes. It emits
an ``RDFCanonicalizationWarning``, and ``--diff-stable`` has no effect on that
path -- it warns rather than silently ignoring the request.

Canonicalization itself is implemented by the
`diffable-rdf <https://github.com/ASCS-eV/diffable-rdf>`_ library;
``linkml_runtime.utils.rdf_canonicalize.canonicalize_rdf_graph`` is a thin
adapter that re-emits the library's log warnings as Python warnings.


Docs
----

Expand Down
159 changes: 159 additions & 0 deletions docs/generators/shacl.rst
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,165 @@ Example Output:
shacl:targetClass <https://w3id.org/linkml/tests/kitchen_sink/Person> .


Class Expressions
^^^^^^^^^^^^^^^^^

Class-level boolean expressions become the SHACL logical constraint components
their metamodel definitions map to (`SHACL §4.6
<https://www.w3.org/TR/shacl/#core-components-logical>`__):

================== =====================================================
LinkML SHACL, on the class's ``sh:NodeShape``
================== =====================================================
``any_of`` ``sh:or`` over the member shapes
``all_of`` ``sh:and`` over the member shapes
``exactly_one_of`` ``sh:xone`` over the member shapes
``none_of`` one ``sh:not`` per member
================== =====================================================

Each member becomes an anonymous node shape. ``is_a`` gives ``sh:class``, and
nested expressions recurse. Each entry of ``slot_conditions`` gives an
``sh:property`` whose path is that of the slot as induced for the class, so
``slot_usage`` applies:

* ``required``, ``value_presence`` and the cardinalities give ``sh:minCount`` /
``sh:maxCount``;
* ``minimum_value`` / ``maximum_value`` give ``sh:minInclusive`` /
``sh:maxInclusive``, and ``equals_number`` gives both, so that ``5`` also
matches ``5.0``;
* ``pattern`` gives ``sh:pattern``;
* ``equals_string`` and ``equals_string_in`` give ``sh:in``; on an enum slot the
values are the permissible values as the enum renders them, the IRI of their
``meaning`` where they have one;
* ``range`` gives the same class, type or enum constraint as a slot's range.

SHACL allows ``sh:minInclusive``, ``sh:maxInclusive``, ``sh:in`` and
``sh:pattern`` at most once per shape. Where one condition needs one of them
twice, for example ``minimum_value`` next to ``equals_number``, the second value
goes into an ``sh:and`` member of the property shape, where it applies to the
same values.

A slot condition constrains only the values that are present, so it also holds
when the slot is absent - unless ``required: true``, ``value_presence: PRESENT``
or a minimum or exact cardinality of at least 1 requires the slot. Inside
``none_of``, at any depth, a condition that constrains values requires the slot,
so that an absent slot is not rejected by the negation - unless the condition
decides presence itself, through ``required``, ``value_presence`` or a maximum
or exact cardinality of 0. The JSON Schema generator requires the slot in a
class's own ``none_of`` for every condition that sets neither ``required`` nor
``value_presence``.

.. code-block:: yaml

GeodeticReferenceSystem:
slots: [code, name]
any_of:
- slot_conditions:
code:
required: true
- slot_conditions:
name:
required: true

.. code-block:: turtle

ex:GeodeticReferenceSystem a sh:NodeShape ;
sh:or ( [ sh:property [ sh:path ex:code ; sh:minCount 1 ] ]
[ sh:property [ sh:path ex:name ; sh:minCount 1 ] ] ) ;
...

An expression is attached to the shape of the class that declares it. Like
every ``sh:targetClass``, it reaches instances of subclasses where the data
graph states the ``rdfs:subClassOf`` (`SHACL §2.1.3.2
<https://www.w3.org/TR/shacl/#targetClass>`__); the ``sh:class`` that ``is_a``
gives recognises instances of subclasses the same way, as it does for a slot's
range.

An operator whose members use anything else is skipped as a whole and logged as
a warning, because leaving out one member would change what the operator
admits. That covers, for example, ``has_member`` or a slot-level ``any_of``
inside a slot condition, a condition on a name that is not a slot, a condition
on the identifier slot (the node's IRI rather than a property), and
``equals_string`` on a slot whose range does not hold strings.


Rule constraints (SHACL-SPARQL)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

LinkML `rules <https://linkml.io/linkml/schemas/advanced.html#rules>`_ express
cross-parameter, conditional validation ("if slot A holds X, slot B must
..."). Plain per-slot SHACL property shapes cannot express these, so the
generator translates recognised rule shapes into
`SHACL-SPARQL constraints <https://www.w3.org/TR/shacl/#sparql-constraints>`_
(``sh:sparql`` / ``sh:SPARQLConstraint``) on the class's ``sh:NodeShape``.
Generation is controlled by ``--emit-rules/--no-emit-rules`` (default: on).

Three named patterns are recognised first:

* **Boolean guard** — precondition ``value_presence: PRESENT`` on a value
slot, postcondition ``equals_string: "true"`` on a *boolean-range* flag
slot: if the value is present, the flag must be true.
* **Presence implies value** — precondition ``value_presence: PRESENT``,
postcondition ``equals_string`` / ``equals_string_in`` on a target slot:
if the guard is present, the target must hold one of the allowed values.
Enum values resolve to their ``meaning`` IRIs; values without ``meaning``
compare as string literals.
* **Exclusive value** — precondition ``equals_string`` and postcondition
``maximum_cardinality`` on the *same* multivalued slot: if the value is
present, the slot has at most N values.

Combinations outside the named patterns are handled by a compositional
fallback that conjoins the preconditions and negates a single postcondition:
conditional-required (``required: true``), conditional-absent
(``value_presence: ABSENT``), numeric threshold preconditions
(``minimum_value`` / ``maximum_value``), a one-hop nested precondition into
an inlined child object (``range_expression.slot_conditions``), and
``has_member`` list membership.

The translation contract is *skip, never mis-translate*: a rule whose
conditions set any operator outside the translated set (including
expression-level ``any_of``/``all_of``/``none_of``/``exactly_one_of``), or
whose slot keys resolve to no slot, is skipped and logged at ``DEBUG``.
``deactivated`` rules are skipped; ``bidirectional``, ``open_world``, and
``elseconditions`` warn (the forward direction is emitted).

Example:

.. code-block:: yaml

classes:
Weather:
slots: [sun_altitude, daytime]
rules:
- description: If sun_altitude is present, daytime must be day or twilight.
preconditions:
slot_conditions:
sun_altitude:
value_presence: PRESENT
postconditions:
slot_conditions:
daytime:
equals_string_in: [day, twilight]

generates (abridged):

.. code-block:: turtle

ex:Weather a sh:NodeShape ;
sh:sparql [ a sh:SPARQLConstraint ;
sh:message "If sun_altitude is present, daytime must be day or twilight." ;
sh:select """SELECT $this WHERE {
$this <https://example.org/sun_altitude> ?value .
OPTIONAL { $this <https://example.org/daytime> ?target . }
FILTER ( !BOUND(?target) || ?target NOT IN (<https://example.org/Day>, <https://example.org/Twilight>) )
}""" ] .

``$this`` is pre-bound to each focus node per
`SHACL §5.3.1 <https://www.w3.org/TR/shacl/#sparql-constraints-prebound>`_.
Note that SPARQL-based constraints require a SHACL processor with
SHACL-SPARQL support (e.g. ``pyshacl`` with ``advanced=True``).


Command Line
^^^^^^^^^^^^

Expand Down
9 changes: 8 additions & 1 deletion packages/linkml/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ dependencies = [ # Specifier syntax: https://peps.python.org/pep-0631/
"openpyxl",
"parse",
"prefixcommons >= 0.1.7",
"prefixmaps >= 0.2.2",
# TODO(prefixmaps-0.2.8): Replace git pin with "prefixmaps >= 0.2.8" once released,
# then remove [tool.hatch.metadata] allow-direct-references and regenerate uv.lock.
# Tracked in: https://github.com/linkml/prefixmaps/issues/82
"prefixmaps @ git+https://github.com/linkml/prefixmaps@75435150a1b31760b9780af2b64a265943a9b263",
"pydantic>=2.13.5,<3.0.0",
"pyjsg >= 0.12.3",
"pyshex >= 0.9.0",
Expand Down Expand Up @@ -207,6 +210,10 @@ vcs = "git"
style = "pep440"
fallback-version = "0.0.0"

[tool.hatch.metadata]
# TODO(prefixmaps-0.2.8): Remove this section once the git pin is replaced with >= 0.2.8
allow-direct-references = true

[tool.hatch.version]
source = "uv-dynamic-versioning"

Expand Down
Loading
Loading