diff --git a/docs/generators/jsonld-context.rst b/docs/generators/jsonld-context.rst
index da5e4df794..18cec34644 100644
--- a/docs/generators/jsonld-context.rst
+++ b/docs/generators/jsonld-context.rst
@@ -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
^^^^^^^^^^^
diff --git a/docs/generators/owl.rst b/docs/generators/owl.rst
index 4b6f076fe8..9ce2ee0994 100644
--- a/docs/generators/owl.rst
+++ b/docs/generators/owl.rst
@@ -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
diff --git a/docs/schemas/advanced.md b/docs/schemas/advanced.md
index c56a808379..5e19b5d14f 100644
--- a/docs/schemas/advanced.md
+++ b/docs/schemas/advanced.md
@@ -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 ;
+ 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`.
diff --git a/packages/linkml/src/linkml/generators/common/subproperty.py b/packages/linkml/src/linkml/generators/common/subproperty.py
index 9b136e2429..4687c38218 100644
--- a/packages/linkml/src/linkml/generators/common/subproperty.py
+++ b/packages/linkml/src/linkml/generators/common/subproperty.py
@@ -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:
"""
@@ -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.
diff --git a/packages/linkml/src/linkml/generators/jsonldcontextgen.py b/packages/linkml/src/linkml/generators/jsonldcontextgen.py
index 5462b29f8b..4916fa07c3 100644
--- a/packages/linkml/src/linkml/generators/jsonldcontextgen.py
+++ b/packages/linkml/src/linkml/generators/jsonldcontextgen.py
@@ -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",
@@ -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
@@ -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")
@@ -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
@@ -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:
@@ -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.
@@ -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",
diff --git a/packages/linkml/src/linkml/generators/owlgen.py b/packages/linkml/src/linkml/generators/owlgen.py
index 3f16c4965c..5580a7dcd0 100644
--- a/packages/linkml/src/linkml/generators/owlgen.py
+++ b/packages/linkml/src/linkml/generators/owlgen.py
@@ -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
@@ -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__)
@@ -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.
@@ -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:
@@ -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:
@@ -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:
@@ -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:
@@ -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:
@@ -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:
@@ -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",
diff --git a/packages/linkml/src/linkml/generators/prefixmapgen.py b/packages/linkml/src/linkml/generators/prefixmapgen.py
index eaf6d590e7..c635520d46 100644
--- a/packages/linkml/src/linkml/generators/prefixmapgen.py
+++ b/packages/linkml/src/linkml/generators/prefixmapgen.py
@@ -8,16 +8,13 @@
import click
from jsonasobj2 import JsonObj, as_json
-from rdflib import XSD, Namespace
+from rdflib import Namespace
from linkml._version import __version__
from linkml.utils.generator import Generator, shared_arguments
from linkml_runtime.linkml_model.meta import ClassDefinition, SlotDefinition
-from linkml_runtime.linkml_model.types import SHEX
from linkml_runtime.utils.formatutils import camelcase
-URI_RANGES = (XSD.anyURI, SHEX.nonliteral, SHEX.bnode, SHEX.iri)
-
@dataclass
class PrefixGenerator(Generator):
diff --git a/packages/linkml/src/linkml/generators/shaclgen.py b/packages/linkml/src/linkml/generators/shaclgen.py
index eb6b48e9d6..edaf6f7e38 100644
--- a/packages/linkml/src/linkml/generators/shaclgen.py
+++ b/packages/linkml/src/linkml/generators/shaclgen.py
@@ -3,6 +3,7 @@
import string
from collections.abc import Callable
from dataclasses import dataclass
+from typing import ClassVar
import click
from jsonasobj2 import JsonObj, as_dict
@@ -11,7 +12,7 @@
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
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
@@ -19,6 +20,7 @@
from linkml_runtime.linkml_model.meta import ClassDefinition, ElementName, PresenceEnum
from linkml_runtime.utils.formatutils import underscore
from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph
+from linkml_runtime.utils.rdf_terms import TermKind, type_term, value_term
from linkml_runtime.utils.yamlutils import TypedNode, extended_float, extended_int, extended_str
logger = logging.getLogger(__name__)
@@ -437,6 +439,9 @@ def st_node_pv(p, v):
default_value = ifabsent_processor.process_slot(s, c)
if default_value:
+ if s.range in sv.all_types() and type_term(sv, s.range).kind.is_node:
+ # The default is a value of the slot, so it has the slot's term kind.
+ default_value = value_term(sv, s.range, str(default_value))
prop_pv(SH.defaultValue, default_value)
if self.emit_rules:
@@ -719,40 +724,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
- # 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 .
- _IRI_ONLY_TYPE_URIS = frozenset(
- {
- "xsd:anyURI",
- }
- )
+ # SHACL §4.1.3 node kinds for the node term kinds of LinkML types.
+ _SH_NODE_KINDS: ClassVar[dict[TermKind, URIRef]] = {
+ TermKind.IRI: SH.IRI,
+ TermKind.BLANK_NODE: SH.BlankNode,
+ TermKind.BLANK_NODE_OR_IRI: SH.BlankNodeOrIRI,
+ }
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)
- elif type_uri:
+ term = type_term(sv, r) if rt.uri else None
+ if term is not None and term.kind.is_node:
+ func(SH.nodeKind, self._SH_NODE_KINDS[term.kind])
+ elif term is not None:
func(SH.nodeKind, SH.Literal)
- func(SH.datatype, URIRef(sv.get_uri(rt, expand=True)))
+ func(SH.datatype, URIRef(term.datatype))
if rt.pattern:
func(SH.pattern, Literal(rt.pattern))
if rt.annotations and self.include_annotations:
@@ -790,17 +778,17 @@ def _get_subproperty_values(self, slot) -> list:
"""
Get all valid values from slot hierarchy for subproperty_of constraint.
- Values are formatted according to range type:
- - uri/uriorcurie: Returns URIRef objects with full URIs
- - string: Returns Literal objects with slot names
+ Values take the term kind of the slot's range type:
+ - IRI types (uri, uriorcurie): URIRef objects with full URIs
+ - literal types (string, curie): Literal objects with slot names or CURIEs
:param slot: SlotDefinition with subproperty_of set
:return: List of URIRef or Literal objects for sh:in constraint
"""
sv = self.schemaview
- # SHACL uses full URIs for URI-like ranges
- use_uris = is_uri_range(sv, slot.range)
+ # sh:in matches terms exactly (SHACL §4.8.3), so the values take the slot's term kind.
+ use_uris = slot.range in sv.all_types() and type_term(sv, slot.range).kind.is_node
# Get string values from shared utility
# For URI ranges, get full URIs; for string ranges, get formatted names
diff --git a/packages/linkml/src/linkml/generators/shexgen.py b/packages/linkml/src/linkml/generators/shexgen.py
index e74e45225e..0fdddb92cb 100644
--- a/packages/linkml/src/linkml/generators/shexgen.py
+++ b/packages/linkml/src/linkml/generators/shexgen.py
@@ -6,7 +6,7 @@
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, URIRef
from ShExJSG import ShExC
from ShExJSG.SchemaWithContext import Schema
from ShExJSG.ShExJ import IRIREF, EachOf, NodeConstraint, Shape, ShapeOr, TripleConstraint
@@ -23,10 +23,10 @@
SlotDefinitionName,
TypeDefinition,
)
-from linkml_runtime.linkml_model.types import SHEX
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.rdf_terms import TermKind, type_term
@dataclass
@@ -38,6 +38,8 @@ class ShExGenerator(Generator):
file_extension = "shex.rdf"
visit_all_class_slots = False
uses_schemaloader = True
+ # ShEx 2.1 §5.4.2 node kinds for the node term kinds of LinkML types
+ _SHEX_NODE_KINDS = {TermKind.IRI: "iri", TermKind.BLANK_NODE: "bnode", TermKind.BLANK_NODE_OR_IRI: "nonliteral"}
# ObjectVars
diff_stable: bool = False
@@ -71,16 +73,15 @@ def visit_schema(self, **_):
context = self.shex["@context"]
self.shex["@context"] = [context, {"@base": self.namespaces._base}]
# Emit all of the type definitions
+ type_view = self.type_schemaview()
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):
- self.shapes.append(NodeConstraint(id=model_uri, nodeKind="iri"))
- elif typ_type_uri == SHEX.nonLiteral:
- self.shapes.append(NodeConstraint(id=model_uri, nodeKind="nonliteral"))
+ term = type_term(type_view, typ.name)
+ if term.kind.is_node:
+ self.shapes.append(NodeConstraint(id=model_uri, nodeKind=self._SHEX_NODE_KINDS[term.kind]))
else:
- self.shapes.append(NodeConstraint(id=model_uri, datatype=self.namespaces.uri_for(typ.uri)))
+ self.shapes.append(NodeConstraint(id=model_uri, datatype=URIRef(term.datatype)))
else:
typeof_uri = self._class_or_type_uri(typ.typeof)
self.shapes.append(Shape(id=model_uri, expression=typeof_uri))
diff --git a/packages/linkml/src/linkml/generators/yarrrmlgen.py b/packages/linkml/src/linkml/generators/yarrrmlgen.py
index 6847dafbd1..887c0eaae3 100644
--- a/packages/linkml/src/linkml/generators/yarrrmlgen.py
+++ b/packages/linkml/src/linkml/generators/yarrrmlgen.py
@@ -10,6 +10,7 @@
from linkml._version import __version__
from linkml.utils.generator import Generator, shared_arguments
from linkml_runtime.linkml_model.meta import ClassDefinition
+from linkml_runtime.utils.rdf_terms import TermKind, type_term
class FlowList(list):
@@ -343,10 +344,14 @@ def _po_list_for_class(self, c: ClassDefinition, inline_owners: dict) -> list[di
# Scalar values and datatype mapping
datatype = None
- t = sv.get_type(s.range) if s.range else None
-
- if t:
- datatype = sv.get_uri(t, expand=False)
+ if s.range in sv.all_types():
+ term = type_term(sv, s.range)
+ if term.kind.is_node:
+ # YARRRML term types are iri, blank and literal; a kind that admits IRIs maps to iri.
+ term_type = "blank" if term.kind is TermKind.BLANK_NODE else "iri"
+ po.append({"p": pred, "o": {"value": f"$({var})", "type": term_type}})
+ continue
+ datatype = sv.induced_type(s.range).uri
if datatype:
po.append({"p": pred, "o": {"value": f"$({var})", "datatype": str(datatype)}})
diff --git a/packages/linkml/src/linkml/utils/deprecation.py b/packages/linkml/src/linkml/utils/deprecation.py
index 9cded2f46b..fc34c87093 100644
--- a/packages/linkml/src/linkml/utils/deprecation.py
+++ b/packages/linkml/src/linkml/utils/deprecation.py
@@ -322,6 +322,21 @@ def warn(self, stack_level=3, **kwargs):
recommendation="Use `package` (`--package` on the command line) instead.",
issue=3780,
),
+ Deprecation(
+ name="xsd-anyuri-as-iri-option",
+ deprecated_in=SemVer.from_str("1.13.0"),
+ removed_in=SemVer.from_str("1.14.0"),
+ message=(
+ "The `xsd_anyuri_as_iri` option (`--xsd-anyuri-as-iri` on the command line) of gen-owl and "
+ "gen-jsonld-context has no effect: values of `uri`, `uriorcurie` and the types derived from "
+ "them are IRIs in every generator."
+ ),
+ recommendation=(
+ "Remove the option. To keep a URI reference as text (an xsd:anyURI literal), use a type "
+ "derived from `string` with `uri: xsd:anyURI`."
+ ),
+ issue=2495,
+ ),
) # type: tuple[Deprecation, ...]
EMITTED = set() # type: set[str]
@@ -357,6 +372,7 @@ def deprecation_warning(name: str, stack_level: int = 3):
METADATA_FLAG = "metadata-flag"
MATERIALIZE_PATTERNS_GENERATOR_OPTION = "materialize-patterns-generator-option"
+XSD_ANYURI_AS_IRI_OPTION = "xsd-anyuri-as-iri-option"
T = TypeVar("T")
diff --git a/packages/linkml/src/linkml/utils/generator.py b/packages/linkml/src/linkml/utils/generator.py
index 8ea9e28da9..fb6ba55806 100644
--- a/packages/linkml/src/linkml/utils/generator.py
+++ b/packages/linkml/src/linkml/utils/generator.py
@@ -36,7 +36,7 @@
from linkml.cli.logging import DEFAULT_LOG_LEVEL_INT, log_level_option
from linkml.utils.deprecation import METADATA_FLAG, deprecation_warning
from linkml.utils.mergeutils import alias_root
-from linkml.utils.schemaloader import SchemaLoader
+from linkml.utils.schemaloader import UNKNOWN_NAMESPACE, SchemaLoader
from linkml.utils.typereferences import References
from linkml_runtime import SchemaView
from linkml_runtime.linkml_model.meta import (
@@ -47,6 +47,7 @@
ElementName,
EnumDefinition,
EnumDefinitionName,
+ Prefix,
PrefixPrefixPrefix,
SchemaDefinition,
SlotDefinition,
@@ -175,6 +176,8 @@ class Generator(metaclass=abc.ABCMeta):
it is preserved here so external callers/subclasses relying on it are not
silently broken by the switch to a property-backed field.
"""
+ _loaded_schemaview: ClassVar[SchemaView | None] = None
+ """SchemaView built by :meth:`type_schemaview` for a SchemaLoader-based generator."""
@property
def namespaces(self) -> Namespaces | None:
@@ -214,6 +217,25 @@ def namespaces(self, value: Namespaces | None) -> None:
:attr:`namespaces` property."""
self._namespaces = value
+ def type_schemaview(self) -> SchemaView:
+ """Return a SchemaView of the schema for questions about its types, on either generator path.
+
+ SchemaLoader-based generators have no :attr:`schemaview`. Their loaded schema
+ already has its imports resolved, so the view is built once from a copy of it
+ with the loaded prefixes, without reopening source files. Placeholder namespaces
+ that SchemaLoader gives undeclared prefixes are left out.
+ """
+ if self.schemaview is not None:
+ return self.schemaview
+ if self._loaded_schemaview is None:
+ schema = deepcopy(self.schema)
+ schema.imports = []
+ for prefix, namespace in self.namespaces.items():
+ if not prefix.startswith("@") and not str(namespace).startswith(UNKNOWN_NAMESPACE):
+ schema.prefixes[prefix] = Prefix(prefix, str(namespace))
+ self._loaded_schemaview = SchemaView(schema)
+ return self._loaded_schemaview
+
namespaces: Namespaces | None = namespaces
"""Constructor kwarg backing the ``namespaces`` property (see above).
@@ -970,7 +992,7 @@ def add_prefix(self, ncname: str) -> None:
"""
if ncname not in self.namespaces:
self.logger.warning(f"Unrecognized prefix: {ncname}")
- self.namespaces[ncname] = f"http://example.org/UNKNOWN/{ncname}/"
+ self.namespaces[ncname] = f"{UNKNOWN_NAMESPACE}{ncname}/"
self.emit_prefixes.add(ncname)
def get_metamodel_slot_name(self, slot_name: str) -> str:
diff --git a/packages/linkml/src/linkml/utils/schemaloader.py b/packages/linkml/src/linkml/utils/schemaloader.py
index 3bb0e0aee9..8625a1d855 100644
--- a/packages/linkml/src/linkml/utils/schemaloader.py
+++ b/packages/linkml/src/linkml/utils/schemaloader.py
@@ -32,6 +32,9 @@
from linkml_runtime.utils.namespaces import Namespaces
from linkml_runtime.utils.yamlutils import TypedNode
+# Namespace given to a prefix that is used but not declared; not a declaration of the schema.
+UNKNOWN_NAMESPACE = "http://example.org/UNKNOWN/"
+
lgr = logging.getLogger(__name__)
@@ -1067,7 +1070,7 @@ def check_prefix(self, prefix_or_curie_or_uri: str) -> None:
if prefix:
if prefix not in self.namespaces:
self.logger.warning(f"{TypedNode.yaml_loc(prefix_or_curie_or_uri)}Unrecognized prefix: {prefix}")
- self.namespaces[prefix] = f"http://example.org/UNKNOWN/{prefix}/"
+ self.namespaces[prefix] = f"{UNKNOWN_NAMESPACE}{prefix}/"
else:
case_adjusted_prefix = self.namespaces.prefix_for(prefix_or_curie_or_uri, case_shift=True)
if case_adjusted_prefix != prefix:
diff --git a/packages/linkml_runtime/src/linkml_runtime/dumpers/rdflib_dumper.py b/packages/linkml_runtime/src/linkml_runtime/dumpers/rdflib_dumper.py
index d7a133f355..b426ff16f4 100644
--- a/packages/linkml_runtime/src/linkml_runtime/dumpers/rdflib_dumper.py
+++ b/packages/linkml_runtime/src/linkml_runtime/dumpers/rdflib_dumper.py
@@ -4,7 +4,7 @@
from curies import Converter
from pydantic import BaseModel
-from rdflib import XSD, Graph, URIRef
+from rdflib import Graph, URIRef
from rdflib.collection import Collection
from rdflib.namespace import RDF
from rdflib.term import BNode, Literal, Node
@@ -12,6 +12,7 @@
from linkml_runtime.dumpers.dumper_root import Dumper
from linkml_runtime.linkml_model import ElementName, PermissibleValue, SlotDefinition
from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph
+from linkml_runtime.utils.rdf_terms import value_term
from linkml_runtime.utils.schemaview import SchemaView
from linkml_runtime.utils.yamlutils import YAMLRoot
@@ -102,20 +103,10 @@ def inject_triples(
else:
return Literal(element.text)
if target_type in schemaview.all_types():
- t = schemaview.get_type(target_type)
- dt_uri = t.uri
- if dt_uri:
- if dt_uri in ("rdfs:Resource", "xsd:anyURI"):
- return URIRef(schemaview.expand_curie(element))
- elif dt_uri == "xsd:string":
- return Literal(element)
- else:
- if "xsd" not in namespaces:
- namespaces["xsd"] = XSD
- return Literal(element, datatype=namespaces.uri_for(dt_uri))
- else:
- logger.warning(f"No datatype specified for : {t.name}, using plain Literal")
+ if schemaview.induced_type(target_type).uri is None:
+ logger.warning(f"No datatype specified for : {target_type}, using plain Literal")
return Literal(element)
+ return value_term(schemaview, target_type, element)
element_vars = {k: v for k, v in vars(element).items() if not k.startswith("_")}
if len(element_vars) == 0:
id_slot = schemaview.get_identifier_slot(target_type)
diff --git a/packages/linkml_runtime/src/linkml_runtime/loaders/rdflib_loader.py b/packages/linkml_runtime/src/linkml_runtime/loaders/rdflib_loader.py
index 85c20605cc..dc3606d4cf 100644
--- a/packages/linkml_runtime/src/linkml_runtime/loaders/rdflib_loader.py
+++ b/packages/linkml_runtime/src/linkml_runtime/loaders/rdflib_loader.py
@@ -22,6 +22,7 @@
)
from linkml_runtime.loaders.loader_root import Loader
from linkml_runtime.utils.formatutils import underscore
+from linkml_runtime.utils.rdf_terms import TermKind, link_root, type_term
from linkml_runtime.utils.schemaview import SchemaView
from linkml_runtime.utils.yamlutils import YAMLRoot
@@ -268,6 +269,8 @@ def _object_to_value(
)
return o.value
elif isinstance(o, BNode):
+ if self._range_term_kind(slot, schemaview) in (TermKind.BLANK_NODE, TermKind.BLANK_NODE_OR_IRI):
+ return f"_:{o}"
if not is_inlined:
logger.error(f"blank nodes should be inlined; {slot_name}={o} in {subject}")
return Pointer(o)
@@ -296,8 +299,11 @@ def _object_to_value(
v = pv.text
break
elif TypeDefinition.class_name in range_applicable_elements:
- if cast_literals:
- v = namespaces.curie_for(o)
+ range_kind = self._range_term_kind(slot, schemaview)
+ if cast_literals or (range_kind is not None and range_kind.is_node):
+ # A uri value is a complete URI; values of other types may be written as CURIEs.
+ keep_iri = range_kind is not None and link_root(schemaview, slot.range) == "uri"
+ v = None if keep_iri else namespaces.curie_for(o)
if v is None:
v = str(o)
logger.debug(f"Casting {o} to string")
@@ -324,6 +330,13 @@ def _get_id_dict(self, node: VALID_SUBJECT, schemaview: SchemaView, cn: ClassDef
raise Exception(f"Unexpected blank node {node}, type {cn} expects {id_slot.name} identifier")
return {}
+ @staticmethod
+ def _range_term_kind(slot: SlotDefinition, schemaview: SchemaView) -> TermKind | None:
+ """Return the RDF term kind of the slot's values if its range is a type, else ``None``."""
+ if slot.range in schemaview.all_types():
+ return type_term(schemaview, slot.range).kind
+ return None
+
def _uri_to_id(self, node: VALID_SUBJECT, id_slot: SlotDefinition, schemaview: SchemaView) -> str:
if schemaview.is_slot_percent_encoded(id_slot):
return urllib.parse.unquote(node).replace(schemaview.namespaces()._base, "")
diff --git a/packages/linkml_runtime/src/linkml_runtime/utils/rdf_terms.py b/packages/linkml_runtime/src/linkml_runtime/utils/rdf_terms.py
new file mode 100644
index 0000000000..73e8411234
--- /dev/null
+++ b/packages/linkml_runtime/src/linkml_runtime/utils/rdf_terms.py
@@ -0,0 +1,207 @@
+"""The RDF term that a value of a LinkML type becomes.
+
+A slot whose range is a type holds IRIs, blank nodes or literals, never a mix:
+RDF keeps the three kinds of term distinct (RDF 1.1 Concepts §3.1), and OWL 2 DL
+types every property as either an object or a data property (OWL 2 Structural
+Specification §5.8.1). One rule decides the kind for the RDF generators and for
+the RDF dumper and loader:
+
+1. A type whose datatype is a node kind has nodes of that kind. The node kinds
+ are those of ShEx (``shex:iri``, ``shex:bnode``, ``shex:nonliteral``;
+ ShEx 2.1 §5.4.2), which ``objectidentifier`` and ``nodeidentifier`` use, and
+ ``rdfs:Resource``, which marks a type whose values are nodes.
+2. A type whose datatype is ``xsd:anyURI`` and that is ``uri`` or ``uriorcurie``
+ from ``linkml:types``, or is derived from one of them, has IRIs.
+3. Every other type has literals of its datatype. That includes a type derived
+ from ``string`` with ``uri: xsd:anyURI``: a URI reference held as text, which
+ may be relative (XSD 1.1 Part 2 §3.3.17), whereas an RDF IRI must be absolute
+ (RDF 1.1 Concepts §3.2).
+
+>>> from linkml_runtime.utils.schemaview import SchemaView
+>>> sv = SchemaView('''
+... id: https://example.org/t
+... name: t
+... prefixes:
+... linkml: https://w3id.org/linkml/
+... ex: https://example.org/t/
+... default_prefix: ex
+... imports: [linkml:types]
+... types:
+... ImageLink:
+... typeof: uri
+... FilePath:
+... typeof: string
+... uri: xsd:anyURI
+... ''')
+>>> type_term(sv, "uri")
+TypeTerm(kind=, datatype=None)
+>>> type_term(sv, "ImageLink").kind
+
+>>> type_term(sv, "FilePath")
+TypeTerm(kind=, datatype='http://www.w3.org/2001/XMLSchema#anyURI')
+>>> type_term(sv, "nodeidentifier").kind
+
+>>> value_term(sv, "ImageLink", "ex:me.jpg")
+rdflib.term.URIRef('https://example.org/t/me.jpg')
+>>> value_term(sv, "FilePath", "./docs/cv.pdf")
+rdflib.term.Literal('./docs/cv.pdf', datatype=rdflib.term.URIRef('http://www.w3.org/2001/XMLSchema#anyURI'))
+"""
+
+import re
+from dataclasses import dataclass
+from enum import Enum
+from functools import lru_cache
+from typing import Any
+
+from rdflib import RDFS, XSD, BNode, Literal, URIRef
+from rdflib.term import Node
+
+from linkml_runtime.linkml_model.types import SHEX
+from linkml_runtime.utils.schemaview import SchemaView
+
+
+class TermKind(Enum):
+ """The kind of RDF term a value becomes (RDF 1.1 Concepts §3.1)."""
+
+ IRI = "iri"
+ BLANK_NODE = "bnode"
+ BLANK_NODE_OR_IRI = "nonliteral"
+ LITERAL = "literal"
+
+ @property
+ def is_node(self) -> bool:
+ """Whether values of this kind are nodes (IRIs or blank nodes) rather than literals."""
+ return self is not TermKind.LITERAL
+
+
+# Datatype IRIs that name a kind of node rather than a literal datatype. ShEx
+# spells the non-literal kind ``nonliteral``; ``linkml:types`` declares
+# ``nodeidentifier`` with ``shex:nonLiteral``, so both spellings are listed.
+NODE_KINDS: dict[str, TermKind] = {
+ str(SHEX.iri): TermKind.IRI,
+ str(SHEX.bnode): TermKind.BLANK_NODE,
+ str(SHEX.nonliteral): TermKind.BLANK_NODE_OR_IRI,
+ str(SHEX.nonLiteral): TermKind.BLANK_NODE_OR_IRI,
+ str(RDFS.Resource): TermKind.BLANK_NODE_OR_IRI,
+}
+
+# The types of ``linkml:types`` whose values are IRIs, with the Python base their
+# definitions declare. A link root is matched by name and definition: the name
+# alone would admit a user type that happens to be called ``uri``, and
+# ``from_schema`` cannot be used because merging imports rewrites it.
+LINK_ROOTS: dict[str, str] = {"uri": "URI", "uriorcurie": "URIorCURIE"}
+
+# The vocabularies the built-in datatypes and the node kinds above come from.
+# A schema may use their usual prefixes without declaring them; a declaration
+# in the schema takes precedence.
+VOCABULARY_PREFIXES: dict[str, str] = {"xsd": str(XSD), "shex": str(SHEX), "rdfs": str(RDFS)}
+
+# RFC 3986 §3.1: an absolute URI starts with a scheme followed by ":".
+_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
+
+
+@dataclass(frozen=True)
+class TypeTerm:
+ """The RDF term kind of a type's values and, for literals, their datatype IRI."""
+
+ kind: TermKind
+ datatype: str | None = None
+
+
+def _expand(sv: SchemaView, uri: str, type_name: str) -> str:
+ """Expand the datatype *uri* of *type_name*, a CURIE or an IRI, with the schema's prefixes.
+
+ A declared prefix is expanded even when the local part contains a colon, which
+ ``SchemaView.expand_curie`` leaves alone. An IRI is recognised by ``://``; one of
+ another form, such as ``urn:``, needs its scheme declared as a prefix.
+
+ :raises ValueError: if the prefix is neither declared nor one of :data:`VOCABULARY_PREFIXES`
+ """
+ if "://" in uri:
+ return uri
+ prefix, _, local = uri.partition(":")
+ namespaces = sv.namespaces()
+ if prefix in namespaces:
+ return str(namespaces[prefix]) + local
+ if prefix in VOCABULARY_PREFIXES:
+ return VOCABULARY_PREFIXES[prefix] + local
+ raise ValueError(f"Type {type_name!r} has datatype {uri!r}, but the schema does not declare prefix {prefix!r}")
+
+
+def expanded_datatype(sv: SchemaView, type_name: str) -> str | None:
+ """Return the expanded IRI of the datatype that *type_name* declares or inherits.
+
+ :raises ValueError: if the datatype is a CURIE whose prefix the schema does not declare
+ """
+ uri = sv.induced_type(type_name).uri
+ return _expand(sv, uri, type_name) if uri else None
+
+
+@lru_cache(maxsize=4096)
+def link_root(sv: SchemaView, type_name: str) -> str | None:
+ """Return the link root of ``linkml:types`` that *type_name* is or derives from, else ``None``.
+
+ The link roots are ``uri``, whose values are complete URIs, and ``uriorcurie``,
+ whose values may be written as CURIEs. A root is matched by name and definition
+ (:data:`LINK_ROOTS`), through the ``typeof`` chain.
+ """
+ for ancestor_name in sv.type_ancestors(type_name, reflexive=True):
+ base = LINK_ROOTS.get(ancestor_name)
+ if base is None:
+ continue
+ ancestor = sv.get_type(ancestor_name)
+ if (
+ ancestor.typeof is None
+ and ancestor.base == base
+ and ancestor.uri is not None
+ and _expand(sv, ancestor.uri, ancestor_name) == str(XSD.anyURI)
+ ):
+ return ancestor_name
+ return None
+
+
+@lru_cache(maxsize=4096)
+def type_term(sv: SchemaView, type_name: str) -> TypeTerm:
+ """Return the RDF term kind of the values of the type *type_name* (see the module rules).
+
+ :raises ValueError: if the type's datatype uses an undeclared prefix
+ """
+ datatype = expanded_datatype(sv, type_name)
+ if datatype in NODE_KINDS:
+ return TypeTerm(NODE_KINDS[datatype])
+ if datatype == str(XSD.anyURI) and link_root(sv, type_name) is not None:
+ return TypeTerm(TermKind.IRI)
+ return TypeTerm(TermKind.LITERAL, datatype)
+
+
+def value_term(sv: SchemaView, type_name: str, value: Any) -> Node:
+ """Return the RDF term for *value* of the type *type_name*.
+
+ IRIs are expanded from CURIEs. A value of a node type may name a blank node
+ (``_:b1``) when its kind allows blank nodes. A literal keeps the Python value,
+ so rdflib writes its canonical lexical form (``True`` becomes ``"true"``);
+ ``xsd:string`` values become plain literals, which RDF 1.1 gives that datatype.
+
+ :raises ValueError: if a value of an IRI type is a relative reference, which
+ cannot be an RDF IRI (RDF 1.1 Concepts §3.2)
+ """
+ term = type_term(sv, type_name)
+ if not term.kind.is_node:
+ if term.datatype is None or term.datatype == str(XSD.string):
+ return Literal(value)
+ return Literal(value, datatype=URIRef(term.datatype))
+ text = str(value)
+ if text.startswith("_:") and term.kind in (TermKind.BLANK_NODE, TermKind.BLANK_NODE_OR_IRI):
+ return BNode(text[2:])
+ if term.kind is TermKind.BLANK_NODE:
+ raise ValueError(f"Value {text!r} of type {type_name!r} must be a blank node label such as '_:b1'")
+ if text.startswith("_:"):
+ raise ValueError(f"Value {text!r} of type {type_name!r} is a blank node, but values of this type are IRIs")
+ iri = sv.expand_curie(text)
+ if not _SCHEME.match(iri):
+ raise ValueError(
+ f"Value {text!r} of type {type_name!r} is a relative reference, but values of this type are "
+ "IRIs, which must be absolute. Use a type derived from string with uri: xsd:anyURI to keep "
+ "URI references as text."
+ )
+ return URIRef(iri)
diff --git a/tests/linkml/test_base/__snapshots__/annotations.context.jsonld b/tests/linkml/test_base/__snapshots__/annotations.context.jsonld
index cb67923cc6..84d8f7877d 100644
--- a/tests/linkml/test_base/__snapshots__/annotations.context.jsonld
+++ b/tests/linkml/test_base/__snapshots__/annotations.context.jsonld
@@ -8,7 +8,7 @@
"@id": "annotations"
},
"tag": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "tag"
},
"value": {
diff --git a/tests/linkml/test_base/__snapshots__/annotations.json b/tests/linkml/test_base/__snapshots__/annotations.json
index bc51cec7c6..bffa4074a3 100644
--- a/tests/linkml/test_base/__snapshots__/annotations.json
+++ b/tests/linkml/test_base/__snapshots__/annotations.json
@@ -503,7 +503,7 @@
"@id": "annotations"
},
"tag": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "tag"
},
"value": {
diff --git a/tests/linkml/test_base/__snapshots__/extensions.context.jsonld b/tests/linkml/test_base/__snapshots__/extensions.context.jsonld
index cebb3ee890..730a52a220 100644
--- a/tests/linkml/test_base/__snapshots__/extensions.context.jsonld
+++ b/tests/linkml/test_base/__snapshots__/extensions.context.jsonld
@@ -4,7 +4,7 @@
"linkml": "https://w3id.org/linkml/",
"@vocab": "https://w3id.org/linkml/",
"tag": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "tag"
},
"value": {
diff --git a/tests/linkml/test_base/__snapshots__/extensions.json b/tests/linkml/test_base/__snapshots__/extensions.json
index f9604e0ea6..6f7d53ac3a 100644
--- a/tests/linkml/test_base/__snapshots__/extensions.json
+++ b/tests/linkml/test_base/__snapshots__/extensions.json
@@ -442,7 +442,7 @@
"linkml": "https://w3id.org/linkml/",
"@vocab": "https://w3id.org/linkml/",
"tag": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "tag"
},
"value": {
diff --git a/tests/linkml/test_base/__snapshots__/mappings.context.jsonld b/tests/linkml/test_base/__snapshots__/mappings.context.jsonld
index b209ca8a52..e14c87bb34 100644
--- a/tests/linkml/test_base/__snapshots__/mappings.context.jsonld
+++ b/tests/linkml/test_base/__snapshots__/mappings.context.jsonld
@@ -12,35 +12,35 @@
"skos": "http://www.w3.org/2004/02/skos/core#",
"@vocab": "https://w3id.org/linkml/",
"broad_mappings": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "skos:broadMatch"
},
"close_mappings": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "skos:closeMatch"
},
"deprecated_element_has_exact_replacement": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "deprecated_element_has_exact_replacement"
},
"deprecated_element_has_possible_replacement": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "deprecated_element_has_possible_replacement"
},
"exact_mappings": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "skos:exactMatch"
},
"mappings": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "skos:mappingRelation"
},
"narrow_mappings": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "skos:narrowMatch"
},
"related_mappings": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "skos:relatedMatch"
},
"@base": "https://w3id.org/linkml/"
diff --git a/tests/linkml/test_base/__snapshots__/mappings.json b/tests/linkml/test_base/__snapshots__/mappings.json
index babcbe09bd..02354810c1 100644
--- a/tests/linkml/test_base/__snapshots__/mappings.json
+++ b/tests/linkml/test_base/__snapshots__/mappings.json
@@ -486,35 +486,35 @@
"skos": "http://www.w3.org/2004/02/skos/core#",
"@vocab": "https://w3id.org/linkml/",
"broad_mappings": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "skos:broadMatch"
},
"close_mappings": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "skos:closeMatch"
},
"deprecated_element_has_exact_replacement": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "deprecated_element_has_exact_replacement"
},
"deprecated_element_has_possible_replacement": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "deprecated_element_has_possible_replacement"
},
"exact_mappings": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "skos:exactMatch"
},
"mappings": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "skos:mappingRelation"
},
"narrow_mappings": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "skos:narrowMatch"
},
"related_mappings": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "skos:relatedMatch"
}
},
diff --git a/tests/linkml/test_base/__snapshots__/meta.context.jsonld b/tests/linkml/test_base/__snapshots__/meta.context.jsonld
index 9a33f0f10b..d51c9c8bf3 100644
--- a/tests/linkml/test_base/__snapshots__/meta.context.jsonld
+++ b/tests/linkml/test_base/__snapshots__/meta.context.jsonld
@@ -41,7 +41,7 @@
"@id": "skos:prefLabel"
},
"contexts": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "contexts"
},
"predicate": {
@@ -68,7 +68,7 @@
"@id": "allowed"
},
"source": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "dcterms:source"
},
"description": {
@@ -121,11 +121,11 @@
"@id": "boolean_slot"
},
"broad_mappings": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "skos:broadMatch"
},
"categories": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "dcterms:subject"
},
"children_are_mutually_disjoint": {
@@ -133,7 +133,7 @@
"@id": "children_are_mutually_disjoint"
},
"class_uri": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "class_uri"
},
"classes": {
@@ -145,11 +145,11 @@
"@id": "classification_rules"
},
"close_mappings": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "skos:closeMatch"
},
"code_set": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "code_set"
},
"code_set_tag": {
@@ -162,7 +162,7 @@
"@id": "skos:note"
},
"concepts": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "concepts"
},
"conforms_to": {
@@ -173,11 +173,11 @@
"@id": "consider_nulls_inequal"
},
"contributors": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "dcterms:contributor"
},
"created_by": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "pav:createdBy"
},
"created_on": {
@@ -203,18 +203,18 @@
"@id": "defining_slots"
},
"definition_uri": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "definition_uri"
},
"deprecated": {
"@id": "deprecated"
},
"deprecated_element_has_exact_replacement": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "deprecated_element_has_exact_replacement"
},
"deprecated_element_has_possible_replacement": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "deprecated_element_has_possible_replacement"
},
"derivation": {
@@ -255,7 +255,7 @@
"@id": "enum_range"
},
"enum_uri": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "enum_uri"
},
"enums": {
@@ -280,7 +280,7 @@
"@id": "equals_string_in"
},
"exact_mappings": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "skos:exactMatch"
},
"exact_cardinality": {
@@ -300,7 +300,7 @@
"@id": "examples"
},
"tag": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "tag"
},
"value": {
@@ -322,7 +322,7 @@
"@id": "framework"
},
"from_schema": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "skos:inScheme"
},
"generation_date": {
@@ -334,11 +334,11 @@
"@id": "has_member"
},
"has_quantity_kind": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "qudt:hasQuantityKind"
},
"id": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "id"
},
"id_prefixes": {
@@ -362,7 +362,7 @@
"@id": "ifabsent"
},
"implements": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "implements"
},
"implicit_prefix": {
@@ -372,7 +372,7 @@
"@id": "import_as"
},
"import_from": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "import_from"
},
"import_map": {
@@ -383,7 +383,7 @@
"@id": "imported_from"
},
"imports": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "imports"
},
"in_language": {
@@ -426,7 +426,7 @@
"@id": "inlined_as_simple_dict"
},
"instantiates": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "instantiates"
},
"interpolated": {
@@ -508,7 +508,7 @@
"@id": "type"
},
"mappings": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "skos:mappingRelation"
},
"matches": {
@@ -527,7 +527,7 @@
"@id": "maximum_value"
},
"meaning": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "meaning"
},
"metamodel_version": {
@@ -558,7 +558,7 @@
"@id": "mixins"
},
"modified_by": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "oslc:modifiedBy"
},
"multivalued": {
@@ -567,7 +567,7 @@
},
"name": "@id",
"narrow_mappings": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "skos:narrowMatch"
},
"none_of": {
@@ -590,7 +590,7 @@
"@id": "open_world"
},
"owned_by": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "owned_by"
},
"owner": {
@@ -624,7 +624,7 @@
"@id": "sh:prefix"
},
"prefix_reference": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "sh:namespace"
},
"prefixes": {
@@ -632,7 +632,7 @@
"@id": "sh:declare"
},
"publisher": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "dcterms:publisher"
},
"pv_formula": {
@@ -675,7 +675,7 @@
"@id": "reflexive_transitive_form_of"
},
"related_mappings": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "skos:relatedMatch"
},
"relational_logical_characteristic": {
@@ -691,7 +691,7 @@
"@id": "relational_role"
},
"relationship_types": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "relationship_types"
},
"repr": {
@@ -717,7 +717,7 @@
"@id": "sh:rule"
},
"see_also": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "rdfs:seeAlso"
},
"setting_key": {
@@ -754,7 +754,7 @@
"@id": "slot_names_unique"
},
"slot_uri": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "slot_uri"
},
"slot_usage": {
@@ -773,11 +773,11 @@
"@id": "source_file_size"
},
"source_nodes": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "source_nodes"
},
"source_ontology": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "source_ontology"
},
"status": {
@@ -799,7 +799,7 @@
"@id": "structured_pattern"
},
"subclass_of": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "subclass_of"
},
"subproperty_of": {
@@ -852,7 +852,7 @@
"@id": "type_mappings"
},
"uri": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "uri"
},
"typeof": {
@@ -904,7 +904,7 @@
"@id": "value_specification_constant"
},
"values_from": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "values_from"
},
"version": {
diff --git a/tests/linkml/test_base/__snapshots__/meta.json b/tests/linkml/test_base/__snapshots__/meta.json
index acda6769d8..98b6fe7d0d 100644
--- a/tests/linkml/test_base/__snapshots__/meta.json
+++ b/tests/linkml/test_base/__snapshots__/meta.json
@@ -8951,7 +8951,7 @@
"@id": "skos:prefLabel"
},
"contexts": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "contexts"
},
"predicate": {
@@ -8978,7 +8978,7 @@
"@id": "allowed"
},
"source": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "dcterms:source"
},
"description": {
@@ -9031,11 +9031,11 @@
"@id": "boolean_slot"
},
"broad_mappings": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "skos:broadMatch"
},
"categories": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "dcterms:subject"
},
"children_are_mutually_disjoint": {
@@ -9043,7 +9043,7 @@
"@id": "children_are_mutually_disjoint"
},
"class_uri": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "class_uri"
},
"classes": {
@@ -9055,11 +9055,11 @@
"@id": "classification_rules"
},
"close_mappings": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "skos:closeMatch"
},
"code_set": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "code_set"
},
"code_set_tag": {
@@ -9072,7 +9072,7 @@
"@id": "skos:note"
},
"concepts": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "concepts"
},
"conforms_to": {
@@ -9083,11 +9083,11 @@
"@id": "consider_nulls_inequal"
},
"contributors": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "dcterms:contributor"
},
"created_by": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "pav:createdBy"
},
"created_on": {
@@ -9113,18 +9113,18 @@
"@id": "defining_slots"
},
"definition_uri": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "definition_uri"
},
"deprecated": {
"@id": "deprecated"
},
"deprecated_element_has_exact_replacement": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "deprecated_element_has_exact_replacement"
},
"deprecated_element_has_possible_replacement": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "deprecated_element_has_possible_replacement"
},
"derivation": {
@@ -9165,7 +9165,7 @@
"@id": "enum_range"
},
"enum_uri": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "enum_uri"
},
"enums": {
@@ -9190,7 +9190,7 @@
"@id": "equals_string_in"
},
"exact_mappings": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "skos:exactMatch"
},
"exact_cardinality": {
@@ -9210,7 +9210,7 @@
"@id": "examples"
},
"tag": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "tag"
},
"value": {
@@ -9232,7 +9232,7 @@
"@id": "framework"
},
"from_schema": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "skos:inScheme"
},
"generation_date": {
@@ -9244,11 +9244,11 @@
"@id": "has_member"
},
"has_quantity_kind": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "qudt:hasQuantityKind"
},
"id": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "id"
},
"id_prefixes": {
@@ -9272,7 +9272,7 @@
"@id": "ifabsent"
},
"implements": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "implements"
},
"implicit_prefix": {
@@ -9282,7 +9282,7 @@
"@id": "import_as"
},
"import_from": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "import_from"
},
"import_map": {
@@ -9293,7 +9293,7 @@
"@id": "imported_from"
},
"imports": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "imports"
},
"in_language": {
@@ -9336,7 +9336,7 @@
"@id": "inlined_as_simple_dict"
},
"instantiates": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "instantiates"
},
"interpolated": {
@@ -9418,7 +9418,7 @@
"@id": "type"
},
"mappings": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "skos:mappingRelation"
},
"matches": {
@@ -9437,7 +9437,7 @@
"@id": "maximum_value"
},
"meaning": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "meaning"
},
"metamodel_version": {
@@ -9468,7 +9468,7 @@
"@id": "mixins"
},
"modified_by": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "oslc:modifiedBy"
},
"multivalued": {
@@ -9477,7 +9477,7 @@
},
"name": "@id",
"narrow_mappings": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "skos:narrowMatch"
},
"none_of": {
@@ -9500,7 +9500,7 @@
"@id": "open_world"
},
"owned_by": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "owned_by"
},
"owner": {
@@ -9534,7 +9534,7 @@
"@id": "sh:prefix"
},
"prefix_reference": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "sh:namespace"
},
"prefixes": {
@@ -9542,7 +9542,7 @@
"@id": "sh:declare"
},
"publisher": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "dcterms:publisher"
},
"pv_formula": {
@@ -9585,7 +9585,7 @@
"@id": "reflexive_transitive_form_of"
},
"related_mappings": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "skos:relatedMatch"
},
"relational_logical_characteristic": {
@@ -9601,7 +9601,7 @@
"@id": "relational_role"
},
"relationship_types": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "relationship_types"
},
"repr": {
@@ -9627,7 +9627,7 @@
"@id": "sh:rule"
},
"see_also": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "rdfs:seeAlso"
},
"setting_key": {
@@ -9664,7 +9664,7 @@
"@id": "slot_names_unique"
},
"slot_uri": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "slot_uri"
},
"slot_usage": {
@@ -9683,11 +9683,11 @@
"@id": "source_file_size"
},
"source_nodes": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "source_nodes"
},
"source_ontology": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "source_ontology"
},
"status": {
@@ -9709,7 +9709,7 @@
"@id": "structured_pattern"
},
"subclass_of": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "subclass_of"
},
"subproperty_of": {
@@ -9762,7 +9762,7 @@
"@id": "type_mappings"
},
"uri": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "uri"
},
"typeof": {
@@ -9814,7 +9814,7 @@
"@id": "value_specification_constant"
},
"values_from": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "values_from"
},
"version": {
diff --git a/tests/linkml/test_biolink_model/__snapshots__/biolink.context.jsonld b/tests/linkml/test_biolink_model/__snapshots__/biolink.context.jsonld
index 509e297b35..16621443df 100644
--- a/tests/linkml/test_biolink_model/__snapshots__/biolink.context.jsonld
+++ b/tests/linkml/test_biolink_model/__snapshots__/biolink.context.jsonld
@@ -639,7 +639,7 @@
"@id": "affects_response_to"
},
"affiliation": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "affiliation"
},
"agent_type": {
@@ -783,7 +783,7 @@
"@id": "catalyzes"
},
"category": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "category"
},
"causal_mechanism_qualifier": {
@@ -1737,7 +1737,7 @@
"@id": "mentions"
},
"mesh_terms": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "mesh_terms"
},
"missing_from": {
@@ -1841,7 +1841,7 @@
"@id": "object_part_qualifier"
},
"object_specialization_qualifier": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "object_specialization_qualifier"
},
"occurs_in": {
@@ -1868,7 +1868,7 @@
"@id": "original_object"
},
"original_predicate": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "original_predicate"
},
"original_subject": {
@@ -1993,7 +1993,7 @@
"@id": "publications"
},
"published_in": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "published_in"
},
"publisher": {
@@ -2082,7 +2082,7 @@
"@id": "resistance_associated_with"
},
"resource_id": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "resource_id"
},
"resource_role": {
@@ -2260,7 +2260,7 @@
"@id": "subject_part_qualifier"
},
"subject_specialization_qualifier": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "subject_specialization_qualifier"
},
"summary": {
@@ -2287,7 +2287,7 @@
"@id": "supporting_document_year"
},
"supporting_documents": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "supporting_documents"
},
"supporting_study_cohort": {
@@ -2303,11 +2303,11 @@
"@id": "supporting_study_metadata"
},
"supporting_study_method_description": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "supporting_study_method_description"
},
"supporting_study_method_type": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "supporting_study_method_type"
},
"supporting_study_size": {
@@ -2412,7 +2412,7 @@
"@id": "update_date"
},
"upstream_resource_ids": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "upstream_resource_ids"
},
"url": {
@@ -2437,7 +2437,7 @@
"@id": "xenologous_to"
},
"xref": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "xref"
},
"AccessibleDnaRegion": {
diff --git a/tests/linkml/test_biolink_model/__snapshots__/biolink.json b/tests/linkml/test_biolink_model/__snapshots__/biolink.json
index 3080ce2608..02bc375a8f 100644
--- a/tests/linkml/test_biolink_model/__snapshots__/biolink.json
+++ b/tests/linkml/test_biolink_model/__snapshots__/biolink.json
@@ -36634,7 +36634,7 @@
"@id": "affects_response_to"
},
"affiliation": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "affiliation"
},
"agent_type": {
@@ -36778,7 +36778,7 @@
"@id": "catalyzes"
},
"category": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "category"
},
"causal_mechanism_qualifier": {
@@ -37732,7 +37732,7 @@
"@id": "mentions"
},
"mesh_terms": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "mesh_terms"
},
"missing_from": {
@@ -37836,7 +37836,7 @@
"@id": "object_part_qualifier"
},
"object_specialization_qualifier": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "object_specialization_qualifier"
},
"occurs_in": {
@@ -37863,7 +37863,7 @@
"@id": "original_object"
},
"original_predicate": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "original_predicate"
},
"original_subject": {
@@ -37988,7 +37988,7 @@
"@id": "publications"
},
"published_in": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "published_in"
},
"publisher": {
@@ -38077,7 +38077,7 @@
"@id": "resistance_associated_with"
},
"resource_id": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "resource_id"
},
"resource_role": {
@@ -38255,7 +38255,7 @@
"@id": "subject_part_qualifier"
},
"subject_specialization_qualifier": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "subject_specialization_qualifier"
},
"summary": {
@@ -38282,7 +38282,7 @@
"@id": "supporting_document_year"
},
"supporting_documents": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "supporting_documents"
},
"supporting_study_cohort": {
@@ -38298,11 +38298,11 @@
"@id": "supporting_study_metadata"
},
"supporting_study_method_description": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "supporting_study_method_description"
},
"supporting_study_method_type": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "supporting_study_method_type"
},
"supporting_study_size": {
@@ -38407,7 +38407,7 @@
"@id": "update_date"
},
"upstream_resource_ids": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "upstream_resource_ids"
},
"url": {
@@ -38432,7 +38432,7 @@
"@id": "xenologous_to"
},
"xref": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "xref"
},
"AccessibleDnaRegion": {
diff --git a/tests/linkml/test_generators/test_jsonldcontextgen.py b/tests/linkml/test_generators/test_jsonldcontextgen.py
index 72089eba44..6c56ab3fc5 100644
--- a/tests/linkml/test_generators/test_jsonldcontextgen.py
+++ b/tests/linkml/test_generators/test_jsonldcontextgen.py
@@ -4,11 +4,13 @@
import pytest
from click.testing import CliRunner
-from rdflib import Graph, Literal, URIRef
+from rdflib import OWL, RDF, XSD, Graph, Literal, URIRef
-from linkml.generators import ContextGenerator, JSONLDGenerator
+from linkml.generators import ContextGenerator, JSONLDGenerator, OwlSchemaGenerator
from linkml.generators.jsonldcontextgen import ContextGenerator as FrameContextGenerator
from linkml.generators.jsonldcontextgen import cli as jsonld_context_cli
+from linkml.generators.owlgen import cli as owl_cli
+from linkml.utils.deprecation import EMITTED, XSD_ANYURI_AS_IRI_OPTION
from tests.linkml.utils.compare_jsonld_context import CompareJsonldContext
from tests.linkml.utils.validate_jsonld_context import RdfExpectations
@@ -1019,376 +1021,132 @@ def test_exclude_external_imports_works_with_mergeimports_false(tmp_path):
assert "ExternalCredential" not in ctx, "External class should be excluded with mergeimports=False"
-def test_xsd_anyuri_as_iri_flag():
- """Test that --xsd-anyuri-as-iri maps uri ranges to @type: @id.
-
- By default, ``range: uri`` (type_uri ``xsd:anyURI``) produces
- ``@type: xsd:anyURI`` (typed literal). With ``xsd_anyuri_as_iri=True``,
- it produces ``@type: @id`` (IRI node reference), aligning the JSON-LD
- context with the SHACL generator which already emits ``sh:nodeKind sh:IRI``
- for the same type.
-
- See:
- - W3C SHACL §4.8.1 sh:nodeKind (https://www.w3.org/TR/shacl/#NodeKindConstraintComponent)
- - JSON-LD 1.1 §4.2.2 Type Coercion (https://www.w3.org/TR/json-ld11/#type-coercion)
- - RDF 1.1 §3.3 Literals vs §3.2 IRIs (https://www.w3.org/TR/rdf11-concepts/)
- """
- schema_yaml = """
-id: https://example.org/test-uri-context
-name: test_uri_context
-
-prefixes:
- ex: https://example.org/
- linkml: https://w3id.org/linkml/
-
-imports:
- - linkml:types
-
-default_prefix: ex
-default_range: string
-
-slots:
- homepage:
- range: uri
- slot_uri: ex:homepage
- node_ref:
- range: nodeidentifier
- slot_uri: ex:nodeRef
- name:
- range: string
- slot_uri: ex:name
-
-classes:
- Thing:
- slots:
- - homepage
- - node_ref
- - name
-"""
- # Default behaviour: uri → xsd:anyURI (backward compatible)
- ctx_default = json.loads(ContextGenerator(schema_yaml).serialize())["@context"]
- assert ctx_default["homepage"]["@type"] == "xsd:anyURI"
-
- # Opt-in: uri → @id (aligned with SHACL sh:nodeKind sh:IRI)
- ctx_iri = json.loads(ContextGenerator(schema_yaml, xsd_anyuri_as_iri=True).serialize())["@context"]
- assert ctx_iri["homepage"]["@type"] == "@id", (
- f"Expected @type: @id for uri range with xsd_anyuri_as_iri=True, got {ctx_iri['homepage'].get('@type')}"
- )
-
- # nodeidentifier is unaffected by the flag (not xsd:anyURI-typed)
- # Its default @type depends on URI_RANGES matching shex:nonLiteral;
- # we only verify the flag doesn't change its behaviour.
- assert ctx_default["node_ref"]["@type"] == ctx_iri["node_ref"]["@type"]
-
- # string → no @type regardless of flag
- assert "@type" not in ctx_default.get("name", {})
- assert "@type" not in ctx_iri.get("name", {})
-
-
-def test_xsd_anyuri_as_iri_with_any_of():
- """The --xsd-anyuri-as-iri flag must also apply to ``any_of`` slots
- whose type branches include ``uri`` mixed with class ranges.
-
- ``_literal_coercion_for_ranges`` resolves mixed any_of type branches
- and must use the extended URI_RANGES when the flag is active.
- """
- schema_yaml = """
-id: https://example.org/test-anyof-uri
-name: test_anyof_uri
-
-prefixes:
- ex: https://example.org/
- linkml: https://w3id.org/linkml/
-
-imports:
- - linkml:types
-
-default_prefix: ex
-default_range: string
-
-classes:
- Container:
- slots:
- - mixed_slot
- Target:
- class_uri: ex:Target
-
-slots:
- mixed_slot:
- slot_uri: ex:mixed
- any_of:
- - range: Target
- - range: uri
-"""
- # Default: mixed class+uri any_of — uri resolves to xsd:anyURI literal,
- # which disagrees with @id from the class branch → no coercion emitted
- ctx_default = json.loads(ContextGenerator(schema_yaml).serialize())["@context"]
- default_type = ctx_default.get("mixed_slot", {}).get("@type")
- assert default_type != "@id", f"Without flag, mixed any_of should not resolve to @id, got {default_type}"
-
- # With flag: uri branch now also resolves to @id, matching the class branch
- # → all branches agree → @id is emitted
- ctx_iri = json.loads(ContextGenerator(schema_yaml, xsd_anyuri_as_iri=True).serialize())["@context"]
- assert ctx_iri["mixed_slot"]["@type"] == "@id", (
- f"Expected @id for mixed any_of with flag, got {ctx_iri.get('mixed_slot', {}).get('@type')}"
- )
-
-
-def test_xsd_anyuri_as_iri_owl():
- """OWL generator must produce owl:ObjectProperty for uri ranges when flag is set.
-
- Without the flag, ``range: uri`` produces ``owl:DatatypeProperty`` with
- ``rdfs:range xsd:anyURI``. With ``xsd_anyuri_as_iri=True``, it should
- produce ``owl:ObjectProperty`` (no rdfs:range restriction), aligning
- with the SHACL generator's ``sh:nodeKind sh:IRI``.
- """
- from rdflib import OWL, RDF, URIRef
-
- from linkml.generators.owlgen import OwlSchemaGenerator
-
- schema_yaml = """
-id: https://example.org/test-owl-uri
-name: test_owl_uri
-prefixes:
- ex: https://example.org/
- linkml: https://w3id.org/linkml/
-imports:
- - linkml:types
-default_prefix: ex
-default_range: string
-slots:
- homepage:
- range: uri
- slot_uri: ex:homepage
- name:
- range: string
- slot_uri: ex:name
-classes:
- Thing:
- slots:
- - homepage
- - name
-"""
- # Default: uri → DatatypeProperty (must disable type_objects which
- # unconditionally returns ObjectProperty for all type-ranged slots)
- gen_default = OwlSchemaGenerator(schema_yaml, type_objects=False)
- g_default = gen_default.as_graph()
- homepage_uri = URIRef("https://example.org/homepage")
- default_rdf_type = set(g_default.objects(homepage_uri, RDF.type))
- assert OWL.DatatypeProperty in default_rdf_type, (
- f"Without flag, homepage should be DatatypeProperty, got {default_rdf_type}"
- )
-
- # With flag: uri → ObjectProperty
- gen_iri = OwlSchemaGenerator(schema_yaml, xsd_anyuri_as_iri=True, type_objects=False)
- g_iri = gen_iri.as_graph()
- iri_rdf_type = set(g_iri.objects(homepage_uri, RDF.type))
- assert OWL.ObjectProperty in iri_rdf_type, f"With flag, homepage should be ObjectProperty, got {iri_rdf_type}"
- assert OWL.DatatypeProperty not in iri_rdf_type, (
- f"With flag, homepage should NOT be DatatypeProperty, got {iri_rdf_type}"
- )
-
- # String slot must remain DatatypeProperty regardless of flag
- name_uri = URIRef("https://example.org/name")
- name_rdf_type = set(g_iri.objects(name_uri, RDF.type))
- assert OWL.DatatypeProperty in name_rdf_type, f"String slot should remain DatatypeProperty, got {name_rdf_type}"
-
-
-def test_xsd_anyuri_as_iri_uriorcurie_range():
- """``uriorcurie`` also maps to ``xsd:anyURI`` and must behave identically
- to ``uri`` when the ``--xsd-anyuri-as-iri`` flag is active.
-
- This is a high-priority coverage gap: ``uriorcurie`` is distinct from
- ``uri`` at the LinkML level but shares the same XSD type.
- """
- schema_yaml = """
-id: https://example.org/test-uriorcurie
-name: test_uriorcurie
-
+URI_TERMS_SCHEMA = """
+id: https://example.org/uri-terms
+name: uri_terms
prefixes:
ex: https://example.org/
linkml: https://w3id.org/linkml/
-
imports:
- linkml:types
-
default_prefix: ex
default_range: string
-
+types:
+ ImageLink:
+ typeof: uri
+ FilePath:
+ typeof: string
+ uri: xsd:anyURI
slots:
- reference:
- range: uriorcurie
- slot_uri: ex:reference
- homepage:
- range: uri
- slot_uri: ex:homepage
-
+ homepage: {range: uri}
+ related: {range: uriorcurie}
+ image: {range: ImageLink}
+ file_path: {range: FilePath}
+ node: {range: objectidentifier}
+ any_node: {range: nodeidentifier}
+ code: {range: curie}
+ name: {}
+ link_or_thing:
+ any_of: [{range: uri}, {range: Thing}]
classes:
Thing:
- slots:
- - reference
- - homepage
+ attributes:
+ id: {identifier: true}
+ slots: [homepage, related, image, file_path, node, any_node, code, name, link_or_thing]
+ Special:
+ slots: [name]
+ slot_usage:
+ name: {range: uri}
"""
- ctx_default = json.loads(ContextGenerator(schema_yaml).serialize())["@context"]
- assert ctx_default["reference"]["@type"] == "xsd:anyURI"
- assert ctx_default["homepage"]["@type"] == "xsd:anyURI"
- ctx_iri = json.loads(ContextGenerator(schema_yaml, xsd_anyuri_as_iri=True).serialize())["@context"]
- assert ctx_iri["reference"]["@type"] == "@id", "uriorcurie should map to @id with xsd_anyuri_as_iri=True"
- assert ctx_iri["homepage"]["@type"] == "@id", "uri should map to @id with xsd_anyuri_as_iri=True"
+@pytest.mark.parametrize(
+ "slot,coercion",
+ [
+ ("homepage", "@id"),
+ ("related", "@id"),
+ ("image", "@id"),
+ ("file_path", "xsd:anyURI"),
+ ("node", "@id"),
+ ("any_node", "@id"),
+ ("code", None),
+ ("name", None),
+ ("link_or_thing", "@id"),
+ ],
+)
+def test_type_coercion_follows_term_kind(slot, coercion):
+ """Slots get the coercion of their values' RDF term kind (``linkml_runtime.utils.rdf_terms``).
-def test_xsd_anyuri_as_iri_curie_range_unchanged():
- """``curie`` maps to ``xsd:string`` (not ``xsd:anyURI``), so the flag
- must NOT affect its coercion.
-
- This documents the cross-type boundary: ``uri`` and ``uriorcurie``
- share ``xsd:anyURI``, but ``curie`` uses ``xsd:string``.
+ ``uri``, ``uriorcurie``, types derived from them and the node types are IRIs
+ (``@id``); a string-derived ``xsd:anyURI`` type keeps URI references as typed
+ literals; ``curie`` and ``string`` are plain strings.
"""
- schema_yaml = """
-id: https://example.org/test-curie
-name: test_curie
-
-prefixes:
- ex: https://example.org/
- linkml: https://w3id.org/linkml/
-
-imports:
- - linkml:types
-
-default_prefix: ex
-default_range: string
-
-slots:
- curie_slot:
- range: curie
- slot_uri: ex:curieSlot
- uri_slot:
- range: uri
- slot_uri: ex:uriSlot
-
-classes:
- Thing:
- slots:
- - curie_slot
- - uri_slot
-"""
- ctx_default = json.loads(ContextGenerator(schema_yaml).serialize())["@context"]
- ctx_iri = json.loads(ContextGenerator(schema_yaml, xsd_anyuri_as_iri=True).serialize())["@context"]
+ ctx = json.loads(ContextGenerator(URI_TERMS_SCHEMA).serialize())["@context"]
+ assert ctx[slot].get("@type") == coercion
- # curie (xsd:string) must be unaffected by the flag
- curie_default = ctx_default.get("curie_slot", {}).get("@type")
- curie_iri = ctx_iri.get("curie_slot", {}).get("@type")
- assert curie_default == curie_iri, f"curie coercion should not change with flag: {curie_default} vs {curie_iri}"
- # uri (xsd:anyURI) must change — sanity check
- assert ctx_iri["uri_slot"]["@type"] == "@id"
+def test_type_schemaview_on_both_generator_paths():
+ """A SchemaView-based generator answers type questions from its own view; a SchemaLoader-based one builds one."""
+ owl = OwlSchemaGenerator(URI_TERMS_SCHEMA)
+ assert owl.type_schemaview() is owl.schemaview
+ assert ContextGenerator(URI_TERMS_SCHEMA).type_schemaview().induced_type("ImageLink").uri == "xsd:anyURI"
-def test_xsd_anyuri_as_iri_owl_curie_unchanged():
- """OWL generator must keep ``range: curie`` as DatatypeProperty even with flag.
+def test_class_scoped_range_override_follows_term_kind():
+ """A ``slot_usage`` override to a link type is coerced to ``@id`` in the class-scoped context."""
+ ctx = json.loads(ContextGenerator(URI_TERMS_SCHEMA).serialize())["@context"]
+ assert ctx["Special"]["@context"]["name"]["@type"] == "@id"
- ``curie`` maps to ``xsd:string`` (not ``xsd:anyURI``), so the
- ``--xsd-anyuri-as-iri`` flag must not promote it to ObjectProperty.
- This verifies cross-generator consistency: the JSON-LD context generator
- already correctly excludes ``curie`` via ``URI_RANGES_WITH_XSD``; the
- OWL generator must match via ``is_xsd_anyuri_range()``.
- """
- from rdflib import OWL, RDF, URIRef
-
- from linkml.generators.owlgen import OwlSchemaGenerator
-
- schema_yaml = """
-id: https://example.org/test-owl-curie
-name: test_owl_curie
-prefixes:
- ex: https://example.org/
- linkml: https://w3id.org/linkml/
-imports:
- - linkml:types
-default_prefix: ex
-default_range: string
-slots:
- compact_id:
- range: curie
- slot_uri: ex:compactId
- homepage:
- range: uri
- slot_uri: ex:homepage
-classes:
- Thing:
- slots:
- - compact_id
- - homepage
-"""
- compact_id_uri = URIRef("https://example.org/compact_id")
- homepage_uri = URIRef("https://example.org/homepage")
-
- # With flag: curie must stay DatatypeProperty, uri must become ObjectProperty
- gen = OwlSchemaGenerator(schema_yaml, xsd_anyuri_as_iri=True, type_objects=False)
- g = gen.as_graph()
- curie_types = set(g.objects(compact_id_uri, RDF.type))
- assert OWL.DatatypeProperty in curie_types, f"curie slot must remain DatatypeProperty with flag, got {curie_types}"
- assert OWL.ObjectProperty not in curie_types, (
- f"curie slot must NOT become ObjectProperty with flag, got {curie_types}"
+def test_links_are_iris_and_uri_references_stay_text():
+ """Through the generated context, links become IRIs and a relative file path stays an ``xsd:anyURI`` literal."""
+ ctx = json.loads(ContextGenerator(URI_TERMS_SCHEMA).serialize())
+ doc = {
+ **ctx,
+ "id": "ex:t1",
+ "homepage": "https://alice.example/",
+ "related": "ex:other",
+ "file_path": "./docs/cv.pdf",
+ }
+ graph = Graph().parse(data=json.dumps(doc), format="json-ld", base="https://reader.example/downloads/doc.json")
+ subject = URIRef("https://example.org/t1")
+ assert graph.value(subject, URIRef("https://example.org/homepage")) == URIRef("https://alice.example/")
+ assert graph.value(subject, URIRef("https://example.org/related")) == URIRef("https://example.org/other")
+ assert graph.value(subject, URIRef("https://example.org/file_path")) == Literal(
+ "./docs/cv.pdf", datatype=XSD.anyURI
)
- # Sanity: uri must become ObjectProperty
- uri_types = set(g.objects(homepage_uri, RDF.type))
- assert OWL.ObjectProperty in uri_types, f"uri slot should be ObjectProperty with flag, got {uri_types}"
+@pytest.mark.parametrize("value", [True, False])
+@pytest.mark.parametrize("generator", [ContextGenerator, OwlSchemaGenerator])
+def test_xsd_anyuri_as_iri_is_deprecated_without_effect(generator, value):
+ """Passing the former option reports the deprecation and changes nothing, in either polarity."""
+ EMITTED.discard(XSD_ANYURI_AS_IRI_OPTION)
+ flagged = generator(URI_TERMS_SCHEMA, xsd_anyuri_as_iri=value).serialize()
+ assert XSD_ANYURI_AS_IRI_OPTION in EMITTED
+ assert flagged == generator(URI_TERMS_SCHEMA).serialize()
-def test_xsd_anyuri_as_iri_cli_flag():
- """Verify the ``--xsd-anyuri-as-iri`` flag is wired through Click."""
- import tempfile
- from pathlib import Path
- from click.testing import CliRunner
+def _homepage_is_a_link_in_context(output: str) -> bool:
+ return json.loads(output)["@context"]["homepage"]["@type"] == "@id"
- from linkml.generators.jsonldcontextgen import cli
- schema_yaml = """
-id: https://example.org/test-cli
-name: test_cli
+def _homepage_is_a_link_in_owl(output: str) -> bool:
+ graph = Graph().parse(data=output, format="turtle")
+ return (URIRef("https://example.org/homepage"), RDF.type, OWL.ObjectProperty) in graph
-prefixes:
- ex: https://example.org/
- linkml: https://w3id.org/linkml/
-
-imports:
- - linkml:types
-
-default_prefix: ex
-default_range: string
-slots:
- homepage:
- range: uri
- slot_uri: ex:homepage
-
-classes:
- Thing:
- slots:
- - homepage
-"""
- with tempfile.TemporaryDirectory() as tmpdir:
- schema_path = Path(tmpdir) / "test.yaml"
- schema_path.write_text(schema_yaml)
-
- runner = CliRunner()
-
- # Without flag
- result_default = runner.invoke(cli, [str(schema_path)])
- assert result_default.exit_code == 0, result_default.output
- ctx_default = json.loads(result_default.output)["@context"]
- assert ctx_default["homepage"]["@type"] == "xsd:anyURI"
-
- # With flag
- result_iri = runner.invoke(cli, [str(schema_path), "--xsd-anyuri-as-iri"])
- assert result_iri.exit_code == 0, result_iri.output
- ctx_iri = json.loads(result_iri.output)["@context"]
- assert ctx_iri["homepage"]["@type"] == "@id"
+@pytest.mark.parametrize(
+ "cli,homepage_is_a_link",
+ [(jsonld_context_cli, _homepage_is_a_link_in_context), (owl_cli, _homepage_is_a_link_in_owl)],
+)
+def test_xsd_anyuri_as_iri_cli_option_is_accepted(tmp_path, cli, homepage_is_a_link):
+ """Both command lines still accept the option, so existing build scripts keep working."""
+ schema_path = tmp_path / "schema.yaml"
+ schema_path.write_text(URI_TERMS_SCHEMA, encoding="utf-8")
+ result = CliRunner().invoke(cli, [str(schema_path), "--xsd-anyuri-as-iri"])
+ assert result.exit_code == 0, result.output
+ assert homepage_is_a_link(result.stdout)
# Enum @type: @vocab context generation (JSON-LD 1.1 §4.2.3 + §4.1.8)
diff --git a/tests/linkml/test_generators/test_shaclgen.py b/tests/linkml/test_generators/test_shaclgen.py
index cf6764ad6d..e772ae9d26 100644
--- a/tests/linkml/test_generators/test_shaclgen.py
+++ b/tests/linkml/test_generators/test_shaclgen.py
@@ -400,7 +400,7 @@ def check_slot_default_value(slot: URIRef, default_value: Any, datatype: str = N
# pyoxigraph's RDFC-1.0 serialization drops explicit ^^xsd:string
# per RDF 1.1 (plain literals and xsd:string are equivalent).
# Accept either form for xsd:string typed values.
- expected = Literal(default_value, datatype=datatype)
+ expected = default_value if isinstance(default_value, URIRef) else Literal(default_value, datatype=datatype)
if (subject, SH.defaultValue, expected) in g:
return
if datatype and str(datatype) == "http://www.w3.org/2001/XMLSchema#string":
@@ -446,10 +446,10 @@ def check_slot_default_value(slot: URIRef, default_value: Any, datatype: str = N
"2024-02-08T09:39:25",
datatype=ShaclDataType.DATETIME.uri_ref,
)
+ # A uri value is an IRI (sh:nodeKind sh:IRI), so its default is one too.
check_slot_default_value(
URIRef("https://w3id.org/linkml/tests/kitchen_sink/ifabsent_uri"),
- "https://w3id.org/linkml/tests/kitchen_sink/ifabsent_boolean",
- datatype=ShaclDataType.URI.uri_ref,
+ URIRef("https://w3id.org/linkml/tests/kitchen_sink/ifabsent_boolean"),
)
check_slot_default_value(URIRef("https://w3id.org/linkml/tests/kitchen_sink/ifabsent_not_literal"), "heartfelt")
diff --git a/tests/linkml/test_generators/test_uri_term_kinds.py b/tests/linkml/test_generators/test_uri_term_kinds.py
new file mode 100644
index 0000000000..e109603ff8
--- /dev/null
+++ b/tests/linkml/test_generators/test_uri_term_kinds.py
@@ -0,0 +1,323 @@
+"""Every generator and the RDF dumper give a URI-like value the same RDF term kind.
+
+The rule is ``linkml_runtime.utils.rdf_terms.type_term``: ``uri``, ``uriorcurie``,
+the types derived from them and the node types (``objectidentifier``,
+``nodeidentifier``, ``rdfs:Resource``, ``shex:bnode``) are IRIs or blank nodes; a type
+derived from ``string`` with ``uri: xsd:anyURI`` keeps URI references as ``xsd:anyURI``
+literals; ``curie`` stays an ``xsd:string`` literal.
+"""
+
+import json
+
+import pytest
+import yaml
+from pyshacl import validate
+from rdflib import OWL, RDF, SH, XSD, BNode, Graph, Literal, URIRef
+
+from linkml.generators.jsonldcontextgen import ContextGenerator
+from linkml.generators.owlgen import OwlSchemaGenerator
+from linkml.generators.pythongen import PythonGenerator
+from linkml.generators.shaclgen import ShaclGenerator
+from linkml.generators.shexgen import ShExGenerator
+from linkml.generators.yarrrmlgen import YarrrmlGenerator
+from linkml_runtime import SchemaView
+from linkml_runtime.dumpers import rdflib_dumper
+from linkml_runtime.loaders import rdflib_loader
+from linkml_runtime.utils.compile_python import compile_python
+
+EX = "https://example.org/terms/"
+
+SCHEMA = """
+id: https://example.org/terms
+name: terms
+prefixes:
+ linkml: https://w3id.org/linkml/
+ ex: https://example.org/terms/
+default_prefix: ex
+default_range: string
+imports:
+ - linkml:types
+types:
+ ImageLink:
+ typeof: uri
+ FilePath:
+ typeof: string
+ uri: xsd:anyURI
+ ResourceRef:
+ typeof: uriorcurie
+ uri: rdfs:Resource
+ BlankOnly:
+ typeof: string
+ uri: shex:bnode
+classes:
+ Container:
+ tree_root: true
+ attributes:
+ documents:
+ range: Document
+ multivalued: true
+ inlined_as_list: true
+ Document:
+ class_uri: ex:Document
+ attributes:
+ id:
+ identifier: true
+ homepage:
+ range: uri
+ related:
+ range: uriorcurie
+ image:
+ range: ImageLink
+ file_path:
+ range: FilePath
+ node:
+ range: objectidentifier
+ any_node:
+ range: nodeidentifier
+ code:
+ range: curie
+ cross_ref:
+ range: ResourceRef
+ anon:
+ range: BlankOnly
+ title:
+ range: string
+"""
+
+# slot -> (type, kind of its values): "iri", "nonliteral" (IRI or blank node), "bnode", or a literal datatype
+SLOTS = {
+ "homepage": ("Uri", "iri"),
+ "related": ("Uriorcurie", "iri"),
+ "image": ("ImageLink", "iri"),
+ "file_path": ("FilePath", XSD.anyURI),
+ "node": ("Objectidentifier", "iri"),
+ "any_node": ("Nodeidentifier", "nonliteral"),
+ "code": ("Curie", XSD.string),
+ "cross_ref": ("ResourceRef", "nonliteral"),
+ "anon": ("BlankOnly", "bnode"),
+ "title": ("String", XSD.string),
+}
+# SHACL node kinds (§4.1.3) of the node term kinds
+SH_NODE_KINDS = {"iri": SH.IRI, "nonliteral": SH.BlankNodeOrIRI, "bnode": SH.BlankNode}
+
+DATA = {
+ "id": "ex:d1",
+ "homepage": "https://example.org/terms/home",
+ "related": "ex:other",
+ "image": "https://example.org/terms/me.jpg",
+ "file_path": "./docs/cv.pdf",
+ "node": "ex:target",
+ "any_node": "ex:target",
+ "code": "ex:code",
+ "cross_ref": "ex:ref",
+ "anon": "_:n1",
+ "title": "Alice",
+}
+
+EXPECTED_TERMS = {
+ "homepage": URIRef(EX + "home"),
+ "related": URIRef(EX + "other"),
+ "image": URIRef(EX + "me.jpg"),
+ "file_path": Literal("./docs/cv.pdf", datatype=XSD.anyURI),
+ "node": URIRef(EX + "target"),
+ "any_node": URIRef(EX + "target"),
+ "code": Literal("ex:code"),
+ "cross_ref": URIRef(EX + "ref"),
+ "anon": BNode("n1"),
+ "title": Literal("Alice"),
+}
+
+
+@pytest.fixture(scope="module")
+def shapes() -> Graph:
+ return Graph().parse(data=ShaclGenerator(SCHEMA).serialize(), format="turtle")
+
+
+@pytest.fixture(scope="module")
+def module():
+ return compile_python(PythonGenerator(SCHEMA).serialize())
+
+
+def _dumped(module, document: dict) -> Graph:
+ """The RDF of *document* written by the runtime RDF dumper."""
+ container = module.Container(documents=[module.Document(**document)])
+ return rdflib_dumper.as_rdf_graph(container, SchemaView(SCHEMA))
+
+
+def _via_context(document: dict) -> Graph:
+ """The RDF of *document* read through the generated JSON-LD context."""
+ context = json.loads(ContextGenerator(SCHEMA).serialize())["@context"]
+ doc = {"@context": context, "documents": [{"@type": "Document", **document}]}
+ return Graph().parse(data=json.dumps(doc), format="json-ld", base="https://reader.example/downloads/doc.json")
+
+
+@pytest.mark.parametrize("slot", SLOTS)
+def test_json_ld_context(slot):
+ coercion = json.loads(ContextGenerator(SCHEMA).serialize())["@context"][slot].get("@type")
+ kind = SLOTS[slot][1]
+ expected = {"iri": "@id", "nonliteral": "@id", "bnode": "@id", XSD.anyURI: "xsd:anyURI", XSD.string: None}[kind]
+ assert coercion == expected
+
+
+@pytest.mark.parametrize("slot", SLOTS)
+def test_shacl(slot, shapes):
+ prop = next(shapes.subjects(SH.path, URIRef(EX + slot)))
+ node_kind, datatype = shapes.value(prop, SH.nodeKind), shapes.value(prop, SH.datatype)
+ kind = SLOTS[slot][1]
+ if kind in SH_NODE_KINDS:
+ assert (node_kind, datatype) == (SH_NODE_KINDS[kind], None)
+ else:
+ assert (node_kind, datatype) == (SH.Literal, kind)
+
+
+def _owl_property(slot: str, **kwargs) -> tuple[set, set]:
+ """The OWL property types of *slot* and the fillers of its universal restrictions."""
+ graph = OwlSchemaGenerator(SCHEMA, metaclasses=False, **kwargs).as_graph()
+ prop = URIRef(EX + slot)
+ fillers = {f for r in graph.subjects(OWL.onProperty, prop) for f in graph.objects(r, OWL.allValuesFrom)}
+ return set(graph.objects(prop, RDF.type)), fillers
+
+
+@pytest.mark.parametrize("slot", SLOTS)
+def test_owl(slot):
+ """Node-valued slots are object properties ranging over owl:Thing (OWL 2 §5.3, §5.8.1)."""
+ types, fillers = _owl_property(slot, type_objects=False)
+ kind = SLOTS[slot][1]
+ if kind in SH_NODE_KINDS:
+ assert (types, fillers) == ({OWL.ObjectProperty}, {OWL.Thing})
+ else:
+ assert (types, fillers) == ({OWL.DatatypeProperty}, {kind})
+
+
+@pytest.mark.parametrize("slot", SLOTS)
+def test_owl_type_objects_mode_is_unchanged(slot):
+ """With ``type_objects``, every typed value is an individual of the type's class, whatever its term kind."""
+ types, fillers = _owl_property(slot, type_objects=True)
+ assert types == {OWL.ObjectProperty}
+ assert [str(f).rsplit("/", 1)[-1] for f in fillers] == [SLOTS[slot][0]]
+
+
+@pytest.mark.parametrize("slot", SLOTS)
+def test_shex(slot):
+ shapes = json.loads(ShExGenerator(SCHEMA, format="json").serialize())["shapes"]
+ type_name, kind = SLOTS[slot]
+ shape = next(s for s in shapes if s["id"].rsplit("/", 1)[-1] == type_name)
+ if kind in SH_NODE_KINDS:
+ assert shape.get("nodeKind") == kind
+ else:
+ assert shape.get("datatype") == str(kind)
+
+
+@pytest.mark.parametrize("slot", SLOTS)
+def test_yarrrml(slot):
+ mapping = yaml.safe_load(YarrrmlGenerator(SCHEMA).serialize())["mappings"]["Document"]["po"]
+ obj = next(po["o"] for po in mapping if po["p"] == f"ex:{slot}")
+ kind = SLOTS[slot][1]
+ if kind in SH_NODE_KINDS:
+ assert obj["type"] == {"iri": "iri", "nonliteral": "iri", "bnode": "blank"}[kind]
+ else:
+ assert obj["datatype"] == {XSD.anyURI: "xsd:anyURI", XSD.string: "xsd:string"}[kind]
+
+
+@pytest.mark.parametrize("path", ["dumper", "context"])
+def test_both_data_paths_give_the_same_terms_and_conform(module, shapes, path):
+ """The dumper and the generated context write the same terms, and the generated shapes accept them.
+
+ The relative file path stays text on both paths; it is not resolved against the reader's location.
+ """
+ graph = _dumped(module, DATA) if path == "dumper" else _via_context(DATA)
+ subject = URIRef(EX + "d1")
+ assert {slot: graph.value(subject, URIRef(EX + slot)) for slot in SLOTS} == EXPECTED_TERMS
+ conforms, _, report = validate(graph, shacl_graph=shapes)
+ assert conforms, report
+
+
+@pytest.mark.parametrize(
+ "slot,wrong",
+ [
+ ("homepage", Literal(EX + "home", datatype=XSD.anyURI)),
+ ("image", Literal("ex:me.jpg")),
+ ("file_path", URIRef("https://example.org/cv.pdf")),
+ ("node", BNode("b1")),
+ ("any_node", Literal("ex:target", datatype=URIRef("http://www.w3.org/ns/shex#nonLiteral"))),
+ ("code", URIRef(EX + "code")),
+ ("cross_ref", Literal("ex:ref", datatype=URIRef("http://www.w3.org/2000/01/rdf-schema#Resource"))),
+ ("anon", URIRef(EX + "n1")),
+ ],
+)
+def test_shapes_reject_the_wrong_term_kind(module, shapes, slot, wrong):
+ graph = _dumped(module, DATA)
+ subject = URIRef(EX + "d1")
+ graph.set((subject, URIRef(EX + slot), wrong))
+ conforms, _, report = validate(graph, shacl_graph=shapes)
+ assert not conforms, f"{slot}={wrong!r} accepted"
+
+
+@pytest.mark.parametrize("cast_literals", [True, False])
+def test_dumped_terms_load_back(module, cast_literals):
+ """The loader accepts the dumper's terms, also in strict mode; ``uri`` values stay complete, CURIEs stay CURIEs."""
+ graph = _dumped(module, DATA)
+ [container] = rdflib_loader.from_rdf_graph(
+ graph, schemaview=SchemaView(SCHEMA), target_class=module.Container, cast_literals=cast_literals
+ )
+ loaded = {slot: getattr(container.documents[0], slot) for slot in SLOTS}
+ assert loaded == {slot: DATA[slot] for slot in SLOTS}
+
+
+def test_loader_keeps_blank_node_labels(module):
+ """A blank node in an IRI-or-blank-node slot loads as its ``_:`` label instead of an inlined object."""
+ graph = Graph()
+ container, document = BNode(), URIRef(EX + "d1")
+ graph.add((container, RDF.type, URIRef(EX + "Container")))
+ graph.add((container, URIRef(EX + "documents"), document))
+ graph.add((document, RDF.type, URIRef(EX + "Document")))
+ graph.add((document, URIRef(EX + "cross_ref"), BNode("n1")))
+ [container] = rdflib_loader.from_rdf_graph(graph, schemaview=SchemaView(SCHEMA), target_class=module.Container)
+ assert container.documents[0].cross_ref == "_:n1"
+
+
+@pytest.mark.parametrize("slot", ["homepage", "image"])
+def test_dumper_rejects_relative_links(module, slot):
+ """A relative reference is not an RDF IRI (RDF 1.1 Concepts §3.2); text needs a string-derived type."""
+ with pytest.raises(ValueError, match="relative reference"):
+ _dumped(module, {**DATA, slot: "../img/cover.png"})
+
+
+def test_blank_node_values_through_the_context(shapes):
+ """``_:`` labels in an IRI-or-blank-node slot become blank nodes and conform."""
+ graph = _via_context({**DATA, "any_node": "_:n1", "cross_ref": "_:n2"})
+ subject = URIRef(EX + "d1")
+ assert isinstance(graph.value(subject, URIRef(EX + "any_node")), BNode)
+ assert isinstance(graph.value(subject, URIRef(EX + "cross_ref")), BNode)
+ conforms, _, report = validate(graph, shacl_graph=shapes)
+ assert conforms, report
+
+
+SUBPROPERTY_SCHEMA = """
+id: https://example.org/sub
+name: sub
+prefixes:
+ linkml: https://w3id.org/linkml/
+ ex: https://example.org/sub/
+default_prefix: ex
+imports:
+ - linkml:types
+slots:
+ related_to: {range: curie}
+ part_of: {is_a: related_to}
+classes:
+ Edge:
+ class_uri: ex:Edge
+ attributes:
+ predicate:
+ range: curie
+ subproperty_of: related_to
+"""
+
+
+def test_subproperty_values_take_the_slot_term_kind():
+ """``sh:in`` members are compared exactly (SHACL §4.8.3), so a ``curie`` slot lists CURIE literals."""
+ shapes = Graph().parse(data=ShaclGenerator(SUBPROPERTY_SCHEMA).serialize(), format="turtle")
+ edge = '@prefix ex: . ex:e1 a ex:Edge ; ex:predicate "{}" .'
+ assert validate(Graph().parse(data=edge.format("ex:part_of"), format="turtle"), shacl_graph=shapes)[0]
+ assert not validate(Graph().parse(data=edge.format("ex:unrelated"), format="turtle"), shacl_graph=shapes)[0]
diff --git a/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.merge.context.jsonld b/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.merge.context.jsonld
index 1ce86f3a57..3092b3dd07 100644
--- a/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.merge.context.jsonld
+++ b/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.merge.context.jsonld
@@ -10,7 +10,7 @@
"simple_uri_test": "https://raw.githubusercontent.com/linkml/linkml/main/tests/test_scripts/input/simple_uri_test/",
"@vocab": "https://raw.githubusercontent.com/linkml/linkml/main/tests/test_scripts/input/simple_uri_test/",
"tag": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "linkml:tag"
},
"value": {
diff --git a/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.merge.model_only.context.jsonld b/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.merge.model_only.context.jsonld
index 4cf9c59701..9bd2b80f40 100644
--- a/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.merge.model_only.context.jsonld
+++ b/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.merge.model_only.context.jsonld
@@ -3,7 +3,7 @@
"xsd": "http://www.w3.org/2001/XMLSchema#",
"@vocab": "https://raw.githubusercontent.com/linkml/linkml/main/tests/test_scripts/input/simple_uri_test/",
"tag": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "linkml:tag"
},
"value": {
diff --git a/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.no_merge.context.jsonld b/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.no_merge.context.jsonld
index 1ce86f3a57..3092b3dd07 100644
--- a/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.no_merge.context.jsonld
+++ b/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.no_merge.context.jsonld
@@ -10,7 +10,7 @@
"simple_uri_test": "https://raw.githubusercontent.com/linkml/linkml/main/tests/test_scripts/input/simple_uri_test/",
"@vocab": "https://raw.githubusercontent.com/linkml/linkml/main/tests/test_scripts/input/simple_uri_test/",
"tag": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "linkml:tag"
},
"value": {
diff --git a/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.no_merge.model_only.context.jsonld b/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.no_merge.model_only.context.jsonld
index 4cf9c59701..9bd2b80f40 100644
--- a/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.no_merge.model_only.context.jsonld
+++ b/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.no_merge.model_only.context.jsonld
@@ -3,7 +3,7 @@
"xsd": "http://www.w3.org/2001/XMLSchema#",
"@vocab": "https://raw.githubusercontent.com/linkml/linkml/main/tests/test_scripts/input/simple_uri_test/",
"tag": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "linkml:tag"
},
"value": {
diff --git a/tests/linkml/test_scripts/__snapshots__/genjsonld/simple_uri_test.context.jsonld b/tests/linkml/test_scripts/__snapshots__/genjsonld/simple_uri_test.context.jsonld
index d7274380da..ad8978b81f 100644
--- a/tests/linkml/test_scripts/__snapshots__/genjsonld/simple_uri_test.context.jsonld
+++ b/tests/linkml/test_scripts/__snapshots__/genjsonld/simple_uri_test.context.jsonld
@@ -10,7 +10,7 @@
"simple_uri_test": "https://raw.githubusercontent.com/linkml/linkml/main/tests/test_scripts/input/simple_uri_test/",
"@vocab": "https://raw.githubusercontent.com/linkml/linkml/main/tests/test_scripts/input/simple_uri_test/",
"tag": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "linkml:tag"
},
"value": {
diff --git a/tests/linkml/test_utils/__snapshots__/uriandcurie.json b/tests/linkml/test_utils/__snapshots__/uriandcurie.json
index 2c39326ff5..97198624f6 100644
--- a/tests/linkml/test_utils/__snapshots__/uriandcurie.json
+++ b/tests/linkml/test_utils/__snapshots__/uriandcurie.json
@@ -196,7 +196,7 @@
"@id": "hasNcName"
},
"hasURI": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "hasURI"
},
"id": "@id",
diff --git a/tests/linkml/test_utils/__snapshots__/uriandcurie.jsonld b/tests/linkml/test_utils/__snapshots__/uriandcurie.jsonld
index 47f2eb296b..f87f92da56 100644
--- a/tests/linkml/test_utils/__snapshots__/uriandcurie.jsonld
+++ b/tests/linkml/test_utils/__snapshots__/uriandcurie.jsonld
@@ -15,7 +15,7 @@
"@id": "hasNcName"
},
"hasURI": {
- "@type": "xsd:anyURI",
+ "@type": "@id",
"@id": "hasURI"
},
"id": "@id",
diff --git a/tests/linkml_runtime/test_utils/test_rdf_terms.py b/tests/linkml_runtime/test_utils/test_rdf_terms.py
new file mode 100644
index 0000000000..92b08f9833
--- /dev/null
+++ b/tests/linkml_runtime/test_utils/test_rdf_terms.py
@@ -0,0 +1,202 @@
+"""Tests for the RDF term kind of values of LinkML types (``linkml_runtime.utils.rdf_terms``)."""
+
+import pytest
+from rdflib import XSD, BNode, Literal, URIRef
+
+from linkml_runtime.utils.rdf_terms import TermKind, TypeTerm, link_root, type_term, value_term
+from linkml_runtime.utils.schemaview import SchemaView
+
+ANYURI = str(XSD.anyURI)
+
+SCHEMA = """
+id: https://example.org/terms
+name: terms
+prefixes:
+ linkml: https://w3id.org/linkml/
+ ex: https://example.org/terms/
+default_prefix: ex
+imports:
+ - linkml:types
+types:
+ ImageLink:
+ typeof: uri
+ FilePath:
+ typeof: string
+ uri: xsd:anyURI
+ RootAnyUri:
+ uri: xsd:anyURI
+ base: str
+ ResourceRef:
+ typeof: uriorcurie
+ uri: rdfs:Resource
+ TextLink:
+ typeof: uri
+ uri: xsd:string
+ FullIriLink:
+ typeof: uri
+ uri: http://www.w3.org/2001/XMLSchema#anyURI
+ FullIriText:
+ typeof: string
+ uri: http://www.w3.org/2001/XMLSchema#anyURI
+ BlankOnly:
+ typeof: string
+ uri: shex:bnode
+"""
+
+
+@pytest.fixture(scope="module")
+def sv() -> SchemaView:
+ return SchemaView(SCHEMA)
+
+
+@pytest.mark.parametrize(
+ "type_name,expected",
+ [
+ ("uri", TypeTerm(TermKind.IRI)),
+ ("uriorcurie", TypeTerm(TermKind.IRI)),
+ ("ImageLink", TypeTerm(TermKind.IRI)),
+ ("FullIriLink", TypeTerm(TermKind.IRI)),
+ ("objectidentifier", TypeTerm(TermKind.IRI)),
+ ("nodeidentifier", TypeTerm(TermKind.BLANK_NODE_OR_IRI)),
+ ("ResourceRef", TypeTerm(TermKind.BLANK_NODE_OR_IRI)),
+ ("BlankOnly", TypeTerm(TermKind.BLANK_NODE)),
+ ("FilePath", TypeTerm(TermKind.LITERAL, ANYURI)),
+ ("FullIriText", TypeTerm(TermKind.LITERAL, ANYURI)),
+ ("RootAnyUri", TypeTerm(TermKind.LITERAL, ANYURI)),
+ ("TextLink", TypeTerm(TermKind.LITERAL, str(XSD.string))),
+ ("curie", TypeTerm(TermKind.LITERAL, str(XSD.string))),
+ ("string", TypeTerm(TermKind.LITERAL, str(XSD.string))),
+ ("integer", TypeTerm(TermKind.LITERAL, str(XSD.integer))),
+ ],
+)
+def test_type_term(sv, type_name, expected):
+ """Links and node types are nodes; every other type, including text anyURI types, is a literal.
+
+ An explicit datatype on a type derived from ``uri`` wins (``TextLink``), and the
+ spelling of the datatype IRI (CURIE or full IRI) does not matter.
+ """
+ assert type_term(sv, type_name) == expected
+
+
+def test_vocabulary_prefix_need_not_be_declared(sv):
+ """``ResourceRef`` uses ``rdfs:`` without declaring it; the vocabulary prefix is recognised."""
+ assert "rdfs" not in sv.namespaces()
+ assert type_term(sv, "ResourceRef").kind is TermKind.BLANK_NODE_OR_IRI
+
+
+def test_undeclared_prefix_fails_fast():
+ """A datatype with a prefix that is neither declared nor a vocabulary prefix is an error."""
+ sv = SchemaView(SCHEMA + " Odd:\n typeof: string\n uri: foo:bar\n")
+ with pytest.raises(ValueError, match="does not declare prefix 'foo'"):
+ type_term(sv, "Odd")
+
+
+@pytest.mark.parametrize(
+ "prefix,uri,expected",
+ [
+ ("ex: https://example.org/c/", "ex:a:b", "https://example.org/c/a:b"),
+ ("urn: 'urn:'", "urn:example:dt", "urn:example:dt"),
+ ],
+)
+def test_declared_prefix_expands_a_local_part_with_a_colon(prefix, uri, expected):
+ """A declared prefix expands whatever the local part, so a ``urn:`` datatype works once ``urn`` is declared."""
+ sv = SchemaView(
+ f"id: https://example.org/c\nname: c\nprefixes:\n {prefix}\ntypes:\n Odd:\n base: str\n uri: {uri}\n"
+ )
+ assert type_term(sv, "Odd") == TypeTerm(TermKind.LITERAL, expected)
+
+
+@pytest.mark.parametrize(
+ "type_name,expected",
+ [
+ ("uri", "uri"),
+ ("ImageLink", "uri"),
+ ("TextLink", "uri"),
+ ("FullIriLink", "uri"),
+ ("uriorcurie", "uriorcurie"),
+ ("ResourceRef", "uriorcurie"),
+ ("RootAnyUri", None),
+ ("FilePath", None),
+ ("objectidentifier", None),
+ ("curie", None),
+ ("string", None),
+ ],
+)
+def test_link_root(sv, type_name, expected):
+ """``uri`` values are complete URIs and ``uriorcurie`` values may be CURIEs; the root is found through ``typeof``."""
+ assert link_root(sv, type_name) == expected
+
+
+OWN_TYPES = """
+id: https://example.org/own
+name: own
+prefixes:
+ ex: https://example.org/own/
+ xsd: http://www.w3.org/2001/XMLSchema#
+default_prefix: ex
+types:
+ string:
+ uri: xsd:string
+ base: str
+ uri:
+ typeof: string
+ uri: xsd:anyURI
+ uriorcurie:
+ uri: xsd:anyURI
+ base: URIorCURIE
+"""
+
+
+@pytest.mark.parametrize("type_name,kind", [("uri", TermKind.LITERAL), ("uriorcurie", TermKind.IRI)])
+def test_link_types_are_identified_by_their_definition(type_name, kind):
+ """A user type that is merely called ``uri`` is text; a verbatim copy of ``uriorcurie`` is a link."""
+ assert type_term(SchemaView(OWN_TYPES), type_name).kind is kind
+
+
+def test_merged_schema_keeps_link_types():
+ """Merging imports rewrites ``from_schema``; the link types are still recognised."""
+ sv = SchemaView(SCHEMA)
+ sv.merge_imports()
+ assert type_term(sv, "uri").kind is TermKind.IRI
+ assert type_term(sv, "ImageLink").kind is TermKind.IRI
+
+
+@pytest.mark.parametrize(
+ "type_name,value,expected",
+ [
+ ("uri", "https://example.org/x", URIRef("https://example.org/x")),
+ ("uriorcurie", "ex:x", URIRef("https://example.org/terms/x")),
+ ("objectidentifier", "ex:x", URIRef("https://example.org/terms/x")),
+ ("nodeidentifier", "_:b1", BNode("b1")),
+ ("ResourceRef", "_:b1", BNode("b1")),
+ ("FilePath", "./docs/cv.pdf", Literal("./docs/cv.pdf", datatype=XSD.anyURI)),
+ ("FilePath", "ex:x", Literal("ex:x", datatype=XSD.anyURI)),
+ ("curie", "ex:x", Literal("ex:x")),
+ ("string", "text", Literal("text")),
+ ("boolean", True, Literal(True)),
+ ],
+)
+def test_value_term(sv, type_name, value, expected):
+ """IRIs are expanded, text stays as written, and literals keep their native value."""
+ term = value_term(sv, type_name, value)
+ assert term == expected
+ assert type(term) is type(expected)
+
+
+def test_boolean_literal_has_canonical_lexical_form(sv):
+ assert value_term(sv, "boolean", True).n3() == '"true"^^'
+
+
+@pytest.mark.parametrize(
+ "type_name,value,message",
+ [
+ ("uri", "./docs/cv.pdf", "relative reference"),
+ ("ImageLink", "../img/a.png", "relative reference"),
+ ("uriorcurie", "_:b1", "is a blank node"),
+ ("BlankOnly", "https://example.org/x", "blank node label"),
+ ],
+)
+def test_value_term_rejects_values_of_the_wrong_kind(sv, type_name, value, message):
+ """A relative reference cannot be an RDF IRI (RDF 1.1 Concepts §3.2), nor a blank node an IRI."""
+ with pytest.raises(ValueError, match=message):
+ value_term(sv, type_name, value)