From 7327548938deeebf9cee7564dcf8a3daf80cd1cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Mon, 27 Jul 2026 19:15:15 +0300 Subject: [PATCH] Serve raw ontology graphs without RDFS inference, token-safe @typeof matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the application ontology's owl:imports closure natively via ontapi (OntModelFactory.createModel over a ScopedGraphRepository view) instead of manually flattening the closure and materializing an RDFS-inferred model. No inference is applied anymore — every consumer (constructor/constraint inheritance, client-side (rdfs:subClassOf)* queries) traverses hierarchies explicitly. rdfs:Class terms are promoted to owl:Class in a separate union member so no document graph is polluted; closure union graphs are cached in a bounded map on Application, keyed by ontology URI. The shared repository now only ever holds raw per-document graphs, which ProxyRequestFilter serves directly for closure documents — asserted triples only, identical to a direct document GET. The DESCRIBE fallback over the in-memory closure (terms minted in external namespaces) is inference-free as well. This fixes inferred rdf:type rdfs:Resource leaking into proxied namespace documents, where the extra type produced multi-token @typeof and silently degraded View blocks to a generic property list. Client-side, all exact-equality @typeof comparisons switch to token-aware tokenize(@typeof, ' '), per RDFa's whitespace-separated list semantics. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 11 ++ http-tests/proxy/GET-proxied-ontology-ns.sh | 25 +++- .../atomgraph/linkeddatahub/Application.java | 20 ++- .../linkeddatahub/resource/Namespace.java | 6 +- .../resource/admin/ClearOntology.java | 6 +- .../server/filter/request/OntologyFilter.java | 85 ++++++------- .../filter/request/ProxyRequestFilter.java | 28 ++++- .../server/io/ValidatingModelProvider.java | 3 +- .../server/util/ScopedGraphRepository.java | 119 ++++++++++++++++++ .../xsl/bootstrap/2.3.2/client/block.xsl | 6 +- .../bootstrap/2.3.2/client/block/chart.xsl | 2 +- .../bootstrap/2.3.2/client/block/object.xsl | 2 +- .../bootstrap/2.3.2/client/block/query.xsl | 6 +- .../xsl/bootstrap/2.3.2/client/block/view.xsl | 38 +++--- .../OntologyImportsCharacterizationTest.java | 66 +++++++--- .../util/SPINConstraintValidationTest.java | 7 +- 16 files changed, 318 insertions(+), 112 deletions(-) create mode 100644 src/main/java/com/atomgraph/linkeddatahub/server/util/ScopedGraphRepository.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a125b3c5..ab8920a2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## [5.6.2] - 2026-07-27 +### Changed +- No RDFS inference over application ontologies anymore: the `owl:imports` closure is resolved natively by ontapi (`OntModelFactory.createModel` over a `ScopedGraphRepository` view) as a live union graph instead of being manually flattened, RDFS-inferred and materialized. All consumers already traverse hierarchies explicitly (constructor/constraint inheritance, `(rdfs:subClassOf)*` client queries); `rdfs:Class` terms are promoted to `owl:Class` in a separate union member so no document graph is polluted. Closure union graphs are cached in a bounded map on `Application`, keyed by ontology URI +- The Linked Data proxy serves ontology-closure documents with their raw graphs — asserted triples only, identical to a direct document GET; the `DESCRIBE` fallback over the in-memory closure (for terms minted in external namespaces) is now inference-free as well +- `Namespace` no-query GET serves the raw ontology graph from the shared repository instead of building a fresh `createRepository` per request + +### Fixed +- Proxied ontology documents carried inferred `rdf:type rdfs:Resource` on every term (from the RDFS-materialized in-memory model), producing multi-token `@typeof` that broke View block rendering client-side +- Block/View/query/chart templates matched `@typeof` by exact string equality, breaking on any multi-typed resource; all comparisons switched to token-aware `tokenize(@typeof, ' ')` (RDFa defines `@typeof` as a whitespace-separated list) +- Entity-inlining and SEF compilation moved to `pre-integration-test` so `maven-war-plugin:war` cannot overwrite entity-resolved overlay stylesheets before the Dockerfile copies `target/ROOT` + ## [5.6.1] - 2026-07-26 ### Security - SSRF: `URLValidator` blocks wildcard/any-local (`0.0.0.0`, `::`) addresses and checks every resolved address; loopback stays reachable, `ALLOW_INTERNAL_URLS` remains the escape hatch (LNK-003/LNK-009) diff --git a/http-tests/proxy/GET-proxied-ontology-ns.sh b/http-tests/proxy/GET-proxied-ontology-ns.sh index c97f48505..152360441 100755 --- a/http-tests/proxy/GET-proxied-ontology-ns.sh +++ b/http-tests/proxy/GET-proxied-ontology-ns.sh @@ -49,9 +49,9 @@ clear-ontology.sh \ --ontology "$namespace" # request the namespace document URI (without fragment) via ?uri= proxy. -# the namespace document is not DataManager-mapped and not a registered app, -# so ProxyRequestFilter falls through to the OntModel DESCRIBE path, which -# returns descriptions of all #-fragment terms in that namespace. +# the namespace document is not DataManager-mapped, not a registered app and not a graph +# in the ontology closure, so ProxyRequestFilter falls through to the closure DESCRIBE +# fallback, which returns descriptions of all #-fragment terms in that namespace. response=$(curl -k -f -s \ -G \ @@ -60,7 +60,24 @@ response=$(curl -k -f -s \ --data-urlencode "uri=${namespace_uri}" \ "$END_USER_BASE_URL") -# verify both class descriptions are present in the response +# verify both class descriptions are present in the response and no inferred triples leak echo "$response" | grep -q "$class1" echo "$response" | grep -q "$class2" +! echo "$response" | grep -q "http://www.w3.org/2000/01/rdf-schema#Resource" + +# request the ontology document itself via ?uri= proxy: it is a graph in the ontology closure, +# so it must be served with its raw graph — asserted triples only, identical to a direct +# document GET. Inferred rdf:type rdfs:Resource used to leak from the RDFS-materialized +# in-memory model here, breaking client-side @typeof matching of View blocks. + +doc_response=$(curl -k -f -s \ + -G \ + -E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \ + -H "Accept: application/n-triples" \ + --data-urlencode "uri=${ontology_doc}" \ + "$END_USER_BASE_URL") + +echo "$doc_response" | grep -q "$class1" +echo "$doc_response" | grep -q "$class2" +! echo "$doc_response" | grep -q "http://www.w3.org/2000/01/rdf-schema#Resource" diff --git a/src/main/java/com/atomgraph/linkeddatahub/Application.java b/src/main/java/com/atomgraph/linkeddatahub/Application.java index 9147b37fc..b83be580b 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/Application.java +++ b/src/main/java/com/atomgraph/linkeddatahub/Application.java @@ -161,6 +161,7 @@ import javax.net.ssl.TrustManagerFactory; import jakarta.servlet.ServletContext; import javax.xml.transform.Source; +import org.apache.jena.ontapi.UnionGraph; import org.apache.jena.ontapi.model.OntModel; import org.apache.jena.query.Dataset; import org.apache.jena.query.Query; @@ -199,6 +200,7 @@ import javax.xml.parsers.ParserConfigurationException; import javax.xml.transform.TransformerException; import javax.xml.transform.stream.StreamSource; +import net.jodah.expiringmap.ExpirationPolicy; import net.jodah.expiringmap.ExpiringMap; import net.sf.saxon.om.TreeInfo; import net.sf.saxon.s9api.Processor; @@ -288,6 +290,7 @@ public class Application extends ResourceConfig private final KeyStore keyStore, trustStore; private final URI secretaryWebIDURI; private final List supportedLanguages; + private final ExpiringMap ontologyGraphs = ExpiringMap.builder().maxSize(1000).expirationPolicy(ExpirationPolicy.ACCESSED).expiration(1, TimeUnit.HOURS).build(); // assembled ontology imports-closure union graphs, keyed by ontology URI; evicted entries are transparently rebuilt by OntologyFilter on the next cache miss private final ExpiringMap webIDmodelCache = ExpiringMap.builder().expiration(Long.parseLong(System.getProperty("com.atomgraph.linkeddatahub.webIDCacheExpiration", "86400")), TimeUnit.SECONDS).build(); // TTL (seconds) configurable via WEBID_CACHE_EXPIRATION; a lower value bounds how long a revoked WebID stays cached private final ExpiringMap oidcModelCache = ExpiringMap.builder().variableExpiration().build(); private final ExpiringMap jwksCache = ExpiringMap.builder().expiration(Long.parseLong(System.getProperty("com.atomgraph.linkeddatahub.jwksCacheExpiration", "86400")), TimeUnit.SECONDS).build(); // Cache JWKS responses; TTL (seconds) configurable via JWKS_CACHE_EXPIRATION @@ -1804,10 +1807,23 @@ public OntologyRepository createRepository(EndUserApplication app) return appRepository; } - + + /** + * Returns the cache of assembled ontology imports-closure union graphs, keyed by ontology URI + * (origin-scoped per dataspace, so a single map cannot collide across applications). + * The union graph is ontapi's view over the raw per-document graphs cached in the (per-app or + * system) repository; it is not a document graph itself and is never served on the wire. + * + * @return ontology URI to union graph map + */ + public Map getOntologyGraphs() + { + return ontologyGraphs; + } + /** * Returns a registry of readable and writeable media types. - * + * * @return registry object */ public MediaTypes getMediaTypes() diff --git a/src/main/java/com/atomgraph/linkeddatahub/resource/Namespace.java b/src/main/java/com/atomgraph/linkeddatahub/resource/Namespace.java index 5ce68929e..50cd157ca 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/resource/Namespace.java +++ b/src/main/java/com/atomgraph/linkeddatahub/resource/Namespace.java @@ -140,9 +140,9 @@ public Response get(@QueryParam(QUERY) Query query, // the application ontology MUST use a URI! This is the URI this ontology endpoint is deployed on by the Dispatcher class String ontologyURI = getApplication().getOntology().getURI(); if (log.isDebugEnabled()) log.debug("Returning raw namespace ontology: {}", ontologyURI); - // not returning the injected in-memory ontology because it has inferences applied to it; - // a fresh, mapping-seeded repository serves the raw SPARQL-loaded ontology - OntologyRepository repository = getSystem().createRepository(getApplication().as(EndUserApplication.class)); + // not returning the injected in-memory ontology because it is the full imports closure (a union view); + // the shared repository serves the standalone raw ontology graph + OntologyRepository repository = getSystem().getRepository(getApplication().as(EndUserApplication.class)); return getResponseBuilder(org.apache.jena.rdf.model.ModelFactory.createModelForGraph(repository.get(ontologyURI))).build(); } else throw new BadRequestException("SPARQL query string not provided"); diff --git a/src/main/java/com/atomgraph/linkeddatahub/resource/admin/ClearOntology.java b/src/main/java/com/atomgraph/linkeddatahub/resource/admin/ClearOntology.java index 9ce40f1a6..190fbd346 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/resource/admin/ClearOntology.java +++ b/src/main/java/com/atomgraph/linkeddatahub/resource/admin/ClearOntology.java @@ -78,12 +78,14 @@ public Response post(@FormParam("uri") String ontologyURI, @HeaderParam("Referer EndUserApplication endUserApp = getApplication().as(AdminApplication.class).getEndUserApplication(); // we're assuming the current app is admin OntologyRepository repository = getSystem().getRepository(endUserApp); - if (repository.isCached(ontologyURI)) + if (repository.isCached(ontologyURI) || getSystem().getOntologyGraphs().containsKey(ontologyURI)) { if (log.isDebugEnabled()) log.debug("Clearing ontology with URI '{}' from memory", ontologyURI); repository.remove(ontologyURI); + getSystem().getOntologyGraphs().remove(ontologyURI); URI ontologyDocURI = UriBuilder.fromUri(ontologyURI).fragment(null).build(); // skip fragment from the ontology URI to get its graph URI + repository.remove(ontologyDocURI.toString()); // the raw graph is also aliased under the fragment-stripped document URI // frontend proxy still uses URL-pattern BAN for direct document GETs (until Stage 3 brings xkey tagging to varnish-frontend). // xkey purge covers proxied SPARQL CONSTRUCT/SELECT responses tagged by their backend (varnish-admin / varnish-end-user). URI frontendProxy = getSystem().getFrontendProxy(); @@ -110,7 +112,7 @@ public Response post(@FormParam("uri") String ontologyURI, @HeaderParam("Referer } // !!! we need to reload the ontology model before returning a response, to make sure the next request already gets the new version !!! - OntologyFilter.loadOntology(repository, ontologyURI); + getSystem().getOntologyGraphs().put(ontologyURI, OntologyFilter.loadOntology(repository, ontologyURI)); } if (referer != null) return Response.seeOther(referer).build(); diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/OntologyFilter.java b/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/OntologyFilter.java index e9bd7af71..653012665 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/OntologyFilter.java +++ b/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/OntologyFilter.java @@ -19,14 +19,13 @@ import com.atomgraph.linkeddatahub.apps.model.Application; import com.atomgraph.linkeddatahub.apps.model.EndUserApplication; import com.atomgraph.client.util.jena.PrefixGraphRepository; +import com.atomgraph.linkeddatahub.server.util.ScopedGraphRepository; import com.atomgraph.linkeddatahub.vocabulary.LAPP; import com.atomgraph.server.exception.OntologyException; import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; -import java.util.HashSet; import java.util.Optional; -import java.util.Set; import jakarta.annotation.Priority; import jakarta.inject.Inject; import jakarta.ws.rs.container.ContainerRequestContext; @@ -34,12 +33,13 @@ import jakarta.ws.rs.container.PreMatching; import org.apache.jena.ontapi.OntModelFactory; import org.apache.jena.ontapi.OntSpecification; +import org.apache.jena.ontapi.UnionGraph; import org.apache.jena.ontapi.model.OntModel; import org.apache.jena.rdf.model.Model; import org.apache.jena.rdf.model.ModelFactory; -import org.apache.jena.vocabulary.OWL; import org.apache.jena.vocabulary.RDF; import org.apache.jena.vocabulary.RDFS; +import org.apache.jena.vocabulary.OWL; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -131,8 +131,9 @@ public OntModel getOntology(Application app) } /** - * Loads the ontology model for the specified ontology URI, building its owl:imports closure with - * RDFS inference and materializing the inferences into the repository cache. + * Returns the ontology model for the specified ontology URI, assembling its owl:imports closure + * on a cache miss. The returned model is a fresh per-request wrapper over the shared closure + * union graph. * * @param app application resource * @param uri ontology URI @@ -146,64 +147,54 @@ public OntModel getOntology(Application app, String uri) final PrefixGraphRepository repository = app.canAs(EndUserApplication.class) ? getSystem().getRepository(app.as(EndUserApplication.class)) : getSystem().getRepository(); - // only build the materialized model if the ontology is not already cached; the double check under the - // repository lock ensures a single thread materializes it (loadOntology is a compound load + inference + - // put, not atomic), so concurrent cold requests don't duplicate the work or race each other's writes - if (!repository.isCached(uri)) + // only assemble the closure if it is not already cached; the double check under the repository + // lock ensures a single thread assembles it (loadOntology is a compound load + union build, not + // atomic), so concurrent cold requests don't duplicate the work or race each other's writes + UnionGraph union = getSystem().getOntologyGraphs().get(uri); + if (union == null) { synchronized (repository) { - if (!repository.isCached(uri)) loadOntology(repository, uri); + union = getSystem().getOntologyGraphs().get(uri); + if (union == null) + { + union = loadOntology(repository, uri); + getSystem().getOntologyGraphs().put(uri, union); + } } } - return OntModelFactory.createModel(repository.get(uri), OntSpecification.OWL2_FULL_MEM); + return OntModelFactory.createModel(union, OntSpecification.OWL2_FULL_MEM); } /** - * Builds and caches the materialized ontology model. Assembles the owl:imports closure into a single - * graph (so ontapi never manages a union-graph hierarchy over the shared repository), applies RDFS - * inference over the flattened closure, and materializes the inferences into the repository cache so - * the rules engine is not invoked on every request. + * Assembles the ontology's owl:imports closure as a union graph. ontapi resolves the closure through + * a scoped repository view: raw per-document graphs are read through (and cached in) the shared + * repository, while ontapi's union-graph bookkeeping stays local to the view — the shared repository + * keeps serving raw document graphs, and duplicate ontology IDs across applications cannot collide. + * No inference is applied: all consumers traverse class/property hierarchies explicitly. * * @param repository graph repository * @param uri ontology URI + * @return closure union graph */ - public static void loadOntology(PrefixGraphRepository repository, String uri) + public static UnionGraph loadOntology(PrefixGraphRepository repository, String uri) { if (log.isDebugEnabled()) log.debug("Started loading ontology with URI '{}'", uri); - Model union = ModelFactory.createDefaultModel(); - Set closure = new HashSet<>(); - loadClosure(repository, uri, union, closure); - OntModel inferred = OntModelFactory.createModel(union.getGraph(), OntSpecification.OWL2_FULL_MEM_RDFS_INF); - OntModel materialized = OntModelFactory.createModel(OntSpecification.OWL2_FULL_MEM); - materialized.add(inferred); - // promote rdfs:Class to owl:Class so OWL2 profiles recognise third-party vocab terms (e.g. sp:Describe in sp.ttl) - inferred.listSubjectsWithProperty(RDF.type, RDFS.Class).forEach(r -> materialized.add(r, RDF.type, OWL.Class)); - repository.put(uri, materialized.getGraph()); - // cache imported graphs under their fragment-stripped document URIs too - closure.stream().filter(closureURI -> !closureURI.equals(uri)).forEach(importURI -> addDocumentModel(repository, importURI)); + ScopedGraphRepository scoped = new ScopedGraphRepository(repository); + OntModel ontology = OntModelFactory.createModel(repository.get(uri), OntSpecification.OWL2_FULL_MEM, scoped); + UnionGraph union = (UnionGraph)ontology.getGraph(); + // promote rdfs:Class to owl:Class so the OWL2 profile recognises third-party vocab terms (e.g. sp:Describe + // in sp.ttl) as named classes. The promotions live in their own union member so no document graph is + // polluted; carrying no owl:Ontology header, the member is ignored by ontapi's union-graph listener + Model promotions = ModelFactory.createDefaultModel(); + ontology.listSubjectsWithProperty(RDF.type, RDFS.Class).forEach(r -> promotions.add(r, RDF.type, OWL.Class)); + if (!promotions.isEmpty()) union.addSubGraph(promotions.getGraph()); + // cache closure graphs under their fragment-stripped document URIs too + scoped.ids().filter(closureURI -> closureURI.startsWith("http://") || closureURI.startsWith("https://")). + forEach(closureURI -> addDocumentModel(repository, closureURI)); if (log.isDebugEnabled()) log.debug("Finished loading ontology with URI '{}'", uri); - } - - /** - * Recursively loads the transitive owl:imports closure of an ontology into a single union model, - * fetching each graph via the repository (SPARQL-first / bundled mappings). - * - * @param repository graph repository - * @param uri ontology URI - * @param union accumulator model - * @param seen accumulator of visited URIs (prevents cycles) - */ - public static void loadClosure(PrefixGraphRepository repository, String uri, Model union, Set seen) - { - if (!seen.add(uri)) return; - Model model = ModelFactory.createModelForGraph(repository.get(uri)); - union.add(model); - model.listObjectsOfProperty(OWL.imports).toList().forEach(imp -> - { - if (imp.isURIResource()) loadClosure(repository, imp.asResource().getURI(), union, seen); - }); + return union; } /** diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/ProxyRequestFilter.java b/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/ProxyRequestFilter.java index 9166b28ea..4a87ab3a0 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/ProxyRequestFilter.java +++ b/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/ProxyRequestFilter.java @@ -21,7 +21,10 @@ import com.atomgraph.client.vocabulary.AC; import com.atomgraph.core.exception.BadGatewayException; import com.atomgraph.core.util.ModelUtils; +import com.atomgraph.client.util.jena.PrefixGraphRepository; +import com.atomgraph.linkeddatahub.apps.model.Application; import com.atomgraph.linkeddatahub.apps.model.Dataset; +import com.atomgraph.linkeddatahub.apps.model.EndUserApplication; import org.apache.jena.ontapi.model.OntModel; import com.atomgraph.linkeddatahub.client.GraphStoreClient; import com.atomgraph.linkeddatahub.client.filter.auth.IDTokenDelegationFilter; @@ -186,12 +189,29 @@ public void filter(ContainerRequestContext requestContext) throws IOException return; } - // serve terms from the app's in-memory namespace ontology (full imports closure) via DESCRIBE. - // covers both slash-based term URIs (e.g. schema:category) and hash-based namespaces - // (e.g. sioc:UserAccount → ac:document-uri strips to sioc:ns, so we also describe all - // ?term where STR(?term) starts with "#") if (isSafeMethod && getOntology().isPresent()) { + // serve documents of the app's ontology imports closure with their raw graphs — asserted triples + // only, identical to a direct document GET. Hash-based term URIs (e.g. ldh:View) arrive here + // fragment-stripped as their document URI. The isCached guard restricts serving to graphs already + // loaded as part of the closure, so arbitrary external URIs cannot trigger repository loading + // outside the SSRF-validated external client path below. + Optional appOpt = (Optional)requestContext.getProperty(LAPP.Application.getURI()); + PrefixGraphRepository repository = appOpt != null && appOpt.isPresent() && appOpt.get().canAs(EndUserApplication.class) ? + getSystem().getRepository(appOpt.get().as(EndUserApplication.class)) : getSystem().getRepository(); + if (repository.isCached(targetURI.toString())) + { + if (log.isDebugEnabled()) log.debug("Serving URI from the ontology closure cache: {}", targetURI); + Model model = org.apache.jena.rdf.model.ModelFactory.createModelForGraph(repository.get(targetURI.toString())); + requestContext.abortWith(getResponse(model, Response.Status.OK)); + return; + } + + // fall back to DESCRIBE over the in-memory closure union (asserted triples only — no inference) + // for term URIs whose document is not a closure graph: terms minted in external namespaces but + // defined in the app ontology. Covers both slash-based term URIs (e.g. schema:category) and + // hash-based namespaces (e.g. sioc:UserAccount → ac:document-uri strips to sioc:ns, so we also + // describe all ?term where STR(?term) starts with "#") ParameterizedSparqlString pss = new ParameterizedSparqlString( "DESCRIBE ?doc ?term WHERE { ?term ?p ?o FILTER(STRSTARTS(STR(?term), CONCAT(STR(?doc), \"#\"))) }"); pss.setIri("doc", targetURI.toString()); diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/io/ValidatingModelProvider.java b/src/main/java/com/atomgraph/linkeddatahub/server/io/ValidatingModelProvider.java index 018bca6c2..757c86bc1 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/server/io/ValidatingModelProvider.java +++ b/src/main/java/com/atomgraph/linkeddatahub/server/io/ValidatingModelProvider.java @@ -239,8 +239,9 @@ public Resource processRead(Resource resource) // this logic really belongs in a if (getApplication().isPresent() && getApplication().get().canAs(AdminApplication.class) && resource.hasProperty(RDF.type, OWL.Ontology)) { - // clear cached OntModel if ontology is updated. TO-DO: send event instead + // clear cached raw graph and closure union graph if ontology is updated. TO-DO: send event instead getSystem().getRepository().remove(resource.getURI()); + getSystem().getOntologyGraphs().remove(resource.getURI()); } if (getApplication().isPresent() && resource.hasProperty(RDF.type, ACL.Authorization)) diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/util/ScopedGraphRepository.java b/src/main/java/com/atomgraph/linkeddatahub/server/util/ScopedGraphRepository.java new file mode 100644 index 000000000..c41190223 --- /dev/null +++ b/src/main/java/com/atomgraph/linkeddatahub/server/util/ScopedGraphRepository.java @@ -0,0 +1,119 @@ +/** + * Copyright 2026 Martynas Jusevičius + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.atomgraph.linkeddatahub.server.util; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.apache.jena.graph.Graph; +import org.apache.jena.ontapi.GraphRepository; + +/** + * A graph repository view that reads through a shared backing repository but keeps writes local. + *

