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 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            final FedoraResource description = resource.getDescription();
129
130            // special logic for handling rdf:type updates.
131            // if the object is an already-existing mixin, update
132            // the node's mixins. If it isn't, just treat it normally.
133            final Property property = s.getPredicate();
134            final RDFNode objectNode = s.getObject();
135            if (property.equals(RDF.type) && objectNode.isResource()) {
136                final Resource mixinResource = objectNode.asResource();
137                jcrRdfTools.addMixin(description, mixinResource, input.getModel().getNsPrefixMap());
138                statements.put(input, Operation.ADD);
139                return;
140            }
141
142            jcrRdfTools.addProperty(description, property, objectNode, input.getModel().getNsPrefixMap());
143            statements.put(input, Operation.ADD);
144        } catch (final ConstraintViolationException e) {
145            throw e;
146        } catch (final javax.jcr.AccessDeniedException e) {
147            throw new AccessDeniedException(e);
148        } catch (final RepositoryException | RepositoryRuntimeException e) {
149            exceptions.add(e);
150        }
151
152    }
153
154    /**
155     * When a statement is removed, remove it from the JCR properties
156     *
157     * @param s the given statement
158     */
159    @Override
160    public void removedStatement(final Statement s) {
161        if (Operation.REMOVE == statements.get(s)) {
162            return;
163        }
164        try {
165            // if it's not about the right kind of node, ignore it.
166            final Resource subject = s.getSubject();
167            validateSubject(subject);
168            LOGGER.trace(">> removing statement {}", s);
169
170            final FedoraResource resource = idTranslator.convert(subject);
171            final FedoraResource description = resource.getDescription();
172
173            // special logic for handling rdf:type updates.
174            // if the object is an already-existing mixin, update
175            // the node's mixins. If it isn't, just treat it normally.
176            final Property property = s.getPredicate();
177            final RDFNode objectNode = s.getObject();
178
179            if (property.equals(RDF.type) && objectNode.isResource()) {
180                final Resource mixinResource = objectNode.asResource();
181                jcrRdfTools.removeMixin(description, mixinResource, s.getModel().getNsPrefixMap());
182                statements.put(s, Operation.REMOVE);
183                return;
184            }
185
186            jcrRdfTools.removeProperty(description, property, objectNode, s.getModel().getNsPrefixMap());
187            statements.put(s, Operation.REMOVE);
188        } catch (final ConstraintViolationException e) {
189            throw e;
190        } catch (final RepositoryException | RepositoryRuntimeException e) {
191            exceptions.add(e);
192        }
193
194    }
195
196    /**
197     * If it's not the right kind of node, throw an appropriate unchecked exception.
198     *
199     * @param subject to validate
200     */
201    private void validateSubject(final Resource subject) {
202        final String subjectURI = subject.getURI();
203        // blank nodes are okay
204        if (!subject.isAnon()) {
205            // hash URIs with the same base as the topic are okay
206            final int hashIndex = subjectURI.lastIndexOf("#");
207            if (!(hashIndex > 0 && topic.getURI().equals(subjectURI.substring(0, hashIndex)))) {
208                // the topic itself is okay
209                if (!topic.equals(subject.asNode())) {
210                    // it's a bad subject, but it could still be in-domain
211                    if (idTranslator.inDomain(subject)) {
212                        LOGGER.error("{} is not in the topic of this RDF, which is {}.", subject, topic);
213                        throw new IncorrectTripleSubjectException(subject +
214                                " is not in the topic of this RDF, which is " + topic);
215                    }
216                    // it's not even in the right domain!
217                    LOGGER.error("subject ({}) is not in repository domain.", subject);
218                    throw new OutOfDomainSubjectException(subject.asNode());
219                }
220            }
221        }
222    }
223
224    /**
225     * Assert that no exceptions were thrown while this listener was processing change
226     */
227    public void assertNoExceptions() {
228        if (!exceptions.isEmpty()) {
229            throw new MalformedRdfException(exceptions.stream().map(Exception::getMessage).collect(joining("\n")));
230        }
231    }
232}