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
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`.
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
60 changes: 23 additions & 37 deletions packages/linkml/src/linkml/generators/owlgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@

from linkml import METAMODEL_NAMESPACE_NAME
from linkml._version import __version__
from linkml.generators.common.subproperty import is_xsd_anyuri_range
from linkml.utils.deprecation import deprecation_warning
from linkml.utils.deprecation import XSD_ANYURI_AS_IRI_OPTION, deprecation_warning
from linkml.utils.generator import Generator, shared_arguments
from linkml.utils.language_tags import LanguageTagResolver
from linkml_runtime import SchemaView
Expand All @@ -44,6 +43,7 @@
from linkml_runtime.utils.formatutils import camelcase, underscore
from linkml_runtime.utils.introspection import package_schemaview
from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph
from linkml_runtime.utils.rdf_terms import type_term

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -226,23 +226,9 @@ def _present(values: Iterable[_T | None]) -> list[_T]:

return [value for value in values if value is not None]

xsd_anyuri_as_iri: bool = False
"""Treat ``range: uri`` / ``range: uriorcurie`` slots as ``owl:ObjectProperty``
instead of ``owl:DatatypeProperty`` with ``rdfs:range xsd:anyURI``.

This aligns the OWL output with the SHACL generator (which emits
``sh:nodeKind sh:IRI``) and the JSON-LD context generator (which emits
``@type: @id`` when its own ``--xsd-anyuri-as-iri`` flag is set).

Without this flag, ``range: uri`` produces a semantic inconsistency:
OWL says the value is a literal (``DatatypeProperty``), while SHACL and
JSON-LD say it is an IRI node. Enabling the flag makes all three
generators consistent.

When enabled, URI-range slots:
- become ``owl:ObjectProperty`` (not ``owl:DatatypeProperty``)
- have no ``rdfs:range`` restriction (any IRI is valid)
"""
xsd_anyuri_as_iri: bool | None = None
"""Deprecated and without effect: slots whose values are IRIs or blank nodes
(:func:`~linkml_runtime.utils.rdf_terms.type_term`) are always object properties."""

default_language: str | None = None
"""Default BCP 47 language tag for human-readable string literals.
Expand All @@ -268,6 +254,8 @@ def __post_init__(self) -> None:
# once here; per-element tags are validated lazily, with at most one
# warning per distinct malformed tag.
self._language_resolver = LanguageTagResolver(self.default_language)
if self.xsd_anyuri_as_iri is not None:
deprecation_warning(XSD_ANYURI_AS_IRI_OPTION, stack_level=4)
super().__post_init__()

def _resolve_language(self, element: "Definition | PermissibleValue | None" = None) -> str | None:
Expand Down Expand Up @@ -801,7 +789,8 @@ def slot_node_owltypes(
"""
Determine the OWL types of a named slot or slot expression

The OWL type is either OWL.Thing or RDFS.Datatype
The OWL type is either OWL.Thing or RDFS.Datatype. A type range gives OWL.Thing
when its values are IRIs or blank nodes (:meth:`_type_values_are_nodes`).

:param slot:
:param owning_class:
Expand All @@ -818,7 +807,7 @@ def slot_node_owltypes(
if not (range_class and range_class.class_uri == "linkml:Any"):
node_types.add(OWL.Thing)
if slot.range in sv.all_types():
node_types.add(RDFS.Datatype)
node_types.add(OWL.Thing if self._type_values_are_nodes(slot.range) else RDFS.Datatype)
for k in ["any_of", "all_of", "exactly_one_of", "none_of"]:
subslots = getattr(slot, k, None)
if not subslots:
Expand Down Expand Up @@ -905,7 +894,7 @@ def _get_slot_nodes(
this_owl_types: set[OWL_TYPE] = set()
if slot_range:
if slot_range in sv.all_types(imports=True):
if self.xsd_anyuri_as_iri and is_xsd_anyuri_range(sv, slot_range):
if self._type_values_are_nodes(slot_range):
self.slot_is_literal_map[main_slot.name].add(False)
this_owl_types.add(OWL.Thing)
else:
Expand Down Expand Up @@ -1546,12 +1535,14 @@ def _boolean_expression(
graph.add((node, RDF.type, RDFS.Datatype))
return node

def _range_is_datatype(self, slot: SlotDefinition) -> bool:
if self.type_objects:
return False
if self.xsd_anyuri_as_iri and is_xsd_anyuri_range(self.schemaview, slot.range):
return False
return slot.range in self.schema.types
def _type_values_are_nodes(self, type_name: str) -> bool:
"""Whether values of the type *type_name* are IRIs or blank nodes rather than literals.

This follows :func:`~linkml_runtime.utils.rdf_terms.type_term`. With
:attr:`type_objects`, every typed value is an individual of the type's class
instead, so no type is treated differently.
"""
return not self.type_objects and type_term(self.schemaview, type_name).kind.is_node

def _range_uri(self, slot: SlotDefinition) -> URIRef:
if slot.range in self.schema.types:
Expand Down Expand Up @@ -1699,7 +1690,7 @@ def slot_owl_type(self, slot: SlotDefinition) -> URIRef:
elif slot_range in sv.all_enums():
return OWL.ObjectProperty
elif slot_range in sv.all_types():
if self.xsd_anyuri_as_iri and is_xsd_anyuri_range(sv, slot_range):
if self._type_values_are_nodes(slot_range):
return OWL.ObjectProperty
return OWL.DatatypeProperty
else:
Expand Down Expand Up @@ -1827,14 +1818,9 @@ def slot_owl_type(self, slot: SlotDefinition) -> URIRef:
)
@click.option(
"--xsd-anyuri-as-iri/--no-xsd-anyuri-as-iri",
default=False,
show_default=True,
help=(
"Treat range: uri / range: uriorcurie slots as owl:ObjectProperty (IRI node) "
"instead of owl:DatatypeProperty with rdfs:range xsd:anyURI (literal). "
"Aligns OWL output with the SHACL generator (sh:nodeKind sh:IRI) and "
"the JSON-LD context generator (--xsd-anyuri-as-iri → @type: @id)."
),
default=None,
help="Deprecated and without effect: slots whose values are IRIs, such as uri and uriorcurie, are always "
"object properties.",
)
@click.option(
"--default-language",
Expand Down
Loading
Loading