From 88e231b425a11322b8f27b2b69e9479775f6f75c Mon Sep 17 00:00:00 2001 From: jdsika Date: Wed, 21 Oct 2026 17:56:41 +0200 Subject: [PATCH] fix(rdf): give values of URI-like types one RDF term kind in every generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A value of a type is an IRI, a blank node or a literal according to one rule, linkml_runtime.utils.rdf_terms.type_term, which gen-jsonld-context, gen-owl, gen-shacl, gen-shex, gen-yarrrml and the RDF dumper and loader now share: - uri, uriorcurie and the types derived from them are IRIs; - objectidentifier is an IRI, and nodeidentifier and types with uri: rdfs:Resource are IRIs or blank nodes; - every other type is a literal of its datatype, including a type derived from string with uri: xsd:anyURI, which keeps URI references as text. The rule resolves the inherited, expanded datatype, so its spelling does not matter, and recognises uri and uriorcurie by name and definition rather than by from_schema, which merging imports rewrites. A datatype with an undeclared prefix is an error (xsd, shex and rdfs are known). The dumper writes the inherited datatype of a derived type and rejects relative values of IRI types (RDF 1.1 Concepts §3.2); the loader accepts IRIs for node-kind ranges in strict mode, keeps uri values complete, compacts uriorcurie values to CURIEs and keeps blank-node labels. gen-shacl gives sh:in values and sh:defaultValue the slot's term kind. The --xsd-anyuri-as-iri option of gen-owl and gen-jsonld-context is deprecated in 1.13.0, has no effect, and is scheduled for removal in 1.14.0. Signed-off-by: jdsika --- docs/generators/jsonld-context.rst | 6 + docs/generators/owl.rst | 3 + docs/schemas/advanced.md | 56 +++ .../linkml/generators/common/subproperty.py | 33 -- .../src/linkml/generators/jsonldcontextgen.py | 75 ++- .../linkml/src/linkml/generators/owlgen.py | 60 +-- .../src/linkml/generators/prefixmapgen.py | 5 +- .../linkml/src/linkml/generators/shaclgen.py | 56 +-- .../linkml/src/linkml/generators/shexgen.py | 17 +- .../src/linkml/generators/yarrrmlgen.py | 13 +- .../linkml/src/linkml/utils/deprecation.py | 16 + packages/linkml/src/linkml/utils/generator.py | 26 +- .../linkml/src/linkml/utils/schemaloader.py | 5 +- .../linkml_runtime/dumpers/rdflib_dumper.py | 19 +- .../linkml_runtime/loaders/rdflib_loader.py | 17 +- .../src/linkml_runtime/utils/rdf_terms.py | 207 ++++++++ .../__snapshots__/annotations.context.jsonld | 2 +- .../test_base/__snapshots__/annotations.json | 2 +- .../__snapshots__/extensions.context.jsonld | 2 +- .../test_base/__snapshots__/extensions.json | 2 +- .../__snapshots__/mappings.context.jsonld | 16 +- .../test_base/__snapshots__/mappings.json | 16 +- .../__snapshots__/meta.context.jsonld | 78 ++-- .../linkml/test_base/__snapshots__/meta.json | 78 ++-- .../__snapshots__/biolink.context.jsonld | 26 +- .../__snapshots__/biolink.json | 26 +- .../test_generators/test_jsonldcontextgen.py | 442 ++++-------------- tests/linkml/test_generators/test_shaclgen.py | 6 +- .../test_generators/test_uri_term_kinds.py | 323 +++++++++++++ .../simple_uri_test.merge.context.jsonld | 2 +- ...e_uri_test.merge.model_only.context.jsonld | 2 +- .../simple_uri_test.no_merge.context.jsonld | 2 +- ...ri_test.no_merge.model_only.context.jsonld | 2 +- .../genjsonld/simple_uri_test.context.jsonld | 2 +- .../test_utils/__snapshots__/uriandcurie.json | 2 +- .../__snapshots__/uriandcurie.jsonld | 2 +- .../test_utils/test_rdf_terms.py | 202 ++++++++ 37 files changed, 1190 insertions(+), 659 deletions(-) create mode 100644 packages/linkml_runtime/src/linkml_runtime/utils/rdf_terms.py create mode 100644 tests/linkml/test_generators/test_uri_term_kinds.py create mode 100644 tests/linkml_runtime/test_utils/test_rdf_terms.py 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)