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