From a57e0a1adb5a19d5695e4f7c10bb891d7c9997f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Tue, 16 Jun 2026 11:53:26 +0200 Subject: [PATCH] Adapt GenerateOntologyViews to LDH `ldh:view` vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LinkedDataHub deprecated `ldh:template` (attached to classes) in favor of `ldh:view` / `ldh:inverseView` attached to properties — see LDH commits b54d8c8b6 (#267) and 1ec5da016. - Iterate distinct properties instead of (class, property) tuples - Attach views via `ldh:view` on the property (forward direction) - Skip `owl:FunctionalProperty` (clean property-level replacement for the old class-scoped `owl:maxQualifiedCardinality 1` check) - Drop class/range from view URI naming; sha1-suffix on local-name collisions Inverse views (`ldh:inverseView`) intentionally deferred. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../content/generate_ontology_views.py | 80 ++++++++----------- uv.lock | 2 +- 2 files changed, 34 insertions(+), 48 deletions(-) diff --git a/src/web_algebra/operations/linkeddatahub/content/generate_ontology_views.py b/src/web_algebra/operations/linkeddatahub/content/generate_ontology_views.py index 29a78e4..9e6eecd 100644 --- a/src/web_algebra/operations/linkeddatahub/content/generate_ontology_views.py +++ b/src/web_algebra/operations/linkeddatahub/content/generate_ontology_views.py @@ -1,23 +1,24 @@ +import hashlib from rdflib import URIRef, Literal, Namespace, Graph from rdflib.namespace import RDF, RDFS, XSD, DCTERMS from web_algebra.operation import Operation class GenerateOntologyViews(Operation): - """Generates LinkedDataHub view templates for non-functional properties. + """Generates LinkedDataHub views for ontology properties. Takes an extracted ontology graph and generates an RDF graph containing: - ldh:View resources for each non-functional property - SPIN sp:Select queries for retrieving related resources - - ldh:template links from classes to views + - ldh:view links from properties to views - A property is considered non-functional if it does not have a - owl:maxQualifiedCardinality restriction of 1. + Functional properties (declared `owl:FunctionalProperty`) are skipped: + they yield at most one value, so a table view would be redundant. """ @classmethod def description(cls) -> str: - return "Generates LinkedDataHub view templates and SPIN queries for non-functional properties" + return "Generates LinkedDataHub views and SPIN queries for ontology properties (excluding owl:FunctionalProperty)" @classmethod def inputSchema(cls) -> dict: @@ -41,15 +42,15 @@ def inputSchema(cls) -> dict: } def execute(self, ontology: Graph, base_uri: URIRef, service_uri: URIRef) -> Graph: - """Generate LDH view templates for non-functional properties + """Generate LDH views for ontology properties Args: - ontology: RDF graph containing classes and properties with optional restrictions + ontology: RDF graph containing property declarations base_uri: Base URI for generating view and query resource URIs service_uri: URI of the sd:Service resource to be referenced by queries Returns: - RDF graph containing ldh:View, sp:Select, and ldh:template triples + RDF graph containing ldh:View, sp:Select, and ldh:view triples """ # Define namespaces LDH = Namespace("https://w3id.org/atomgraph/linkeddatahub#") @@ -57,29 +58,20 @@ def execute(self, ontology: Graph, base_uri: URIRef, service_uri: URIRef) -> Gra SPIN = Namespace("http://spinrdf.org/spin#") AC = Namespace("https://w3id.org/atomgraph/client#") - # Query to find all non-functional properties with their classes + # Find all distinct datatype/object properties that are not owl:FunctionalProperty. + # Views attach to properties (LDH `ldh:view` has rdfs:domain rdf:Property), so we + # iterate by property rather than by (class, property) pair. query = """ PREFIX rdf: - PREFIX rdfs: PREFIX owl: - SELECT DISTINCT ?class ?property ?propertyType ?range + SELECT DISTINCT ?property ?propertyType WHERE { - # Get all properties with their domain - ?property a ?propertyType ; - rdfs:domain ?class ; - rdfs:range ?range . + ?property a ?propertyType . FILTER(?propertyType IN (owl:DatatypeProperty, owl:ObjectProperty)) - - # Exclude functional properties (those with maxQualifiedCardinality = 1) - FILTER NOT EXISTS { - ?class rdfs:subClassOf ?restriction . - ?restriction a owl:Restriction ; - owl:onProperty ?property ; - owl:maxQualifiedCardinality 1 . - } + FILTER NOT EXISTS { ?property a owl:FunctionalProperty } } - ORDER BY ?class ?property + ORDER BY ?property """ results = ontology.query(query) @@ -94,48 +86,42 @@ def execute(self, ontology: Graph, base_uri: URIRef, service_uri: URIRef) -> Gra g.bind("rdfs", RDFS) g.bind("rdf", RDF) - # Generate views and queries for each non-functional property + seen_locals: set[str] = set() + for row in results: row_dict = row.asdict() - class_uri = row_dict["class"] property_uri = row_dict["property"] property_type = row_dict["propertyType"] - range_uri = row_dict["range"] - # Validate that all values are URIRefs - if not isinstance(class_uri, URIRef): - raise TypeError(f"Expected class to be URIRef, got {type(class_uri)}") if not isinstance(property_uri, URIRef): raise TypeError(f"Expected property to be URIRef, got {type(property_uri)}") if not isinstance(property_type, URIRef): raise TypeError(f"Expected propertyType to be URIRef, got {type(property_type)}") - if not isinstance(range_uri, URIRef): - raise TypeError(f"Expected range to be URIRef, got {type(range_uri)}") - # Extract local names for URIs - class_local = self._get_local_name(class_uri) + # Disambiguate when two properties share a local name (different namespaces). property_local = self._get_local_name(property_uri) + if property_local in seen_locals: + suffix = hashlib.sha1(str(property_uri).encode()).hexdigest()[:6] + property_local = f"{property_local}_{suffix}" + seen_locals.add(property_local) - # Generate URIs for view and query - view_uri = URIRef(f"{base_uri}#{class_local}_{property_local}_View") - query_uri = URIRef(f"{base_uri}#{class_local}_{property_local}_Query") + view_uri = URIRef(f"{base_uri}#{property_local}_View") + query_uri = URIRef(f"{base_uri}#{property_local}_Query") - # Generate human-readable title title = f"{property_local}" + sparql_text = self._generate_sparql_query(property_uri) - # Generate SPARQL query text - sparql_text = self._generate_sparql_query(property_uri, property_type, range_uri) - - # Create ldh:template link from class to view - g.add((class_uri, LDH.template, view_uri)) + # Attach view to property via ldh:view (forward direction). + # TODO: emit ldh:inverseView for selected object properties in a follow-up. + g.add((property_uri, LDH.view, view_uri)) - # Create ldh:View resource + # ldh:View resource g.add((view_uri, RDF.type, LDH.View)) g.add((view_uri, DCTERMS.title, Literal(title))) g.add((view_uri, SPIN.query, query_uri)) g.add((view_uri, AC.mode, AC.TableMode)) - # Create sp:Select query resource + # sp:Select query resource g.add((query_uri, RDF.type, SP.Select)) g.add((query_uri, DCTERMS.title, Literal(f"Select {property_local}"))) g.add((query_uri, RDFS.label, Literal(f"Select {property_local}"))) @@ -153,8 +139,8 @@ def _get_local_name(self, uri: URIRef) -> str: return uri_str.split('/')[-1] return uri_str - def _generate_sparql_query(self, property_uri: URIRef, property_type: URIRef, range_uri: URIRef) -> str: - """Generate SPARQL SELECT query for a property""" + def _generate_sparql_query(self, property_uri: URIRef) -> str: + """Generate SPARQL SELECT query for a property (forward direction)""" sparql = f"""SELECT DISTINCT ?related ?label WHERE {{ GRAPH ?relatedGraph {{ diff --git a/uv.lock b/uv.lock index 49bc26e..cf763f8 100644 --- a/uv.lock +++ b/uv.lock @@ -888,7 +888,7 @@ wheels = [ [[package]] name = "web-algebra" -version = "1.2.0" +version = "1.3.0" source = { editable = "." } dependencies = [ { name = "mcp", extra = ["cli"] },