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