/*
* Licensed to DuraSpace under one or more contributor license agreements.
* See the NOTICE file distributed with this work for additional information
* regarding copyright ownership.
*
* DuraSpace licenses this file to you 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 org.fcrepo.kernel.modeshape.utils;
import static java.util.stream.Collectors.joining;
import static org.slf4j.LoggerFactory.getLogger;
import javax.jcr.RepositoryException;
import javax.jcr.Session;
import org.fcrepo.kernel.api.exception.AccessDeniedException;
import org.fcrepo.kernel.api.exception.ConstraintViolationException;
import org.fcrepo.kernel.api.exception.IncorrectTripleSubjectException;
import org.fcrepo.kernel.api.exception.MalformedRdfException;
import org.fcrepo.kernel.api.exception.OutOfDomainSubjectException;
import org.fcrepo.kernel.api.exception.RepositoryRuntimeException;
import org.fcrepo.kernel.api.identifiers.IdentifierConverter;
import org.fcrepo.kernel.api.models.FedoraResource;
import org.fcrepo.kernel.modeshape.rdf.JcrRdfTools;
import org.slf4j.Logger;
import org.apache.jena.graph.Node;
import org.apache.jena.rdf.listeners.StatementListener;
import org.apache.jena.rdf.model.Resource;
import org.apache.jena.rdf.model.Statement;
import org.apache.jena.rdf.model.RDFNode;
import org.apache.jena.rdf.model.Property;
import org.apache.jena.vocabulary.RDF;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Listen to Jena statement events, and when the statement is changed in the
* graph store, make the change within JCR as well.
*
* @author awoods
*/
public class JcrPropertyStatementListener extends StatementListener {
private static final Logger LOGGER = getLogger(JcrPropertyStatementListener.class);
private static enum Operation {
ADD, REMOVE
}
/*
* map statement hashcodes to the last successful operation on the statement
* to prevent large, redundant SPARQL solution sets from exhausting the heap
*/
private final Map<Statement,Operation> statements;
private final JcrRdfTools jcrRdfTools;
private final IdentifierConverter<Resource, FedoraResource> idTranslator;
private final List<Exception> exceptions;
private final Node topic;
/**
* Construct a statement listener within the given session
*
* @param idTranslator the id translator
* @param session the session
* @param topic the topic of the RDF statement
*/
public JcrPropertyStatementListener(final IdentifierConverter<Resource, FedoraResource> idTranslator,
final Session session, final Node topic) {
this(idTranslator, new JcrRdfTools(idTranslator, session), topic);
}
/**
* Construct a statement listener within the given session
* This listener is not reusable across requests.
*
* @param idTranslator the id translator
* @param jcrRdfTools the jcr rdf tools
* @param topic the topic of the RDF statement
*/
public JcrPropertyStatementListener(final IdentifierConverter<Resource, FedoraResource> idTranslator,
final JcrRdfTools jcrRdfTools, final Node topic) {
super();
this.idTranslator = idTranslator;
this.jcrRdfTools = jcrRdfTools;
this.exceptions = new ArrayList<>();
this.topic = topic;
this.statements = new ConcurrentHashMap<>();
}
/**
* When a statement is added to the graph, serialize it to a JCR property
*
* @param input the input statement
*/
@Override
public void addedStatement(final Statement input) {
if (Operation.ADD == statements.get(input)) {
return;
}
try {
final Resource subject = input.getSubject();
validateSubject(subject);
LOGGER.debug(">> adding statement {}", input);
final Statement s = jcrRdfTools.skolemize(idTranslator, input, topic.toString());
final FedoraResource resource = idTranslator.convert(s.getSubject());
// special logic for handling rdf:type updates.
// if the object is an already-existing mixin, update
// the node's mixins. If it isn't, just treat it normally.
final Property property = s.getPredicate();
final RDFNode objectNode = s.getObject();
if (property.equals(RDF.type) && objectNode.isResource()) {
final Resource mixinResource = objectNode.asResource();
jcrRdfTools.addMixin(resource, mixinResource, input.getModel().getNsPrefixMap());
statements.put(input, Operation.ADD);
return;
}
jcrRdfTools.addProperty(resource, property, objectNode, input.getModel().getNsPrefixMap());
statements.put(input, Operation.ADD);
} catch (final ConstraintViolationException e) {
throw e;
} catch (final javax.jcr.AccessDeniedException e) {
throw new AccessDeniedException(e);
} catch (final RepositoryException | RepositoryRuntimeException e) {
exceptions.add(e);
}
}
/**
* When a statement is removed, remove it from the JCR properties
*
* @param s the given statement
*/
@Override
public void removedStatement(final Statement s) {
if (Operation.REMOVE == statements.get(s)) {
return;
}
try {
// if it's not about the right kind of node, ignore it.
final Resource subject = s.getSubject();
validateSubject(subject);
LOGGER.trace(">> removing statement {}", s);
final FedoraResource resource = idTranslator.convert(subject);
// special logic for handling rdf:type updates.
// if the object is an already-existing mixin, update
// the node's mixins. If it isn't, just treat it normally.
final Property property = s.getPredicate();
final RDFNode objectNode = s.getObject();
if (property.equals(RDF.type) && objectNode.isResource()) {
final Resource mixinResource = objectNode.asResource();
jcrRdfTools.removeMixin(resource, mixinResource, s.getModel().getNsPrefixMap());
statements.put(s, Operation.REMOVE);
return;
}
jcrRdfTools.removeProperty(resource, property, objectNode, s.getModel().getNsPrefixMap());
statements.put(s, Operation.REMOVE);
} catch (final ConstraintViolationException e) {
throw e;
} catch (final RepositoryException | RepositoryRuntimeException e) {
exceptions.add(e);
}
}
/**
* If it's not the right kind of node, throw an appropriate unchecked exception.
*
* @param subject
*/
private void validateSubject(final Resource subject) {
final String subjectURI = subject.getURI();
// blank nodes are okay
if (!subject.isAnon()) {
// hash URIs with the same base as the topic are okay
final int hashIndex = subjectURI.lastIndexOf("#");
if (!(hashIndex > 0 && topic.getURI().equals(subjectURI.substring(0, hashIndex)))) {
// the topic itself is okay
if (!topic.equals(subject.asNode())) {
// it's a bad subject, but it could still be in-domain
if (idTranslator.inDomain(subject)) {
LOGGER.error("{} is not in the topic of this RDF, which is {}.", subject, topic);
throw new IncorrectTripleSubjectException(subject +
" is not in the topic of this RDF, which is " + topic);
}
// it's not even in the right domain!
LOGGER.error("subject ({}) is not in repository domain.", subject);
throw new OutOfDomainSubjectException(subject.asNode());
}
}
}
}
/**
* Assert that no exceptions were thrown while this listener was processing change
*/
public void assertNoExceptions() {
if (!exceptions.isEmpty()) {
throw new MalformedRdfException(exceptions.stream().map(Exception::getMessage).collect(joining("\n")));
}
}
}