Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions packages/linkml/src/linkml/generators/common/subproperty.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
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:
"""
Expand Down Expand Up @@ -63,6 +67,35 @@ 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.
Expand Down
22 changes: 20 additions & 2 deletions packages/linkml/src/linkml/generators/jsonldcontextgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@

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",
Expand Down Expand Up @@ -58,6 +62,12 @@ class ContextGenerator(Generator):
"""If True, elements from imported schemas won't be included in the generated context"""
_local_classes: set | None = field(default=None, repr=False)
_local_slots: set | None = field(default=None, repr=False)
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.
"""

# Framing (opt-in via CLI flag)
emit_frame: bool = False
Expand Down Expand Up @@ -224,6 +234,7 @@ 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
Expand All @@ -232,7 +243,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 range_uri in uri_ranges:
coercions.add("@id")
else:
coercions.add(range_type.uri)
Expand Down Expand Up @@ -275,9 +286,10 @@ 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.namespaces.uri_for(range_type.uri) in uri_ranges:
slot_def["@type"] = "@id"
else:
slot_def["@type"] = range_type.uri
Expand Down Expand Up @@ -390,6 +402,12 @@ def serialize(
help="Use --exclude-imports to exclude imported elements from the generated JSON-LD context. This is useful when "
"extending an ontology whose terms already have context definitions in their own JSON-LD context file.",
)
@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.",
)
@click.version_option(__version__, "-V", "--version")
def cli(yamlfile, emit_frame, embed_context_in_frame, output, **args):
"""Generate jsonld @context definition from LinkML model"""
Expand Down
56 changes: 47 additions & 9 deletions packages/linkml/src/linkml/generators/owlgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +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.generator import Generator, shared_arguments
from linkml_runtime import SchemaView
Expand Down Expand Up @@ -203,6 +204,24 @@ class OwlSchemaGenerator(Generator):
constraint that every instance of the abstract class must also be an instance of one of its
direct subclasses."""

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)
"""

def as_graph(self) -> Graph:
"""
Generate an rdflib Graph from the LinkML schema.
Expand Down Expand Up @@ -746,14 +765,19 @@ def transform_class_slot_expression(
this_owl_types = set()
if range:
if range in sv.all_types(imports=True):
self.slot_is_literal_map[main_slot.name].add(True)
this_owl_types.add(RDFS.Literal)
typ = sv.get_type(range)
if self.type_objects:
# TODO
owl_exprs.append(self._type_uri(typ.name))
if self.xsd_anyuri_as_iri and is_xsd_anyuri_range(sv, range):
# xsd:anyURI ranges become ObjectProperty with no rdfs:range
self.slot_is_literal_map[main_slot.name].add(False)
this_owl_types.add(OWL.Thing)
else:
owl_exprs.append(self._type_uri(typ.name))
self.slot_is_literal_map[main_slot.name].add(True)
this_owl_types.add(RDFS.Literal)
typ = sv.get_type(range)
if self.type_objects:
# TODO
owl_exprs.append(self._type_uri(typ.name))
else:
owl_exprs.append(self._type_uri(typ.name))
elif range in sv.all_enums(imports=True):
# TODO: enums fill this in
owl_exprs.append(self._enum_uri(EnumDefinitionName(range)))
Expand Down Expand Up @@ -1330,8 +1354,9 @@ def _boolean_expression(
def _range_is_datatype(self, slot: SlotDefinition) -> bool:
if self.type_objects:
return False
else:
return slot.range in self.schema.types
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 _range_uri(self, slot: SlotDefinition) -> URIRef:
if slot.range in self.schema.types:
Expand Down Expand Up @@ -1450,6 +1475,8 @@ def slot_owl_type(self, slot: SlotDefinition) -> URIRef:
elif range in sv.all_enums():
return OWL.ObjectProperty
elif range in sv.all_types():
if self.xsd_anyuri_as_iri and is_xsd_anyuri_range(sv, range):
return OWL.ObjectProperty
return OWL.DatatypeProperty
else:
raise Exception(f"Unknown range: {slot.range}")
Expand Down Expand Up @@ -1572,6 +1599,17 @@ def slot_owl_type(self, slot: SlotDefinition) -> URIRef:
"By default such axioms are emitted for every abstract class that has direct is_a children."
),
)
@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)."
),
)
@click.version_option(__version__, "-V", "--version")
def cli(yamlfile, metadata_profile: str, **kwargs):
"""Generate an OWL representation of a LinkML model
Expand Down
Loading
Loading