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
39 changes: 39 additions & 0 deletions docs/generators/jsonld-context.rst
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,45 @@ to gen-prefix-map:

gen-prefix-map --flatprefixes personinfo.yaml > personinfo.prefixmap.json

URIs as IRIs or as literals
---------------------------

By default a ``uri`` or ``uriorcurie`` slot is coerced to ``xsd:anyURI``, a typed
literal. ``--xsd-anyuri-as-iri`` coerces it to ``@id`` instead, so the value becomes an
IRI node, matching the ``sh:nodeKind sh:IRI`` the SHACL generator emits. The OWL
generator accepts the same flag.

The flag applies to ``uri``, ``uriorcurie`` and their derived types when the
effective datatype remains ``xsd:anyURI``. Datatype IRIs are expanded before
comparison, so full IRIs and alternative prefixes behave identically. A type
derived from ``string`` that declares ``uri: xsd:anyURI`` stays a typed literal with or
without it. Use one for a URI reference that is data rather than a link, such as a file
path that may be relative: as ``@id`` it would be resolved against the document base.

.. code-block:: yaml

types:
FilePath:
typeof: string
uri: xsd:anyURI # always "@type": "xsd:anyURI", sh:datatype xsd:anyURI
slots:
homepage:
range: uri # "@id" with --xsd-anyuri-as-iri
file_path:
range: FilePath

The RDF dumper, SHACL and ShEx generators distinguish the same URI family from
literal-valued types. An explicit datatype override such as ``uri: xsd:string``
remains literal-valued, including when the type inherits from ``uri``.

`XSD anyURI <https://www.w3.org/TR/xmlschema11-2/#anyURI>`__ admits relative URI
references as literal values. This differs from an RDF IRI node: `JSON-LD type
coercion <https://www.w3.org/TR/json-ld11/#type-coercion>`__ with ``@id`` interprets
strings as identifiers and resolves relative references against the base IRI.
The existing option selects the JSON-LD/OWL representation of LinkML's URI
family; it is not needed to preserve a deliberately literal-valued type. No
additional option is introduced.


Docs
----
Expand Down
55 changes: 23 additions & 32 deletions packages/linkml/src/linkml/generators/common/subproperty.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
- Collecting and deduplicating slot hierarchy values
"""

from rdflib.namespace import XSD

from linkml_runtime.linkml_model.meta import SlotDefinition
from linkml_runtime.utils.formatutils import underscore
from linkml_runtime.utils.schemaview import SchemaView
Expand All @@ -15,9 +17,27 @@
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_xsd_anyuri_range(sv: SchemaView, range_type: str | None) -> bool:
"""Whether a URI-family type's effective datatype is ``xsd:anyURI``.

LinkML's standard ``uri`` and ``uriorcurie`` types and their descendants
participate in the existing literal-to-IRI option. String-derived types do
not. Resolve the datatype before comparing so prefix aliases and explicit
datatype overrides have the same meaning in every generator.

The roots are defined at https://w3id.org/linkml/types. The runtime dumper
implements the same small rule independently because the two packages may
be released separately; real serialization tests check their agreement.
"""
if range_type not in sv.all_types():
return False
typ = sv.induced_type(range_type)
return (
bool(typ.uri)
and sv.expand_curie(typ.uri) == str(XSD.anyURI)
and any(ancestor in ("uri", "uriorcurie") for ancestor in sv.type_ancestors(range_type))
)


def is_uri_range(sv: SchemaView, range_type: str | None) -> bool:
Expand Down Expand Up @@ -67,35 +87,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
48 changes: 38 additions & 10 deletions packages/linkml/src/linkml/generators/jsonldcontextgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import json
import os
import re
from copy import deepcopy
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
Expand All @@ -14,19 +15,16 @@
from rdflib import SKOS, XSD, Namespace

from linkml._version import __version__
from linkml.generators.common.subproperty import is_xsd_anyuri_range
from linkml.utils.deprecation import deprecated_fields
from linkml.utils.generator import Generator, shared_arguments
from linkml_runtime.linkml_model.meta import ClassDefinition, EnumDefinition, SlotDefinition
from linkml_runtime.linkml_model.meta import ClassDefinition, EnumDefinition, Prefix, SlotDefinition
from linkml_runtime.linkml_model.types import SHEX
from linkml_runtime.utils.formatutils import camelcase, underscore
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 @@ -85,8 +83,11 @@ class ContextGenerator(Generator):
"""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.
``sh:nodeKind sh:IRI`` for the same types. Types derived from ``uri`` or
``uriorcurie`` follow them; a type derived from ``string`` that declares
``uri: xsd:anyURI`` stays a typed literal, as in the OWL generator.
"""
_type_view_cache: SchemaView | None = field(default=None, repr=False)

# Framing (opt-in via CLI flag)
emit_frame: bool = False
Expand Down Expand Up @@ -299,7 +300,6 @@ 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
Expand All @@ -308,7 +308,7 @@ def _literal_coercion_for_ranges(self, ranges: list[str]) -> tuple[bool, str | N
range_uri = self.namespaces.uri_for(range_type.uri)
if range_uri == XSD.string:
coercions.add(None)
elif range_uri in uri_ranges:
elif self._coerces_to_id(range_name):
coercions.add("@id")
else:
coercions.add(range_type.uri)
Expand All @@ -319,6 +319,35 @@ def _literal_coercion_for_ranges(self, ranges: list[str]) -> tuple[bool, str | N
return True, next(iter(coercions))
return False, None

def _coerces_to_id(self, range_name: str) -> bool:
"""Whether values of the LinkML type *range_name* are IRIs (``@type: @id``) rather than literals.

