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)