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
2 changes: 1 addition & 1 deletion .github/workflows/pypi-publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,6 @@ jobs:

- name: Publish package 📦 to PyPI
if: github.event_name == 'release'
uses: pypa/gh-action-pypi-publish@v1.14.2
uses: pypa/gh-action-pypi-publish@v1.14.3
with:
verbose: true
66 changes: 66 additions & 0 deletions docs/generators/json-schema.rst
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,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
6 changes: 6 additions & 0 deletions docs/generators/jsonld-context.rst
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ 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`).

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

Expand Down
3 changes: 3 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
56 changes: 56 additions & 0 deletions docs/schemas/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,3 +211,59 @@ details of how to use this in code.
In some cases, you may want to use a URI or CURIE as a range. To do this,
create a class with `class_uri` set to the URI or CURIE and use that class
as the range.

(links-and-uri-references)=
## Links and URI references

A value of a URI-like type is either a link, which becomes an RDF IRI, or a URI
reference held as text, which becomes an `xsd:anyURI` literal. The type decides,
in the same way for every generator and for the RDF dumper and loader:

| Type | RDF term | Use for |
|---|---|---|
| `uri`, `uriorcurie` and types derived from them | IRI (CURIEs are expanded) | links to resources |
| `objectidentifier` | IRI | references to objects |
| `nodeidentifier`, or a type with `uri: rdfs:Resource` | IRI or blank node (`_:b1`) | references to nodes |
| a type derived from `string` with `uri: xsd:anyURI` | `xsd:anyURI` literal, as written | URI references held as data, such as relative file paths |
| `curie` | `xsd:string` literal, not expanded | compact identifiers held as text |

```yaml
types:
FilePath:
typeof: string
uri: xsd:anyURI
description: A file location, which may be relative.
slots:
homepage:
range: uri
file_path:
range: FilePath
```

For `homepage: https://alice.example.org/` and `file_path: ./docs/cv.pdf` the RDF is

```turtle
ex:alice ex:homepage <https://alice.example.org/> ;
ex:file_path "./docs/cv.pdf"^^xsd:anyURI .
```

gen-jsonld-context coerces `homepage` to `@id` and `file_path` to `xsd:anyURI`,
gen-shacl requires `sh:nodeKind sh:IRI` for one and `sh:datatype xsd:anyURI` for
the other, and gen-owl makes `homepage` an object property and `file_path` a data
property.

