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}