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 <[email protected]> 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.lang3.StringUtils; 034import org.apache.logging.log4j.LogManager; 035import org.apache.logging.log4j.Logger; 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.CloseableCoreSession; 044import org.nuxeo.ecm.core.api.CoreInstance; 045import org.nuxeo.ecm.core.api.CoreSession; 046import org.nuxeo.ecm.core.api.DocumentModel; 047import org.nuxeo.ecm.core.api.DocumentModelList; 048import org.nuxeo.ecm.core.api.DocumentRef; 049import org.nuxeo.ecm.core.api.DocumentSecurityException; 050import org.nuxeo.ecm.core.api.IdRef; 051import org.nuxeo.ecm.core.api.IterableQueryResult; 052import org.nuxeo.ecm.core.api.NuxeoException; 053import org.nuxeo.ecm.core.api.security.SecurityConstants; 054import org.nuxeo.ecm.core.cache.Cache; 055import org.nuxeo.ecm.core.cache.CacheService; 056import org.nuxeo.ecm.core.query.sql.NXQL; 057import org.nuxeo.ecm.core.schema.FacetNames; 058import org.nuxeo.ecm.platform.filemanager.api.FileImporterContext; 059import org.nuxeo.ecm.platform.filemanager.api.FileManager; 060import org.nuxeo.ecm.platform.query.api.PageProvider; 061import org.nuxeo.ecm.platform.query.api.PageProviderService; 062import org.nuxeo.runtime.api.Framework; 063import org.nuxeo.runtime.services.config.ConfigurationService; 064 065/** 066 * {@link DocumentModel} backed implementation of a {@link FolderItem}. 067 * 068 * @author Antoine Taillefer 069 */ 070public class DocumentBackedFolderItem extends AbstractDocumentBackedFileSystemItem implements FolderItem { 071 072 private static final Logger log = LogManager.getLogger(DocumentBackedFolderItem.class); 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 (CloseableCoreSession session = CoreInstance.openCoreSession(repositoryName, principal)) { 125 // Update doc properties 126 DocumentModel doc = getDocument(session); 127 doc.setPropertyValue("dc:title", name); 128 doc.putContextData(CoreSession.SOURCE, "drive"); 129 doc = session.saveDocument(doc); 130 session.save(); 131 // Update FileSystemItem attributes 132 this.docTitle = name; 133 this.name = name; 134 updateLastModificationDate(doc); 135 } 136 } 137 138 /*--------------------- FolderItem -----------------*/ 139 @Override 140 @SuppressWarnings("unchecked") 141 public List<FileSystemItem> getChildren() { 142 try (CloseableCoreSession session = CoreInstance.openCoreSession(repositoryName, principal)) { 143 PageProviderService pageProviderService = Framework.getService(PageProviderService.class); 144 Map<String, Serializable> props = new HashMap<>(); 145 props.put(CORE_SESSION_PROPERTY, (Serializable) session); 146 PageProvider<DocumentModel> childrenPageProvider = (PageProvider<DocumentModel>) pageProviderService.getPageProvider( 147 FOLDER_ITEM_CHILDREN_PAGE_PROVIDER, null, null, 0L, props, docId); 148 long pageSize = childrenPageProvider.getPageSize(); 149 150 List<FileSystemItem> children = new ArrayList<>(); 151 int nbChildren = 0; 152 boolean reachedPageSize = false; 153 boolean hasNextPage = true; 154 // Since query results are filtered, make sure we iterate on PageProvider to get at most its page size 155 // number of 156 // FileSystemItems 157 while (nbChildren < pageSize && hasNextPage) { 158 List<DocumentModel> dmChildren = childrenPageProvider.getCurrentPage(); 159 for (DocumentModel dmChild : dmChildren) { 160 // NXP-19442: Avoid useless and costly call to DocumentModel#getLockInfo 161 FileSystemItem child = getFileSystemItemAdapterService().getFileSystemItem(dmChild, this, false, 162 false, false); 163 if (child != null) { 164 children.add(child); 165 nbChildren++; 166 if (nbChildren == pageSize) { 167 reachedPageSize = true; 168 break; 169 } 170 } 171 } 172 if (!reachedPageSize) { 173 hasNextPage = childrenPageProvider.isNextPageAvailable(); 174 if (hasNextPage) { 175 childrenPageProvider.nextPage(); 176 } 177 } 178 } 179 180 return children; 181 } 182 } 183 184 @Override 185 public boolean getCanScrollDescendants() { 186 return canScrollDescendants; 187 } 188 189 @Override 190 public ScrollFileSystemItemList scrollDescendants(String scrollId, int batchSize, long keepAlive) { 191 Semaphore semaphore = Framework.getService(FileSystemItemAdapterService.class).getScrollBatchSemaphore(); 192 try { 193 log.trace("Thread [{}] acquiring scroll batch semaphore", Thread::currentThread); 194 semaphore.acquire(); 195 try { 196 log.trace("Thread [{}] acquired scroll batch semaphore, available permits reduced to {}", 197 Thread::currentThread, semaphore::availablePermits); 198 return doScrollDescendants(scrollId, batchSize, keepAlive); 199 } finally { 200 semaphore.release(); 201 log.trace("Thread [{}] released scroll batch semaphore, available permits increased to {}", 202 Thread::currentThread, semaphore::availablePermits); 203 } 204 } catch (InterruptedException cause) { 205 Thread.currentThread().interrupt(); 206 throw new NuxeoException("Scroll batch interrupted", cause); 207 } 208 } 209 210 protected ScrollFileSystemItemList doScrollDescendants(String scrollId, int batchSize, long keepAlive) { 211 try (CloseableCoreSession session = CoreInstance.openCoreSession(repositoryName, principal)) { 212 213 // Limit batch size sent by the client 214 checkBatchSize(batchSize); 215 216 // Scroll through a batch of documents 217 ScrollDocumentModelList descendantDocsBatch = getScrollBatch(scrollId, batchSize, session, keepAlive); 218 String newScrollId = descendantDocsBatch.getScrollId(); 219 if (descendantDocsBatch.isEmpty()) { 220 // No more descendants left to return 221 return new ScrollFileSystemItemListImpl(newScrollId, 0); 222 } 223 224 // Adapt documents as FileSystemItems 225 List<FileSystemItem> descendants = adaptDocuments(descendantDocsBatch, session); 226 log.debug("Retrieved {} descendants of FolderItem {} (batchSize = {})", descendants::size, () -> docPath, 227 () -> batchSize); 228 return new ScrollFileSystemItemListImpl(newScrollId, descendants); 229 } 230 } 231 232 protected void checkBatchSize(int batchSize) { 233 int maxDescendantsBatchSize = Integer.parseInt(Framework.getService(ConfigurationService.class).getProperty( 234 MAX_DESCENDANTS_BATCH_SIZE_PROPERTY, MAX_DESCENDANTS_BATCH_SIZE_DEFAULT)); 235 if (batchSize > maxDescendantsBatchSize) { 236 throw new NuxeoException(String.format( 237 "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.", 238 batchSize, maxDescendantsBatchSize, MAX_DESCENDANTS_BATCH_SIZE_PROPERTY)); 239 } 240 } 241 242 @SuppressWarnings("unchecked") 243 protected ScrollDocumentModelList getScrollBatch(String scrollId, int batchSize, CoreSession session, 244 long keepAlive) { // NOSONAR 245 Cache scrollingCache = Framework.getService(CacheService.class).getCache(DESCENDANTS_SCROLL_CACHE); 246 if (scrollingCache == null) { 247 throw new NuxeoException("Cache not found: " + DESCENDANTS_SCROLL_CACHE); 248 } 249 String newScrollId; 250 List<String> descendantIds; 251 if (StringUtils.isEmpty(scrollId)) { 252 // Perform initial query to fetch ids of all the descendant documents and put the result list in a 253 // cache, aka "search context" 254 descendantIds = new ArrayList<>(); 255 StringBuilder sb = new StringBuilder( 256 String.format("SELECT ecm:uuid FROM Document WHERE ecm:ancestorId = '%s'", docId)); 257 sb.append(" AND ecm:isTrashed = 0"); 258 sb.append(" AND ecm:mixinType != 'HiddenInNavigation'"); 259 // Don't need to add ecm:isVersion = 0 because versions are already excluded by the 260 // ecm:ancestorId clause since they have no path 261 String query = sb.toString(); 262 log.debug("Executing initial query to scroll through the descendants of {}: {}", docPath, query); 263 try (IterableQueryResult res = session.queryAndFetch(sb.toString(), NXQL.NXQL)) { 264 Iterator<Map<String, Serializable>> it = res.iterator(); 265 while (it.hasNext()) { 266 descendantIds.add((String) it.next().get(NXQL.ECM_UUID)); 267 } 268 } 269 // Generate a scroll id 270 newScrollId = UUID.randomUUID().toString(); 271 log.debug("Put initial query result list (search context) in the {} cache at key (scrollId) {}", 272 DESCENDANTS_SCROLL_CACHE, newScrollId); 273 scrollingCache.put(newScrollId, (Serializable) descendantIds); 274 } else { 275 // Get the descendant ids from the cache 276 descendantIds = (List<String>) scrollingCache.get(scrollId); 277 if (descendantIds == null) { 278 throw new NuxeoException(String.format("No search context found in the %s cache for scrollId [%s]", 279 DESCENDANTS_SCROLL_CACHE, scrollId)); 280 } 281 newScrollId = scrollId; 282 } 283 284 if (descendantIds.isEmpty()) { 285 return new ScrollDocumentModelList(newScrollId, 0); 286 } 287 288 // Extract a batch of descendant ids 289 List<String> descendantIdsBatch = getBatch(descendantIds, batchSize); 290 // Update descendant ids in the cache 291 scrollingCache.put(newScrollId, (Serializable) descendantIds); 292 // Fetch documents from VCS 293 DocumentModelList descendantDocsBatch = fetchFromVCS(descendantIdsBatch, session); 294 return new ScrollDocumentModelList(newScrollId, descendantDocsBatch); 295 } 296 297 /** 298 * Extracts batchSize elements from the input list. 299 */ 300 protected List<String> getBatch(List<String> ids, int batchSize) { 301 List<String> batch = new ArrayList<>(batchSize); 302 int idCount = 0; 303 Iterator<String> it = ids.iterator(); 304 while (it.hasNext() && idCount < batchSize) { 305 batch.add(it.next()); 306 it.remove(); 307 idCount++; 308 } 309 return batch; 310 } 311 312 protected DocumentModelList fetchFromVCS(List<String> ids, CoreSession session) { 313 DocumentModelList res = null; 314 int size = ids.size(); 315 int start = 0; 316 int end = Math.min(VCS_CHUNK_SIZE, size); 317 boolean done = false; 318 while (!done) { 319 DocumentModelList docs = fetchFromVcsChunk(ids.subList(start, end), session); 320 if (res == null) { 321 res = docs; 322 } else { 323 res.addAll(docs); 324 } 325 if (end >= ids.size()) { 326 done = true; 327 } else { 328 start = end; 329 end = Math.min(start + VCS_CHUNK_SIZE, size); 330 } 331 } 332 return res; 333 } 334 335 protected DocumentModelList fetchFromVcsChunk(final List<String> ids, CoreSession session) { 336 int docCount = ids.size(); 337 StringBuilder sb = new StringBuilder(); 338 sb.append("SELECT * FROM Document WHERE ecm:uuid IN ("); 339 for (int i = 0; i < docCount; i++) { 340 sb.append(NXQL.escapeString(ids.get(i))); 341 if (i < docCount - 1) { 342 sb.append(", "); 343 } 344 } 345 sb.append(")"); 346 String query = sb.toString(); 347 log.debug("Fetching {} documents from VCS: {}", docCount, query); 348 return session.query(query); 349 } 350 351 /** 352 * Adapts the given {@link DocumentModelList} as {@link FileSystemItem}s using a cache for the {@link FolderItem} 353 * ancestors. 354 */ 355 protected List<FileSystemItem> adaptDocuments(DocumentModelList docs, CoreSession session) { 356 Map<DocumentRef, FolderItem> ancestorCache = new HashMap<>(); 357 log.trace("Caching current FolderItem for doc {}: {}", () -> docPath, this::getPath); 358 ancestorCache.put(new IdRef(docId), this); 359 List<FileSystemItem> descendants = new ArrayList<>(docs.size()); 360 for (DocumentModel doc : docs) { 361 FolderItem parent = populateAncestorCache(ancestorCache, doc, session, false); 362 if (parent == null) { 363 log.debug("Cannot adapt parent document of {} as a FileSystemItem, skipping descendant document", 364 doc::getPathAsString); 365 continue; 366 } 367 // NXP-19442: Avoid useless and costly call to DocumentModel#getLockInfo 368 FileSystemItem descendant = getFileSystemItemAdapterService().getFileSystemItem(doc, parent, false, false, 369 false); 370 if (descendant != null) { 371 if (descendant.isFolder()) { 372 log.trace("Caching descendant FolderItem for doc {}: {}", doc::getPathAsString, 373 descendant::getPath); 374 ancestorCache.put(doc.getRef(), (FolderItem) descendant); 375 } 376 descendants.add(descendant); 377 } 378 } 379 return descendants; 380 } 381 382 protected FolderItem populateAncestorCache(Map<DocumentRef, FolderItem> cache, DocumentModel doc, 383 CoreSession session, boolean cacheItem) { 384 DocumentRef parentDocRef = session.getParentDocumentRef(doc.getRef()); 385 if (parentDocRef == null) { 386 throw new RootlessItemException("Reached repository root"); 387 } 388 389 FolderItem parentItem = cache.get(parentDocRef); 390 if (parentItem != null) { 391 log.trace("Found parent FolderItem in cache for doc {}: {}", doc::getPathAsString, parentItem::getPath); 392 return getFolderItem(cache, doc, parentItem, cacheItem); 393 } 394 395 log.trace("No parent FolderItem found in cache for doc {}, computing ancestor cache", doc::getPathAsString); 396 DocumentModel parentDoc = null; 397 try { 398 parentDoc = session.getDocument(parentDocRef); 399 } catch (DocumentSecurityException e) { 400 log.debug("User {} has no READ access on parent of document {} ({}).", principal::getName, 401 doc::getPathAsString, doc::getId, () -> e); 402 return null; 403 } 404 parentItem = populateAncestorCache(cache, parentDoc, session, true); 405 if (parentItem == null) { 406 return null; 407 } 408 return getFolderItem(cache, doc, parentItem, cacheItem); 409 } 410 411 protected FolderItem getFolderItem(Map<DocumentRef, FolderItem> cache, DocumentModel doc, FolderItem parentItem, 412 boolean cacheItem) { 413 if (cacheItem) { 414 // NXP-19442: Avoid useless and costly call to DocumentModel#getLockInfo 415 FileSystemItem fsItem = getFileSystemItemAdapterService().getFileSystemItem(doc, parentItem, true, false, 416 false); 417 if (fsItem == null) { 418 log.debug( 419 "Reached document {} that cannot be adapted as a (possibly virtual) descendant of the top level folder item.", 420 doc::getPathAsString); 421 return null; 422 } 423 FolderItem folderItem = (FolderItem) fsItem; 424 log.trace("Caching FolderItem for doc {}: {}", doc::getPathAsString, folderItem::getPath); 425 cache.put(doc.getRef(), folderItem); 426 return folderItem; 427 } else { 428 return parentItem; 429 } 430 } 431 432 @Override 433 public boolean getCanCreateChild() { 434 return canCreateChild; 435 } 436 437 @Override 438 public FolderItem createFolder(String name, boolean overwrite) { 439 try (CloseableCoreSession session = CoreInstance.openCoreSession(repositoryName, principal)) { 440 DocumentModel folder = getFileManager().createFolder(session, name, docPath, overwrite); 441 if (folder == null) { 442 throw new NuxeoException(String.format( 443 "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.", 444 name, docPath)); 445 } 446 return (FolderItem) getFileSystemItemAdapterService().getFileSystemItem(folder, this); 447 } catch (NuxeoException e) { 448 e.addInfo(String.format("Error while trying to create folder %s as a child of doc %s", name, docPath)); 449 throw e; 450 } catch (IOException e) { 451 throw new NuxeoException( 452 String.format("Error while trying to create folder %s as a child of doc %s", name, docPath), e); 453 } 454 } 455 456 @Override 457 public FileItem createFile(Blob blob, boolean overwrite) { 458 String fileName = blob.getFilename(); 459 try (CloseableCoreSession session = CoreInstance.openCoreSession(repositoryName, principal)) { 460 FileImporterContext context = FileImporterContext.builder(session, blob, docPath) 461 .overwrite(overwrite) 462 .excludeOneToMany(true) 463 .build(); 464 DocumentModel file = getFileManager().createOrUpdateDocument(context); 465 if (file == null) { 466 throw new NuxeoException(String.format( 467 "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.", 468 fileName, docPath)); 469 } 470 return (FileItem) getFileSystemItemAdapterService().getFileSystemItem(file, this); 471 } catch (NuxeoException e) { 472 e.addInfo(String.format("Error while trying to create file %s as a child of doc %s", fileName, docPath)); 473 throw e; 474 } catch (IOException e) { 475 throw new NuxeoException( 476 String.format("Error while trying to create file %s as a child of doc %s", fileName, docPath), e); 477 } 478 } 479 480 /*--------------------- Object -----------------*/ 481 // Override equals and hashCode to explicitly show that their implementation rely on the parent class and doesn't 482 // depend on the fields added to this class. 483 @Override 484 public boolean equals(Object obj) { 485 return super.equals(obj); 486 } 487 488 @Override 489 public int hashCode() { 490 return super.hashCode(); 491 } 492 493 /*--------------------- Protected -----------------*/ 494 protected void initialize(DocumentModel doc) { 495 this.name = docTitle; 496 this.folder = true; 497 this.canCreateChild = !doc.hasFacet(FacetNames.PUBLISH_SPACE); 498 if (canCreateChild) { 499 if (Framework.getService(ConfigurationService.class) 500 .isBooleanPropertyTrue(PERMISSION_CHECK_OPTIMIZED_PROPERTY)) { 501 // In optimized mode consider that canCreateChild <=> canRename because canRename <=> WriteProperties 502 // and by default WriteProperties <=> Write <=> AddChildren 503 this.canCreateChild = canRename; 504 } else { 505 // In non optimized mode check AddChildren 506 this.canCreateChild = doc.getCoreSession().hasPermission(doc.getRef(), SecurityConstants.ADD_CHILDREN); 507 } 508 } 509 this.canScrollDescendants = true; 510 } 511 512 protected FileManager getFileManager() { 513 return Framework.getService(FileManager.class); 514 } 515 516 /*---------- Needed for JSON deserialization ----------*/ 517 protected void setCanCreateChild(boolean canCreateChild) { 518 this.canCreateChild = canCreateChild; 519 } 520 521 protected void setCanScrollDescendants(boolean canScrollDescendants) { 522 this.canScrollDescendants = canScrollDescendants; 523 } 524 525}