An RDF IRI must be absolute ([RDF 1.1 Concepts §3.2](https://www.w3.org/TR/rdf11-concepts/#section-IRIs)),
so the RDF dumper rejects a relative value of a link type, such as `./home.html` in
`homepage`, and JSON Schema validation rejects it for `uri` and the types derived
from it (`format: uri`). Through a JSON-LD context, `@id` values are resolved
against the base IRI of the document. Give values that may be relative a text type
such as `FilePath`.

The RDF loader reads an IRI back as a complete URI for `uri` and the types derived
from it, and as a CURIE, where the schema declares the prefix, for `uriorcurie`,
`objectidentifier` and `nodeidentifier`.

The `--xsd-anyuri-as-iri` option of gen-owl and gen-jsonld-context is deprecated
and has no effect: links are IRIs without it. A schema that relied on the earlier
default, where `uri` values were `xsd:anyURI` literals in the JSON-LD context and
in OWL, keeps that output by giving those slots a text type such as `FilePath`.
2 changes: 1 addition & 1 deletion packages/linkml/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ tests-rustgen = [
"maturin>=1.15.0"
]
typedb = [
"typedb-driver>=3.13.6,<4.0",
"typedb-driver>=3.14.0,<4.0",
]
bigquery = [
"sqlalchemy-bigquery >= 1.9.0",
Expand Down
33 changes: 0 additions & 33 deletions packages/linkml/src/linkml/generators/common/subproperty.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,6 @@
CURIE_TYPES: frozenset[str] = frozenset({"uriorcurie", "curie"})
URI_TYPES: frozenset[str] = frozenset({"uri"})

# Types whose XSD mapping is xsd:anyURI (not xsd:string).
# ``curie`` maps to xsd:string and is deliberately excluded.
_ANYURI_TYPES: frozenset[str] = frozenset({"uri", "uriorcurie"})


def is_uri_range(sv: SchemaView, range_type: str | None) -> bool:
"""
Expand Down Expand Up @@ -67,35 +63,6 @@ def is_curie_range(sv: SchemaView, range_type: str | None) -> bool:
return False


def is_xsd_anyuri_range(sv: SchemaView, range_type: str | None) -> bool:
"""Check if range type resolves to ``xsd:anyURI``.

Returns True for ``uri``, ``uriorcurie``, and types that inherit from them.
Returns False for ``curie`` (which maps to ``xsd:string``).

This is the correct predicate for the ``--xsd-anyuri-as-iri`` flag: only
types whose XSD representation is ``xsd:anyURI`` should be promoted from
literal to IRI semantics. ``curie`` is a compact string representation
that resolves to ``xsd:string`` and must not be affected.

:param sv: SchemaView for type ancestry lookup
:param range_type: The range type to check
:return: True if range type maps to xsd:anyURI
"""
if range_type is None:
return False

if range_type in _ANYURI_TYPES:
return True

if range_type in sv.all_types():
type_ancestors = set(sv.type_ancestors(range_type))
if type_ancestors & _ANYURI_TYPES:
return True

return False


def format_slot_value_for_range(sv: SchemaView, slot_name: str, range_type: str | None) -> str:
"""
Format slot value according to the declared range type.
Expand Down
75 changes: 31 additions & 44 deletions packages/linkml/src/linkml/generators/jsonldcontextgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,13 @@
from rdflib import SKOS, XSD, Namespace

from linkml._version import __version__
from linkml.utils.deprecation import deprecated_fields
from linkml.utils.deprecation import XSD_ANYURI_AS_IRI_OPTION, deprecated_fields, deprecation_warning
from linkml.utils.generator import Generator, shared_arguments
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
from linkml_runtime.utils.rdf_terms import type_term
from linkml_runtime.utils.schemaview import SchemaView

URI_RANGES = (SHEX.nonliteral, SHEX.bnode, SHEX.iri)

# Extended URI_RANGES that also treats xsd:anyURI as an IRI reference (@id)
# rather than a typed literal. Opt-in via --xsd-anyuri-as-iri flag.
URI_RANGES_WITH_XSD = (*URI_RANGES, XSD.anyURI)

ENUM_CONTEXT = {
"text": "skos:notation",
"description": "skos:prefLabel",
Expand Down Expand Up @@ -81,12 +75,9 @@ class ContextGenerator(Generator):
context keys. Identifier slots retain their slot-name key and map to ``@id``.
"""

xsd_anyuri_as_iri: bool = False
"""Map xsd:anyURI-typed ranges (uri, uriorcurie) to ``@type: @id`` instead of ``@type: xsd:anyURI``.

This aligns the JSON-LD context with the SHACL generator, which emits
``sh:nodeKind sh:IRI`` for the same types.
"""
xsd_anyuri_as_iri: bool | None = None
"""Deprecated and without effect: values of ``uri``, ``uriorcurie`` and the types
derived from them are always coerced to ``@id``."""

# Framing (opt-in via CLI flag)
emit_frame: bool = False
Expand All @@ -95,6 +86,8 @@ class ContextGenerator(Generator):
frame_root: str | None = None

def __post_init__(self) -> None:
if self.xsd_anyuri_as_iri is not None:
deprecation_warning(XSD_ANYURI_AS_IRI_OPTION, stack_level=4)
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 @@ -299,19 +292,9 @@ def _literal_coercion_for_ranges(self, ranges: list[str]) -> tuple[bool, str | N
and "could not resolve safely because the branches disagree".
"""
coercions: set[str | None] = set()
uri_ranges = URI_RANGES_WITH_XSD if self.xsd_anyuri_as_iri else URI_RANGES
for range_name in ranges:
if range_name not in self.schema.types:
continue

range_type = self.schema.types[range_name]
range_uri = self.namespaces.uri_for(range_type.uri)
if range_uri == XSD.string:
coercions.add(None)
elif range_uri in uri_ranges:
coercions.add("@id")
else:
coercions.add(range_type.uri)
if range_name in self.schema.types:
coercions.add(self._type_coercion(range_name))

if not coercions:
return False, None
Expand Down Expand Up @@ -413,14 +396,9 @@ def visit_slot(self, aliased_slot_name: str, slot: SlotDefinition) -> None:
skos = "skos"
self.emit_prefixes.add(skos)
else:
range_type = self.schema.types[slot.range]
uri_ranges = URI_RANGES_WITH_XSD if self.xsd_anyuri_as_iri else URI_RANGES
if self.namespaces.uri_for(range_type.uri) == XSD.string:
pass
elif self.namespaces.uri_for(range_type.uri) in uri_ranges:
slot_def["@type"] = "@id"
else:
slot_def["@type"] = range_type.uri
coercion = self._type_coercion(slot.range)
if coercion is not None:
slot_def["@type"] = coercion

if self.fix_multivalue_containers and slot.multivalued:
if slot.inlined and not slot.inlined_as_list:
Expand Down Expand Up @@ -457,15 +435,25 @@ def _resolve_slot_type(self, range_name: str) -> str | None:
if range_name in self.schema.enums:
return None
if range_name in self.schema.types:
range_type = self.schema.types[range_name]
uri = self.namespaces.uri_for(range_type.uri)
if uri == XSD.string:
return None
if uri in URI_RANGES:
return "@id"
return range_type.uri
return self._type_coercion(range_name)
return None

def _type_coercion(self, type_name: str) -> str | None:
"""Return the JSON-LD ``@type`` coercion of values of the type *type_name*.

Values of node types (:func:`~linkml_runtime.utils.rdf_terms.type_term`) are
coerced to ``@id``, which also turns ``_:`` labels into blank nodes. Literals
are coerced to their datatype as the schema writes it; plain strings need no
coercion, so the result is ``None``.
"""
view = self.type_schemaview()
term = type_term(view, type_name)
if term.kind.is_node:
return "@id"
if term.datatype is None or term.datatype == str(XSD.string):
return None
return view.induced_type(type_name).uri

def _build_scoped_context(self, cls: ClassDefinition) -> dict:
"""Build a scoped JSON-LD context for class-level slot range overrides.

Expand Down Expand Up @@ -605,9 +593,8 @@ def serialize(
)
@click.option(
"--xsd-anyuri-as-iri/--no-xsd-anyuri-as-iri",
default=False,
show_default=True,
help="Map xsd:anyURI-typed ranges (uri, uriorcurie) to @type: @id instead of @type: xsd:anyURI.",
default=None,
help="Deprecated and without effect: values of uri, uriorcurie and the types derived from them are always IRIs.",
)
@click.option(
"--use-curies/--not-use-curies",
Expand Down
Loading
Loading