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