diff --git a/docs/generators/jsonld-context.rst b/docs/generators/jsonld-context.rst index da5e4df794..0a8be4bbed 100644 --- a/docs/generators/jsonld-context.rst +++ b/docs/generators/jsonld-context.rst @@ -125,6 +125,45 @@ to gen-prefix-map: gen-prefix-map --flatprefixes personinfo.yaml > personinfo.prefixmap.json +URIs as IRIs or as literals +--------------------------- + +By default a ``uri`` or ``uriorcurie`` slot is coerced to ``xsd:anyURI``, a typed +literal. ``--xsd-anyuri-as-iri`` coerces it to ``@id`` instead, so the value becomes an +IRI node, matching the ``sh:nodeKind sh:IRI`` the SHACL generator emits. The OWL +generator accepts the same flag. + +The flag applies to ``uri``, ``uriorcurie`` and their derived types when the +effective datatype remains ``xsd:anyURI``. Datatype IRIs are expanded before +comparison, so full IRIs and alternative prefixes behave identically. A type +derived from ``string`` that declares ``uri: xsd:anyURI`` stays a typed literal with or +without it. Use one for a URI reference that is data rather than a link, such as a file +path that may be relative: as ``@id`` it would be resolved against the document base. + +.. code-block:: yaml + + types: + FilePath: + typeof: string + uri: xsd:anyURI # always "@type": "xsd:anyURI", sh:datatype xsd:anyURI + slots: + homepage: + range: uri # "@id" with --xsd-anyuri-as-iri + file_path: + range: FilePath + +The RDF dumper, SHACL and ShEx generators distinguish the same URI family from +literal-valued types. An explicit datatype override such as ``uri: xsd:string`` +remains literal-valued, including when the type inherits from ``uri``. + +`XSD anyURI `__ admits relative URI +references as literal values. This differs from an RDF IRI node: `JSON-LD type +coercion `__ with ``@id`` interprets +strings as identifiers and resolves relative references against the base IRI. +The existing option selects the JSON-LD/OWL representation of LinkML's URI +family; it is not needed to preserve a deliberately literal-valued type. No +additional option is introduced. + Docs ---- diff --git a/packages/linkml/src/linkml/generators/common/subproperty.py b/packages/linkml/src/linkml/generators/common/subproperty.py index 9b136e2429..d78c8e3f09 100644 --- a/packages/linkml/src/linkml/generators/common/subproperty.py +++ b/packages/linkml/src/linkml/generators/common/subproperty.py @@ -7,6 +7,8 @@ - Collecting and deduplicating slot hierarchy values """ +from rdflib.namespace import XSD + from linkml_runtime.linkml_model.meta import SlotDefinition from linkml_runtime.utils.formatutils import underscore from linkml_runtime.utils.schemaview import SchemaView @@ -15,9 +17,27 @@ CURIE_TYPES: frozenset[str] = frozenset({"uriorcurie", "curie"}) URI_TYPES: frozenset[str] = frozenset({"uri"}) -# Types whose XSD mapping is xsd:anyURI (not xsd:string). -# ``curie`` maps to xsd:string and is deliberately excluded. -_ANYURI_TYPES: frozenset[str] = frozenset({"uri", "uriorcurie"}) + +def is_xsd_anyuri_range(sv: SchemaView, range_type: str | None) -> bool: + """Whether a URI-family type's effective datatype is ``xsd:anyURI``. + + LinkML's standard ``uri`` and ``uriorcurie`` types and their descendants + participate in the existing literal-to-IRI option. String-derived types do + not. Resolve the datatype before comparing so prefix aliases and explicit + datatype overrides have the same meaning in every generator. + + The roots are defined at https://w3id.org/linkml/types. The runtime dumper + implements the same small rule independently because the two packages may + be released separately; real serialization tests check their agreement. + """ + if range_type not in sv.all_types(): + return False + typ = sv.induced_type(range_type) + return ( + bool(typ.uri) + and sv.expand_curie(typ.uri) == str(XSD.anyURI) + and any(ancestor in ("uri", "uriorcurie") for ancestor in sv.type_ancestors(range_type)) + ) def is_uri_range(sv: SchemaView, range_type: str | None) -> bool: @@ -67,35 +87,6 @@ def is_curie_range(sv: SchemaView, range_type: str | None) -> bool: return False -def is_xsd_anyuri_range(sv: SchemaView, range_type: str | None) -> bool: - """Check if range type resolves to ``xsd:anyURI``. - - Returns True for ``uri``, ``uriorcurie``, and types that inherit from them. - Returns False for ``curie`` (which maps to ``xsd:string``). - - This is the correct predicate for the ``--xsd-anyuri-as-iri`` flag: only - types whose XSD representation is ``xsd:anyURI`` should be promoted from - literal to IRI semantics. ``curie`` is a compact string representation - that resolves to ``xsd:string`` and must not be affected. - - :param sv: SchemaView for type ancestry lookup - :param range_type: The range type to check - :return: True if range type maps to xsd:anyURI - """ - if range_type is None: - return False - - if range_type in _ANYURI_TYPES: - return True - - if range_type in sv.all_types(): - type_ancestors = set(sv.type_ancestors(range_type)) - if type_ancestors & _ANYURI_TYPES: - return True - - return False - - def format_slot_value_for_range(sv: SchemaView, slot_name: str, range_type: str | None) -> str: """ Format slot value according to the declared range type. diff --git a/packages/linkml/src/linkml/generators/jsonldcontextgen.py b/packages/linkml/src/linkml/generators/jsonldcontextgen.py index 5462b29f8b..1e9e34f851 100644 --- a/packages/linkml/src/linkml/generators/jsonldcontextgen.py +++ b/packages/linkml/src/linkml/generators/jsonldcontextgen.py @@ -5,6 +5,7 @@ import json import os import re +from copy import deepcopy from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -14,19 +15,16 @@ from rdflib import SKOS, XSD, Namespace from linkml._version import __version__ +from linkml.generators.common.subproperty import is_xsd_anyuri_range from linkml.utils.deprecation import deprecated_fields from linkml.utils.generator import Generator, shared_arguments -from linkml_runtime.linkml_model.meta import ClassDefinition, EnumDefinition, SlotDefinition +from linkml_runtime.linkml_model.meta import ClassDefinition, EnumDefinition, Prefix, SlotDefinition from linkml_runtime.linkml_model.types import SHEX from linkml_runtime.utils.formatutils import camelcase, underscore from linkml_runtime.utils.schemaview import SchemaView URI_RANGES = (SHEX.nonliteral, SHEX.bnode, SHEX.iri) -# Extended URI_RANGES that also treats xsd:anyURI as an IRI reference (@id) -# rather than a typed literal. Opt-in via --xsd-anyuri-as-iri flag. -URI_RANGES_WITH_XSD = (*URI_RANGES, XSD.anyURI) - ENUM_CONTEXT = { "text": "skos:notation", "description": "skos:prefLabel", @@ -85,8 +83,11 @@ class ContextGenerator(Generator): """Map xsd:anyURI-typed ranges (uri, uriorcurie) to ``@type: @id`` instead of ``@type: xsd:anyURI``. This aligns the JSON-LD context with the SHACL generator, which emits - ``sh:nodeKind sh:IRI`` for the same types. + ``sh:nodeKind sh:IRI`` for the same types. Types derived from ``uri`` or + ``uriorcurie`` follow them; a type derived from ``string`` that declares + ``uri: xsd:anyURI`` stays a typed literal, as in the OWL generator. """ + _type_view_cache: SchemaView | None = field(default=None, repr=False) # Framing (opt-in via CLI flag) emit_frame: bool = False @@ -299,7 +300,6 @@ def _literal_coercion_for_ranges(self, ranges: list[str]) -> tuple[bool, str | N and "could not resolve safely because the branches disagree". """ coercions: set[str | None] = set() - uri_ranges = URI_RANGES_WITH_XSD if self.xsd_anyuri_as_iri else URI_RANGES for range_name in ranges: if range_name not in self.schema.types: continue @@ -308,7 +308,7 @@ def _literal_coercion_for_ranges(self, ranges: list[str]) -> tuple[bool, str | N range_uri = self.namespaces.uri_for(range_type.uri) if range_uri == XSD.string: coercions.add(None) - elif range_uri in uri_ranges: + elif self._coerces_to_id(range_name): coercions.add("@id") else: coercions.add(range_type.uri) @@ -319,6 +319,35 @@ def _literal_coercion_for_ranges(self, ranges: list[str]) -> tuple[bool, str | N return True, next(iter(coercions)) return False, None + def _coerces_to_id(self, range_name: str) -> bool: + """Whether values of the LinkML type *range_name* are IRIs (``@type: @id``) rather than literals. + + The ShEx node types always are. ``xsd:anyURI`` is, with ``--xsd-anyuri-as-iri``, only + for ``uri``, ``uriorcurie`` and types derived from them, the same rule the OWL + generator applies (:func:`is_xsd_anyuri_range`). A type derived from ``string`` that + declares ``uri: xsd:anyURI`` is a typed literal: a URI reference kept as a string, which + must not be resolved against the document base. + """ + range_uri = self.namespaces.uri_for(self.schema.types[range_name].uri) + if range_uri in URI_RANGES: + return True + return self.xsd_anyuri_as_iri and range_uri == XSD.anyURI and is_xsd_anyuri_range(self._type_view(), range_name) + + def _type_view(self) -> SchemaView: + """A SchemaView of the schema, for type ancestry; built once when the generator has none.""" + if self.schemaview is not None: + return self.schemaview + if self._type_view_cache is None: + # SchemaLoader already resolved the imports; source_file is only + # provenance and may no longer name an available file. + type_schema = deepcopy(self.schema) + type_schema.imports = [] + for prefix, namespace in self.namespaces.items(): + if not prefix.startswith("@"): + type_schema.prefixes[prefix] = Prefix(prefix, str(namespace)) + self._type_view_cache = SchemaView(type_schema) + return self._type_view_cache + def _vocab_eligible_enum(self, enum: EnumDefinition) -> tuple[bool, str | None]: """Check if an enum qualifies for ``@type: @vocab`` context generation. @@ -414,10 +443,9 @@ def visit_slot(self, aliased_slot_name: str, slot: SlotDefinition) -> None: self.emit_prefixes.add(skos) else: range_type = self.schema.types[slot.range] - uri_ranges = URI_RANGES_WITH_XSD if self.xsd_anyuri_as_iri else URI_RANGES if self.namespaces.uri_for(range_type.uri) == XSD.string: pass - elif self.namespaces.uri_for(range_type.uri) in uri_ranges: + elif self._coerces_to_id(slot.range): slot_def["@type"] = "@id" else: slot_def["@type"] = range_type.uri diff --git a/packages/linkml/src/linkml/generators/shaclgen.py b/packages/linkml/src/linkml/generators/shaclgen.py index 4731b9f0b8..b839fdaa9f 100644 --- a/packages/linkml/src/linkml/generators/shaclgen.py +++ b/packages/linkml/src/linkml/generators/shaclgen.py @@ -11,12 +11,13 @@ from rdflib.namespace import RDF, RDFS, SH, XSD from linkml._version import __version__ -from linkml.generators.common.subproperty import get_subproperty_values, is_uri_range +from linkml.generators.common.subproperty import get_subproperty_values, is_uri_range, is_xsd_anyuri_range from linkml.generators.shacl.shacl_data_type import ShaclDataType from linkml.generators.shacl.shacl_ifabsent_processor import ShaclIfAbsentProcessor from linkml.utils.generator import Generator, shared_arguments from linkml.utils.language_tags import LanguageTagResolver from linkml_runtime.linkml_model.meta import ClassDefinition, ElementName, PresenceEnum +from linkml_runtime.linkml_model.types import SHEX from linkml_runtime.utils.formatutils import underscore from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph from linkml_runtime.utils.yamlutils import TypedNode, extended_float, extended_int, extended_str @@ -685,37 +686,23 @@ def _add_enum(self, g: Graph, func: Callable, r: ElementName) -> None: ) func(SH["in"], pv_node) - # Type URIs denoting non-literal (IRI or blank-node) values. - # SHACL §4.8.1 - # 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", - } - ) - def _add_type(self, func: Callable, r: ElementName) -> None: sv = self.schemaview # Types can inherit URI and pattern constraints. rt = sv.induced_type(r) type_uri = rt.uri expanded = sv.get_uri(rt, expand=True) if type_uri else None - if type_uri and (type_uri in self._NON_LITERAL_TYPE_URIS or expanded in self._NON_LITERAL_TYPE_URIS): - if type_uri in self._IRI_ONLY_TYPE_URIS: - func(SH.nodeKind, SH.IRI) - else: - func(SH.nodeKind, SH.BlankNodeOrIRI) + # Resolve vocabulary IRIs before selecting RDF node kinds. The ShEx + # vocabulary distinguishes IRI-only and non-literal (IRI or blank) nodes. + node_kind = None + if expanded == str(SHEX.iri): + node_kind = SH.IRI + elif expanded == str(SHEX.nonLiteral): + node_kind = SH.BlankNodeOrIRI + elif expanded == str(XSD.anyURI) and is_xsd_anyuri_range(sv, r): + node_kind = SH.IRI + if node_kind is not None: + func(SH.nodeKind, node_kind) elif type_uri: func(SH.nodeKind, SH.Literal) func(SH.datatype, URIRef(sv.get_uri(rt, expand=True))) diff --git a/packages/linkml/src/linkml/generators/shexgen.py b/packages/linkml/src/linkml/generators/shexgen.py index 40a93ffbc9..1a99decb3c 100644 --- a/packages/linkml/src/linkml/generators/shexgen.py +++ b/packages/linkml/src/linkml/generators/shexgen.py @@ -2,23 +2,25 @@ import os import urllib.parse as urlparse +from copy import deepcopy from dataclasses import dataclass, field import click from jsonasobj import as_json as as_json_1 -from rdflib import OWL, RDF, XSD, Graph, Namespace +from rdflib import OWL, RDF, Graph, Namespace from ShExJSG import ShExC from ShExJSG.SchemaWithContext import Schema from ShExJSG.ShExJ import IRIREF, EachOf, NodeConstraint, Shape, ShapeOr, TripleConstraint from linkml import METAMODEL_NAMESPACE, METAMODEL_NAMESPACE_NAME from linkml._version import __version__ -from linkml.generators.common.subproperty import get_subproperty_values +from linkml.generators.common.subproperty import get_subproperty_values, is_xsd_anyuri_range from linkml.utils.generator import Generator, shared_arguments from linkml_runtime.linkml_model.meta import ( ClassDefinition, ElementName, EnumDefinition, + Prefix, SlotDefinition, SlotDefinitionName, TypeDefinition, @@ -27,6 +29,7 @@ from linkml_runtime.utils.formatutils import camelcase, sfx from linkml_runtime.utils.metamodelcore import URIorCURIE from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph +from linkml_runtime.utils.schemaview import SchemaView @dataclass @@ -67,12 +70,20 @@ def visit_schema(self, **_): # Adjust the schema context to include the base model URI context = self.shex["@context"] self.shex["@context"] = [context, {"@base": self.namespaces._base}] + # SchemaLoader has already resolved imports. Inspect a copy of that + # effective schema without reopening source files or changing provenance. + type_schema = deepcopy(self.schema) + type_schema.imports = [] + for prefix, namespace in self.namespaces.items(): + if not prefix.startswith("@"): + type_schema.prefixes[prefix] = Prefix(prefix, str(namespace)) + type_view = SchemaView(type_schema) # Emit all of the type definitions for typ in self.schema.types.values(): model_uri = self._class_or_type_uri(typ) if typ.uri: typ_type_uri = self.namespaces.uri_for(typ.uri) - if typ_type_uri in (XSD.anyURI, SHEX.iri): + if typ_type_uri == SHEX.iri or is_xsd_anyuri_range(type_view, typ.name): self.shapes.append(NodeConstraint(id=model_uri, nodeKind="iri")) elif typ_type_uri == SHEX.nonLiteral: self.shapes.append(NodeConstraint(id=model_uri, nodeKind="nonliteral")) 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 177186051a..8c3da30dca 100644 --- a/packages/linkml_runtime/src/linkml_runtime/dumpers/rdflib_dumper.py +++ b/packages/linkml_runtime/src/linkml_runtime/dumpers/rdflib_dumper.py @@ -5,7 +5,7 @@ from curies import Converter from pydantic import BaseModel from rdflib import XSD, Graph, URIRef -from rdflib.namespace import RDF +from rdflib.namespace import RDF, RDFS from rdflib.term import BNode, Literal, Node from linkml_runtime.dumpers.dumper_root import Dumper @@ -101,17 +101,19 @@ def inject_triples( else: return Literal(element.text) if target_type in schemaview.all_types(): - t = schemaview.get_type(target_type) + t = schemaview.induced_type(target_type) dt_uri = t.uri if dt_uri: - if dt_uri in ("rdfs:Resource", "xsd:anyURI"): + if "xsd" not in namespaces: + namespaces["xsd"] = XSD + datatype = namespaces.uri_for(dt_uri) + uri_ancestry = any(name in ("uri", "uriorcurie") for name in schemaview.type_ancestors(target_type)) + if datatype == RDFS.Resource or (datatype == XSD.anyURI and uri_ancestry): return URIRef(schemaview.expand_curie(element)) - elif dt_uri == "xsd:string": + elif datatype == XSD.string: return Literal(element) else: - if "xsd" not in namespaces: - namespaces["xsd"] = XSD - return Literal(element, datatype=namespaces.uri_for(dt_uri)) + return Literal(element, datatype=datatype) else: logger.warning(f"No datatype specified for : {t.name}, using plain Literal") return Literal(element) diff --git a/tests/linkml/test_generators/test_anyuri_literal_types.py b/tests/linkml/test_generators/test_anyuri_literal_types.py new file mode 100644 index 0000000000..06a83b898f --- /dev/null +++ b/tests/linkml/test_generators/test_anyuri_literal_types.py @@ -0,0 +1,253 @@ +"""``xsd:anyURI``: an IRI for ``uri`` and ``uriorcurie``, a typed literal for types derived from ``string``. + +A schema can need both behaviours. A slot that points to a resource is an IRI node; a slot +that holds a URI reference as data - a relative file path such as ``./data/file.png`` - must +stay a literal, because JSON-LD resolves an ``@id`` against the document base and turns the +path into a machine-specific absolute IRI. The second is a type derived from ``string`` that +declares ``uri: xsd:anyURI``. + +The OWL generator already decides by type ancestry (:func:`is_xsd_anyuri_range`). These tests +pin the JSON-LD context generator, the SHACL generator and the RDF dumper to the same rule. +""" + +import json + +import pytest +import yaml +from pyshacl import validate +from pyshex import ShExEvaluator +from rdflib import OWL, RDF, SH, XSD, BNode, Graph, Literal, URIRef +from rdflib.compare import isomorphic + +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_runtime.dumpers import rdflib_dumper +from linkml_runtime.utils.compile_python import compile_python +from linkml_runtime.utils.schemaview import SchemaView + +SCHEMA = """ +id: https://example.org/anyuri +name: anyuri +prefixes: + ex: https://example.org/ + linkml: https://w3id.org/linkml/ +imports: + - linkml:types +default_prefix: ex +default_range: string + +types: + FilePath: + typeof: string + uri: xsd:anyURI + description: A URI reference kept as a literal; it may be relative. + Homepage: + typeof: uri + description: A uri by derivation, so an IRI. + +slots: + id: + identifier: true + range: uriorcurie + link: + range: uri + slot_uri: ex:link + homepage: + range: Homepage + slot_uri: ex:homepage + path: + range: FilePath + slot_uri: ex:path + +classes: + Resource: + slots: [id, link, homepage, path] +""" + +EX = "https://example.org/" + + +@pytest.mark.parametrize("xsd_anyuri_as_iri", [False, True]) +def test_context_keeps_string_derived_anyuri_a_literal(xsd_anyuri_as_iri: bool) -> None: + """The existing option promotes only the URI family, not literal-valued types.""" + ctx = json.loads(ContextGenerator(SCHEMA, xsd_anyuri_as_iri=xsd_anyuri_as_iri).serialize())["@context"] + assert ctx["path"]["@type"] == "xsd:anyURI" + expected_iri = "@id" if xsd_anyuri_as_iri else "xsd:anyURI" + assert ctx["link"]["@type"] == expected_iri + assert ctx["homepage"]["@type"] == expected_iri + + +ANY_OF_SCHEMA = """ +id: https://example.org/anyuri-any-of +name: anyuri_any_of +prefixes: + ex: https://example.org/ + linkml: https://w3id.org/linkml/ +imports: + - linkml:types +default_prefix: ex +default_range: string +types: + FilePath: + typeof: string + uri: xsd:anyURI +slots: + either_path: + slot_uri: ex:eitherPath + any_of: + - range: Target + - range: FilePath + either_uri: + slot_uri: ex:eitherUri + any_of: + - range: Target + - range: uri +classes: + Target: + class_uri: ex:Target + Holder: + slots: [either_path, either_uri] +""" + + +def test_context_any_of_with_string_derived_anyuri_is_not_promoted() -> None: + """A mixed union coerces scalar values to literals; class values can use explicit node objects.""" + ctx = json.loads(ContextGenerator(ANY_OF_SCHEMA, xsd_anyuri_as_iri=True).serialize())["@context"] + assert ctx["either_path"]["@type"] == "xsd:anyURI" + # a uri branch agrees with the class branch, as before + assert ctx["either_uri"]["@type"] == "@id" + + +def _property_shape(g: Graph, path: str) -> URIRef | BNode: + """Find the generated property shape for an example property.""" + return next(ps for ps in g.subjects(SH.path, URIRef(EX + path))) + + +def test_shacl_string_derived_anyuri_is_a_typed_literal() -> None: + """SHACL preserves literal types while retaining URI-family node kinds.""" + g = Graph().parse(data=ShaclGenerator(SCHEMA, mergeimports=True).serialize(), format="turtle") + + path = _property_shape(g, "path") + assert g.value(path, SH.nodeKind) == SH.Literal + assert g.value(path, SH.datatype) == XSD.anyURI + + for iri_slot in ("link", "homepage"): + ps = _property_shape(g, iri_slot) + assert g.value(ps, SH.nodeKind) == SH.IRI, iri_slot + assert g.value(ps, SH.datatype) is None, iri_slot + + +@pytest.mark.parametrize("xsd_anyuri_as_iri", [False, True]) +def test_owl_string_derived_anyuri_is_a_datatype_property(xsd_anyuri_as_iri: bool) -> None: + """OWL and JSON-LD use the same option boundary.""" + g = OwlSchemaGenerator(SCHEMA, xsd_anyuri_as_iri=xsd_anyuri_as_iri, type_objects=False).as_graph() + assert (URIRef(EX + "path"), RDF.type, OWL.DatatypeProperty) in g + expected = OWL.ObjectProperty if xsd_anyuri_as_iri else OWL.DatatypeProperty + assert (URIRef(EX + "link"), RDF.type, expected) in g + + +def test_dumper_string_derived_anyuri_is_a_typed_literal() -> None: + """Generated Python instances preserve relative URI literals in RDF.""" + module = compile_python(str(PythonGenerator(SCHEMA, mergeimports=True).serialize())) + resource = module.Resource( + id="ex:r1", + link="https://example.org/target", + homepage="https://example.org/home", + path="./data/file.png", + ) + g = rdflib_dumper.as_rdf_graph(resource, schemaview=SchemaView(SCHEMA)) + subject = URIRef(EX + "r1") + + assert g.value(subject, URIRef(EX + "path")) == Literal("./data/file.png", datatype=XSD.anyURI) + assert g.value(subject, URIRef(EX + "link")) == URIRef("https://example.org/target") + assert g.value(subject, URIRef(EX + "homepage")) == URIRef("https://example.org/home") + + +@pytest.mark.parametrize("datatype", ["xsd:anyURI", "xs:anyURI", str(XSD.anyURI)]) +@pytest.mark.parametrize("derived", [False, True]) +@pytest.mark.parametrize("path", ["./data/file.png", "#section", "https://example.net/file"]) +def test_rdf_representation_contract(datatype: str, derived: bool, path: str) -> None: + """Generated contexts and Python RDF agree, and both shape languages validate them.""" + schema = yaml.safe_load(SCHEMA) + schema["prefixes"]["xs"] = str(XSD) + schema["types"]["FilePath"]["uri"] = datatype + schema["types"]["Homepage"]["uri"] = datatype + if derived: + for name in ("FilePath", "Homepage"): + schema["types"][name + "Child"] = {"typeof": name} + schema["slots"]["path"]["range"] = "FilePathChild" + schema["slots"]["homepage"]["range"] = "HomepageChild" + source = yaml.safe_dump(schema) + context = json.loads(ContextGenerator(source, xsd_anyuri_as_iri=True).serialize())["@context"] + document = { + "@context": context, + "@type": "Resource", + "id": "ex:r1", + "link": "https://example.org/target", + "homepage": "https://example.org/home", + "path": path, + } + graph = Graph().parse(data=json.dumps(document), format="json-ld", publicID="https://unrelated.example/base/") + subject = URIRef(EX + "r1") + predicate = URIRef(EX + "path") + assert graph.value(subject, predicate) == Literal(path, datatype=XSD.anyURI) + module = compile_python(str(PythonGenerator(source).serialize())) + instance = module.Resource(**{key: value for key, value in document.items() if not key.startswith("@")}) + dumped = rdflib_dumper.as_rdf_graph(instance, schemaview=SchemaView(source)) + assert isomorphic(graph, dumped) + + shacl = ShaclGenerator(source).as_graph() + shex = ShExGenerator(source).serialize() + assert validate(graph, shacl_graph=shacl, meta_shacl=True)[0] + results = ShExEvaluator(rdf=graph, schema=shex, focus=subject, start=EX + "Resource").evaluate() + assert results and all(result.result for result in results), [result.reason for result in results] + owl = OwlSchemaGenerator(source, xsd_anyuri_as_iri=True, type_objects=False).as_graph() + assert (predicate, RDF.type, OWL.DatatypeProperty) in owl + assert (URIRef(EX + "homepage"), RDF.type, OWL.ObjectProperty) in owl + + for wrong in (URIRef("https://example.org/file"), Literal(path)): + graph.set((subject, predicate, wrong)) + assert not validate(graph, shacl_graph=shacl)[0] + results = ShExEvaluator(rdf=graph, schema=shex, focus=subject, start=EX + "Resource").evaluate() + assert results and not any(result.result for result in results) + + +def test_context_uses_supplied_schema_without_reopening_source() -> None: + """A source_file annotation is provenance, not permission to replace the given schema.""" + schema = SchemaView(SCHEMA).schema + schema.source_file = "/not/a/real/schema.yaml" + context = json.loads(ContextGenerator(schema, xsd_anyuri_as_iri=True).serialize())["@context"] + assert context["path"]["@type"] == "xsd:anyURI" + assert context["homepage"]["@type"] == "@id" + + +def test_uri_ancestry_with_string_datatype_is_not_promoted() -> None: + """An explicit datatype override must agree across all generators.""" + schema = yaml.safe_load(SCHEMA) + schema["types"]["Homepage"]["uri"] = "xsd:string" + source = yaml.safe_dump(schema) + context = json.loads(ContextGenerator(source, xsd_anyuri_as_iri=True).serialize())["@context"] + assert context["homepage"].get("@type") != "@id" + owl = OwlSchemaGenerator(source, xsd_anyuri_as_iri=True, type_objects=False).as_graph() + assert (URIRef(EX + "homepage"), RDF.type, OWL.DatatypeProperty) in owl + shacl = ShaclGenerator(source).as_graph() + assert shacl.value(_property_shape(shacl, "homepage"), SH.datatype) == XSD.string + dumper = rdflib_dumper.inject_triples("https://example.org/value", SchemaView(source), Graph(), "Homepage") + assert dumper == Literal("https://example.org/value") + + +@pytest.mark.parametrize("as_node", [False, True]) +def test_jsonld_union_preserves_literal_and_explicit_node_values(as_node: bool) -> None: + """Both alternatives retain their RDF terms through JSON-LD expansion and SHACL.""" + context = json.loads(ContextGenerator(ANY_OF_SCHEMA, xsd_anyuri_as_iri=True).serialize())["@context"] + value = {"@id": EX + "target", "@type": "Target"} if as_node else "./data/file.png" + document = {"@context": context, "@id": EX + "holder", "@type": "Holder", "either_path": value} + graph = Graph().parse(data=json.dumps(document), format="json-ld", publicID="https://unrelated.example/base/") + expected = URIRef(EX + "target") if as_node else Literal(value, datatype=XSD.anyURI) + assert graph.value(URIRef(EX + "holder"), URIRef(EX + "eitherPath")) == expected + assert validate(graph, shacl_graph=ShaclGenerator(ANY_OF_SCHEMA).as_graph(), meta_shacl=True)[0] + graph.set((URIRef(EX + "holder"), URIRef(EX + "eitherPath"), Literal("wrong datatype"))) + assert not validate(graph, shacl_graph=ShaclGenerator(ANY_OF_SCHEMA).as_graph())[0] diff --git a/tests/linkml/test_generators/test_jsonldcontextgen.py b/tests/linkml/test_generators/test_jsonldcontextgen.py index 72089eba44..44ab1e9afc 100644 --- a/tests/linkml/test_generators/test_jsonldcontextgen.py +++ b/tests/linkml/test_generators/test_jsonldcontextgen.py @@ -1291,8 +1291,8 @@ def test_xsd_anyuri_as_iri_owl_curie_unchanged(): ``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()``. + already excludes ``curie``, and both generators decide by + ``is_xsd_anyuri_range()``. """ from rdflib import OWL, RDF, URIRef diff --git a/tests/linkml_runtime/test_loaders_dumpers/test_rdflib_dumper.py b/tests/linkml_runtime/test_loaders_dumpers/test_rdflib_dumper.py index 2df96c656a..c5ec8ebabc 100644 --- a/tests/linkml_runtime/test_loaders_dumpers/test_rdflib_dumper.py +++ b/tests/linkml_runtime/test_loaders_dumpers/test_rdflib_dumper.py @@ -9,7 +9,7 @@ from linkml_runtime import DataNotFoundError, MappingError from linkml_runtime.dumpers import rdflib_dumper, yaml_dumper -from linkml_runtime.linkml_model import Prefix +from linkml_runtime.linkml_model import Prefix, SchemaDefinition from linkml_runtime.loaders import rdflib_loader, yaml_loader from linkml_runtime.utils.schemaview import SchemaView from tests.linkml_runtime.test_loaders_dumpers import INPUT_DIR, OUTPUT_DIR @@ -33,6 +33,31 @@ logger = logging.getLogger(__name__) +@pytest.mark.parametrize("datatype", ["xsd:anyURI", "xs:anyURI", str(XSD.anyURI)]) +@pytest.mark.parametrize("parent, is_iri", [("uri", True), ("uriorcurie", True), ("string", False), ("curie", False)]) +def test_derived_uri_representation(datatype: str, parent: str, is_iri: bool) -> None: + """Type ancestry and expanded datatype jointly determine the serialized RDF term.""" + schema = SchemaView( + SchemaDefinition( + **{ + "id": "https://example.org/schema", + "name": "uri_representation", + "imports": ["linkml:types"], + "prefixes": {"linkml": "https://w3id.org/linkml/", "ex": "https://example.org/", "xs": str(XSD)}, + "types": {"Parent": {"typeof": parent, "uri": datatype}, "Child": {"typeof": "Parent"}}, + } + ) + ) + value = "ex:resource" + term = rdflib_dumper.inject_triples(value, schema, Graph(), "Child") + expected = URIRef("https://example.org/resource") if is_iri else Literal(value, datatype=XSD.anyURI) + assert term == expected + graph = Graph() + graph.add((URIRef("https://example.org/s"), URIRef("https://example.org/p"), term)) + reparsed = Graph().parse(data=graph.serialize(format="turtle"), format="turtle") + assert reparsed.value(URIRef("https://example.org/s"), URIRef("https://example.org/p")) == expected + + INPUT_PATH = Path(INPUT_DIR) OUTPUT_PATH = Path(OUTPUT_DIR)