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.rdf;
019
020import static org.apache.jena.rdf.model.ModelFactory.createDefaultModel;
021import static org.apache.jena.rdf.model.ResourceFactory.createResource;
022import static java.util.UUID.randomUUID;
023import static javax.jcr.PropertyType.REFERENCE;
024import static javax.jcr.PropertyType.STRING;
025import static javax.jcr.PropertyType.UNDEFINED;
026import static javax.jcr.PropertyType.WEAKREFERENCE;
027import static org.fcrepo.kernel.api.FedoraTypes.FEDORA_SKOLEM;
028import static org.fcrepo.kernel.api.FedoraTypes.FEDORA_LASTMODIFIED;
029import static org.fcrepo.kernel.api.FedoraTypes.FEDORA_LASTMODIFIEDBY;
030import static org.fcrepo.kernel.api.FedoraTypes.FEDORA_CREATED;
031import static org.fcrepo.kernel.api.FedoraTypes.FEDORA_CREATEDBY;
032import static org.fcrepo.kernel.api.FedoraTypes.FEDORA_PAIRTREE;
033import static org.fcrepo.kernel.api.FedoraTypes.FEDORA_RESOURCE;
034import static org.fcrepo.kernel.api.RdfLexicon.isManagedPredicate;
035import static org.fcrepo.kernel.modeshape.FedoraJcrConstants.JCR_LASTMODIFIED;
036import static org.fcrepo.kernel.modeshape.FedoraJcrConstants.JCR_LASTMODIFIEDBY;
037import static org.fcrepo.kernel.modeshape.FedoraJcrConstants.JCR_CREATED;
038import static org.fcrepo.kernel.modeshape.FedoraJcrConstants.JCR_CREATEDBY;
039import static org.fcrepo.kernel.modeshape.RdfJcrLexicon.jcrProperties;
040import static org.fcrepo.kernel.modeshape.RdfJcrLexicon.JCR_NAMESPACE;
041import static org.fcrepo.kernel.modeshape.identifiers.NodeResourceConverter.nodeToResource;
042import static org.fcrepo.kernel.modeshape.rdf.converters.PropertyConverter.getPropertyNameFromPredicate;
043import static org.fcrepo.kernel.modeshape.utils.FedoraTypesUtils.getClosestExistingAncestor;
044import static org.fcrepo.kernel.modeshape.utils.FedoraTypesUtils.getJcrNode;
045import static org.fcrepo.kernel.modeshape.utils.FedoraTypesUtils.getPropertyType;
046import static org.fcrepo.kernel.modeshape.utils.FedoraTypesUtils.isReferenceProperty;
047import static org.modeshape.jcr.api.JcrConstants.NT_FOLDER;
048import static org.slf4j.LoggerFactory.getLogger;
049
050import java.util.HashMap;
051import java.util.Map;
052
053import javax.jcr.Node;
054import javax.jcr.PathNotFoundException;
055import javax.jcr.RepositoryException;
056import javax.jcr.Session;
057import javax.jcr.Value;
058import javax.jcr.ValueFormatException;
059import javax.jcr.ValueFactory;
060import javax.jcr.nodetype.NodeTypeManager;
061import javax.jcr.nodetype.NodeTypeTemplate;
062
063import com.google.common.annotations.VisibleForTesting;
064import org.apache.jena.rdf.model.AnonId;
065import org.apache.jena.rdf.model.Model;
066import org.apache.jena.rdf.model.Statement;
067
068import org.fcrepo.kernel.modeshape.services.AbstractService;
069import org.fcrepo.kernel.api.models.FedoraResource;
070import org.fcrepo.kernel.api.RdfLexicon;
071import org.fcrepo.kernel.api.exception.MalformedRdfException;
072import org.fcrepo.kernel.api.exception.RepositoryRuntimeException;
073import org.fcrepo.kernel.api.exception.ServerManagedPropertyException;
074import org.fcrepo.kernel.api.identifiers.IdentifierConverter;
075import org.fcrepo.kernel.api.services.functions.HierarchicalIdentifierSupplier;
076import org.fcrepo.kernel.api.services.functions.UniqueValueSupplier;
077import org.fcrepo.kernel.modeshape.rdf.converters.ValueConverter;
078import org.fcrepo.kernel.modeshape.utils.BNodeSkolemizationUtil;
079import org.fcrepo.kernel.modeshape.utils.NodePropertiesTools;
080
081import org.modeshape.jcr.api.JcrTools;
082import org.slf4j.Logger;
083
084import com.google.common.collect.BiMap;
085import com.google.common.collect.ImmutableBiMap;
086import org.apache.jena.rdf.model.RDFNode;
087import org.apache.jena.rdf.model.Resource;
088
089/**
090 * A set of helpful tools for converting JCR properties to RDF
091 *
092 * @author Chris Beer
093 * @author ajs6f
094 * @since May 10, 2013
095 */
096public class JcrRdfTools {
097
098    private static final Logger LOGGER = getLogger(JcrRdfTools.class);
099
100    /**
101     * A map of JCR namespaces to Fedora's RDF namespaces
102     */
103    public static BiMap<String, String> jcrNamespacesToRDFNamespaces =
104        ImmutableBiMap.of(JCR_NAMESPACE,
105                RdfLexicon.REPOSITORY_NAMESPACE);
106
107    /**
108     * A map of Fedora's RDF namespaces to the JCR equivalent
109     */
110    public static BiMap<String, String> rdfNamespacesToJcrNamespaces =
111        jcrNamespacesToRDFNamespaces.inverse();
112
113    private final IdentifierConverter<Resource, FedoraResource> idTranslator;
114    private final ValueConverter valueConverter;
115
116    private final Session session;
117    private final NodePropertiesTools nodePropertiesTools = new NodePropertiesTools();
118
119    @VisibleForTesting
120    protected JcrTools jcrTools = new JcrTools();
121
122    private final Map<AnonId, Resource> skolemizedBnodeMap;
123
124    private static final Model m = createDefaultModel();
125
126    private static final UniqueValueSupplier pidMinter =  new DefaultPathMinter();
127    /**
128     * Constructor with even more context.
129     *
130     * @param idTranslator the id translator
131     * @param session the session
132     */
133    public JcrRdfTools(final IdentifierConverter<Resource, FedoraResource> idTranslator,
134                       final Session session) {
135        this.idTranslator = idTranslator;
136        this.session = session;
137        this.valueConverter = new ValueConverter(session, idTranslator);
138        this.skolemizedBnodeMap = new HashMap<>();
139    }
140
141    /**
142     * Convert a Fedora RDF Namespace into its JCR equivalent
143     *
144     * @param rdfNamespaceUri a namespace from an RDF document
145     * @return the JCR namespace, or the RDF namespace if no matching JCR
146     *         namespace is found
147     */
148    public static String getJcrNamespaceForRDFNamespace(
149            final String rdfNamespaceUri) {
150        if (rdfNamespacesToJcrNamespaces.containsKey(rdfNamespaceUri)) {
151            return rdfNamespacesToJcrNamespaces.get(rdfNamespaceUri);
152        }
153        return rdfNamespaceUri;
154    }
155
156    /**
157     * Convert a JCR namespace into an RDF namespace fit for downstream
158     * consumption.
159     *
160     * @param jcrNamespaceUri a namespace from the JCR NamespaceRegistry
161     * @return an RDF namespace for downstream consumption.
162     */
163    public static String getRDFNamespaceForJcrNamespace(
164            final String jcrNamespaceUri) {
165        if (jcrNamespacesToRDFNamespaces.containsKey(jcrNamespaceUri)) {
166            return jcrNamespacesToRDFNamespaces.get(jcrNamespaceUri);
167        }
168        return jcrNamespaceUri;
169    }
170
171    /**
172     * Create a JCR value from an RDFNode for a given JCR property
173     * @param node the JCR node we want a property for
174     * @param data an RDF Node (possibly with a DataType)
175     * @param propertyName name of the property to populate (used to use the right type for the value)
176     * @return the JCR value from an RDFNode for a given JCR property
177     * @throws RepositoryException if repository exception occurred
178     */
179    public Value createValue(final Node node,
180                             final RDFNode data,
181                             final String propertyName) throws RepositoryException {
182        final ValueFactory valueFactory = node.getSession().getValueFactory();
183        return createValue(valueFactory, data, getPropertyType(node, propertyName).orElse(UNDEFINED));
184    }
185
186    /**
187     * Create a JCR value from an RDF node with the given JCR type
188     * @param valueFactory the given value factory
189     * @param data the rdf node data
190     * @param type the given JCR type
191     * @return created value
192     * @throws RepositoryException if repository exception occurred
193     */
194    public Value createValue(final ValueFactory valueFactory, final RDFNode data, final int type)
195        throws RepositoryException {
196        assert (valueFactory != null);
197
198
199        if (type == UNDEFINED || type == STRING) {
200            return valueConverter.reverse().convert(data);
201        } else if (type == REFERENCE || type == WEAKREFERENCE) {
202            // reference to another node (by path)
203            if (!data.isURIResource()) {
204                throw new ValueFormatException("Reference properties can only refer to URIs, not literals");
205            }
206
207            try {
208                final Node nodeFromGraphSubject = getJcrNode(idTranslator.convert(data.asResource()));
209                return valueFactory.createValue(nodeFromGraphSubject, type == WEAKREFERENCE);
210            } catch (final RepositoryRuntimeException e) {
211                throw new MalformedRdfException("Unable to find referenced node", e);
212            }
213        } else if (data.isResource()) {
214            LOGGER.debug("Using default JCR value creation for RDF resource: {}",
215                    data);
216            return valueFactory.createValue(data.asResource().getURI(), type);
217        } else {
218            LOGGER.debug("Using default JCR value creation for RDF literal: {}",
219                    data);
220            return valueFactory.createValue(data.asLiteral().getString(), type);
221        }
222    }
223
224    /**
225     * Add a mixin to a node
226     * @param resource the fedora resource
227     * @param mixinResource the mixin resource
228     * @param namespaces the namespace
229     * @throws RepositoryException if repository exception occurred
230     */
231    public void addMixin(final FedoraResource resource,
232                         final Resource mixinResource,
233                         final Map<String,String> namespaces)
234            throws RepositoryException {
235
236        final Node node = getJcrNode(resource);
237        final Session session = node.getSession();
238        final String mixinName = getPropertyNameFromPredicate(node, mixinResource, namespaces);
239        if (!repositoryHasType(session, mixinName)) {
240            final NodeTypeManager mgr = session.getWorkspace().getNodeTypeManager();
241            final NodeTypeTemplate type = mgr.createNodeTypeTemplate();
242            type.setName(mixinName);
243            type.setMixin(true);
244            type.setQueryable(true);
245            mgr.registerNodeType(type, false);
246        }
247
248        if (node.isNodeType(mixinName)) {
249            LOGGER.trace("Subject {} is already a {}; skipping", node, mixinName);
250            return;
251        }
252
253        if (node.canAddMixin(mixinName)) {
254            LOGGER.debug("Adding mixin: {} to node: {}.", mixinName, node.getPath());
255            node.addMixin(mixinName);
256        } else {
257            throw new MalformedRdfException("Could not persist triple containing type assertion: "
258                    + mixinResource.toString()
259                    + " because no such mixin/type can be added to this node: "
260                    + node.getPath() + "!");
261        }
262    }
263
264    /**
265     * Add property to a node
266     * @param resource the fedora resource
267     * @param predicate the predicate
268     * @param value the value
269     * @param namespaces the namespace
270     * @throws RepositoryException if repository exception occurred
271     */
272    public void addProperty(final FedoraResource resource,
273                            final org.apache.jena.rdf.model.Property predicate,
274                            final RDFNode value,
275                            final Map<String,String> namespaces) throws RepositoryException {
276
277        final Node node = getJcrNode(resource);
278
279        if (isManagedPredicate.test(predicate) || jcrProperties.contains(predicate)) {
280
281            throw new ServerManagedPropertyException("Could not persist triple containing predicate "
282                    + predicate.toString()
283                    + " to node "
284                    + node.getPath());
285        }
286
287        String propertyName =
288                getPropertyNameFromPredicate(node, predicate, namespaces);
289
290        // In earlier versions of Fedora the following JCR properties were
291        // used for properties.  When the behavior changed such that they could
292        // be set explicitly in some cases, we changed the underlying representation
293        // such that the "fedora"-namespaced property could be set, and when present
294        // would be presented instead of the underlying JCR-managed property.
295        if (propertyName.equals(JCR_LASTMODIFIEDBY)) {
296            propertyName = FEDORA_LASTMODIFIEDBY;
297        } else if (propertyName.equals(JCR_LASTMODIFIED)) {
298            propertyName = FEDORA_LASTMODIFIED;
299        } else if (propertyName.equals(JCR_CREATEDBY)) {
300            propertyName = FEDORA_CREATEDBY;
301        } else if (propertyName.equals(JCR_CREATED)) {
302            propertyName = FEDORA_CREATED;
303        }
304
305        if (value.isURIResource()
306                && idTranslator.inDomain(value.asResource())
307                && !isReferenceProperty(node, propertyName)) {
308            nodePropertiesTools.addReferencePlaceholders(idTranslator, node, propertyName, value.asResource());
309        } else {
310            final Value v = createValue(node, value, propertyName);
311            nodePropertiesTools.appendOrReplaceNodeProperty(node, propertyName, v);
312        }
313    }
314
315    protected boolean repositoryHasType(final Session session, final String mixinName) throws RepositoryException {
316        return session.getWorkspace().getNodeTypeManager().hasNodeType(mixinName);
317    }
318
319    /**
320     * Remove a mixin from a node
321     * @param resource the resource
322     * @param mixinResource the mixin resource
323     * @param nsPrefixMap the prefix map
324     * @throws RepositoryException if repository exception occurred
325     */
326    public void removeMixin(final FedoraResource resource,
327                            final Resource mixinResource,
328                            final Map<String, String> nsPrefixMap) throws RepositoryException {
329
330        final Node node = getJcrNode(resource);
331        final String mixinName = getPropertyNameFromPredicate(node, mixinResource, nsPrefixMap);
332        if (repositoryHasType(session, mixinName) && node.isNodeType(mixinName)) {
333            node.removeMixin(mixinName);
334        }
335
336    }
337
338    /**
339     * Remove a property from a node
340     * @param resource the fedora resource
341     * @param predicate the predicate
342     * @param objectNode the object node
343     * @param nsPrefixMap the prefix map
344     * @throws RepositoryException if repository exception occurred
345     */
346    public void removeProperty(final FedoraResource resource,
347                               final org.apache.jena.rdf.model.Property predicate,
348                               final RDFNode objectNode,
349                               final Map<String, String> nsPrefixMap) throws RepositoryException {
350
351        final Node node = getJcrNode(resource);
352        final String propertyName = getPropertyNameFromPredicate(node, predicate, nsPrefixMap);
353
354        if (isManagedPredicate.test(predicate) || jcrProperties.contains(predicate)) {
355
356            throw new ServerManagedPropertyException("Could not remove triple containing predicate "
357                    + predicate.toString()
358                    + " to node "
359                    + node.getPath());
360        }
361
362        if (objectNode.isURIResource()
363                && idTranslator.inDomain(objectNode.asResource())
364                && !isReferenceProperty(node, propertyName)) {
365            nodePropertiesTools.removeReferencePlaceholders(idTranslator,
366                    node,
367                    propertyName,
368                    objectNode.asResource());
369        } else {
370            final Value v = createValue(node, objectNode, propertyName);
371            nodePropertiesTools.removeNodeProperty(node, propertyName, v);
372        }
373    }
374
375    /**
376     * Convert an external statement into a persistable statement by skolemizing
377     * blank nodes, creating hash-uri subjects, etc
378     *
379     * @param idTranslator the property of idTranslator
380     * @param t the statement
381     * @param topic the topic
382     * @return the persistable statement
383     * @throws RepositoryException if repository exception occurred
384     */
385    public Statement skolemize(final IdentifierConverter<Resource, FedoraResource> idTranslator, final Statement t,
386            final String topic) throws RepositoryException {
387
388        Statement skolemized = t;
389
390        if (t.getSubject().isAnon()) {
391            skolemized = m.createStatement(getSkolemizedResource(idTranslator, skolemized.getSubject(), topic),
392                    skolemized.getPredicate(),
393                    skolemized.getObject());
394        } else if (idTranslator.inDomain(t.getSubject()) && t.getSubject().getURI().contains("#")) {
395            findOrCreateHashUri(idTranslator, t.getSubject());
396        }
397
398        if (t.getObject().isAnon()) {
399            skolemized = m.createStatement(skolemized.getSubject(), skolemized.getPredicate(), getSkolemizedResource
400                    (idTranslator, skolemized.getObject(), topic));
401        } else if (t.getObject().isResource()
402                && idTranslator.inDomain(t.getObject().asResource())
403                && t.getObject().asResource().getURI().contains("#")) {
404            findOrCreateHashUri(idTranslator, t.getObject().asResource());
405        }
406
407        return skolemized;
408    }
409
410    private void findOrCreateHashUri(final IdentifierConverter<Resource, FedoraResource> idTranslator,
411                                     final Resource s) throws RepositoryException {
412        final String absPath = idTranslator.asString(s);
413
414        if (!absPath.isEmpty() && !session.nodeExists(absPath)) {
415            final Node closestExistingAncestor = getClosestExistingAncestor(session, absPath);
416
417            final Node orCreateNode = jcrTools.findOrCreateNode(session, absPath, NT_FOLDER);
418            orCreateNode.addMixin(FEDORA_RESOURCE);
419
420            final Node parent = orCreateNode.getParent();
421
422            if (!parent.getName().equals("#")) {
423                throw new AssertionError("Hash URI resource created with too much hierarchy: " + s);
424            }
425
426            // We require the closest node to be either "#" resource, or its parent.
427            if (!parent.equals(closestExistingAncestor)
428                    && !parent.getParent().equals(closestExistingAncestor)) {
429                throw new PathNotFoundException("Unexpected request to create new resource " + s);
430            }
431
432            if (parent.isNew()) {
433                parent.addMixin(FEDORA_PAIRTREE);
434            }
435        }
436    }
437
438    private Resource getSkolemizedResource(final IdentifierConverter<Resource, FedoraResource> idTranslator,
439            final RDFNode resource, final String topic) throws RepositoryException {
440        final AnonId id = resource.asResource().getId();
441
442        if (!skolemizedBnodeMap.containsKey(id)) {
443            if (BNodeSkolemizationUtil.isSkolemizeToHashURIs()) {
444                final String base = idTranslator.asString(createResource(topic));
445                final Resource skolemizedSubject = idTranslator.toDomain(base + "#" + blankNodeIdentifier());
446                findOrCreateHashUri(idTranslator, skolemizedSubject);
447                skolemizedBnodeMap.put(id, skolemizedSubject);
448
449            } else {
450                jcrTools.findOrCreateNode(session, skolemizedPrefix());
451                final String pid = pidMinter.get();
452                final String path = skolemizedPrefix() + pid;
453                final Node preexistingNode = getClosestExistingAncestor(session, path);
454                final Node orCreateNode = jcrTools.findOrCreateNode(session, path);
455                orCreateNode.addMixin(FEDORA_SKOLEM);
456
457                if (preexistingNode != null) {
458                    AbstractService.tagHierarchyWithPairtreeMixin(preexistingNode,
459                            orCreateNode);
460                }
461
462                final Resource skolemizedSubject = nodeToResource(idTranslator).convert(orCreateNode);
463                skolemizedBnodeMap.put(id, skolemizedSubject);
464            }
465        }
466
467        return skolemizedBnodeMap.get(id);
468    }
469
470    private static String skolemizedPrefix() {
471        return "/.well-known/genid/";
472    }
473
474    private String blankNodeIdentifier() {
475        return "genid" + randomUUID().toString();
476    }
477
478    private static class DefaultPathMinter implements HierarchicalIdentifierSupplier { }
479}