+ * Handed to {@code OntModelFactory.createModel(Graph, OntSpecification, GraphRepository)} so that + * ontapi's union-graph bookkeeping — a {@code UnionGraph} wrapper per ontology in the imports + * closure, with listeners — lands in this instance's private store instead of the shared repository. + * The shared repository must keep answering {@code get(uri)} with the raw per-document graph (that is + * what proxied and direct document GETs serve), and duplicate ontology IDs across applications must + * not collide in one store. Reads fall through to the backing repository, triggering its + * SPARQL-first/mapped/HTTP loading and raw caching as usual, so resolving an imports closure through + * this view populates the shared raw cache as a side effect. + *

+ * After model construction, {@link #ids()} equals the set of resolved closure ontology IDs. + * + * @author Martynas Jusevičius {@literal } + */ +public class ScopedGraphRepository implements GraphRepository +{ + + private final GraphRepository backing; + private final Map local = new HashMap<>(); + + /** + * Constructs the view over a shared backing repository. + * + * @param backing shared graph repository + */ + public ScopedGraphRepository(GraphRepository backing) + { + this.backing = backing; + } + + @Override + public Graph get(String id) + { + Graph graph = local.get(id); + if (graph != null) return graph; + + return getBacking().get(id); + } + + @Override + public Stream ids() + { + return List.copyOf(local.keySet()).stream(); + } + + @Override + public Graph put(String id, Graph graph) + { + return local.put(id, graph); + } + + @Override + public Graph remove(String id) + { + return local.remove(id); + } + + @Override + public void clear() + { + local.clear(); + } + + @Override + public boolean contains(String id) + { + return local.containsKey(id) || getBacking().contains(id); + } + + @Override + public long count() + { + return local.size(); + } + + @Override + public Stream graphs() + { + return List.copyOf(local.values()).stream(); + } + + /** + * Returns the shared backing repository. + * + * @return graph repository + */ + public GraphRepository getBacking() + { + return backing; + } + +} diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/block.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/block.xsl index 96fa08abf..930eee006 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/block.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/block.xsl @@ -311,7 +311,7 @@ exclude-result-prefixes="#all" - + @@ -323,7 +323,7 @@ exclude-result-prefixes="#all" - + @@ -355,7 +355,7 @@ exclude-result-prefixes="#all" - + diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/block/chart.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/block/chart.xsl index 726a0b9fa..cd64d38f2 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/block/chart.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/block/chart.xsl @@ -254,7 +254,7 @@ exclude-result-prefixes="#all" - + diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/block/object.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/block/object.xsl index 4c51aad8a..72f691de3 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/block/object.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/block/object.xsl @@ -52,7 +52,7 @@ exclude-result-prefixes="#all" - + diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/block/query.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/block/query.xsl index 13682e71a..965ab2dbc 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/block/query.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/block/query.xsl @@ -107,7 +107,7 @@ exclude-result-prefixes="#all" - + @@ -261,7 +261,7 @@ exclude-result-prefixes="#all" - + @@ -307,7 +307,7 @@ exclude-result-prefixes="#all" - +

  • diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/block/view.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/block/view.xsl index 269fd4b34..6651e5369 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/block/view.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/block/view.xsl @@ -61,7 +61,7 @@ exclude-result-prefixes="#all" - + @@ -1203,7 +1203,7 @@ exclude-result-prefixes="#all" - + BLOCK DELEGATION: view-mode handler triggered @@ -1218,7 +1218,7 @@ exclude-result-prefixes="#all" - + BLOCK DELEGATION: pager previous triggered @@ -1233,7 +1233,7 @@ exclude-result-prefixes="#all" - + BLOCK DELEGATION: pager next triggered @@ -1248,7 +1248,7 @@ exclude-result-prefixes="#all" - + BLOCK DELEGATION: container-order triggered @@ -1264,7 +1264,7 @@ exclude-result-prefixes="#all" - + BLOCK DELEGATION: btn-order-by triggered @@ -1278,8 +1278,8 @@ exclude-result-prefixes="#all" - - + + @@ -1320,8 +1320,8 @@ exclude-result-prefixes="#all" - - + + @@ -1362,8 +1362,8 @@ exclude-result-prefixes="#all" - - + + @@ -1405,8 +1405,8 @@ exclude-result-prefixes="#all" - - + + @@ -1451,8 +1451,8 @@ exclude-result-prefixes="#all" - - + + @@ -1494,7 +1494,7 @@ exclude-result-prefixes="#all" - + @@ -1599,7 +1599,7 @@ exclude-result-prefixes="#all" - + @@ -1650,7 +1650,7 @@ exclude-result-prefixes="#all" - + diff --git a/src/test/java/com/atomgraph/linkeddatahub/server/filter/request/OntologyImportsCharacterizationTest.java b/src/test/java/com/atomgraph/linkeddatahub/server/filter/request/OntologyImportsCharacterizationTest.java index 312a61415..bafae14a9 100644 --- a/src/test/java/com/atomgraph/linkeddatahub/server/filter/request/OntologyImportsCharacterizationTest.java +++ b/src/test/java/com/atomgraph/linkeddatahub/server/filter/request/OntologyImportsCharacterizationTest.java @@ -19,6 +19,7 @@ import com.atomgraph.client.util.jena.PrefixGraphRepository; import org.apache.jena.ontapi.OntModelFactory; import org.apache.jena.ontapi.OntSpecification; +import org.apache.jena.ontapi.UnionGraph; import org.apache.jena.ontapi.model.OntModel; import org.apache.jena.rdf.model.Model; import org.apache.jena.rdf.model.ModelFactory; @@ -31,9 +32,10 @@ import static org.junit.jupiter.api.Assertions.*; /** - * Pins {@link OntologyFilter#loadOntology}: it flattens the owl:imports closure into one graph, - * applies RDFS inference, and materializes the inferences into the repository cache — without ontapi - * managing a union-graph hierarchy over the shared repository (which collides on duplicate ontology IDs). + * Pins {@link OntologyFilter#loadOntology}: it assembles the owl:imports closure as a union graph + * (resolved natively by ontapi over a scoped repository view), applies no inference, and + * leaves the shared repository holding raw per-document graphs — which is what proxied and direct + * document GETs serve. * * @author Martynas Jusevičius {@literal } */ @@ -45,7 +47,7 @@ public class OntologyImportsCharacterizationTest private static final String NS = "http://example.org/ns#"; @Test - public void testLoadOntologyFlattensClosureWithMaterializedRDFSInference() + public void testLoadOntologyResolvesClosureWithoutInference() { PrefixGraphRepository repository = new PrefixGraphRepository(null); @@ -70,22 +72,52 @@ public void testLoadOntologyFlattensClosureWithMaterializedRDFSInference() base.add(baseOnt, OWL.imports, base.createResource(IMPORT_URI)); repository.put(BASE_URI, base.getGraph()); - OntologyFilter.loadOntology(repository, BASE_URI); + UnionGraph union = OntologyFilter.loadOntology(repository, BASE_URI); + Model closure = ModelFactory.createModelForGraph(union); - Model result = ModelFactory.createModelForGraph(repository.get(BASE_URI)); - // (a) imported terms flattened into the cached graph - assertTrue(result.contains(b, RDFS.subClassOf, a), "imported terms should be flattened in"); - // (b) RDFS inference materialized as a concrete triple: x a A - assertTrue(result.contains(x, RDF.type, a), "RDFS-inferred 'x a A' should be materialized in the cached graph"); - // (c) the import is also cached under its (fragment-stripped) document URI - assertTrue(repository.isCached(IMPORT_URI), "import should remain cached"); - // (d) REGRESSION GUARD: both owl:Class and rdfs:Class-only terms must be recognized as OntClasses by the returned - // model, so GET /ns?forClass= resolves the class and runs its SPIN constructor. - // OntologyFilter promotes all rdfs:Class subjects to owl:Class so OWL2 profiles (which do not recognize bare - // rdfs:Class) can find third-party vocab terms like sp:Describe. - OntModel ontology = OntModelFactory.createModel(repository.get(BASE_URI), OntSpecification.OWL2_FULL_MEM); + // (a) imported terms are visible through the closure union + assertTrue(closure.contains(b, RDFS.subClassOf, a), "imported terms should be visible through the closure union"); + // (b) no inference: neither type propagation nor vacuous rdfs:Resource typing appears + assertFalse(closure.contains(x, RDF.type, a), "no RDFS type propagation expected in the closure"); + assertFalse(closure.contains(x, RDF.type, RDFS.Resource), "no vacuous rdfs:Resource typing expected in the closure"); + // (c) the shared repository still holds the RAW document graphs — this is what document GETs serve + assertTrue(ModelFactory.createModelForGraph(repository.get(BASE_URI)).isIsomorphicWith(base), "repository must keep serving the raw base ontology graph"); + assertTrue(ModelFactory.createModelForGraph(repository.get(IMPORT_URI)).isIsomorphicWith(imported), "repository must keep serving the raw imported ontology graph"); + // (d) REGRESSION GUARD: both owl:Class and rdfs:Class-only terms must be recognized as OntClasses by the model + // wrapped over the union, so GET /ns?forClass= resolves the class and runs its SPIN constructor. + // OntologyFilter promotes rdfs:Class subjects to owl:Class in a separate union member so the OWL2 profile + // (which does not recognize bare rdfs:Class) can find third-party vocab terms like sp:Describe. + OntModel ontology = OntModelFactory.createModel(union, OntSpecification.OWL2_FULL_MEM); assertNotNull(ontology.getOntClass(NS + "A"), "owl:Class term must be recognized as an OntClass under OWL2_FULL_MEM"); assertNotNull(ontology.getOntClass(NS + "B"), "rdfs:Class-only term must be recognized as an OntClass after promotion"); + // the promotion must not leak into the raw document graphs + assertFalse(ModelFactory.createModelForGraph(repository.get(IMPORT_URI)).contains(b, RDF.type, OWL.Class), "owl:Class promotion must not be written into the raw document graph"); + } + + @Test + public void testLoadOntologyToleratesImportCycles() + { + PrefixGraphRepository repository = new PrefixGraphRepository(null); + + String firstURI = "http://example.org/first"; + String secondURI = "http://example.org/second"; + Resource term = ResourceFactory.createResource(NS + "Term"); + + Model first = ModelFactory.createDefaultModel(); + Resource firstOnt = first.createResource(firstURI); + first.add(firstOnt, RDF.type, OWL.Ontology); + first.add(firstOnt, OWL.imports, first.createResource(secondURI)); + repository.put(firstURI, first.getGraph()); + + Model second = ModelFactory.createDefaultModel(); + Resource secondOnt = second.createResource(secondURI); + second.add(secondOnt, RDF.type, OWL.Ontology); + second.add(secondOnt, OWL.imports, second.createResource(firstURI)); + second.add(term, RDF.type, OWL.Class); + repository.put(secondURI, second.getGraph()); + + UnionGraph union = OntologyFilter.loadOntology(repository, firstURI); + assertTrue(ModelFactory.createModelForGraph(union).contains(term, RDF.type, OWL.Class), "cyclic imports must resolve without recursing infinitely"); } } diff --git a/src/test/java/com/atomgraph/linkeddatahub/server/util/SPINConstraintValidationTest.java b/src/test/java/com/atomgraph/linkeddatahub/server/util/SPINConstraintValidationTest.java index 6d2062f9c..c2a69790a 100644 --- a/src/test/java/com/atomgraph/linkeddatahub/server/util/SPINConstraintValidationTest.java +++ b/src/test/java/com/atomgraph/linkeddatahub/server/util/SPINConstraintValidationTest.java @@ -63,11 +63,8 @@ private OntModel loadOntology() "com/atomgraph/linkeddatahub/ldh.ttl" }) RDFDataMgr.read(closure, classpath); - // mirror OntologyFilter.loadOntology: RDFS-infer then materialize into a plain OWL2_FULL_MEM graph - OntModel inferred = OntModelFactory.createModel(closure.getGraph(), OntSpecification.OWL2_FULL_MEM_RDFS_INF); - OntModel materialized = OntModelFactory.createModel(OntSpecification.OWL2_FULL_MEM); - materialized.add(inferred); - return materialized; + // mirror OntologyFilter.loadOntology: a plain OWL2_FULL_MEM model over the assembled closure, no inference + return OntModelFactory.createModel(closure.getGraph(), OntSpecification.OWL2_FULL_MEM); } /** A dh:Item with NO dct:title — violates the MissingTitle constraint. */