The ShEx node types always are. ``xsd:anyURI`` is, with ``--xsd-anyuri-as-iri``, only
for ``uri``, ``uriorcurie`` and types derived from them, the same rule the OWL
generator applies (:func:`is_xsd_anyuri_range`). A type derived from ``string`` that
declares ``uri: xsd:anyURI`` is a typed literal: a URI reference kept as a string, which
must not be resolved against the document base.
"""
range_uri = self.namespaces.uri_for(self.schema.types[range_name].uri)
if range_uri in URI_RANGES:
return True
return self.xsd_anyuri_as_iri and range_uri == XSD.anyURI and is_xsd_anyuri_range(self._type_view(), range_name)

def _type_view(self) -> SchemaView:
"""A SchemaView of the schema, for type ancestry; built once when the generator has none."""
if self.schemaview is not None:
return self.schemaview
if self._type_view_cache is None:
# SchemaLoader already resolved the imports; source_file is only
# provenance and may no longer name an available file.
type_schema = deepcopy(self.schema)
type_schema.imports = []
for prefix, namespace in self.namespaces.items():
if not prefix.startswith("@"):
type_schema.prefixes[prefix] = Prefix(prefix, str(namespace))
self._type_view_cache = SchemaView(type_schema)
return self._type_view_cache

def _vocab_eligible_enum(self, enum: EnumDefinition) -> tuple[bool, str | None]:
"""Check if an enum qualifies for ``@type: @vocab`` context generation.

Expand Down Expand Up @@ -414,10 +443,9 @@ def visit_slot(self, aliased_slot_name: str, slot: SlotDefinition) -> None:
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:
elif self._coerces_to_id(slot.range):
slot_def["@type"] = "@id"
else:
slot_def["@type"] = range_type.uri
Expand Down
39 changes: 13 additions & 26 deletions packages/linkml/src/linkml/generators/shaclgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@
from rdflib.namespace import RDF, RDFS, SH, XSD

from linkml._version import __version__
from linkml.generators.common.subproperty import get_subproperty_values, is_uri_range
from linkml.generators.common.subproperty import get_subproperty_values, is_uri_range, is_xsd_anyuri_range
from linkml.generators.shacl.shacl_data_type import ShaclDataType
from linkml.generators.shacl.shacl_ifabsent_processor import ShaclIfAbsentProcessor
from linkml.utils.generator import Generator, shared_arguments
from linkml.utils.language_tags import LanguageTagResolver
from linkml_runtime.linkml_model.meta import ClassDefinition, ElementName, PresenceEnum
from linkml_runtime.linkml_model.types import SHEX
from linkml_runtime.utils.formatutils import underscore
from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph
from linkml_runtime.utils.yamlutils import TypedNode, extended_float, extended_int, extended_str
Expand Down Expand Up @@ -685,37 +686,23 @@ def _add_enum(self, g: Graph, func: Callable, r: ElementName) -> None:
)
func(SH["in"], pv_node)

# Type URIs denoting non-literal (IRI or blank-node) values.
# SHACL §4.8.1 <https://www.w3.org/TR/shacl/#NodeKindConstraintComponent>
# defines sh:IRI, sh:BlankNode, and sh:BlankNodeOrIRI as valid node kinds.
# These URIs map to sh:IRI or sh:BlankNodeOrIRI constraints (never sh:Literal).
_NON_LITERAL_TYPE_URIS = frozenset(
{
"xsd:anyURI", # uri, uriorcurie → sh:IRI
"http://www.w3.org/ns/shex#nonLiteral", # nodeidentifier → sh:BlankNodeOrIRI
"http://www.w3.org/ns/shex#iri", # future-proofing → sh:IRI
}
)
# IRI-only subset: uri/uriorcurie must be strict IRI references (sh:IRI),
# while nodeidentifier (shex:nonLiteral) allows blank nodes too (sh:BlankNodeOrIRI).
# See RDF 1.1 §3.2–3.3 <https://www.w3.org/TR/rdf11-concepts/#section-IRIs>.
_IRI_ONLY_TYPE_URIS = frozenset(
{
"xsd:anyURI",
}
)

