001/* 002 * (C) Copyright 2012 Nuxeo SA (http://nuxeo.com/) and others. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 * 016 * Contributors: 017 * Antoine Taillefer <ataillefer@nuxeo.com> 018 */ 019package org.nuxeo.drive.adapter.impl; 020 021import static org.nuxeo.ecm.platform.query.nxql.CoreQueryDocumentPageProvider.CORE_SESSION_PROPERTY; 022 023import java.io.IOException; 024import java.io.Serializable; 025import java.util.ArrayList; 026import java.util.HashMap; 027import java.util.Iterator; 028import java.util.List; 029import java.util.Map; 030import java.util.UUID; 031import java.util.concurrent.Semaphore; 032 033import org.apache.commons.lang.StringUtils; 034import org.apache.commons.logging.Log; 035import org.apache.commons.logging.LogFactory; 036import org.nuxeo.drive.adapter.FileItem; 037import org.nuxeo.drive.adapter.FileSystemItem; 038import org.nuxeo.drive.adapter.FolderItem; 039import org.nuxeo.drive.adapter.RootlessItemException; 040import org.nuxeo.drive.adapter.ScrollFileSystemItemList; 041import org.nuxeo.drive.service.FileSystemItemAdapterService; 042import org.nuxeo.ecm.core.api.Blob; 043import org.nuxeo.ecm.core.api.CoreInstance; 044import org.nuxeo.ecm.core.api.CoreSession; 045import org.nuxeo.ecm.core.api.DocumentModel; 046import org.nuxeo.ecm.core.api.DocumentModelList; 047import org.nuxeo.ecm.core.api.DocumentRef; 048import org.nuxeo.ecm.core.api.DocumentSecurityException; 049import org.nuxeo.ecm.core.api.IdRef; 050import org.nuxeo.ecm.core.api.IterableQueryResult; 051import org.nuxeo.ecm.core.api.NuxeoException; 052import org.nuxeo.ecm.core.api.security.SecurityConstants; 053import org.nuxeo.ecm.core.cache.Cache; 054import org.nuxeo.ecm.core.cache.CacheService; 055import org.nuxeo.ecm.core.query.sql.NXQL; 056import org.nuxeo.ecm.core.schema.FacetNames; 057import org.nuxeo.ecm.platform.filemanager.api.FileManager; 058import org.nuxeo.ecm.platform.query.api.PageProvider; 059import org.nuxeo.ecm.platform.query.api.PageProviderService; 060import org.nuxeo.runtime.api.Framework; 061import org.nuxeo.runtime.services.config.ConfigurationService; 062 063/** 064 * {@link DocumentModel} backed implementation of a {@link FolderItem}. 065 * 066 * @author Antoine Taillefer 067 */ 068public class DocumentBackedFolderItem extends AbstractDocumentBackedFileSystemItem implements FolderItem { 069 070 private static final Log log = LogFactory.getLog(DocumentBackedFolderItem.class); 071 072 private static final long serialVersionUID = 1L; 073 074 private static final String FOLDER_ITEM_CHILDREN_PAGE_PROVIDER = "FOLDER_ITEM_CHILDREN"; 075 076 protected static final String DESCENDANTS_SCROLL_CACHE = "driveDescendantsScroll"; 077 078 protected static final String MAX_DESCENDANTS_BATCH_SIZE_PROPERTY = "org.nuxeo.drive.maxDescendantsBatchSize"; 079 080 protected static final String MAX_DESCENDANTS_BATCH_SIZE_DEFAULT = "1000"; 081 082 protected static final int VCS_CHUNK_SIZE = 100; 083 084 protected boolean canCreateChild; 085 086 protected boolean canScrollDescendants; 087 088 public DocumentBackedFolderItem(String factoryName, DocumentModel doc) { 089 this(factoryName, doc, false); 090 } 091 092 public DocumentBackedFolderItem(String factoryName, DocumentModel doc, boolean relaxSyncRootConstraint) { 093 this(factoryName, doc, relaxSyncRootConstraint, true); 094 } 095 096 public DocumentBackedFolderItem(String factoryName, DocumentModel doc, boolean relaxSyncRootConstraint, 097 boolean getLockInfo) { 098 super(factoryName, doc, relaxSyncRootConstraint, getLockInfo); 099 initialize(doc); 100 } 101 102 public DocumentBackedFolderItem(String factoryName, FolderItem parentItem, DocumentModel doc) { 103 this(factoryName, parentItem, doc, false); 104 } 105 106 public DocumentBackedFolderItem(String factoryName, FolderItem parentItem, DocumentModel doc, 107 boolean relaxSyncRootConstraint) { 108 this(factoryName, parentItem, doc, relaxSyncRootConstraint, true); 109 } 110 111 public DocumentBackedFolderItem(String factoryName, FolderItem parentItem, DocumentModel doc, 112 boolean relaxSyncRootConstraint, boolean getLockInfo) { 113 super(factoryName, parentItem, doc, relaxSyncRootConstraint, getLockInfo); 114 initialize(doc); 115 } 116 117 protected DocumentBackedFolderItem() { 118 // Needed for JSON deserialization 119 } 120 121 /*--------------------- FileSystemItem ---------------------*/ 122 @Override 123 public void rename(String name) { 124 try (CoreSession session = CoreInstance.openCoreSession(repositoryName, principal)) { 125 // Update doc properties 126 DocumentModel doc = getDocument(session); 127 doc.setPropertyValue("dc:title", name); 128 doc = session.saveDocument(doc); 129 session.save(); 130 // Update FileSystemItem attributes 131 this.docTitle = name; 132 this.name = name; 133 updateLastModificationDate(doc); 134 } 135 } 136 137 /*--------------------- FolderItem -----------------*/ 138 @Override 139 @SuppressWarnings("unchecked") 140 public List<FileSystemItem> getChildren() { 141 try (CoreSession session = CoreInstance.openCoreSession(repositoryName, principal)) { 142 PageProviderService pageProviderService = Framework.getLocalService(PageProviderService.class); 143 Map<String, Serializable> props = new HashMap<String, Serializable>(); 144 props.put(CORE_SESSION_PROPERTY, (Serializable) session); 145 PageProvider<DocumentModel> childrenPageProvider = (PageProvider<DocumentModel>) pageProviderService.getPageProvider( 146 FOLDER_ITEM_CHILDREN_PAGE_PROVIDER, null, null, 0L, props, docId); 147 Long pageSize = childrenPageProvider.getPageSize(); 148 149 List<FileSystemItem> children = new ArrayList<FileSystemItem>(); 150 int nbChildren = 0; 151 boolean reachedPageSize = false; 152 boolean hasNextPage = true; 153 // Since query results are filtered, make sure we iterate on PageProvider to get at most its page size 154 // number of 155 // FileSystemItems 156 while (nbChildren < pageSize && hasNextPage) { 157 List<DocumentModel> dmChildren = childrenPageProvider.getCurrentPage(); 158 for (DocumentModel dmChild : dmChildren) { 159 // NXP-19442: Avoid useless and costly call to DocumentModel#getLockInfo 160 FileSystemItem child = getFileSystemItemAdapterService().getFileSystemItem(dmChild, this, false, 161 false, false); 162 if (child != null) { 163 children.add(child); 164 nbChildren++; 165 if (nbChildren == pageSize) { 166 reachedPageSize = true; 167 break; 168 } 169 } 170 } 171 if (!reachedPageSize) { 172 hasNextPage = childrenPageProvider.isNextPageAvailable(); 173 if (hasNextPage) { 174 childrenPageProvider.nextPage(); 175 } 176 } 177 } 178 179 return children; 180 } 181 } 182 183 @Override 184 public boolean getCanScrollDescendants() { 185 return canScrollDescendants; 186 } 187 188 @Override 189 public ScrollFileSystemItemList scrollDescendants(String scrollId, int batchSize, long keepAlive) { 190 Semaphore semaphore = Framework.getService(FileSystemItemAdapterService.class).getScrollBatchSemaphore(); 191 try { 192 if (log.isTraceEnabled()) { 193 log.trace(String.format("Thread [%s] acquiring scroll batch semaphore", 194 Thread.currentThread().getName())); 195 } 196 semaphore.acquire(); 197 try { 198 if (log.isTraceEnabled()) { 199 log.trace(String.format( 200 "Thread [%s] acquired scroll batch semaphore, available permits reduced to %d", 201 Thread.currentThread().getName(), semaphore.availablePermits())); 202 } 203 return doScrollDescendants(scrollId, batchSize, keepAlive); 204 } finally { 205 semaphore.release(); 206 if (log.isTraceEnabled()) { 207 log.trace(String.format( 208 "Thread [%s] released scroll batch semaphore, available permits increased to %d", 209 Thread.currentThread().getName(), semaphore.availablePermits())); 210 } 211 } 212 } catch (InterruptedException cause) { 213 Thread.currentThread().interrupt(); 214 throw new NuxeoException("Scroll batch interrupted", cause); 215 } 216 } 217 218 @SuppressWarnings("unchecked") 219 protected ScrollFileSystemItemList doScrollDescendants(String scrollId, int batchSize, long keepAlive) { 220 try (CoreSession session = CoreInstance.openCoreSession(repositoryName, principal)) { 221 222 // Limit batch size sent by the client 223 checkBatchSize(batchSize); 224 225 // Scroll through a batch of documents 226 ScrollDocumentModelList descendantDocsBatch = getScrollBatch(scrollId, batchSize, session, keepAlive); 227 String newScrollId = descendantDocsBatch.getScrollId(); 228 if (descendantDocsBatch.isEmpty()) { 229 // No more descendants left to return 230 return new ScrollFileSystemItemListImpl(newScrollId, 0); 231 } 232 233 // Adapt documents as FileSystemItems 234 List<FileSystemItem> descendants = adaptDocuments(descendantDocsBatch, session); 235 if (log.isDebugEnabled()) { 236 log.debug(String.format("Retrieved %d descendants of FolderItem %s (batchSize = %d)", 237 descendants.size(), docPath, batchSize)); 238 } 239 return new ScrollFileSystemItemListImpl(newScrollId, descendants); 240 } 241 } 242 243 protected void checkBatchSize(int batchSize) { 244 int maxDescendantsBatchSize = Integer.parseInt(Framework.getService(ConfigurationService.class).getProperty( 245 MAX_DESCENDANTS_BATCH_SIZE_PROPERTY, MAX_DESCENDANTS_BATCH_SIZE_DEFAULT)); 246 if (batchSize > maxDescendantsBatchSize) { 247 throw new NuxeoException(String.format( 248 "Batch size %d is greater than the maximum batch size allowed %d. If you need to increase this limit you can set the %s configuration property but this is not recommended for performance reasons.", 249 batchSize, maxDescendantsBatchSize, MAX_DESCENDANTS_BATCH_SIZE_PROPERTY)); 250 } 251 } 252 253 @SuppressWarnings("unchecked") 254 protected ScrollDocumentModelList getScrollBatch(String scrollId, int batchSize, CoreSession session, 255 long keepAlive) { 256 Cache scrollingCache = Framework.getService(CacheService.class).getCache(DESCENDANTS_SCROLL_CACHE); 257 if (scrollingCache == null) { 258 throw new NuxeoException("Cache not found: " + DESCENDANTS_SCROLL_CACHE); 259 } 260 String newScrollId; 261 List<String> descendantIds; 262 if (StringUtils.isEmpty(scrollId)) { 263 // Perform initial query to fetch ids of all the descendant documents and put the result list in a 264 // cache, aka "search context" 265 descendantIds = new ArrayList<>(); 266 StringBuilder sb = new StringBuilder( 267 String.format("SELECT ecm:uuid FROM Document WHERE ecm:ancestorId = '%s'", docId)); 268 sb.append(" AND ecm:currentLifeCycleState != 'deleted'"); 269 sb.append(" AND ecm:mixinType != 'HiddenInNavigation'"); 270 // Don't need to add ecm:isCheckedInVersion = 0 because versions are already excluded by the 271 // ecm:ancestorId clause since they have no path 272 String query = sb.toString(); 273 if (log.isDebugEnabled()) { 274 log.debug(String.format("Executing initial query to scroll through the descendants of %s: %s", docPath, 275 query)); 276 } 277 try (IterableQueryResult res = session.queryAndFetch(sb.toString(), NXQL.NXQL)) { 278 Iterator<Map<String, Serializable>> it = res.iterator(); 279 while (it.hasNext()) { 280 descendantIds.add((String) it.next().get(NXQL.ECM_UUID)); 281 } 282 } 283 // Generate a scroll id 284 newScrollId = UUID.randomUUID().toString(); 285 if (log.isDebugEnabled()) { 286 log.debug(String.format( 287 "Put initial query result list (search context) in the %s cache at key (scrollId) %s", 288 DESCENDANTS_SCROLL_CACHE, newScrollId)); 289 } 290 scrollingCache.put(newScrollId, (Serializable) descendantIds); 291 } else { 292 // Get the descendant ids from the cache 293 descendantIds = (List<String>) scrollingCache.get(scrollId); 294 if (descendantIds == null) { 295 throw new NuxeoException(String.format("No search context found in the %s cache for scrollId [%s]", 296 DESCENDANTS_SCROLL_CACHE, scrollId)); 297 } 298 newScrollId = scrollId; 299 } 300 301 if (descendantIds.isEmpty()) { 302 return new ScrollDocumentModelList(newScrollId, 0); 303 } 304 305 // Extract a batch of descendant ids 306 List<String> descendantIdsBatch = getBatch(descendantIds, batchSize); 307 // Update descendant ids in the cache 308 scrollingCache.put(newScrollId, (Serializable) descendantIds); 309 // Fetch documents from VCS 310 DocumentModelList descendantDocsBatch = fetchFromVCS(descendantIdsBatch, session); 311 return new ScrollDocumentModelList(newScrollId, descendantDocsBatch); 312 } 313 314 /** 315 * Extracts batchSize elements from the input list. 316 */ 317 protected List<String> getBatch(List<String> ids, int batchSize) { 318 List<String> batch = new ArrayList<>(batchSize); 319 int idCount = 0; 320 Iterator<String> it = ids.iterator(); 321 while (it.hasNext() && idCount < batchSize) { 322 batch.add(it.next()); 323 it.remove(); 324 idCount++; 325 } 326 return batch; 327 } 328 329 protected DocumentModelList fetchFromVCS(List<String> ids, CoreSession session) { 330 DocumentModelList res = null; 331 int size = ids.size(); 332 int start = 0; 333 int end = Math.min(VCS_CHUNK_SIZE, size); 334 boolean done = false; 335 while (!done) { 336 DocumentModelList docs = fetchFromVcsChunk(ids.subList(start, end), session); 337 if (res == null) { 338 res = docs; 339 } else { 340 res.addAll(docs); 341 } 342 if (end >= ids.size()) { 343 done = true; 344 } else { 345 start = end; 346 end = Math.min(start + VCS_CHUNK_SIZE, size); 347 } 348 } 349 return res; 350 } 351 352 protected DocumentModelList fetchFromVcsChunk(final List<String> ids, CoreSession session) { 353 int docCount = ids.size(); 354 StringBuilder sb = new StringBuilder(); 355 sb.append("SELECT * FROM Document WHERE ecm:uuid IN ("); 356 for (int i = 0; i < docCount; i++) { 357 sb.append(NXQL.escapeString(ids.get(i))); 358 if (i < docCount - 1) { 359 sb.append(", "); 360 } 361 } 362 sb.append(")"); 363 String query = sb.toString(); 364 if (log.isDebugEnabled()) { 365 log.debug(String.format("Fetching %d documents from VCS: %s", docCount, query)); 366 } 367 return session.query(query); 368 } 369 370 /** 371 * Adapts the given {@link DocumentModelList} as {@link FileSystemItem}s using a cache for the {@link FolderItem} 372 * ancestors. 373 */ 374 protected List<FileSystemItem> adaptDocuments(DocumentModelList docs, CoreSession session) { 375 Map<DocumentRef, FolderItem> ancestorCache = new HashMap<>(); 376 if (log.isTraceEnabled()) { 377 log.trace(String.format("Caching current FolderItem for doc %s: %s", docPath, getPath())); 378 } 379 ancestorCache.put(new IdRef(docId), this); 380 List<FileSystemItem> descendants = new ArrayList<>(docs.size()); 381 for (DocumentModel doc : docs) { 382 FolderItem parent = populateAncestorCache(ancestorCache, doc, session, false); 383 // NXP-19442: Avoid useless and costly call to DocumentModel#getLockInfo 384 FileSystemItem descendant = getFileSystemItemAdapterService().getFileSystemItem(doc, parent, false, false, 385 false); 386 if (descendant != null) { 387 if (descendant.isFolder()) { 388 if (log.isTraceEnabled()) { 389 log.trace(String.format("Caching descendant FolderItem for doc %s: %s", doc.getPathAsString(), 390 descendant.getPath())); 391 } 392 ancestorCache.put(doc.getRef(), (FolderItem) descendant); 393 } 394 descendants.add(descendant); 395 } 396 } 397 return descendants; 398 } 399 400 protected FolderItem populateAncestorCache(Map<DocumentRef, FolderItem> cache, DocumentModel doc, 401 CoreSession session, boolean cacheItem) { 402 // TODO: handle collections 403 DocumentRef parentDocRef = session.getParentDocumentRef(doc.getRef()); 404 if (parentDocRef == null) { 405 throw new RootlessItemException("Reached repository root"); 406 } 407 408 FolderItem parentItem = cache.get(parentDocRef); 409 if (parentItem != null) { 410 if (log.isTraceEnabled()) { 411 log.trace(String.format("Found parent FolderItem in cache for doc %s: %s", doc.getPathAsString(), 412 parentItem.getPath())); 413 } 414 return getFolderItem(cache, doc, parentItem, cacheItem); 415 } 416 417 if (log.isTraceEnabled()) { 418 log.trace(String.format("No parent FolderItem found in cache for doc %s, computing ancestor cache", 419 doc.getPathAsString())); 420 } 421 DocumentModel parentDoc = null; 422 try { 423 parentDoc = session.getDocument(parentDocRef); 424 } catch (DocumentSecurityException e) { 425 throw new RootlessItemException(String.format("User %s has no READ access on parent of document %s (%s).", 426 principal.getName(), doc.getPathAsString(), doc.getId()), e); 427 } 428 parentItem = populateAncestorCache(cache, parentDoc, session, true); 429 return getFolderItem(cache, doc, parentItem, cacheItem); 430 } 431 432 protected FolderItem getFolderItem(Map<DocumentRef, FolderItem> cache, DocumentModel doc, FolderItem parentItem, 433 boolean cacheItem) { 434 if (cacheItem) { 435 // NXP-19442: Avoid useless and costly call to DocumentModel#getLockInfo 436 FileSystemItem fsItem = getFileSystemItemAdapterService().getFileSystemItem(doc, parentItem, true, false, 437 false); 438 if (fsItem == null) { 439 throw new RootlessItemException(String.format( 440 "Reached a document %s that cannot be adapted as a (possibly virtual) descendant of the top level folder item.", 441 doc.getPathAsString())); 442 } 443 FolderItem folderItem = (FolderItem) fsItem; 444 if (log.isTraceEnabled()) { 445 log.trace(String.format("Caching FolderItem for doc %s: %s", doc.getPathAsString(), 446 folderItem.getPath())); 447 } 448 cache.put(doc.getRef(), folderItem); 449 return folderItem; 450 } else { 451 return parentItem; 452 } 453 } 454 455 @Override 456 public boolean getCanCreateChild() { 457 return canCreateChild; 458 } 459 460 @Override 461 public FolderItem createFolder(String name) { 462 try (CoreSession session = CoreInstance.openCoreSession(repositoryName, principal)) { 463 DocumentModel folder = getFileManager().createFolder(session, name, docPath); 464 if (folder == null) { 465 throw new NuxeoException(String.format( 466 "Cannot create folder named '%s' as a child of doc %s. Probably because of the allowed sub-types for this doc type, please check them.", 467 name, docPath)); 468 } 469 return (FolderItem) getFileSystemItemAdapterService().getFileSystemItem(folder, this); 470 } catch (NuxeoException e) { 471 e.addInfo(String.format("Error while trying to create folder %s as a child of doc %s", name, docPath)); 472 throw e; 473 } catch (IOException e) { 474 throw new NuxeoException( 475 String.format("Error while trying to create folder %s as a child of doc %s", name, docPath), e); 476 } 477 } 478 479 @Override 480 public FileItem createFile(Blob blob) { 481 String fileName = blob.getFilename(); 482 try (CoreSession session = CoreInstance.openCoreSession(repositoryName, principal)) { 483 // TODO: manage conflict (overwrite should not necessarily be true) 484 DocumentModel file = getFileManager().createDocumentFromBlob(session, blob, docPath, true, fileName); 485 if (file == null) { 486 throw new NuxeoException(String.format( 487 "Cannot create file '%s' as a child of doc %s. Probably because there are no file importers registered, please check the contributions to the <extension target=\"org.nuxeo.ecm.platform.filemanager.service.FileManagerService\" point=\"plugins\"> extension point.", 488 fileName, docPath)); 489 } 490 return (FileItem) getFileSystemItemAdapterService().getFileSystemItem(file, this); 491 } catch (NuxeoException e) { 492 e.addInfo(String.format("Error while trying to create file %s as a child of doc %s", fileName, docPath)); 493 throw e; 494 } catch (IOException e) { 495 throw new NuxeoException( 496 String.format("Error while trying to create file %s as a child of doc %s", fileName, docPath), e); 497 } 498 } 499 500 /*--------------------- Protected -----------------*/ 501 protected void initialize(DocumentModel doc) { 502 this.name = docTitle; 503 this.folder = true; 504 this.canCreateChild = !doc.hasFacet(FacetNames.PUBLISH_SPACE); 505 if (canCreateChild) { 506 if (Framework.getService(ConfigurationService.class) 507 .isBooleanPropertyTrue(PERMISSION_CHECK_OPTIMIZED_PROPERTY)) { 508 // In optimized mode consider that canCreateChild <=> canRename because canRename <=> WriteProperties 509 // and by default WriteProperties <=> Write <=> AddChildren 510 this.canCreateChild = canRename; 511 } else { 512 // In non optimized mode check AddChildren 513 this.canCreateChild = doc.getCoreSession().hasPermission(doc.getRef(), SecurityConstants.ADD_CHILDREN); 514 } 515 } 516 this.canScrollDescendants = true; 517 } 518 519 protected FileManager getFileManager() { 520 return Framework.getLocalService(FileManager.class); 521 } 522 523 /*---------- Needed for JSON deserialization ----------*/ 524 protected void setCanCreateChild(boolean canCreateChild) { 525 this.canCreateChild = canCreateChild; 526 } 527 528 protected void setCanScrollDescendants(boolean canScrollDescendants) { 529 this.canScrollDescendants = canScrollDescendants; 530 } 531 532}