Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5d0f36a
feat(jsonschemagen): emit propertyNames from inlined-dict key slot co…
jdsika Oct 2, 2026
846750b
feat(generators): translate class-level boolean expressions alike to …
jdsika Sep 24, 2026
13f52a2
fix(generators): enforce range_expression on each value alike in SHAC…
jdsika Oct 5, 2026
1372936
feat(generators): enforce has_member alike in SHACL and JSON Schema
jdsika Oct 7, 2026
91d0f47
feat(gen-shacl): translate presence-implies-value rules to SHACL-SPARQL
rmessaou Sep 11, 2026
9510586
feat(gen-shacl): add a compositional fallback for rule-to-SPARQL conv…
rmessaou Oct 6, 2026
6cb42b2
docs(shacl): document rule-to-SHACL-SPARQL constraint generation
jdsika Sep 11, 2026
ac27fc1
feat(gen-owl): honor instantiates on emitted schema resources
jdsika Oct 2, 2026
2c7b38d
fix(rdf): give values of URI-like types one RDF term kind in every ge…
jdsika Oct 21, 2026
9258891
feat(rdf): give declared annotation values and native metadata the te…
jdsika Oct 21, 2026
87a6ef8
feat(owlgen): give annotation properties their OWL role and no logica…
jdsika Oct 21, 2026
2f64325
fix(contextgen): declare JSON-LD 1.1 in generated contexts
jdsika Oct 15, 2026
4a11a1e
docs(owl): document deterministic serialization and --diff-stable
jdsika Sep 11, 2026
fefca18
feat(generators): add --normalize-prefixes flag for well-known prefix…
jdsika Oct 2, 2026
cbfa5ca
fix(rdf): delegate canonicalization to the diffable-rdf library
jdsika Oct 13, 2026
a791f01
test(integration): verify the interactions of the integrated generato…
jdsika Oct 23, 2026
88e3a80
ci(integration): run the OWLAPI consumer on gen-owl output
jdsika Oct 23, 2026
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
46 changes: 46 additions & 0 deletions .github/workflows/owl-annotation-consumer.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: OWL annotation consumer

on:
pull_request:
push:
branches: [main]
merge_group:
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
owlapi:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v10.2.0
with:
version: "0.11.21"
enable-cache: true
- uses: actions/setup-python@v7.0.0
with:
python-version: "3.12"
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "21"
cache: maven
cache-dependency-path: tests/linkml/test_generators/input/owl_annotation_properties/pom.xml
- name: Install Python dependencies
run: uv sync --all-groups
- name: Resolve the pinned OWLAPI dependency tree once
run: >-
mvn --batch-mode
-f tests/linkml/test_generators/input/owl_annotation_properties/pom.xml
org.apache.maven.plugins:maven-dependency-plugin:3.8.1:build-classpath
-Dmdep.outputFile=${{ runner.temp }}/owlapi-classpath.txt
- name: Require profile validity and annotation retention on round-trip
env:
LINKML_REQUIRE_OWLAPI: "1"
LINKML_OWLAPI_CLASSPATH_FILE: ${{ runner.temp }}/owlapi-classpath.txt
run: >-
uv run pytest tests/linkml/test_generators/test_owl_annotation_consumer.py
-q -m integration
26 changes: 26 additions & 0 deletions docs/examples/owl-dcmi-agent-annotations.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
id: https://example.org/model
name: model
prefixes:
ex: https://example.org/
linkml: https://w3id.org/linkml/
dcterms: http://purl.org/dc/terms/
orcid: https://orcid.org/
owl: http://www.w3.org/2002/07/owl#
imports: [linkml:types]
default_prefix: ex
instantiates: [ex:AgentMetadata]
annotations:
creator: orcid:0000-0002-1825-0097
rights: Copyright 2026 The Example Team
classes:
AgentMetadata:
description: Declares the agent metadata that a schema may carry as annotations.
attributes:
creator:
slot_uri: dcterms:creator
range: nodeidentifier
implements: [owl:AnnotationProperty]
rights:
slot_uri: dcterms:rights
range: string
implements: [owl:AnnotationProperty]
84 changes: 84 additions & 0 deletions docs/generators/json-schema.rst
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,24 @@ LinkML supports analogous elements:

Use of these elements will be translated into the appropriate JSON-Schema construct.