def _add_type(self, func: Callable, r: ElementName) -> None:
sv = self.schemaview
# Types can inherit URI and pattern constraints.
rt = sv.induced_type(r)
type_uri = rt.uri
expanded = sv.get_uri(rt, expand=True) if type_uri else None
if type_uri and (type_uri in self._NON_LITERAL_TYPE_URIS or expanded in self._NON_LITERAL_TYPE_URIS):
if type_uri in self._IRI_ONLY_TYPE_URIS:
func(SH.nodeKind, SH.IRI)
else:
func(SH.nodeKind, SH.BlankNodeOrIRI)
# Resolve vocabulary IRIs before selecting RDF node kinds. The ShEx
# vocabulary distinguishes IRI-only and non-literal (IRI or blank) nodes.
node_kind = None
if expanded == str(SHEX.iri):
node_kind = SH.IRI
elif expanded == str(SHEX.nonLiteral):
node_kind = SH.BlankNodeOrIRI
elif expanded == str(XSD.anyURI) and is_xsd_anyuri_range(sv, r):
node_kind = SH.IRI
if node_kind is not None:
func(SH.nodeKind, node_kind)
elif type_uri:
func(SH.nodeKind, SH.Literal)
func(SH.datatype, URIRef(sv.get_uri(rt, expand=True)))
Expand Down
17 changes: 14 additions & 3 deletions packages/linkml/src/linkml/generators/shexgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,25 @@

import os
import urllib.parse as urlparse
from copy import deepcopy
from dataclasses import dataclass, field

import click
from jsonasobj import as_json as as_json_1
from rdflib import OWL, RDF, XSD, Graph, Namespace
from rdflib import OWL, RDF, Graph, Namespace
from ShExJSG import ShExC
from ShExJSG.SchemaWithContext import Schema
from ShExJSG.ShExJ import IRIREF, EachOf, NodeConstraint, Shape, ShapeOr, TripleConstraint

from linkml import METAMODEL_NAMESPACE, METAMODEL_NAMESPACE_NAME
from linkml._version import __version__
from linkml.generators.common.subproperty import get_subproperty_values
from linkml.generators.common.subproperty import get_subproperty_values, is_xsd_anyuri_range
from linkml.utils.generator import Generator, shared_arguments
from linkml_runtime.linkml_model.meta import (
ClassDefinition,
ElementName,
EnumDefinition,
Prefix,
SlotDefinition,
SlotDefinitionName,
TypeDefinition,
Expand All @@ -27,6 +29,7 @@
from linkml_runtime.utils.formatutils import camelcase, sfx
from linkml_runtime.utils.metamodelcore import URIorCURIE
from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph
from linkml_runtime.utils.schemaview import SchemaView


@dataclass
Expand Down Expand Up @@ -67,12 +70,20 @@ def visit_schema(self, **_):
# Adjust the schema context to include the base model URI
context = self.shex["@context"]
self.shex["@context"] = [context, {"@base": self.namespaces._base}]
# SchemaLoader has already resolved imports. Inspect a copy of that
# effective schema without reopening source files or changing provenance.
type_schema = deepcopy(self.schema)
type_schema.imports = []
for prefix, namespace in self.namespaces.items():
if not prefix.startswith("@"):
type_schema.prefixes[prefix] = Prefix(prefix, str(namespace))
type_view = SchemaView(type_schema)
# Emit all of the type definitions
for typ in self.schema.types.values():
model_uri = self._class_or_type_uri(typ)
if typ.uri:
typ_type_uri = self.namespaces.uri_for(typ.uri)
if typ_type_uri in (XSD.anyURI, SHEX.iri):
if typ_type_uri == SHEX.iri or is_xsd_anyuri_range(type_view, typ.name):
self.shapes.append(NodeConstraint(id=model_uri, nodeKind="iri"))
elif typ_type_uri == SHEX.nonLiteral:
self.shapes.append(NodeConstraint(id=model_uri, nodeKind="nonliteral"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from curies import Converter
from pydantic import BaseModel
from rdflib import XSD, Graph, URIRef
from rdflib.namespace import RDF
from rdflib.namespace import RDF, RDFS
from rdflib.term import BNode, Literal, Node

from linkml_runtime.dumpers.dumper_root import Dumper
Expand Down Expand Up @@ -101,17 +101,19 @@ def inject_triples(
else:
return Literal(element.text)
if target_type in schemaview.all_types():
t = schemaview.get_type(target_type)
t = schemaview.induced_type(target_type)
dt_uri = t.uri
if dt_uri:
if dt_uri in ("rdfs:Resource", "xsd:anyURI"):
if "xsd" not in namespaces:
namespaces["xsd"] = XSD
datatype = namespaces.uri_for(dt_uri)
uri_ancestry = any(name in ("uri", "uriorcurie") for name in schemaview.type_ancestors(target_type))
if datatype == RDFS.Resource or (datatype == XSD.anyURI and uri_ancestry):
return URIRef(schemaview.expand_curie(element))
elif dt_uri == "xsd:string":
elif datatype == XSD.string:
return Literal(element)
else:
if "xsd" not in namespaces:
namespaces["xsd"] = XSD
return Literal(element, datatype=namespaces.uri_for(dt_uri))
return Literal(element, datatype=datatype)
else:
logger.warning(f"No datatype specified for : {t.name}, using plain Literal")
return Literal(element)
Expand Down
Loading
Loading