View Javadoc

1   /**
2    * This file Copyright (c) 2012-2013 Magnolia International
3    * Ltd.  (http://www.magnolia-cms.com). All rights reserved.
4    *
5    *
6    * This file is dual-licensed under both the Magnolia
7    * Network Agreement and the GNU General Public License.
8    * You may elect to use one or the other of these licenses.
9    *
10   * This file is distributed in the hope that it will be
11   * useful, but AS-IS and WITHOUT ANY WARRANTY; without even the
12   * implied warranty of MERCHANTABILITY or FITNESS FOR A
13   * PARTICULAR PURPOSE, TITLE, or NONINFRINGEMENT.
14   * Redistribution, except as permitted by whichever of the GPL
15   * or MNA you select, is prohibited.
16   *
17   * 1. For the GPL license (GPL), you can redistribute and/or
18   * modify this file under the terms of the GNU General
19   * Public License, Version 3, as published by the Free Software
20   * Foundation.  You should have received a copy of the GNU
21   * General Public License, Version 3 along with this program;
22   * if not, write to the Free Software Foundation, Inc., 51
23   * Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
24   *
25   * 2. For the Magnolia Network Agreement (MNA), this file
26   * and the accompanying materials are made available under the
27   * terms of the MNA which accompanies this distribution, and
28   * is available at http://www.magnolia-cms.com/mna.html
29   *
30   * Any modifications to this file must keep this entire header
31   * intact.
32   *
33   */
34  package info.magnolia.ui.workbench.container;
35  
36  import info.magnolia.context.MgnlContext;
37  import info.magnolia.jcr.util.NodeTypes;
38  import info.magnolia.ui.api.ModelConstants;
39  import info.magnolia.ui.vaadin.integration.jcr.JcrItemUtil;
40  import info.magnolia.ui.vaadin.integration.jcr.JcrNodeAdapter;
41  import info.magnolia.ui.vaadin.integration.jcr.JcrPropertyAdapter;
42  import info.magnolia.ui.workbench.definition.NodeTypeDefinition;
43  import info.magnolia.ui.workbench.definition.WorkbenchDefinition;
44  
45  import java.util.ArrayList;
46  import java.util.Collection;
47  import java.util.Collections;
48  import java.util.HashMap;
49  import java.util.LinkedHashSet;
50  import java.util.List;
51  import java.util.Map;
52  import java.util.Set;
53  
54  import javax.jcr.Node;
55  import javax.jcr.PathNotFoundException;
56  import javax.jcr.RepositoryException;
57  import javax.jcr.Session;
58  import javax.jcr.query.Query;
59  import javax.jcr.query.QueryManager;
60  import javax.jcr.query.QueryResult;
61  import javax.jcr.query.RowIterator;
62  
63  import org.apache.commons.lang.StringUtils;
64  import org.slf4j.Logger;
65  import org.slf4j.LoggerFactory;
66  
67  import com.vaadin.data.Container;
68  import com.vaadin.data.ContainerHelpers;
69  import com.vaadin.data.Item;
70  import com.vaadin.data.Property;
71  
72  /**
73   * Vaadin container that reads its items from a JCR repository. Implements a simple mechanism for lazy loading items
74   * from a JCR repository and a cache for items and item ids.
75   */
76  public abstract class AbstractJcrContainer extends AbstractContainer implements Container.Sortable, Container.Indexed, Container.ItemSetChangeNotifier {
77  
78      private static final Logger log = LoggerFactory.getLogger(AbstractJcrContainer.class);
79  
80      public static final int DEFAULT_PAGE_LENGTH = 30;
81  
82      public static final int DEFAULT_CACHE_RATIO = 2;
83  
84      /**
85       * String separating a properties name and the uuid of its node.
86       */
87      public static final String PROPERTY_NAME_AND_UUID_SEPARATOR = "@";
88      private static final Long LONG_ZERO = Long.valueOf(0);
89  
90      /**
91       * Node type to use if none is configured.
92       */
93      public static final String DEFAULT_NODE_TYPE = NodeTypes.Content.NAME;
94  
95      private static final String QUERY_LANGUAGE = Query.JCR_JQOM;
96  
97      protected static final String SELECTOR_NAME = "t";
98  
99      protected static final String SELECT_TEMPLATE = "select * from [%s] as " + SELECTOR_NAME;
100 
101     protected static final String WHERE_TEMPLATE_FOR_PATH = " ISDESCENDANTNODE('%s')";
102 
103     protected static final String ORDER_BY = " order by ";
104 
105     protected static final String ASCENDING_KEYWORD = " asc";
106 
107     protected static final String DESCENDING_KEYWORD = " desc";
108 
109     protected static final String JCR_NAME_FUNCTION = "lower(name(" + SELECTOR_NAME + "))";
110 
111     /**
112      * Item and index caches.
113      */
114     private final Map<Long, String> itemIndexes = new HashMap<Long, String>();
115 
116     private final List<String> sortableProperties = new ArrayList<String>();
117 
118     private final List<OrderBy> sorters = new ArrayList<OrderBy>();
119 
120     private final WorkbenchDefinition workbenchDefinition;
121 
122     private int size = Integer.MIN_VALUE;
123 
124     /**
125      * Page length = number of items contained in one page.
126      */
127     private int pageLength = DEFAULT_PAGE_LENGTH;
128 
129     /**
130      * Number of items to cache = cacheRatio x pageLength.
131      */
132     private int cacheRatio = DEFAULT_CACHE_RATIO;
133 
134     private Set<ItemSetChangeListener> itemSetChangeListeners;
135 
136     /**
137      * Starting row number of the currently fetched page.
138      */
139     private int currentOffset;
140 
141 
142     public AbstractJcrContainer(WorkbenchDefinition workbenchDefinition) {
143         this.workbenchDefinition = workbenchDefinition;
144     }
145 
146     public void addSortableProperty(final String sortableProperty) {
147         sortableProperties.add(sortableProperty);
148     }
149 
150     public WorkbenchDefinition getWorkbenchDefinition() {
151         return workbenchDefinition;
152     }
153 
154     @Override
155     public void addItemSetChangeListener(ItemSetChangeListener listener) {
156         if (itemSetChangeListeners == null) {
157             itemSetChangeListeners = new LinkedHashSet<ItemSetChangeListener>();
158         }
159         itemSetChangeListeners.add(listener);
160     }
161 
162     @Override
163     public void addListener(ItemSetChangeListener listener) {
164         addItemSetChangeListener(listener);
165     }
166 
167     @Override
168     public void removeItemSetChangeListener(ItemSetChangeListener listener) {
169         if (itemSetChangeListeners != null) {
170             itemSetChangeListeners.remove(listener);
171             if (itemSetChangeListeners.isEmpty()) {
172                 itemSetChangeListeners = null;
173             }
174         }
175     }
176 
177     @Override
178     public void removeListener(ItemSetChangeListener listener) {
179         removeItemSetChangeListener(listener);
180     }
181 
182     public void fireItemSetChange() {
183         log.debug("Firing item set changed");
184         if (itemSetChangeListeners != null && !itemSetChangeListeners.isEmpty()) {
185             final Container.ItemSetChangeEvent event = new AbstractContainer.ItemSetChangeEvent();
186             Object[] array = itemSetChangeListeners.toArray();
187             for (Object anArray : array) {
188                 ItemSetChangeListener listener = (ItemSetChangeListener) anArray;
189                 listener.containerItemSetChange(event);
190             }
191         }
192     }
193 
194     protected Map<Long, String> getItemIndexes() {
195         return itemIndexes;
196     }
197 
198     public int getPageLength() {
199         return pageLength;
200     }
201 
202     public void setPageLength(int pageLength) {
203         this.pageLength = pageLength;
204     }
205 
206     public int getCacheRatio() {
207         return cacheRatio;
208     }
209 
210     public void setCacheRatio(int cacheRatio) {
211         this.cacheRatio = cacheRatio;
212     }
213 
214     public javax.jcr.Item getJcrItem(Object itemId) {
215         if (itemId == null) {
216             return null;
217         }
218         try {
219             return JcrItemUtil.getJcrItem(getWorkspace(), (String) itemId);
220         } catch (PathNotFoundException p) {
221             log.debug("Could not access itemId {} in workspace {} - {}. Most likely it has been (re)moved in the meantime.", new Object[]{itemId, getWorkspace(), p.toString()});
222         } catch (RepositoryException e) {
223             handleRepositoryException(log, "Could not retrieve jcr item with id: " + itemId, e);
224         }
225         return null;
226     }
227 
228     /**************************************/
229     /** Methods from interface Container **/
230     /**************************************/
231     @Override
232     public Item getItem(Object itemId) {
233         javax.jcr.Item item = getJcrItem(itemId);
234         if (item == null) {
235             return null;
236         }
237         return item.isNode() ? new JcrNodeAdapter((Node) item) : new JcrPropertyAdapter((javax.jcr.Property) item);
238     }
239 
240     @Override
241     public Collection<String> getItemIds() {
242         throw new UnsupportedOperationException(getClass().getName() + " does not support this method.");
243     }
244 
245     @Override
246     public Property<?> getContainerProperty(Object itemId, Object propertyId) {
247         final Item item = getItem(itemId);
248         if (item != null) {
249             return item.getItemProperty(propertyId);
250         }
251 
252         log.warn("Couldn't find item {} so property {} can't be retrieved!", itemId, propertyId);
253         return null;
254     }
255 
256     /**
257      * Gets the number of visible Items in the Container.
258      */
259     @Override
260     public int size() {
261         return size;
262     }
263 
264     @Override
265     public boolean containsId(Object itemId) {
266         return getItem(itemId) != null;
267     }
268 
269     @Override
270     public Item addItem(Object itemId) throws UnsupportedOperationException {
271         fireItemSetChange();
272         return getItem(itemId);
273     }
274 
275     @Override
276     public Object addItem() throws UnsupportedOperationException {
277         throw new UnsupportedOperationException();
278     }
279 
280     @Override
281     public boolean removeAllItems() throws UnsupportedOperationException {
282         throw new UnsupportedOperationException();
283     }
284 
285     /**********************************************/
286     /** Methods from interface Container.Indexed **/
287     /**
288      * *****************************************
289      */
290 
291     @Override
292     public int indexOfId(Object itemId) {
293 
294         if (!containsId(itemId)) {
295             return -1;
296         }
297         int size = size();
298         boolean wrappedAround = false;
299         while (!wrappedAround) {
300             for (Long i : itemIndexes.keySet()) {
301                 if (itemIndexes.get(i).equals(itemId)) {
302                     return i.intValue();
303                 }
304             }
305             // load in the next page.
306             int nextIndex = (currentOffset / (pageLength * cacheRatio) + 1) * (pageLength * cacheRatio);
307             if (nextIndex >= size) {
308                 // Container wrapped around, start from index 0.
309                 wrappedAround = true;
310                 nextIndex = 0;
311             }
312             updateOffsetAndCache(nextIndex);
313         }
314         return -1;
315     }
316 
317     @Override
318     public Object getIdByIndex(int index) {
319         if (index < 0 || index > size - 1) {
320             return null;
321         }
322         final Long idx = Long.valueOf(index);
323         if (itemIndexes.containsKey(idx)) {
324             return itemIndexes.get(idx);
325         }
326         log.debug("item id {} not found in cache. Need to update offset, fetch new item ids from jcr repo and put them in cache.", index);
327         updateOffsetAndCache(index);
328         return itemIndexes.get(idx);
329     }
330 
331     /**********************************************/
332     /** Methods from interface Container.Ordered **/
333     /**
334      * *****************************************
335      */
336 
337     @Override
338     public Object nextItemId(Object itemId) {
339         return getIdByIndex(indexOfId(itemId) + 1);
340     }
341 
342     @Override
343     public Object prevItemId(Object itemId) {
344         return getIdByIndex(indexOfId(itemId) - 1);
345     }
346 
347     @Override
348     public Object firstItemId() {
349         if (size == 0) {
350             return null;
351         }
352         if (!itemIndexes.containsKey(LONG_ZERO)) {
353             updateOffsetAndCache(0);
354         }
355         return itemIndexes.get(LONG_ZERO);
356     }
357 
358     @Override
359     public Object lastItemId() {
360         final Long lastIx = Long.valueOf(size() - 1);
361         if (!itemIndexes.containsKey(lastIx)) {
362             updateOffsetAndCache(size - 1);
363         }
364         return itemIndexes.get(lastIx);
365     }
366 
367     @Override
368     public boolean isFirstId(Object itemId) {
369         return firstItemId().equals(itemId);
370     }
371 
372     @Override
373     public boolean isLastId(Object itemId) {
374         return lastItemId().equals(itemId);
375     }
376 
377     /***********************************************/
378     /** Methods from interface Container.Sortable **/
379     /**
380      * ******************************************
381      */
382     @Override
383     public void sort(Object[] propertyId, boolean[] ascending) {
384         resetOffset();
385         sorters.clear();
386         for (int i = 0; i < propertyId.length; i++) {
387             if (sortableProperties.contains(propertyId[i])) {
388                 OrderBy orderBy = new OrderBy((String) propertyId[i], ascending[i]);
389                 sorters.add(orderBy);
390             }
391         }
392         getPage();
393     }
394 
395     @Override
396     public List<String> getSortableContainerPropertyIds() {
397         return Collections.unmodifiableList(sortableProperties);
398     }
399 
400     @Override
401     public boolean removeItem(Object itemId) throws UnsupportedOperationException {
402         fireItemSetChange();
403         return true;
404     }
405 
406     /************************************/
407     /** UNSUPPORTED CONTAINER FEATURES **/
408     /**
409      * *******************************
410      */
411 
412     @Override
413     public Item addItemAfter(Object previousItemId, Object newItemId) throws UnsupportedOperationException {
414         throw new UnsupportedOperationException();
415     }
416 
417     @Override
418     public Item addItemAt(int index, Object newItemId) throws UnsupportedOperationException {
419         throw new UnsupportedOperationException();
420     }
421 
422     @Override
423     public Object addItemAt(int index) throws UnsupportedOperationException {
424         throw new UnsupportedOperationException();
425     }
426 
427     @Override
428     public Object addItemAfter(Object previousItemId) throws UnsupportedOperationException {
429         throw new UnsupportedOperationException();
430     }
431 
432     /**
433      * Determines a new offset for updating the row cache. The offset is calculated from the given index, and will be
434      * fixed to match the start of a page, based on the value of pageLength.
435      *
436      * @param index Index of the item that was requested, but not found in cache
437      */
438     private void updateOffsetAndCache(int index) {
439         if (itemIndexes.containsKey(Long.valueOf(index))) {
440             return;
441         }
442         currentOffset = (index / (pageLength * cacheRatio)) * (pageLength * cacheRatio);
443         if (currentOffset < 0) {
444             resetOffset();
445         }
446         getPage();
447     }
448 
449     /**
450      * Triggers a refresh if the current row count has changed.
451      */
452     private void updateCount(long newSize) {
453         if (newSize != size) {
454             setSize((int) newSize);
455         }
456     }
457 
458     /**
459      * Fetches a page from the data source based on the values of pageLength, cacheRatio and currentOffset.
460      */
461     private final void getPage() {
462 
463         final String stmt = constructJCRQuery(true);
464         if (StringUtils.isEmpty(stmt)) {
465             return;
466         }
467 
468         try {
469             final QueryResult queryResult = executeQuery(stmt, QUERY_LANGUAGE, pageLength * cacheRatio, currentOffset);
470             updateItems(queryResult);
471         } catch (RepositoryException e) {
472             handleRepositoryException(log, "Cannot get Page with statement: " + stmt, e);
473         }
474     }
475 
476     /**
477      * Updates this container by storing the items found in the query result passed as argument.
478      *
479      * @see #getPage()
480      */
481     private void updateItems(final QueryResult queryResult) throws RepositoryException {
482         long start = System.currentTimeMillis();
483         log.debug("Starting iterating over QueryResult");
484         final RowIterator iterator = queryResult.getRows();
485         long rowCount = currentOffset;
486         while (iterator.hasNext()) {
487             final Node node = iterator.nextRow().getNode(SELECTOR_NAME);
488             final String id = node.getIdentifier();
489             log.debug("Adding node {} to cached items.", id);
490             itemIndexes.put(rowCount++, id);
491         }
492 
493         log.debug("Done in {} ms", System.currentTimeMillis() - start);
494     }
495 
496     /**
497      * @param considerSorting an optional <code>ORDER BY</code> is added if this parameter is <code>true</code>. Sorting options can be configured in the {@link WorkbenchDefinition}.
498      * @return a string representing a JCR statement to retrieve this container's items.
499      *         It creates a JCR query in the form {@code select * from [nodeType] as selector [WHERE] [ORDER BY]"}.
500      *         <p>
501      *         Subclasses can customize the optional <code>WHERE</code> clause by overriding {@link #getQueryWhereClause()}.
502      *         <p>
503      *         The main item type (as configured in the {@link WorkbenchDefinition}) in the <code>SELECT</code> statement can be changed to something different by calling {@link #getQuerySelectStatement()}
504      */
505     protected final String constructJCRQuery(final boolean considerSorting) {
506         final String select = getQuerySelectStatement();
507         final StringBuilder stmt = new StringBuilder(select);
508         // Return results only within the node configured in workbench/path
509         stmt.append(getQueryWhereClause());
510 
511         if (considerSorting) {
512             if (sorters.isEmpty()) {
513                 // no sorters set - use defaultOrder (always ascending)
514                 String defaultOrder = workbenchDefinition.getDefaultOrder();
515                 String[] defaultOrders = defaultOrder.split(",");
516                 for (String current : defaultOrders) {
517                     sorters.add(new OrderBy(current, true));
518                 }
519             }
520             stmt.append(ORDER_BY);
521             String sortOrder;
522             for (OrderBy orderBy : sorters) {
523                 String propertyName = orderBy.getProperty();
524                 sortOrder = orderBy.isAscending() ? ASCENDING_KEYWORD : DESCENDING_KEYWORD;
525                 if (ModelConstants.JCR_NAME.equals(propertyName)) {
526                     stmt.append(JCR_NAME_FUNCTION).append(sortOrder).append(", ");
527                     continue;
528                 }
529                 stmt.append(SELECTOR_NAME);
530                 stmt.append(".[").append(propertyName).append("]").append(sortOrder).append(", ");
531             }
532             stmt.delete(stmt.lastIndexOf(","), stmt.length());
533         }
534         log.debug("Constructed JCR query is {}", stmt);
535         return stmt.toString();
536     }
537 
538     /**
539      * @return the JCR query clause to select only nodes with the path configured in the workspace as String - in case
540      *         it's not configured return a blank string so that all nodes are considered. Internally calls {@link #getQueryWhereClauseWorkspacePath()} to determine
541      *         the path under which to perform the search.
542      */
543     protected String getQueryWhereClause() {
544         String whereClause = "";
545         String clauseWorkspacePath = getQueryWhereClauseWorkspacePath();
546         if (!"".equals(clauseWorkspacePath)) {
547             whereClause = " where " + clauseWorkspacePath;
548         }
549         log.debug("JCR query WHERE clause is {}", whereClause);
550         return whereClause;
551     }
552 
553     /**
554      * @return if {@link WorkbenchDefinition#getPath()} is not null or root ("/"), an ISDESCENDATNODE constraint narrowing the scope of search under the configured path, else an empty string.
555      *         Used by {@link #getQueryWhereClause()} to build a where clause.
556      */
557     protected final String getQueryWhereClauseWorkspacePath() {
558         // By default, search the root and therefore do not need a query clause.
559         String whereClauseWorkspacePath = "";
560         if (StringUtils.isNotBlank(workbenchDefinition.getPath()) && !"/".equals(workbenchDefinition.getPath())) {
561             whereClauseWorkspacePath = String.format(WHERE_TEMPLATE_FOR_PATH, workbenchDefinition.getPath());
562         }
563         log.debug("Workspace path where-clause is {}", whereClauseWorkspacePath);
564         return whereClauseWorkspacePath;
565     }
566 
567     /**
568      * @return a <code>SELECT</code> statement with the main item type as configured in the {@link WorkbenchDefinition}. Can be customized by subclasses to utilize other item types, i.e. {@code select * from [my:fancytype]}. Used internally by {@link #constructJCRQuery(boolean)}.
569      */
570     protected String getQuerySelectStatement() {
571         return String.format(SELECT_TEMPLATE, getMainNodeType());
572     }
573 
574     /**
575      * @return the main NodeType to be used with this container. This is the type that will be used for querying e.g. when populating the list view.
576      */
577     protected String getMainNodeType() {
578         final List<NodeTypeDefinition> nodeTypes = workbenchDefinition.getNodeTypes();
579         return nodeTypes.isEmpty() ? DEFAULT_NODE_TYPE : nodeTypes.get(0).getName();
580     }
581 
582     /**
583      * @see #getPage().
584      */
585     public final void updateSize() {
586         final String stmt = constructJCRQuery(false);
587         try {
588             // query for all items in order to get the size
589             final QueryResult queryResult = executeQuery(stmt, QUERY_LANGUAGE, 0, 0);
590 
591             final long pageSize = queryResult.getRows().getSize();
592             log.debug("Query result set contains {} items", pageSize);
593 
594             updateCount((int) pageSize);
595         } catch (RepositoryException e) {
596             handleRepositoryException(log, "Could not update size with statement: " + stmt, e);
597         }
598     }
599 
600     @Override
601     public List<?> getItemIds(int startIndex, int numberOfItems) {
602         return ContainerHelpers.getItemIdsUsingGetIdByIndex(startIndex, numberOfItems, this);
603     }
604 
605     public String getWorkspace() {
606         return workbenchDefinition.getWorkspace();
607     }
608 
609     /**
610      * Refreshes the container - clears all caches and resets size and offset. Does NOT remove sorting or filtering
611      * rules!
612      */
613     public void refresh() {
614         resetOffset();
615         clearItemIndexes();
616         updateSize();
617     }
618 
619     protected void resetOffset() {
620         currentOffset = 0;
621     }
622 
623     protected void clearItemIndexes() {
624         itemIndexes.clear();
625     }
626 
627     protected int getCurrentOffset() {
628         return currentOffset;
629     }
630 
631     protected void setSize(int size) {
632         this.size = size;
633     }
634 
635     protected QueryResult executeQuery(String statement, String language, long limit, long offset) throws RepositoryException {
636         final Session jcrSession = MgnlContext.getJCRSession(getWorkspace());
637         final QueryManager jcrQueryManager = jcrSession.getWorkspace().getQueryManager();
638         final Query query = jcrQueryManager.createQuery(statement, language);
639         if (limit > 0) {
640             query.setLimit(limit);
641         }
642         if (offset >= 0) {
643             query.setOffset(offset);
644         }
645         log.debug("Executing query against workspace [{}] with statement [{}] and limit {} and offset {}...", new Object[]{getWorkspace(), statement, limit, offset});
646         long start = System.currentTimeMillis();
647         final QueryResult result = query.execute();
648         log.debug("Query execution took {} ms", System.currentTimeMillis() - start);
649 
650         return result;
651     }
652 
653     /**
654      * Central method for uniform treatment of RepositoryExceptions in JcrContainers.
655      *
656      * @param logger logger to be used - passed in so subclasses can still user their proper logger
657      * @param message message to be used in the handling
658      * @param repositoryException exception to be handled
659      */
660     protected void handleRepositoryException(final Logger logger, final String message, final RepositoryException repositoryException) {
661         logger.warn(message + ": " + repositoryException);
662     }
663 }