[BUGFIX] Refactor record querying in deep nested structures in recycler
[Packages/TYPO3.CMS.git] / typo3 / sysext / recycler / Classes / Domain / Model / DeletedRecords.php
1 <?php
2 namespace TYPO3\CMS\Recycler\Domain\Model;
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\Core\Authentication\BackendUserAuthentication;
18 use TYPO3\CMS\Core\Cache\CacheManager;
19 use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
20 use TYPO3\CMS\Core\Database\Connection;
21 use TYPO3\CMS\Core\Database\ConnectionPool;
22 use TYPO3\CMS\Core\Database\Platform\PlatformInformation;
23 use TYPO3\CMS\Core\Database\Query\QueryBuilder;
24 use TYPO3\CMS\Core\Database\Query\QueryHelper;
25 use TYPO3\CMS\Core\Database\Query\Restriction\BackendWorkspaceRestriction;
26 use TYPO3\CMS\Core\DataHandling\DataHandler;
27 use TYPO3\CMS\Core\Type\Bitmask\Permission;
28 use TYPO3\CMS\Core\Utility\GeneralUtility;
29 use TYPO3\CMS\Core\Utility\MathUtility;
30 use TYPO3\CMS\Recycler\Utility\RecyclerUtility;
31
32 /**
33 * Model class for the 'recycler' extension.
34 */
35 class DeletedRecords
36 {
37 /**
38 * Array with all deleted rows
39 *
40 * @var array
41 */
42 protected $deletedRows = [];
43
44 /**
45 * String with the global limit
46 *
47 * @var string
48 */
49 protected $limit = '';
50
51 /**
52 * Array with all available FE tables
53 *
54 * @var array
55 */
56 protected $table = [];
57
58 /**
59 * Object from helper class
60 *
61 * @var RecyclerUtility
62 */
63 protected $recyclerHelper;
64
65 /**
66 * Array with all label fields drom different tables
67 *
68 * @var array
69 */
70 public $label;
71
72 /**
73 * Array with all title fields drom different tables
74 *
75 * @var array
76 */
77 public $title;
78
79 /************************************************************
80 * GET DATA FUNCTIONS
81 *
82 *
83 ************************************************************/
84 /**
85 * Load all deleted rows from $table
86 * If table is not set, it iterates the TCA tables
87 *
88 * @param int $id UID from selected page
89 * @param string $table Tablename
90 * @param int $depth How many levels recursive
91 * @param string $limit MySQL LIMIT
92 * @param string $filter Filter text
93 * @return DeletedRecords
94 */
95 public function loadData($id, $table, $depth, $limit = '', $filter = '')
96 {
97 // set the limit
98 $this->limit = trim($limit);
99 if ($table) {
100 if (in_array($table, RecyclerUtility::getModifyableTables(), true)) {
101 $this->table[] = $table;
102 $this->setData($id, $table, $depth, $filter);
103 }
104 } else {
105 foreach (RecyclerUtility::getModifyableTables() as $tableKey) {
106 // only go into this table if the limit allows it
107 if ($this->limit !== '') {
108 $parts = GeneralUtility::intExplode(',', $this->limit, true);
109 // abort loop if LIMIT 0,0
110 if ($parts[0] === 0 && $parts[1] === 0) {
111 break;
112 }
113 }
114 $this->table[] = $tableKey;
115 $this->setData($id, $tableKey, $depth, $filter);
116 }
117 }
118 return $this;
119 }
120
121 /**
122 * Find the total count of deleted records
123 *
124 * @param int $id UID from record
125 * @param string $table Tablename from record
126 * @param int $depth How many levels recursive
127 * @param string $filter Filter text
128 * @return int
129 */
130 public function getTotalCount($id, $table, $depth, $filter)
131 {
132 $deletedRecords = $this->loadData($id, $table, $depth, '', $filter)->getDeletedRows();
133 $countTotal = 0;
134 foreach ($this->table as $tableName) {
135 $countTotal += count($deletedRecords[$tableName] ?? []);
136 }
137 return $countTotal;
138 }
139
140 /**
141 * Set all deleted rows
142 *
143 * @param int $id UID from record
144 * @param string $table Tablename from record
145 * @param int $depth How many levels recursive
146 * @param string $filter Filter text
147 */
148 protected function setData($id, $table, $depth, $filter)
149 {
150 $deletedField = RecyclerUtility::getDeletedField($table);
151 if (!$deletedField) {
152 return;
153 }
154
155 $id = (int)$id;
156 $tcaCtrl = $GLOBALS['TCA'][$table]['ctrl'];
157 $firstResult = 0;
158 $maxResults = 0;
159
160 // get the limit
161 if (!empty($this->limit)) {
162 // count the number of deleted records for this pid
163 $queryBuilder = $this->getFilteredQueryBuilder($table, $id, $depth, $filter);
164 $queryBuilder->getRestrictions()->removeAll();
165
166 $deletedCount = (int)$queryBuilder
167 ->count('*')
168 ->from($table)
169 ->andWhere(
170 $queryBuilder->expr()->neq(
171 $deletedField,
172 $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)
173 )
174 )
175 ->execute()
176 ->fetchColumn(0);
177
178 // split the limit
179 list($offset, $rowCount) = GeneralUtility::intExplode(',', $this->limit, true);
180 // subtract the number of deleted records from the limit's offset
181 $result = $offset - $deletedCount;
182 // if the result is >= 0
183 if ($result >= 0) {
184 // store the new offset in the limit and go into the next depth
185 $offset = $result;
186 $this->limit = implode(',', [$offset, $rowCount]);
187 // do NOT query this depth; limit also does not need to be set, we set it anyways
188 $allowQuery = false;
189 } else {
190 // the offset for the temporary limit has to remain like the original offset
191 // in case the original offset was just crossed by the amount of deleted records
192 $tempOffset = 0;
193 if ($offset !== 0) {
194 $tempOffset = $offset;
195 }
196 // set the offset in the limit to 0
197 $newOffset = 0;
198 // convert to negative result to the positive equivalent
199 $absResult = abs($result);
200 // if the result now is > limit's row count
201 if ($absResult > $rowCount) {
202 // use the limit's row count as the temporary limit
203 $firstResult = $tempOffset;
204 $maxResults = $rowCount;
205 // set the limit's row count to 0
206 $this->limit = implode(',', [$newOffset, 0]);
207 } else {
208 // if the result now is <= limit's row count
209 // use the result as the temporary limit
210 $firstResult = $tempOffset;
211 $maxResults = $absResult;
212 // subtract the result from the row count
213 $newCount = $rowCount - $absResult;
214 // store the new result in the limit's row count
215 $this->limit = implode(',', [$newOffset, $newCount]);
216 }
217 // allow query for this depth
218 $allowQuery = true;
219 }
220 } else {
221 $allowQuery = true;
222 }
223 // query for actual deleted records
224 if ($allowQuery) {
225 $queryBuilder = $this->getFilteredQueryBuilder($table, $id, $depth, $filter);
226 if ($firstResult) {
227 $queryBuilder->setFirstResult($firstResult);
228 }
229 if ($maxResults) {
230 $queryBuilder->setMaxResults($maxResults);
231 }
232 $recordsToCheck = $queryBuilder->select('*')
233 ->from($table)
234 ->andWhere(
235 $queryBuilder->expr()->eq(
236 $deletedField,
237 $queryBuilder->createNamedParameter(1, \PDO::PARAM_INT)
238 )
239 )
240 ->orderBy('uid')
241 ->execute()
242 ->fetchAll();
243
244 if ($recordsToCheck !== false) {
245 $this->checkRecordAccess($table, $recordsToCheck);
246 $pidList = $this->getTreeList($id, $depth);
247 $this->sortDeletedRowsByPidList($pidList);
248 }
249 }
250 $this->label[$table] = $tcaCtrl['label'];
251 $this->title[$table] = $tcaCtrl['title'];
252 }
253
254 /**
255 * Helper method for setData() to create a QueryBuilder that filters the records by default.
256 *
257 * @param string $table
258 * @param int $pid
259 * @param int $depth
260 * @param string $filter
261 * @return \TYPO3\CMS\Core\Database\Query\QueryBuilder
262 */
263 protected function getFilteredQueryBuilder(string $table, int $pid, int $depth, string $filter): QueryBuilder
264 {
265 $pidList = $this->getTreeList($pid, $depth);
266 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
267 $queryBuilder->getRestrictions()
268 ->removeAll()
269 ->add(GeneralUtility::makeInstance(BackendWorkspaceRestriction::class));
270
271 // create the filter WHERE-clause
272 $filterConstraint = null;
273 if (trim($filter) !== '') {
274 $filterConstraint = $queryBuilder->expr()->like(
275 $GLOBALS['TCA'][$table]['ctrl']['label'],
276 $queryBuilder->createNamedParameter(
277 $queryBuilder->quote('%' . $queryBuilder->escapeLikeWildcards($filter) . '%'),
278 \PDO::PARAM_STR
279 )
280 );
281 if (MathUtility::canBeInterpretedAsInteger($filter)) {
282 $filterConstraint = $queryBuilder->expr()->orX(
283 $queryBuilder->expr()->eq(
284 'uid',
285 $queryBuilder->createNamedParameter($filter, \PDO::PARAM_INT)
286 ),
287 $queryBuilder->expr()->eq(
288 'pid',
289 $queryBuilder->createNamedParameter($filter, \PDO::PARAM_INT)
290 ),
291 $filterConstraint
292 );
293 }
294 }
295
296 $maxBindParameters = PlatformInformation::getMaxBindParameters($queryBuilder->getConnection()->getDatabasePlatform());
297 $pidConstraints = [];
298 foreach (array_chunk($pidList, $maxBindParameters - 10) as $chunk) {
299 $pidConstraints[] = $queryBuilder->expr()->in(
300 'pid',
301 $queryBuilder->createNamedParameter($chunk, Connection::PARAM_INT_ARRAY)
302 );
303 }
304 $queryBuilder->where(
305 $queryBuilder->expr()->andX(
306 $filterConstraint,
307 $queryBuilder->expr()->orX(...$pidConstraints)
308 )
309 );
310
311 return $queryBuilder;
312 }
313
314 /**
315 * Checks whether the current backend user has access to the given records.
316 *
317 * @param string $table Name of the table
318 * @param array $rows Record row
319 */
320 protected function checkRecordAccess($table, array $rows)
321 {
322 $deleteField = '';
323 if ($table === 'pages') {
324 // The "checkAccess" method validates access to the passed table/rows. When access to
325 // a page record gets validated it is necessary to disable the "delete" field temporarily
326 // for the recycler.
327 // Else it wouldn't be possible to perform the check as many methods of BackendUtility
328 // like "BEgetRootLine", etc. will only work on non-deleted records.
329 $deleteField = $GLOBALS['TCA'][$table]['ctrl']['delete'];
330 unset($GLOBALS['TCA'][$table]['ctrl']['delete']);
331 }
332
333 foreach ($rows as $row) {
334 if (RecyclerUtility::checkAccess($table, $row)) {
335 $this->setDeletedRows($table, $row);
336 }
337 }
338
339 if ($table === 'pages') {
340 $GLOBALS['TCA'][$table]['ctrl']['delete'] = $deleteField;
341 }
342 }
343
344 /**
345 * @param array $pidList
346 */
347 protected function sortDeletedRowsByPidList(array $pidList)
348 {
349 foreach ($this->deletedRows as $table => $rows) {
350 // Reset array of deleted rows for current table
351 $this->deletedRows[$table] = [];
352
353 // Get rows for current pid
354 foreach ($pidList as $pid) {
355 $rowsForCurrentPid = array_filter($rows, function ($row) use ($pid) {
356 return (int)$row['pid'] === (int)$pid;
357 });
358
359 // Append sorted records to the array again
360 $this->deletedRows[$table] = array_merge($this->deletedRows[$table], $rowsForCurrentPid);
361 }
362 }
363 }
364
365 /************************************************************
366 * DELETE FUNCTIONS
367 ************************************************************/
368 /**
369 * Delete element from any table
370 *
371 * @param array $recordsArray Representation of the records
372 * @return bool
373 */
374 public function deleteData($recordsArray)
375 {
376 if (is_array($recordsArray)) {
377 /** @var $tce DataHandler **/
378 $tce = GeneralUtility::makeInstance(DataHandler::class);
379 $tce->start([], []);
380 $tce->disableDeleteClause();
381 foreach ($recordsArray as $record) {
382 list($table, $uid) = explode(':', $record);
383 $tce->deleteEl($table, (int)$uid, true, true);
384 }
385 return true;
386 }
387 return false;
388 }
389
390 /************************************************************
391 * UNDELETE FUNCTIONS
392 ************************************************************/
393 /**
394 * Undelete records
395 * If $recursive is TRUE all records below the page uid would be undelete too
396 *
397 * @param array $recordsArray Representation of the records
398 * @param bool $recursive Whether to recursively undelete
399 * @return bool|int
400 */
401 public function undeleteData($recordsArray, $recursive = false)
402 {
403 $result = false;
404 $affectedRecords = 0;
405 $depth = 999;
406 if (is_array($recordsArray)) {
407 $this->deletedRows = [];
408 $cmd = [];
409 foreach ($recordsArray as $record) {
410 list($table, $uid) = explode(':', $record);
411 // get all parent pages and cover them
412 $pid = RecyclerUtility::getPidOfUid($uid, $table);
413 if ($pid > 0) {
414 $parentUidsToRecover = $this->getDeletedParentPages($pid);
415 $count = count($parentUidsToRecover);
416 for ($i = 0; $i < $count; ++$i) {
417 $parentUid = $parentUidsToRecover[$i];
418 $cmd['pages'][$parentUid]['undelete'] = 1;
419 $affectedRecords++;
420 }
421 if (isset($cmd['pages'])) {
422 // reverse the page list to recover it from top to bottom
423 $cmd['pages'] = array_reverse($cmd['pages'], true);
424 }
425 }
426 $cmd[$table][$uid]['undelete'] = 1;
427 $affectedRecords++;
428 if ($table === 'pages' && $recursive) {
429 $this->loadData($uid, '', $depth, '');
430 $childRecords = $this->getDeletedRows();
431 if (!empty($childRecords)) {
432 foreach ($childRecords as $childTable => $childRows) {
433 foreach ($childRows as $childRow) {
434 $cmd[$childTable][$childRow['uid']]['undelete'] = 1;
435 }
436 }
437 }
438 }
439 }
440 if ($cmd) {
441 $tce = GeneralUtility::makeInstance(DataHandler::class);
442 $tce->start([], $cmd);
443 $tce->process_cmdmap();
444 $result = $affectedRecords;
445 }
446 }
447 return $result;
448 }
449
450 /**
451 * Returns deleted parent pages
452 *
453 * @param int $uid
454 * @param array $pages
455 * @return array
456 */
457 protected function getDeletedParentPages($uid, &$pages = [])
458 {
459 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
460 $queryBuilder->getRestrictions()->removeAll();
461 $record = $queryBuilder
462 ->select('uid', 'pid')
463 ->from('pages')
464 ->where(
465 $queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)),
466 $queryBuilder->expr()->eq($GLOBALS['TCA']['pages']['ctrl']['delete'], 1)
467 )
468 ->execute()
469 ->fetch();
470 if ($record) {
471 $pages[] = $record['uid'];
472 if ((int)$record['pid'] !== 0) {
473 $this->getDeletedParentPages($record['pid'], $pages);
474 }
475 }
476
477 return $pages;
478 }
479
480 /************************************************************
481 * SETTER FUNCTIONS
482 ************************************************************/
483 /**
484 * Set deleted rows
485 *
486 * @param string $table Tablename
487 * @param array $row Deleted record row
488 */
489 public function setDeletedRows($table, array $row)
490 {
491 $this->deletedRows[$table][] = $row;
492 }
493
494 /************************************************************
495 * GETTER FUNCTIONS
496 ************************************************************/
497 /**
498 * Get deleted Rows
499 *
500 * @return array Array with all deleted rows from TCA
501 */
502 public function getDeletedRows()
503 {
504 return $this->deletedRows;
505 }
506
507 /**
508 * Get table
509 *
510 * @return array Array with table from TCA
511 */
512 public function getTable()
513 {
514 return $this->table;
515 }
516
517 /**
518 * Get tree list
519 *
520 * @param int $id
521 * @param int $depth
522 * @param int $begin
523 * @return array
524 */
525 protected function getTreeList(int $id, int $depth, int $begin = 0): array
526 {
527 $cache = $this->getCache();
528 $identifier = md5($id . '_' . $depth . '_' . $begin);
529 $pageTree = $cache->get($identifier);
530 if ($pageTree === false) {
531 $pageTree = $this->resolveTree($id, $depth, $begin, $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW));
532 $cache->set($identifier, $pageTree);
533 }
534
535 return $pageTree;
536 }
537
538 /**
539 * @param $id
540 * @param int $depth
541 * @param int $begin
542 * @param string $permsClause
543 * @return array
544 */
545 protected function resolveTree(int $id, int $depth, int $begin = 0, string $permsClause = ''): array
546 {
547 $depth = (int)$depth;
548 $begin = (int)$begin;
549 $id = abs((int)$id);
550 $theList = [];
551 if ($begin === 0) {
552 $theList[] = $id;
553 }
554 if ($depth > 0) {
555 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
556 $queryBuilder->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(BackendWorkspaceRestriction::class));
557 $statement = $queryBuilder->select('uid')
558 ->from('pages')
559 ->where(
560 $queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($id, \PDO::PARAM_INT)),
561 QueryHelper::stripLogicalOperatorPrefix($permsClause)
562 )
563 ->execute();
564 while ($row = $statement->fetch()) {
565 if ($begin <= 0) {
566 $theList[] = $row['uid'];
567 }
568 if ($depth > 1) {
569 $theList = array_merge($theList, $this->resolveTree($row['uid'], $depth - 1, $begin - 1, $permsClause));
570 }
571 }
572 }
573 return $theList;
574 }
575
576 /**
577 * Gets an instance of the memory cache.
578 *
579 * @return FrontendInterface
580 */
581 protected function getCache(): FrontendInterface
582 {
583 return GeneralUtility::makeInstance(CacheManager::class)->getCache('cache_runtime');
584 }
585
586 /**
587 * Returns the BackendUser
588 *
589 * @return BackendUserAuthentication
590 */
591 protected function getBackendUser(): BackendUserAuthentication
592 {
593 return $GLOBALS['BE_USER'];
594 }
595 }