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}