At the class level, a class carries the expressions of its ancestors and mixins.
An ``is_a`` member names a class every instance already belongs to, or none can,
unless only some do, through a descendant: then the instance must match that
class's definition, since a JSON object carries no type.
A slot condition is unknown for an absent slot unless it decides whether the slot
may be absent, and an instance is invalid only when an expression is definitely
false, as described under "Class-level expressions and absent slots" in
:doc:`Advanced features </schemas/advanced>`.
A slot's `range_expression <https://w3id.org/linkml/range_expression>`_
constrains each of its values in the same way, with its conditions on the slots
of the value. For a slot inlined as a dict these are the values of the dict,
except in its compact forms ``{id: value}`` and ``{id: null}``, which the
expression can't reach.
A multivalued slot's `has_member <https://w3id.org/linkml/has_member>`_ becomes
``contains``, and the slot is required, because a slot without a value has no
member; on a slot that isn't multivalued it is ignored, with a warning.
The SHACL generator reads them the same way.

Inlining
^^^^^^^^

Expand Down Expand Up @@ -420,6 +438,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
17 changes: 17 additions & 0 deletions docs/generators/jsonld-context.rst
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,23 @@ declarations and
Any JSON that conforms to the derived JSON Schema (see above) can be
converted to RDF using this context.

Values of ``uri``, ``uriorcurie``, the types derived from them, and the node
types ``objectidentifier`` and ``nodeidentifier`` are coerced to ``@id``; a type
derived from ``string`` with ``uri: xsd:anyURI`` keeps URI references as
``xsd:anyURI`` literals (see
:ref:`links-and-uri-references`).


JSON-LD version
^^^^^^^^^^^^^^^

Generated contexts start with ``"@version": 1.1``. They use JSON-LD 1.1
constructs, such as scoped contexts for enum slots and class-level range
overrides, and ``@prefix`` (see below), which a JSON-LD 1.0 processor would
silently ignore. The number ``1.1`` makes such a processor stop instead
(`JSON-LD 1.1 §1.7 <https://www.w3.org/TR/json-ld11/#syntax-tokens-and-keywords>`__), and selects
JSON-LD 1.1 processing in processors that support both.

CURIE usage
^^^^^^^^^^^

Expand Down
141 changes: 141 additions & 0 deletions docs/generators/owl.rst
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ Mapping
* Each LinkML slot maps to an OWL property

- if the range of the slot is class, then an ObjectProperty is used
- an ObjectProperty is also used when the range is a type whose values are IRIs or blank
nodes: ``uri``, ``uriorcurie`` and the types derived from them, ``objectidentifier`` and
``nodeidentifier`` (see :ref:`links-and-uri-references`)
- otherwise DataProperty is used
- Exception to the above: if ``type_objects`` is set then ObjectProperties are always used;
instead of having ranges of type be mapped to literals, these map to class-shadows of
Expand Down Expand Up @@ -67,6 +70,74 @@ Mapping

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

Annotation values
^^^^^^^^^^^^^^^^^

Native metadata takes the RDF term of its metamodel range: ``see_also`` and ``source``
values are IRIs, ``license`` and ``description`` values are strings, and ``created_on``
is an ``xsd:dateTime`` literal. A link value that is neither an IRI nor a CURIE, such as
a bare element name in ``see_also``, is written as a plain literal, with a warning. An annotation
whose tag a metaclass declares through ``instantiates`` takes the term of that slot's
range in the same way (see :ref:`annotations-in-rdf`); any other annotation value is a
string.

.. literalinclude:: ../examples/owl-dcmi-agent-annotations.yaml
:language: yaml

The ontology header then carries ``dcterms:creator <https://orcid.org/0000-0002-1825-0097>``
and ``dcterms:rights "Copyright 2026 The Example Team"``, and the two predicates are
declared as annotation properties (see below).

Annotation properties
^^^^^^^^^^^^^^^^^^^^^

A slot that ``implements`` ``owl:AnnotationProperty`` is an annotation property
(`OWL 2 §5.5 <https://www.w3.org/TR/owl2-syntax/#Annotation_Properties>`__), and so is
a slot whose property IRI is one of the nine built-in annotation properties, such as a
slot with ``slot_uri: rdfs:label`` emitted with ``--no-use-native-uris``. An annotation
property has no logical meaning (`§10.1 <https://www.w3.org/TR/owl2-syntax/#Annotations>`__),
so the generator emits no class restriction for it: a class that uses the slot gets no
``owl:allValuesFrom`` or cardinality axiom on it. Its domain, a datatype or class range
and its super-properties are emitted as the annotation axioms of
`§10.2 <https://www.w3.org/TR/owl2-syntax/#Annotation_Axioms>`__; a range that is a node
type such as ``nodeidentifier`` gives no range axiom. Built-in annotation properties are
not declared, as OWL 2 reserves them.

