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