From d9d2745a35ed29bfc2d9c6ad7b6ea1ea3af8dc7f Mon Sep 17 00:00:00 2001 From: jdsika Date: Fri, 2 Oct 2026 16:47:40 +0200 Subject: [PATCH] fix(rdf): preserve string-derived anyURI literals across generators A string-derived type with uri: xsd:anyURI holds URI-reference data, including relative paths. Preserve its typed literal in JSON-LD contexts, SHACL, ShEx and runtime RDF serialization, matching OWL datatype mode. Resolve type ancestry and the effective expanded datatype together so inherited types, prefix aliases and explicit datatype overrides agree. Legacy generators inspect a copy of their resolved schema instead of reopening source files. Preserve the existing JSON-LD/OWL IRI option and keep the runtime independent of a new compiler/runtime shared API. Exercise actual JSON-LD expansion, Python-to-RDF serialization, SHACL and ShEx validation, including negative RDF-term substitutions. Test mixed class/literal JSON-LD and SHACL ranges; general ShEx any_of remains a documented pre-existing limitation outside this correction. Signed-off-by: jdsika --- docs/generators/jsonld-context.rst | 39 +++ .../linkml/generators/common/subproperty.py | 55 ++-- .../src/linkml/generators/jsonldcontextgen.py | 48 +++- .../linkml/src/linkml/generators/shaclgen.py | 39 +-- .../linkml/src/linkml/generators/shexgen.py | 17 +- .../linkml_runtime/dumpers/rdflib_dumper.py | 16 +- .../test_anyuri_literal_types.py | 253 ++++++++++++++++++ .../test_generators/test_jsonldcontextgen.py | 4 +- .../test_rdflib_dumper.py | 27 +- 9 files changed, 417 insertions(+), 81 deletions(-) create mode 100644 tests/linkml/test_generators/test_anyuri_literal_types.py 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)