View Javadoc
1   /**
2    * This file Copyright (c) 2018 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.periscope.search.jcr;
35  
36  import static java.util.stream.Collectors.joining;
37  
38  import info.magnolia.context.MgnlContext;
39  import info.magnolia.jcr.iterator.FilteringPropertyIterator;
40  import info.magnolia.jcr.predicate.JCRMgnlPropertyHidingPredicate;
41  import info.magnolia.jcr.util.NodeTypes;
42  import info.magnolia.jcr.util.NodeUtil;
43  import info.magnolia.jcr.util.PropertyUtil;
44  import info.magnolia.periscope.operation.request.InternalNavigationRequest;
45  import info.magnolia.periscope.search.SearchException;
46  import info.magnolia.periscope.search.SearchQuery;
47  import info.magnolia.periscope.search.SearchResult;
48  import info.magnolia.periscope.search.SearchResultSupplier;
49  import info.magnolia.periscope.tag.PeriscopeTagsProvider;
50  
51  import java.time.ZonedDateTime;
52  import java.time.format.DateTimeFormatter;
53  import java.util.StringJoiner;
54  import java.util.stream.Stream;
55  
56  import javax.jcr.Node;
57  import javax.jcr.NodeIterator;
58  import javax.jcr.Property;
59  import javax.jcr.PropertyIterator;
60  import javax.jcr.PropertyType;
61  import javax.jcr.RepositoryException;
62  import javax.jcr.Session;
63  import javax.jcr.query.Query;
64  import javax.jcr.query.QueryManager;
65  
66  import org.apache.commons.collections4.CollectionUtils;
67  import org.apache.commons.lang3.StringUtils;
68  import org.apache.jackrabbit.util.Text;
69  import org.jsoup.Jsoup;
70  
71  import lombok.SneakyThrows;
72  
73  /**
74   * A generic JCR {@link SearchResultSupplier Search result supplier} which is composed of a workspace, node types and property and full-text search option}.
75   */
76  public class JcrSearchResultSupplier implements SearchResultSupplier {
77  
78      private final String name;
79      private final JcrSearchResultSupplierDefinition definition;
80      private final PeriscopeTagsProvider periscopeTagsProvider;
81  
82      public JcrSearchResultSupplier(String name, JcrSearchResultSupplierDefinition definition, PeriscopeTagsProvider periscopeTagsProvider) {
83          this.name = name;
84          this.definition = definition;
85          this.periscopeTagsProvider = periscopeTagsProvider;
86      }
87  
88      public String getWorkspace() {
89          return definition.getWorkspace();
90      }
91  
92      @Override
93      public String getName() {
94          return this.name;
95      }
96  
97      @Override
98      @SneakyThrows(RepositoryException.class)
99      public Stream<SearchResult> search(SearchQuery query) throws SearchException {
100 
101         NodeIterator searchResults = runQuery(query);
102         Stream.Builder<SearchResult> builder = Stream.builder();
103 
104         while (searchResults.hasNext()) {
105             Node node = searchResults.nextNode();
106             // Create result from found node if node matches within the given #nodeTypes
107             if (definition.getNodeTypes().contains(node.getPrimaryNodeType().getName())) {
108                 builder.add(createResult(query, node));
109             }
110         }
111         return builder.build().distinct();
112     }
113 
114     private NodeIterator runQuery(SearchQuery query) throws RepositoryException {
115         final String jcrQueryString = constructJCRQuery(query);
116 
117         Session session = MgnlContext.getJCRSession(definition.getWorkspace());
118         QueryManager manager = session.getWorkspace().getQueryManager();
119 
120         Query jcrQuery = manager.createQuery(jcrQueryString, Query.JCR_SQL2);
121         jcrQuery.setLimit(definition.getResultLimit());
122 
123         return NodeUtil.filterDuplicates(jcrQuery.execute().getNodes());
124     }
125 
126     private String constructJCRQuery(SearchQuery query) {
127         final String escapedQuery = Text.escapeIllegalJcrChars(query.getQuery());
128         String lowerCaseQuery = escapedQuery.toLowerCase();
129         StringBuilder searchQuery = new StringBuilder();
130 
131         // Create a query with 'OR' all given node types which to be appended to the main query below.
132         String nodeTypesQuery = definition.getNodeTypes().stream()
133                 .map(this::getConditionBasedOnNodeType)
134                 .collect(joining(" OR "));
135 
136         searchQuery.append("SELECT * FROM ").append("[nt:base] AS t")
137                 .append(" WHERE ((lower(LOCALNAME()) LIKE ").append("'%").append(lowerCaseQuery).append("%'");
138 
139         // only use isDescendantNode if necessary, since it's super expensive
140         if (!"/".equals(definition.getRootPath())) {
141             searchQuery.append(" AND ISDESCENDANTNODE('").append(definition.getRootPath()).append("')");
142         }
143 
144         if (StringUtils.isNotBlank(definition.getTitleProperty())) {
145             searchQuery.append(String.format(" OR lower(\"%s\") LIKE ", definition.getTitleProperty())).append("'%").append(lowerCaseQuery).append("%'");
146         }
147         searchQuery.append(") AND (").append(nodeTypesQuery).append(")");
148 
149         if (StringUtils.isNotBlank(escapedQuery)) {
150             if (definition.isFullTextSearch()) {
151                 searchQuery.append(" OR ").append("CONTAINS(t.*, '*").append(lowerCaseQuery).append("*')");
152             }
153             if (definition.isPropertySearch()) {
154                 searchQuery.append(" OR t.['").append(escapedQuery).append("'] IS NOT NULL");
155             }
156         }
157         searchQuery.append(")");
158 
159         if (StringUtils.isNotEmpty(periscopeTagsProvider.getTagsProperty()) && CollectionUtils.isNotEmpty(query.getTags())) {
160             StringJoiner clauses = new StringJoiner(" OR ");
161             query.getTags().forEach(tag -> clauses.add("[" + periscopeTagsProvider.getTagsProperty() + "] = '" + tag + "'"));
162             searchQuery.append(" AND ").append("(").append(clauses).append(")");
163         }
164 
165         if (CollectionUtils.isNotEmpty(query.getEditors())) {
166             StringJoiner clauses = new StringJoiner(" OR ");
167             query.getEditors().forEach(editor -> clauses.add("[" + NodeTypes.LastModified.LAST_MODIFIED_BY + "] = '" + editor + "'"));
168             searchQuery.append(" AND ").append("(").append(clauses).append(")");
169         }
170 
171         if (query.getStartDate() != null) {
172             DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS");
173             String date = query.getStartDate().format(formatter);
174             searchQuery.append(" AND ").append("[").append(NodeTypes.LastModified.LAST_MODIFIED).append("]").append(" > ").append("CAST('").append(date).append("' AS DATE)");
175         }
176 
177         if (query.getEndDate() != null) {
178             DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS");
179             String date = query.getEndDate().format(formatter);
180             searchQuery.append(" AND ").append("[").append(NodeTypes.LastModified.LAST_MODIFIED).append("]").append(" < ").append("CAST('").append(date).append("' AS DATE)");
181         }
182 
183         return searchQuery.toString();
184     }
185 
186     private String getConditionBasedOnNodeType(String nodeType) {
187         return String.format("t.[jcr:primaryType] = '%s'", nodeType);
188     }
189 
190     private SearchResult createResult(SearchQuery query, Node node) throws RepositoryException {
191         final String title = StringUtils.isBlank(definition.getTitleProperty()) ? node.getName()
192                 : PropertyUtil.getString(node, definition.getTitleProperty(), node.getName());
193 
194         boolean titleMatchesQuery = query.getQuery().isEmpty() || StringUtils.containsIgnoreCase(title, query.getQuery());
195         String content = titleMatchesQuery ? "" : Jsoup.parse(aggregatedContent(node)).text();
196 
197         // no need to worry about fallback values as JCR does that itself OOTB:
198         // - jcr:primaryType is mandatory
199         // - lastModifiedBy will be 'anonymous' if unauthenticated
200         // - lastModified will be the time of the last change
201         String lastModified = PropertyUtil.getString(node, NodeTypes.LastModified.LAST_MODIFIED);
202         String appPath = NodeUtil.getPathIfPossible(node);
203         if (!definition.getRootPath().equals("/")) {
204             appPath = StringUtils.substringAfter(appPath, definition.getRootPath());
205         }
206 
207         return SearchResult.builder()
208                 .title(title)
209                 .content(content)
210                 .path(appPath)
211                 .operationRequest(new InternalNavigationRequest(definition.getAppName(), definition.getWorkspace(), appPath))
212                 .type(StringUtils.defaultIfBlank(definition.getIcon(), "icon-node-content"))
213                 .lastModifiedBy(PropertyUtil.getString(node, NodeTypes.LastModified.LAST_MODIFIED_BY))
214                 .lastModified((StringUtils.isNotBlank(lastModified)) ? ZonedDateTime.parse(lastModified) : null)
215                 .build();
216     }
217 
218     private String aggregatedContent(Node rootNode) throws RepositoryException {
219         StringBuilder aggregatedContentBuilder = new StringBuilder();
220 
221         NodeUtil.visit(rootNode, node -> {
222             for (PropertyIterator iter = new FilteringPropertyIterator(node.getProperties(), new JCRMgnlPropertyHidingPredicate()); iter.hasNext(); ) {
223                 Property property = iter.nextProperty();
224                 if (property.getType() == PropertyType.STRING && !property.isMultiple()) {
225                     String propertyValue = property.getString().trim();
226                     if (property.getName().equals("extends") && !propertyValue.isEmpty()) {
227                         aggregatedContentBuilder.append("extends ");
228                     }
229                     aggregatedContentBuilder.append(propertyValue);
230                     aggregatedContentBuilder.append(" ");
231                 }
232             }
233         });
234 
235         return aggregatedContentBuilder.toString().trim();
236     }
237 }