001/**
002 * Copyright 2014 DuraSpace, Inc.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package org.fcrepo.kernel.impl.rdf;
017
018import static com.hp.hpl.jena.rdf.model.ModelFactory.createDefaultModel;
019import static java.util.UUID.randomUUID;
020import static javax.jcr.PropertyType.REFERENCE;
021import static javax.jcr.PropertyType.STRING;
022import static javax.jcr.PropertyType.UNDEFINED;
023import static javax.jcr.PropertyType.WEAKREFERENCE;
024import static org.fcrepo.kernel.FedoraJcrTypes.FEDORA_BLANKNODE;
025import static org.fcrepo.kernel.FedoraJcrTypes.FEDORA_PAIRTREE;
026import static org.fcrepo.kernel.FedoraJcrTypes.FEDORA_RESOURCE;
027import static org.fcrepo.kernel.RdfLexicon.JCR_NAMESPACE;
028import static org.fcrepo.kernel.RdfLexicon.isManagedPredicate;
029import static org.fcrepo.kernel.impl.identifiers.NodeResourceConverter.nodeToResource;
030import static org.fcrepo.kernel.impl.rdf.converters.PropertyConverter.getPropertyNameFromPredicate;
031import static org.fcrepo.kernel.impl.utils.FedoraTypesUtils.getClosestExistingAncestor;
032import static org.fcrepo.kernel.impl.utils.FedoraTypesUtils.getPropertyType;
033import static org.fcrepo.kernel.impl.utils.FedoraTypesUtils.isReferenceProperty;
034import static org.modeshape.jcr.api.JcrConstants.NT_FOLDER;
035import static org.slf4j.LoggerFactory.getLogger;
036
037import java.util.HashMap;
038import java.util.Map;
039
040import javax.jcr.Node;
041import javax.jcr.PathNotFoundException;
042import javax.jcr.RepositoryException;
043import javax.jcr.Session;
044import javax.jcr.Value;
045import javax.jcr.ValueFormatException;
046import javax.jcr.ValueFactory;
047import javax.jcr.nodetype.NodeTypeManager;
048import javax.jcr.nodetype.NodeTypeTemplate;
049
050import com.google.common.annotations.VisibleForTesting;
051import com.hp.hpl.jena.rdf.model.AnonId;
052import com.hp.hpl.jena.rdf.model.Model;
053import com.hp.hpl.jena.rdf.model.Statement;
054import org.fcrepo.kernel.models.FedoraResource;
055import org.fcrepo.kernel.RdfLexicon;
056import org.fcrepo.kernel.exception.MalformedRdfException;
057import org.fcrepo.kernel.exception.RepositoryRuntimeException;
058import org.fcrepo.kernel.exception.ServerManagedPropertyException;
059import org.fcrepo.kernel.identifiers.IdentifierConverter;
060import org.fcrepo.kernel.impl.rdf.converters.ValueConverter;
061import org.fcrepo.kernel.impl.utils.NodePropertiesTools;
062import org.modeshape.jcr.api.JcrTools;
063import org.slf4j.Logger;
064
065import com.google.common.collect.BiMap;
066import com.google.common.collect.ImmutableBiMap;
067import com.hp.hpl.jena.rdf.model.RDFNode;
068import com.hp.hpl.jena.rdf.model.Resource;
069
070/**
071 * A set of helpful tools for converting JCR properties to RDF
072 *
073 * @author Chris Beer
074 * @since May 10, 2013
075 */
076public class JcrRdfTools {
077
078    private static final Logger LOGGER = getLogger(JcrRdfTools.class);
079
080    /**
081     * A map of JCR namespaces to Fedora's RDF namespaces
082     */
083    public static BiMap<String, String> jcrNamespacesToRDFNamespaces =
084        ImmutableBiMap.of(JCR_NAMESPACE,
085                RdfLexicon.REPOSITORY_NAMESPACE);
086
087    /**
088     * A map of Fedora's RDF namespaces to the JCR equivalent
089     */
090    public static BiMap<String, String> rdfNamespacesToJcrNamespaces =
091        jcrNamespacesToRDFNamespaces.inverse();
092
093    private final IdentifierConverter<Resource, FedoraResource> idTranslator;
094    private final ValueConverter valueConverter;
095
096    private Session session;
097    private final NodePropertiesTools nodePropertiesTools = new NodePropertiesTools();
098
099    @VisibleForTesting
100    protected JcrTools jcrTools = new JcrTools();
101
102    private final Map<AnonId, Resource> skolemizedBnodeMap;
103
104    private static final Model m = createDefaultModel();
105
106
107    /**
108     * Constructor with even more context.
109     *
110     * @param idTranslator
111     * @param session
112     */
113    public JcrRdfTools(final IdentifierConverter<Resource, FedoraResource> idTranslator,
114                       final Session session) {
115        this.idTranslator = idTranslator;
116        this.session = session;
117        this.valueConverter = new ValueConverter(session, idTranslator);
118        this.skolemizedBnodeMap = new HashMap<>();
119    }
120
121    /**
122     * Convert a Fedora RDF Namespace into its JCR equivalent
123     *
124     * @param rdfNamespaceUri a namespace from an RDF document
125     * @return the JCR namespace, or the RDF namespace if no matching JCR
126     *         namespace is found
127     */
128    public static String getJcrNamespaceForRDFNamespace(
129            final String rdfNamespaceUri) {
130        if (rdfNamespacesToJcrNamespaces.containsKey(rdfNamespaceUri)) {
131            return rdfNamespacesToJcrNamespaces.get(rdfNamespaceUri);
132        }
133        return rdfNamespaceUri;
134    }
135
136    /**
137     * Convert a JCR namespace into an RDF namespace fit for downstream
138     * consumption.
139     *
140     * @param jcrNamespaceUri a namespace from the JCR NamespaceRegistry
141     * @return an RDF namespace for downstream consumption.
142     */
143    public static String getRDFNamespaceForJcrNamespace(
144            final String jcrNamespaceUri) {
145        if (jcrNamespacesToRDFNamespaces.containsKey(jcrNamespaceUri)) {
146            return jcrNamespacesToRDFNamespaces.get(jcrNamespaceUri);
147        }
148        return jcrNamespaceUri;
149    }
150
151    /**
152     * Create a JCR value from an RDFNode for a given JCR property
153     * @param node the JCR node we want a property for
154     * @param data an RDF Node (possibly with a DataType)
155     * @param propertyName name of the property to populate (used to use the right type for the value)
156     * @return
157     * @throws RepositoryException
158     */
159    public Value createValue(final Node node,
160                             final RDFNode data,
161                             final String propertyName) throws RepositoryException {
162        final ValueFactory valueFactory = node.getSession().getValueFactory();
163        return createValue(valueFactory, data, getPropertyType(node, propertyName));
164    }
165
166    /**
167     * Create a JCR value from an RDF node with the given JCR type
168     * @param valueFactory
169     * @param data
170     * @param type
171     * @return created value
172     * @throws RepositoryException
173     */
174    public Value createValue(final ValueFactory valueFactory, final RDFNode data, final int type)
175        throws RepositoryException {
176        assert (valueFactory != null);
177
178
179        if (type == UNDEFINED || type == STRING) {
180            return valueConverter.reverse().convert(data);
181        } else if (type == REFERENCE || type == WEAKREFERENCE) {
182            // reference to another node (by path)
183            if (!data.isURIResource()) {
184                throw new ValueFormatException("Reference properties can only refer to URIs, not literals");
185            }
186
187            try {
188                final Node nodeFromGraphSubject = idTranslator.convert(data.asResource()).getNode();
189                return valueFactory.createValue(nodeFromGraphSubject, type == WEAKREFERENCE);
190            } catch (final RepositoryRuntimeException e) {
191                throw new MalformedRdfException("Unable to find referenced node", e);
192            }
193        } else if (data.isResource()) {
194            LOGGER.debug("Using default JCR value creation for RDF resource: {}",
195                    data);
196            return valueFactory.createValue(data.asResource().getURI(), type);
197        } else {
198            LOGGER.debug("Using default JCR value creation for RDF literal: {}",
199                    data);
200            return valueFactory.createValue(data.asLiteral().getString(), type);
201        }
202    }
203
204    /**
205     * Add a mixin to a node
206     * @param resource
207     * @param mixinResource
208     * @param namespaces
209     * @throws RepositoryException
210     */
211    public void addMixin(final FedoraResource resource,
212                         final Resource mixinResource,
213                         final Map<String,String> namespaces)
214            throws RepositoryException {
215
216        final Node node = resource.getNode();
217        final Session session = node.getSession();
218        final String mixinName = getPropertyNameFromPredicate(node, mixinResource, namespaces);
219        if (!repositoryHasType(session, mixinName)) {
220            final NodeTypeManager mgr = session.getWorkspace().getNodeTypeManager();
221            final NodeTypeTemplate type = mgr.createNodeTypeTemplate();
222            type.setName(mixinName);
223            type.setMixin(true);
224            type.setQueryable(true);
225            mgr.registerNodeType(type, false);
226        }
227
228        if (node.isNodeType(mixinName)) {
229            LOGGER.trace("Subject {} is already a {}; skipping", node, mixinName);
230            return;
231        }
232
233        if (node.canAddMixin(mixinName)) {
234            LOGGER.debug("Adding mixin: {} to node: {}.", mixinName, node.getPath());
235            node.addMixin(mixinName);
236        } else {
237            throw new MalformedRdfException("Could not persist triple containing type assertion: "
238                    + mixinResource.toString()
239                    + " because no such mixin/type can be added to this node: "
240                    + node.getPath() + "!");
241        }
242    }
243
244    /**
245     * Add property to a node
246     * @param resource
247     * @param predicate
248     * @param value
249     * @param namespaces
250     * @throws RepositoryException
251     */
252    public void addProperty(final FedoraResource resource,
253                            final com.hp.hpl.jena.rdf.model.Property predicate,
254                            final RDFNode value,
255                            final Map<String,String> namespaces) throws RepositoryException {
256
257        final Node node = resource.getNode();
258
259        if (isManagedPredicate.apply(predicate)) {
260
261            throw new ServerManagedPropertyException("Could not persist triple containing predicate "
262                    + predicate.toString()
263                    + " to node "
264                    + node.getPath());
265        }
266
267        final String propertyName =
268                getPropertyNameFromPredicate(node, predicate, namespaces);
269
270        if (value.isURIResource()
271                && idTranslator.inDomain(value.asResource())
272                && !isReferenceProperty(node, propertyName)) {
273            nodePropertiesTools.addReferencePlaceholders(idTranslator, node, propertyName, value.asResource());
274        } else {
275            final Value v = createValue(node, value, propertyName);
276            nodePropertiesTools.appendOrReplaceNodeProperty(node, propertyName, v);
277        }
278    }
279
280    protected boolean repositoryHasType(final Session session, final String mixinName) throws RepositoryException {
281        return session.getWorkspace().getNodeTypeManager().hasNodeType(mixinName);
282    }
283
284    /**
285     * Remove a mixin from a node
286     * @param resource
287     * @param mixinResource
288     * @param nsPrefixMap
289     * @throws RepositoryException
290     */
291    public void removeMixin(final FedoraResource resource,
292                            final Resource mixinResource,
293                            final Map<String, String> nsPrefixMap) throws RepositoryException {
294
295        final Node node = resource.getNode();
296        final String mixinName = getPropertyNameFromPredicate(node, mixinResource, nsPrefixMap);
297        if (repositoryHasType(session, mixinName) && node.isNodeType(mixinName)) {
298            node.removeMixin(mixinName);
299        }
300
301    }
302
303    /**
304     * Remove a property from a node
305     * @param resource
306     * @param predicate
307     * @param objectNode
308     * @param nsPrefixMap
309     * @throws RepositoryException
310     */
311    public void removeProperty(final FedoraResource resource,
312                               final com.hp.hpl.jena.rdf.model.Property predicate,
313                               final RDFNode objectNode,
314                               final Map<String, String> nsPrefixMap) throws RepositoryException {
315
316        final Node node = resource.getNode();
317        final String propertyName = getPropertyNameFromPredicate(node, predicate, nsPrefixMap);
318
319        if (isManagedPredicate.apply(predicate)) {
320
321            throw new ServerManagedPropertyException("Could not remove triple containing predicate "
322                    + predicate.toString()
323                    + " to node "
324                    + node.getPath());
325        }
326
327        if (objectNode.isURIResource()
328                && idTranslator.inDomain(objectNode.asResource())
329                && !isReferenceProperty(node, propertyName)) {
330            nodePropertiesTools.removeReferencePlaceholders(idTranslator,
331                    node,
332                    propertyName,
333                    objectNode.asResource());
334        } else {
335            final Value v = createValue(node, objectNode, propertyName);
336            nodePropertiesTools.removeNodeProperty(node, propertyName, v);
337        }
338    }
339
340    /**
341     * Convert an external statement into a persistable statement by skolemizing
342     * blank nodes, creating hash-uri subjects, etc
343     *
344     * @param idTranslator
345     * @param t
346     * @return
347     * @throws RepositoryException
348     */
349    public Statement skolemize(final IdentifierConverter<Resource, FedoraResource> idTranslator, final Statement t)
350            throws RepositoryException {
351
352        Statement skolemized = t;
353
354        if (t.getSubject().isAnon()) {
355            skolemized = m.createStatement(getSkolemizedResource(idTranslator, skolemized.getSubject()),
356                    skolemized.getPredicate(),
357                    skolemized.getObject());
358        } else if (idTranslator.inDomain(t.getSubject()) && t.getSubject().getURI().contains("#")) {
359            findOrCreateHashUri(idTranslator, t.getSubject());
360        }
361
362        if (t.getObject().isAnon()) {
363            skolemized = m.createStatement(skolemized.getSubject(), skolemized.getPredicate(), getSkolemizedResource
364                    (idTranslator, skolemized.getObject()));
365        } else if (t.getObject().isResource()
366                && idTranslator.inDomain(t.getObject().asResource())
367                && t.getObject().asResource().getURI().contains("#")) {
368            findOrCreateHashUri(idTranslator, t.getObject().asResource());
369        }
370
371        return skolemized;
372    }
373
374    private void findOrCreateHashUri(final IdentifierConverter<Resource, FedoraResource> idTranslator,
375                                     final Resource s) throws RepositoryException {
376        final String absPath = idTranslator.asString(s);
377
378        if (!absPath.isEmpty() && !session.nodeExists(absPath)) {
379            final Node closestExistingAncestor = getClosestExistingAncestor(session, absPath);
380
381            final Node orCreateNode = jcrTools.findOrCreateNode(session, absPath, NT_FOLDER);
382            orCreateNode.addMixin(FEDORA_RESOURCE);
383
384            final Node parent = orCreateNode.getParent();
385
386            if (!parent.getName().equals("#")) {
387                throw new AssertionError("Hash URI resource created with too much hierarchy: " + s);
388            }
389
390            // We require the closest node to be either "#" resource, or its parent.
391            if (!parent.equals(closestExistingAncestor)
392                    && !parent.getParent().equals(closestExistingAncestor)) {
393                throw new PathNotFoundException("Unexpected request to create new resource " + s);
394            }
395
396            if (parent.isNew()) {
397                parent.addMixin(FEDORA_PAIRTREE);
398            }
399        }
400    }
401
402    private Resource getSkolemizedResource(final IdentifierConverter<Resource, FedoraResource> idTranslator,
403                                           final RDFNode resource) throws RepositoryException {
404        final AnonId id = resource.asResource().getId();
405
406        if (!skolemizedBnodeMap.containsKey(id)) {
407            final Node orCreateNode = jcrTools.findOrCreateNode(session, skolemizedId());
408            orCreateNode.addMixin(FEDORA_BLANKNODE);
409            final Resource skolemizedSubject = nodeToResource(idTranslator).convert(orCreateNode);
410            skolemizedBnodeMap.put(id, skolemizedSubject);
411        }
412
413        return skolemizedBnodeMap.get(id);
414    }
415
416    private String skolemizedId() {
417        return "/.well-known/genid/" + randomUUID().toString();
418    }
419}