001/* 002 * Licensed to DuraSpace under one or more contributor license agreements. 003 * See the NOTICE file distributed with this work for additional information 004 * regarding copyright ownership. 005 * 006 * DuraSpace licenses this file to you under the Apache License, 007 * Version 2.0 (the "License"); you may not use this file except in 008 * compliance with the License. You may obtain a copy of the License at 009 * 010 * http://www.apache.org/licenses/LICENSE-2.0 011 * 012 * Unless required by applicable law or agreed to in writing, software 013 * distributed under the License is distributed on an "AS IS" BASIS, 014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 015 * See the License for the specific language governing permissions and 016 * limitations under the License. 017 */ 018package org.fcrepo.kernel.modeshape.utils; 019 020import static java.util.stream.Collectors.joining; 021import static org.slf4j.LoggerFactory.getLogger; 022 023import javax.jcr.RepositoryException; 024import javax.jcr.Session; 025 026import org.fcrepo.kernel.api.exception.AccessDeniedException; 027import org.fcrepo.kernel.api.exception.ConstraintViolationException; 028import org.fcrepo.kernel.api.exception.IncorrectTripleSubjectException; 029import org.fcrepo.kernel.api.exception.MalformedRdfException; 030import org.fcrepo.kernel.api.exception.OutOfDomainSubjectException; 031import org.fcrepo.kernel.api.exception.RepositoryRuntimeException; 032import org.fcrepo.kernel.api.identifiers.IdentifierConverter; 033import org.fcrepo.kernel.api.models.FedoraResource; 034import org.fcrepo.kernel.modeshape.rdf.JcrRdfTools; 035 036import org.slf4j.Logger; 037 038import org.apache.jena.graph.Node; 039import org.apache.jena.rdf.listeners.StatementListener; 040import org.apache.jena.rdf.model.Resource; 041import org.apache.jena.rdf.model.Statement; 042import org.apache.jena.rdf.model.RDFNode; 043import org.apache.jena.rdf.model.Property; 044import org.apache.jena.vocabulary.RDF; 045 046import java.util.ArrayList; 047import java.util.List; 048import java.util.Map; 049import java.util.concurrent.ConcurrentHashMap; 050 051/** 052 * Listen to Jena statement events, and when the statement is changed in the 053 * graph store, make the change within JCR as well. 054 * 055 * @author awoods 056 */ 057public class JcrPropertyStatementListener extends StatementListener { 058 059 private static final Logger LOGGER = getLogger(JcrPropertyStatementListener.class); 060 061 private static enum Operation { 062 ADD, REMOVE 063 } 064 065 /* 066 * map statement hashcodes to the last successful operation on the statement 067 * to prevent large, redundant SPARQL solution sets from exhausting the heap 068 */ 069 private final Map<Statement,Operation> statements; 070 071 private final JcrRdfTools jcrRdfTools; 072 073 private final IdentifierConverter<Resource, FedoraResource> idTranslator; 074 075 private final List<Exception> exceptions; 076 077 private final Node topic; 078 079 /** 080 * Construct a statement listener within the given session 081 * 082 * @param idTranslator the id translator 083 * @param session the session 084 * @param topic the topic of the RDF statement 085 */ 086 public JcrPropertyStatementListener(final IdentifierConverter<Resource, FedoraResource> idTranslator, 087 final Session session, final Node topic) { 088 this(idTranslator, new JcrRdfTools(idTranslator, session), topic); 089 } 090 091 /** 092 * Construct a statement listener within the given session 093 * This listener is not reusable across requests. 094 * 095 * @param idTranslator the id translator 096 * @param jcrRdfTools the jcr rdf tools 097 * @param topic the topic of the RDF statement 098 */ 099 public JcrPropertyStatementListener(final IdentifierConverter<Resource, FedoraResource> idTranslator, 100 final JcrRdfTools jcrRdfTools, final Node topic) { 101 super(); 102 this.idTranslator = idTranslator; 103 this.jcrRdfTools = jcrRdfTools; 104 this.exceptions = new ArrayList<>(); 105 this.topic = topic; 106 this.statements = new ConcurrentHashMap<>(); 107 } 108 109 110 /** 111 * When a statement is added to the graph, serialize it to a JCR property 112 * 113 * @param input the input statement 114 */ 115 @Override 116 public void addedStatement(final Statement input) { 117 if (Operation.ADD == statements.get(input)) { 118 return; 119 } 120 try { 121 final Resource subject = input.getSubject(); 122 validateSubject(subject); 123 LOGGER.debug(">> adding statement {}", input); 124 125 final Statement s = jcrRdfTools.skolemize(idTranslator, input, topic.toString()); 126 127 final FedoraResource resource = idTranslator.convert(s.getSubject()); 128 129 // special logic for handling rdf:type updates. 130 // if the object is an already-existing mixin, update 131 // the node's mixins. If it isn't, just treat it normally. 132 final Property property = s.getPredicate(); 133 final RDFNode objectNode = s.getObject(); 134 if (property.equals(RDF.type) && objectNode.isResource()) { 135 final Resource mixinResource = objectNode.asResource(); 136 jcrRdfTools.addMixin(resource, mixinResource, input.getModel().getNsPrefixMap()); 137 statements.put(input, Operation.ADD); 138 return; 139 } 140 141 jcrRdfTools.addProperty(resource, property, objectNode, input.getModel().getNsPrefixMap()); 142 statements.put(input, Operation.ADD); 143 } catch (final ConstraintViolationException e) { 144 throw e; 145 } catch (final javax.jcr.AccessDeniedException e) { 146 throw new AccessDeniedException(e); 147 } catch (final RepositoryException | RepositoryRuntimeException e) { 148 exceptions.add(e); 149 } 150 151 } 152 153 /** 154 * When a statement is removed, remove it from the JCR properties 155 * 156 * @param s the given statement 157 */ 158 @Override 159 public void removedStatement(final Statement s) { 160 if (Operation.REMOVE == statements.get(s)) { 161 return; 162 } 163 try { 164 // if it's not about the right kind of node, ignore it. 165 final Resource subject = s.getSubject(); 166 validateSubject(subject); 167 LOGGER.trace(">> removing statement {}", s); 168 169 final FedoraResource resource = idTranslator.convert(subject); 170 171 // special logic for handling rdf:type updates. 172 // if the object is an already-existing mixin, update 173 // the node's mixins. If it isn't, just treat it normally. 174 final Property property = s.getPredicate(); 175 final RDFNode objectNode = s.getObject(); 176 177 if (property.equals(RDF.type) && objectNode.isResource()) { 178 final Resource mixinResource = objectNode.asResource(); 179 jcrRdfTools.removeMixin(resource, mixinResource, s.getModel().getNsPrefixMap()); 180 statements.put(s, Operation.REMOVE); 181 return; 182 } 183 184 jcrRdfTools.removeProperty(resource, property, objectNode, s.getModel().getNsPrefixMap()); 185 statements.put(s, Operation.REMOVE); 186 } catch (final ConstraintViolationException e) { 187 throw e; 188 } catch (final RepositoryException | RepositoryRuntimeException e) { 189 exceptions.add(e); 190 } 191 192 } 193 194 /** 195 * If it's not the right kind of node, throw an appropriate unchecked exception. 196 * 197 * @param subject 198 */ 199 private void validateSubject(final Resource subject) { 200 final String subjectURI = subject.getURI(); 201 // blank nodes are okay 202 if (!subject.isAnon()) { 203 // hash URIs with the same base as the topic are okay 204 final int hashIndex = subjectURI.lastIndexOf("#"); 205 if (!(hashIndex > 0 && topic.getURI().equals(subjectURI.substring(0, hashIndex)))) { 206 // the topic itself is okay 207 if (!topic.equals(subject.asNode())) { 208 // it's a bad subject, but it could still be in-domain 209 if (idTranslator.inDomain(subject)) { 210 LOGGER.error("{} is not in the topic of this RDF, which is {}.", subject, topic); 211 throw new IncorrectTripleSubjectException(subject + 212 " is not in the topic of this RDF, which is " + topic); 213 } 214 // it's not even in the right domain! 215 LOGGER.error("subject ({}) is not in repository domain.", subject); 216 throw new OutOfDomainSubjectException(subject.asNode()); 217 } 218 } 219 } 220 } 221 222 /** 223 * Assert that no exceptions were thrown while this listener was processing change 224 */ 225 public void assertNoExceptions() { 226 if (!exceptions.isEmpty()) { 227 throw new MalformedRdfException(exceptions.stream().map(Exception::getMessage).collect(joining("\n"))); 228 } 229 } 230}