An IRI is an annotation, an object or a data property, not two of these
(`§5.8.1 <https://www.w3.org/TR/owl2-syntax/#Typing_Constraints_of_OWL_2_DL>`__), so a slot
that implements two roles is an error, as is a logical use of an annotation property: an
inverse or a property characteristic, a unique key, a type designator, or a condition in
a rule or class expression. A super-property (``is_a`` or a mixin) must have the same
role as its sub-property, since a sub-property axiom relates two annotation properties
or two logical properties. Slots without the role keep their object or data property
projection, also when a metaclass declares them; LinkML then puns the annotated element
as an individual, as it does for all metadata.


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 names come from `prefixmaps <https://github.com/linkml/prefixmaps>`_, its
curated ``linked_data`` context over its broader ``merged`` one, and where rdflib
binds a namespace by default, from rdflib, which serializes the output. They can
therefore change with the installed prefixmaps and rdflib versions. Namespace
IRIs that none of them names are left untouched, and a well-known name that the
schema declares for another namespace is kept, with a warning. The option is
also available on ``gen-shacl``, ``gen-jsonld-context`` and ``gen-jsonld``.


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

Expand Down Expand Up @@ -135,6 +206,54 @@ You can control enum and permissible value representation directly in your schem
implements:
- rdfs:Literal

**Metaclass membership with ``instantiates``** - This LinkML field types a schema
element itself; it does not add slots or type that element's data instances. The OWL
generator maps each declared membership to ``rdf:type`` on the emitted resource.
The mapping applies consistently to schemas, classes, slots, locally emitted types,
enums, and non-literal permissible values. No additional option is required: the
schema already declares the relationship.

For example, a permissible value represented as a named individual can declare
membership in an external vocabulary class:

.. code-block:: yaml

enums:
LinkCategory:
implements:
- owl:NamedIndividual
permissible_values:
isLicense:
meaning: ex:isLicense
instantiates:
- vocab:LicenseCategory

.. code-block:: turtle

ex:isLicense a owl:NamedIndividual, ex:LinkCategory, vocab:LicenseCategory .

For an enum of named individuals, its own class still uses ``owl:oneOf``; this does
not close the external class. A permissible value rendered as an OWL class retains
that representation and also participates as an individual in the membership
assertion (OWL punning). Membership does not become ``rdfs:subClassOf`` and is not
inherited by instances of the emitted class. ``instantiates`` is ignored, with a
warning, on a permissible value rendered as a literal, because RDF literals cannot
be subjects of ``rdf:type`` triples, on a resource in the OWL 2 reserved vocabulary,
which cannot be an individual, and for a value that is neither a CURIE nor an IRI,
which would become a relative IRI.

``gen-rdf`` and ``gen-jsonld`` serialize the schema as metamodel data and retain
``linkml:instantiates``. ``gen-owl`` translates that declaration into ontology
membership. Instance validators such as JSON Schema and SHACL must not apply the
schema element's metaclass to ordinary data instances.

See the `LinkML instantiation guide
<https://linkml.io/linkml/howtos/implements-instantiates-guide.html#instantiates-metamodel-extension>`_,
the `instantiates metamodel definition
<https://linkml.io/linkml-model/latest/docs/instantiates/>`_, and the OWL 2
specifications for `metamodeling <https://www.w3.org/TR/owl2-syntax/#Metamodeling>`_
and `mapping class assertions to RDF <https://www.w3.org/TR/owl2-mapping-to-rdf/>`_.

**Using URIs vs. text for permissible values:**

.. code-block:: yaml
Expand Down Expand Up @@ -311,6 +430,28 @@ Other examples
translation of Biolink schema to OWL


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

``gen-owl`` canonicalizes the graph with `RDFC-1.0 <https://www.w3.org/TR/rdf-canon/>`_
before serializing it. For standard RDF, repeated runs over the same schema
therefore produce byte-identical Turtle, and checked-in artifacts do not change
between runs.

RDFC-1.0 numbers blank nodes in canonical order (``_:c14n0``, ``_:c14n1``, ...),
so adding a statement can renumber unrelated blank nodes. ``--diff-stable``
derives labels from blank-node neighbourhoods instead, which reduces this churn
but does not guarantee minimal diffs; see :ref:`rdf-in-version-control`, which
also names the optional dependency it needs. The option is also available on
``gen-rdf``, ``gen-shacl`` and ``gen-shex --format rdf``.

Graphs that are not standard RDF, with a relative IRI or a literal as a
predicate, cannot be canonicalized under RDFC-1.0; ``gen-shacl
--include-annotations`` writes such a predicate for an annotation whose tag is
not a CURIE. They are serialized by rdflib instead, with an
``RDFCanonicalizationWarning``, and ``--diff-stable`` has no effect on them.


Docs
----

Expand Down
Loading
Loading