[TASK] Doctrine: Migrate LiveSearch
[Packages/TYPO3.CMS.git] / typo3 / sysext / backend / Classes / Search / LiveSearch / LiveSearch.php
1 <?php
2 namespace TYPO3\CMS\Backend\Search\LiveSearch;
3
4 /*
5 * This file is part of the TYPO3 CMS project.
6 *
7 * It is free software; you can redistribute it and/or modify it under
8 * the terms of the GNU General Public License, either version 2
9 * of the License, or any later version.
10 *
11 * For the full copyright and license information, please read the
12 * LICENSE.txt file that was distributed with this source code.
13 *
14 * The TYPO3 project - inspiring people to share!
15 */
16
17 use TYPO3\CMS\Backend\Utility\BackendUtility;
18 use TYPO3\CMS\Core\Database\ConnectionPool;
19 use TYPO3\CMS\Core\Database\Query\Expression\CompositeExpression;
20 use TYPO3\CMS\Core\Database\Query\QueryBuilder;
21 use TYPO3\CMS\Core\Database\Query\QueryHelper;
22 use TYPO3\CMS\Core\Imaging\Icon;
23 use TYPO3\CMS\Core\Imaging\IconFactory;
24 use TYPO3\CMS\Core\Type\Bitmask\Permission;
25 use TYPO3\CMS\Core\Utility\GeneralUtility;
26 use TYPO3\CMS\Core\Utility\MathUtility;
27
28 /**
29 * Class for handling backend live search.
30 */
31 class LiveSearch
32 {
33 /**
34 * @var string
35 */
36 const PAGE_JUMP_TABLE = 'pages';
37
38 /**
39 * @var int
40 */
41 const RECURSIVE_PAGE_LEVEL = 99;
42
43 /**
44 * @var int
45 */
46 const GROUP_TITLE_MAX_LENGTH = 15;
47
48 /**
49 * @var int
50 */
51 const RECORD_TITLE_MAX_LENGTH = 28;
52
53 /**
54 * @var string
55 */
56 private $queryString = '';
57
58 /**
59 * @var int
60 */
61 private $startCount = 0;
62
63 /**
64 * @var int
65 */
66 private $limitCount = 5;
67
68 /**
69 * @var string
70 */
71 protected $userPermissions = '';
72
73 /**
74 * @var \TYPO3\CMS\Backend\Search\LiveSearch\QueryParser
75 */
76 protected $queryParser = null;
77
78 /**
79 * Initialize access settings
80 */
81 public function __construct()
82 {
83 $this->userPermissions = $GLOBALS['BE_USER']->getPagePermsClause(1);
84 $this->queryParser = GeneralUtility::makeInstance(\TYPO3\CMS\Backend\Search\LiveSearch\QueryParser::class);
85 }
86
87 /**
88 * Find records from database based on the given $searchQuery.
89 *
90 * @param string $searchQuery
91 * @return array Result list of database search.
92 */
93 public function find($searchQuery)
94 {
95 $recordArray = array();
96 $pageList = array();
97 $mounts = $GLOBALS['BE_USER']->returnWebmounts();
98 foreach ($mounts as $pageId) {
99 $pageList[] = $this->getAvailablePageIds($pageId, self::RECURSIVE_PAGE_LEVEL);
100 }
101 $pageIdList = array_unique(explode(',', implode(',', $pageList)));
102 unset($pageList);
103 if ($this->queryParser->isValidCommand($searchQuery)) {
104 $this->setQueryString($this->queryParser->getSearchQueryValue($searchQuery));
105 $tableName = $this->queryParser->getTableNameFromCommand($searchQuery);
106 if ($tableName) {
107 $recordArray[] = $this->findByTable($tableName, $pageIdList, $this->startCount, $this->limitCount);
108 }
109 } else {
110 $this->setQueryString($searchQuery);
111 $recordArray = $this->findByGlobalTableList($pageIdList);
112 }
113 return $recordArray;
114 }
115
116 /**
117 * Retrieve the page record from given $id.
118 *
119 * @param int $id
120 * @return array
121 */
122 protected function findPageById($id)
123 {
124 $pageRecord = array();
125 $row = BackendUtility::getRecord(self::PAGE_JUMP_TABLE, $id);
126 if (is_array($row)) {
127 $pageRecord = $row;
128 }
129 return $pageRecord;
130 }
131
132 /**
133 * Find records from all registered TCA table & column values.
134 *
135 * @param string $pageIdList Comma separated list of page IDs
136 * @return array Records found in the database matching the searchQuery
137 */
138 protected function findByGlobalTableList($pageIdList)
139 {
140 $limit = $this->limitCount;
141 $getRecordArray = array();
142 foreach ($GLOBALS['TCA'] as $tableName => $value) {
143 // if no access for the table (read or write), skip this table
144 if (!$GLOBALS['BE_USER']->check('tables_select', $tableName) && !$GLOBALS['BE_USER']->check('tables_modify', $tableName)) {
145 continue;
146 }
147 $recordArray = $this->findByTable($tableName, $pageIdList, 0, $limit);
148 $recordCount = count($recordArray);
149 if ($recordCount) {
150 $limit = $limit - $recordCount;
151 $getRecordArray[] = $recordArray;
152 if ($limit <= 0) {
153 break;
154 }
155 }
156 }
157 return $getRecordArray;
158 }
159
160 /**
161 * Find records by given table name.
162 *
163 * @param string $tableName Database table name
164 * @param string $pageIdList Comma separated list of page IDs
165 * @param int $firstResult
166 * @param int $maxResults
167 * @return array Records found in the database matching the searchQuery
168 * @see getRecordArray()
169 * @see makeQuerySearchByTable()
170 * @see extractSearchableFieldsFromTable()
171 */
172 protected function findByTable($tableName, $pageIdList, $firstResult, $maxResults)
173 {
174 $fieldsToSearchWithin = $this->extractSearchableFieldsFromTable($tableName);
175 $getRecordArray = array();
176 if (!empty($fieldsToSearchWithin)) {
177 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
178 ->getQueryBuilderForTable($tableName);
179
180 $queryBuilder
181 ->select('*')
182 ->from($tableName)
183 ->where(
184 $queryBuilder->expr()->in('pid', $pageIdList),
185 $this->makeQuerySearchByTable($tableName, $fieldsToSearchWithin)
186 )
187 ->setFirstResult($firstResult)
188 ->setMaxResults($maxResults);
189
190 if ($tableName === 'pages' && $this->userPermissions) {
191 $queryBuilder->andWhere($this->userPermissions);
192 }
193
194 $orderBy = $GLOBALS['TCA'][$tableName]['ctrl']['sortby'] ?: $GLOBALS['TCA'][$tableName]['ctrl']['default_sortby'];
195 foreach (QueryHelper::parseOrderBy((string)$orderBy) as $orderPair) {
196 list($fieldName, $order) = $orderPair;
197 $queryBuilder->addOrderBy($fieldName, $order);
198 }
199
200 $getRecordArray = $this->getRecordArray($queryBuilder, $tableName);
201 }
202
203 return $getRecordArray;
204 }
205
206 /**
207 * Process the Database operation to get the search result.
208 *
209 * @param QueryBuilder $queryBuilder Database table name
210 * @param string $tableName
211 * @return array
212 * @see getTitleFromCurrentRow()
213 * @see getEditLink()
214 */
215 protected function getRecordArray($queryBuilder, $tableName)
216 {
217 $collect = array();
218 $result = $queryBuilder->execute();
219 $iconFactory = GeneralUtility::makeInstance(IconFactory::class);
220 while ($row = $result->fetch()) {
221 $title = 'id=' . $row['uid'] . ', pid=' . $row['pid'];
222 $collect[] = array(
223 'id' => $tableName . ':' . $row['uid'],
224 'pageId' => $tableName === 'pages' ? $row['uid'] : $row['pid'],
225 'typeLabel' => htmlspecialchars($this->getTitleOfCurrentRecordType($tableName)),
226 'iconHTML' => '<span title="' . htmlspecialchars($title) . '">' . $iconFactory->getIconForRecord($tableName, $row, Icon::SIZE_SMALL)->render() . '</span>',
227 'title' => htmlspecialchars(BackendUtility::getRecordTitle($tableName, $row)),
228 'editLink' => htmlspecialchars($this->getEditLink($tableName, $row))
229 );
230 }
231 return $collect;
232 }
233
234 /**
235 * Build a backend edit link based on given record.
236 *
237 * @param string $tableName Record table name
238 * @param array $row Current record row from database.
239 * @return string Link to open an edit window for record.
240 * @see \TYPO3\CMS\Backend\Utility\BackendUtility::readPageAccess()
241 */
242 protected function getEditLink($tableName, $row)
243 {
244 $pageInfo = BackendUtility::readPageAccess($row['pid'], $this->userPermissions);
245 $calcPerms = $GLOBALS['BE_USER']->calcPerms($pageInfo);
246 $editLink = '';
247 if ($tableName === 'pages') {
248 $localCalcPerms = $GLOBALS['BE_USER']->calcPerms(BackendUtility::getRecord('pages', $row['uid']));
249 $permsEdit = $localCalcPerms & Permission::PAGE_EDIT;
250 } else {
251 $permsEdit = $calcPerms & Permission::CONTENT_EDIT;
252 }
253 // "Edit" link - Only if permissions to edit the page-record of the content of the parent page ($this->id)
254 if ($permsEdit) {
255 $returnUrl = BackendUtility::getModuleUrl('web_list', array('id' => $row['pid']));
256 $editLink = BackendUtility::getModuleUrl('record_edit', array(
257 'edit[' . $tableName . '][' . $row['uid'] . ']' => 'edit',
258 'returnUrl' => $returnUrl
259 ));
260 }
261 return $editLink;
262 }
263
264 /**
265 * Retrieve the record name
266 *
267 * @param string $tableName Record table name
268 * @return string
269 */
270 protected function getTitleOfCurrentRecordType($tableName)
271 {
272 return $GLOBALS['LANG']->sL($GLOBALS['TCA'][$tableName]['ctrl']['title']);
273 }
274
275 /**
276 * Crops a title string to a limited length and if it really was cropped,
277 * wrap it in a <span title="...">|</span>,
278 * which offers a tooltip with the original title when moving mouse over it.
279 *
280 * @param string $title The title string to be cropped
281 * @param int $titleLength Crop title after this length - if not set, BE_USER->uc['titleLen'] is used
282 * @return string The processed title string, wrapped in <span title="...">|</span> if cropped
283 */
284 public function getRecordTitlePrep($title, $titleLength = 0)
285 {
286 // If $titleLength is not a valid positive integer, use BE_USER->uc['titleLen']:
287 if (!$titleLength || !MathUtility::canBeInterpretedAsInteger($titleLength) || $titleLength < 0) {
288 $titleLength = $GLOBALS['BE_USER']->uc['titleLen'];
289 }
290 return htmlspecialchars(GeneralUtility::fixed_lgd_cs($title, $titleLength));
291 }
292
293 /**
294 * Build the MySql where clause by table.
295 *
296 * @param string $tableName Record table name
297 * @param array $fieldsToSearchWithin User right based visible fields where we can search within.
298 * @return CompositeExpression
299 */
300 protected function makeQuerySearchByTable($tableName, array $fieldsToSearchWithin)
301 {
302 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($tableName);
303 $expressionBuilder = $queryBuilder->expr();
304 $constraints = [];
305
306 // If the search string is a simple integer, assemble an equality comparison
307 if (MathUtility::canBeInterpretedAsInteger($this->queryString)) {
308 foreach ($fieldsToSearchWithin as $fieldName) {
309 if ($fieldName !== 'uid'
310 && $fieldName !== 'pid'
311 && !isset($GLOBALS['TCA'][$tableName]['columns'][$fieldName])
312 ) {
313 continue;
314 }
315 $fieldConfig = $GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config'];
316 $fieldType = $fieldConfig['type'];
317 $evalRules = $fieldConfig['eval'] ?: '';
318
319 // Assemble the search condition only if the field is an integer, or is uid or pid
320 if ($fieldName === 'uid'
321 || $fieldName === 'pid'
322 || ($fieldType === 'input' && $evalRules && GeneralUtility::inList($evalRules, 'int'))
323 ) {
324 $constraints[] = $expressionBuilder->eq(
325 $fieldName,
326 $queryBuilder->expr()->literal($this->queryString)
327 );
328 } elseif ($fieldType === 'text'
329 || $fieldType === 'flex'
330 || ($fieldType === 'input' && (!$evalRules || !preg_match('/date|time|int/', $evalRules)))
331 ) {
332 // Otherwise and if the field makes sense to be searched, assemble a like condition
333 $constraints[] = $constraints[] = $expressionBuilder->like(
334 $fieldName,
335 $queryBuilder->quote('%' . (int)$this->queryString . '%')
336 );
337 }
338 }
339 } else {
340 $like = $queryBuilder->quote('%' . $queryBuilder->escapeLikeWildcards($this->queryString) . '%');
341 foreach ($fieldsToSearchWithin as $fieldName) {
342 if (!isset($GLOBALS['TCA'][$tableName]['columns'][$fieldName])) {
343 continue;
344 }
345 $fieldConfig = &$GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config'];
346 $fieldType = $fieldConfig['type'];
347 $evalRules = $fieldConfig['eval'] ?: '';
348
349 // Check whether search should be case-sensitive or not
350 $searchConstraint = $queryBuilder->expr()->andX(
351 $queryBuilder->expr()->comparison(
352 'LOWER(' . $queryBuilder->quoteIdentifier($fieldName) . ')',
353 'LIKE',
354 'LOWER(' . $like . ')'
355 )
356 );
357
358 if (is_array($fieldConfig['search'])) {
359 if (in_array('case', $fieldConfig['search'], true)) {
360 // Replace case insensitive default constraint
361 $searchConstraint = $queryBuilder->expr()->andX($queryBuilder->expr()->like($fieldName, $like));
362 }
363 // Apply additional condition, if any
364 if ($fieldConfig['search']['andWhere']) {
365 $searchConstraint->add(
366 QueryHelper::stripLogicalOperatorPrefix($fieldConfig['search']['andWhere'])
367 );
368 }
369 }
370 // Assemble the search condition only if the field makes sense to be searched
371 if ($fieldType === 'text'
372 || $fieldType === 'flex'
373 || $fieldType === 'input' && (!$evalRules || !preg_match('/date|time|int/', $evalRules))
374 ) {
375 if ($searchConstraint->count() !== 0) {
376 $constraints[] = $searchConstraint;
377 }
378 }
379 }
380 }
381
382 // If no search field conditions have been build ensure no results are returned
383 if (empty($constraints)) {
384 return '0=1';
385 }
386
387 return $expressionBuilder->orX(...$constraints);
388 }
389
390 /**
391 * Get all fields from given table where we can search for.
392 *
393 * @param string $tableName Name of the table for which to get the searchable fields
394 * @return array
395 */
396 protected function extractSearchableFieldsFromTable($tableName)
397 {
398 // Get the list of fields to search in from the TCA, if any
399 if (isset($GLOBALS['TCA'][$tableName]['ctrl']['searchFields'])) {
400 $fieldListArray = GeneralUtility::trimExplode(',', $GLOBALS['TCA'][$tableName]['ctrl']['searchFields'], true);
401 } else {
402 $fieldListArray = array();
403 }
404 // Add special fields
405 if ($GLOBALS['BE_USER']->isAdmin()) {
406 $fieldListArray[] = 'uid';
407 $fieldListArray[] = 'pid';
408 }
409 return $fieldListArray;
410 }
411
412 /**
413 * Safely retrieve the queryString.
414 *
415 * @param string $tableName
416 * @return string
417 */
418 public function getQueryString($tableName = '')
419 {
420 return $GLOBALS['TYPO3_DB']->quoteStr($this->queryString, $tableName);
421 }
422
423 /**
424 * Setter for limit value.
425 *
426 * @param int $limitCount
427 * @return void
428 */
429 public function setLimitCount($limitCount)
430 {
431 $limit = MathUtility::convertToPositiveInteger($limitCount);
432 if ($limit > 0) {
433 $this->limitCount = $limit;
434 }
435 }
436
437 /**
438 * Setter for start count value.
439 *
440 * @param int $startCount
441 * @return void
442 */
443 public function setStartCount($startCount)
444 {
445 $this->startCount = MathUtility::convertToPositiveInteger($startCount);
446 }
447
448 /**
449 * Setter for the search query string.
450 *
451 * @param string $queryString
452 * @return void
453 */
454 public function setQueryString($queryString)
455 {
456 $this->queryString = $queryString;
457 }
458
459 /**
460 * Creates an instance of \TYPO3\CMS\Backend\Tree\View\PageTreeView which will select a
461 * page tree to $depth and return the object. In that object we will find the ids of the tree.
462 *
463 * @param int $id Page id.
464 * @param int $depth Depth to go down.
465 * @return string Comma separated list of uids
466 */
467 protected function getAvailablePageIds($id, $depth)
468 {
469 $tree = GeneralUtility::makeInstance(\TYPO3\CMS\Backend\Tree\View\PageTreeView::class);
470 $tree->init('AND ' . $this->userPermissions);
471 $tree->makeHTML = 0;
472 $tree->fieldArray = array('uid', 'php_tree_stop');
473 if ($depth) {
474 $tree->getTree($id, $depth, '');
475 }
476 $tree->ids[] = $id;
477 // add workspace pid - workspace permissions are taken into account by where clause later
478 $tree->ids[] = -1;
479 $idList = implode(',', $tree->ids);
480 return $idList;
481 }
482 }