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
77 changes: 77 additions & 0 deletions docs/generators/shacl.rst
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,83 @@ Example Output:
shacl:targetClass <https://w3id.org/linkml/tests/kitchen_sink/Person> .


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
82 changes: 80 additions & 2 deletions packages/linkml/src/linkml/generators/jsonldcontextgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

from linkml._version import __version__
from linkml.utils.deprecation import deprecated_fields
from linkml.utils.generator import Generator, shared_arguments
from linkml.utils.generator import Generator, shared_arguments, well_known_prefix_map
from linkml_runtime.linkml_model.meta import ClassDefinition, EnumDefinition, SlotDefinition
from linkml_runtime.linkml_model.types import SHEX
from linkml_runtime.utils.formatutils import camelcase, underscore
Expand Down Expand Up @@ -93,6 +93,9 @@ class ContextGenerator(Generator):
frame_root: str | None = None

def __post_init__(self) -> None:
# Must be set before super().__post_init__() because the parent triggers
# the visitor pattern (visit_schema), which accesses _prefix_remap.
self._prefix_remap: dict[str, str] = {}
super().__post_init__()
if self.namespaces is None:
raise TypeError("Schema text must be supplied to context generator. Preparsed schema will not work")
Expand Down Expand Up @@ -130,22 +133,92 @@ def _collect_external_elements(sv: SchemaView) -> tuple[set[str], set[str]]:
external_slots.update(schema_def.slots.keys())
return external_classes, external_slots

def add_prefix(self, ncname: str) -> None:
"""Add a prefix, applying well-known prefix normalisation when enabled."""
super().add_prefix(self._prefix_remap.get(ncname, ncname))

def visit_schema(self, base: str | Namespace | None = None, output: str | None = None, **_):
# Add any explicitly declared prefixes
# Add any explicitly declared prefixes.
# Direct .add() is safe here: the normalisation block below explicitly
# rewrites emit_prefixes entries for any renamed prefixes (Cases 1-3).
for prefix in self.schema.prefixes.values():
self.emit_prefixes.add(prefix.prefix_prefix)

# Add any prefixes explicitly declared
for pfx in self.schema.emit_prefixes:
self.add_prefix(pfx)

# Normalise well-known prefix names when --normalize-prefixes is set.
# If the schema declares a non-standard alias for a namespace that has
# a well-known standard name (e.g. ``sdo`` for
# ``https://schema.org/``), replace the alias with the standard name
# so that generated JSON-LD contexts use the conventional prefix.
#
# Three cases are handled:
# 1. Standard prefix is not yet bound → just rebind from old to new.
# 2. Standard prefix is bound to a *different* URI:
# a. User-declared (in schema.prefixes) → collision, skip with warning.
# b. Runtime default (e.g. linkml-runtime's ``schema: http://…``)
# → remove stale binding, then rebind.
# 3. Standard prefix is already bound to the *same* URI (duplicate)
# → just drop the non-standard alias.
#
# A remap dict is stored for ``_build_element_id`` because
# ``prefix_suffix()`` splits CURIEs on ``:`` without looking up the
# namespace dict.
self._prefix_remap.clear()
if self.normalize_prefixes:
wk = well_known_prefix_map()
for old_pfx in list(self.namespaces):
url = str(self.namespaces[old_pfx])
std_pfx = wk.get(url)
if not std_pfx or std_pfx == old_pfx:
continue
if std_pfx in self.namespaces:
if str(self.namespaces[std_pfx]) != url:
# Case 2: std_pfx is bound to a different URI.
# If the user explicitly declared std_pfx in the schema,
# it is intentional — skip to avoid data loss.
if std_pfx in self.schema.prefixes:
self.logger.warning(
"Prefix collision: cannot rename '%s' to '%s' because '%s' is "
"already declared for <%s>; skipping normalisation for <%s>",
old_pfx,
std_pfx,
std_pfx,
str(self.namespaces[std_pfx]),
url,
)
continue
# Not user-declared (e.g. linkml-runtime default) — safe to remove
self.emit_prefixes.discard(std_pfx)
del self.namespaces[std_pfx]
else:
# Case 3: standard prefix already bound to same URI
# — just drop the non-standard alias
del self.namespaces[old_pfx]
if old_pfx in self.emit_prefixes:
self.emit_prefixes.discard(old_pfx)
self.emit_prefixes.add(std_pfx)
self._prefix_remap[old_pfx] = std_pfx
continue
# Case 1 (or Case 2 after stale removal): bind standard name
self.namespaces[std_pfx] = self.namespaces[old_pfx]
del self.namespaces[old_pfx]
if old_pfx in self.emit_prefixes:
self.emit_prefixes.discard(old_pfx)
self.emit_prefixes.add(std_pfx)
self._prefix_remap[old_pfx] = std_pfx

# Add the default prefix
if self.schema.default_prefix:
dflt = self.namespaces.prefix_for(self.schema.default_prefix)
if dflt:
self.default_ns = dflt
if self.default_ns:
default_uri = self.namespaces[self.default_ns]
# Direct .add() is safe: default_ns is already resolved from
# the (possibly normalised) namespace bindings above.
self.emit_prefixes.add(self.default_ns)
else:
default_uri = self.schema.default_prefix
Expand Down Expand Up @@ -509,6 +582,11 @@ def _build_element_id(self, definition: Any, uri: str) -> None:
@return: None
"""
uri_prefix, uri_suffix = self.namespaces.prefix_suffix(uri)
# Apply well-known prefix normalisation (e.g. sdo → schema).
# prefix_suffix() splits CURIEs on ':' without checking the
# namespace dict, so it may return a stale alias.
if uri_prefix and uri_prefix in self._prefix_remap:
uri_prefix = self._prefix_remap[uri_prefix]
is_default_namespace = uri_prefix == self.context_body["@vocab"] or uri_prefix == self.namespaces.prefix_for(
self.context_body["@vocab"]
)
Expand Down
2 changes: 2 additions & 0 deletions packages/linkml/src/linkml/generators/jsonldgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,8 @@ def end_schema(
# through the same ``--importmap`` the caller supplied.
context_kwargs.setdefault("importmap", self.importmap)
context_kwargs.setdefault("base_dir", self.base_dir)
# Forward prefix normalisation into the inline @context.
context_kwargs.setdefault("normalize_prefixes", self.normalize_prefixes)
add_prefixes = ContextGenerator(self.original_schema, **context_kwargs).serialize()
add_prefixes_json = loads(add_prefixes)
metamodel_ctx = self.metamodel_context or METAMODEL_CONTEXT_URI
Expand Down
Loading
Loading