From d6a30b05f84bc4fc118a72c0900c0c3c6f829d8b Mon Sep 17 00:00:00 2001 From: Philipp Bergsmann <p.bergsmann@opendo.at> Date: Mon, 13 Feb 2012 19:19:47 +0100 Subject: [PATCH] [FEATURE] Add scheduler task to remove deleted records Scheduler task to remove deleted records from content table(s) which are older than x days. If a deleted record also contains an upload field, then the file is also deleted. Releases: master Resolves: #32651 Change-Id: I58577c05a1a3b228579c05578cc8fdf2e3b393fa Reviewed-on: http://review.typo3.org/9013 Reviewed-by: Markus Klein <klein.t3@reelworx.at> Reviewed-by: Nicole Cordes <typo3@cordes.co> Tested-by: Nicole Cordes <typo3@cordes.co> Reviewed-by: Wouter Wolters <typo3@wouterwolters.nl> Reviewed-by: Anja Leichsenring <aleichsenring@ab-softlab.de> Tested-by: Anja Leichsenring <aleichsenring@ab-softlab.de> --- ...AddSchedulerTaskToRemoveDeletedRecords.rst | 9 + .../Classes/Task/CleanerFieldProvider.php | 207 ++++++++++++++++ .../recycler/Classes/Task/CleanerTask.php | 232 ++++++++++++++++++ .../Private/Language/locallang_tasks.xlf | 35 +++ .../Unit/Task/CleanerFieldProviderTest.php | 152 ++++++++++++ .../Tests/Unit/Task/CleanerTaskTest.php | 111 +++++++++ typo3/sysext/recycler/ext_localconf.php | 8 + typo3/sysext/scheduler/Classes/Scheduler.php | 98 ++++---- 8 files changed, 811 insertions(+), 41 deletions(-) create mode 100644 typo3/sysext/core/Documentation/Changelog/master/Feature-32651-AddSchedulerTaskToRemoveDeletedRecords.rst create mode 100644 typo3/sysext/recycler/Classes/Task/CleanerFieldProvider.php create mode 100644 typo3/sysext/recycler/Classes/Task/CleanerTask.php create mode 100644 typo3/sysext/recycler/Resources/Private/Language/locallang_tasks.xlf create mode 100644 typo3/sysext/recycler/Tests/Unit/Task/CleanerFieldProviderTest.php create mode 100644 typo3/sysext/recycler/Tests/Unit/Task/CleanerTaskTest.php diff --git a/typo3/sysext/core/Documentation/Changelog/master/Feature-32651-AddSchedulerTaskToRemoveDeletedRecords.rst b/typo3/sysext/core/Documentation/Changelog/master/Feature-32651-AddSchedulerTaskToRemoveDeletedRecords.rst new file mode 100644 index 000000000000..acbea451c928 --- /dev/null +++ b/typo3/sysext/core/Documentation/Changelog/master/Feature-32651-AddSchedulerTaskToRemoveDeletedRecords.rst @@ -0,0 +1,9 @@ +============================================================== +Feature: #32651 - Add scheduler task to remove deleted records +============================================================== + +Description +=========== + +A new scheduler task for removing deleted records has been added. The maximum age and +the affected tables are configurable in the task's settings. diff --git a/typo3/sysext/recycler/Classes/Task/CleanerFieldProvider.php b/typo3/sysext/recycler/Classes/Task/CleanerFieldProvider.php new file mode 100644 index 000000000000..eeef26cbf0fb --- /dev/null +++ b/typo3/sysext/recycler/Classes/Task/CleanerFieldProvider.php @@ -0,0 +1,207 @@ +<?php +namespace TYPO3\CMS\Recycler\Task; + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +use TYPO3\CMS\Core\Messaging\FlashMessage; +use TYPO3\CMS\Scheduler\Controller\SchedulerModuleController; +use TYPO3\CMS\Scheduler\Task\AbstractTask; + +/** + * A task that should be run regularly that deletes + * datasets flagged as "deleted" from the DB. + * + * @author Philipp Bergsmann <p.bergsmann@opendo.at> + */ +class CleanerFieldProvider implements \TYPO3\CMS\Scheduler\AdditionalFieldProviderInterface { + + /** + * Gets additional fields to render in the form to add/edit a task + * + * @param array $taskInfo Values of the fields from the add/edit task form + * @param \TYPO3\CMS\Recycler\Task\CleanerTask $task The task object being edited. NULL when adding a task! + * @param SchedulerModuleController $schedulerModule Reference to the scheduler backend module + * @return array A two dimensional array, array('Identifier' => array('fieldId' => array('code' => '', 'label' => '', 'cshKey' => '', 'cshLabel' => '')) + */ + public function getAdditionalFields(array &$taskInfo, $task, SchedulerModuleController $schedulerModule) { + if ($schedulerModule->CMD === 'edit') { + $taskInfo['RecyclerCleanerTCA'] = $task->getTcaTables(); + $taskInfo['RecyclerCleanerPeriod'] = $task->getPeriod(); + } + + $additionalFields['period'] = array( + 'code' => '<input type="text" class="form-control" name="tx_scheduler[RecyclerCleanerPeriod]" value="' . $taskInfo['RecyclerCleanerPeriod'] . '">', + 'label' => 'LLL:EXT:recycler/locallang_tasks.xlf:cleanerTaskPeriod', + 'cshKey' => '', + 'cshLabel' => 'task_recyclerCleaner_selectedPeriod' + ); + + $additionalFields['tca'] = array( + 'code' => $this->getTcaSelectHtml($taskInfo['RecyclerCleanerTCA']), + 'label' => 'LLL:EXT:recycler/locallang_tasks.xlf:cleanerTaskTCA', + 'cshKey' => '', + 'cshLabel' => 'task_recyclerCleaner_selectedTables' + ); + + return $additionalFields; + } + + /** + * Gets the select-box from the TCA-fields + * + * @param array $selectedTables + * @return string + */ + protected function getTcaSelectHtml($selectedTables = array()) { + if (!is_array($selectedTables)) { + $selectedTables = array(); + } + $tcaSelectHtml = '<select name="tx_scheduler[RecyclerCleanerTCA][]" multiple="multiple" class="form-control" size="10">'; + + $options = array(); + foreach ($GLOBALS['TCA'] as $table => $tableConf) { + if (!$tableConf['ctrl']['adminOnly'] && !empty($tableConf['ctrl']['delete'])) { + $selected = in_array($table, $selectedTables, TRUE) ? ' selected="selected"' : ''; + $tableTitle = $this->getLanguageService()->sL($tableConf['ctrl']['title']); + $options[$tableTitle] = '<option' . $selected . ' value="' . $table . '">' . htmlspecialchars($tableTitle . ' (' . $table . ')') . '</option>'; + } + } + ksort($options); + + $tcaSelectHtml .= implode('', $options); + $tcaSelectHtml .= '</select>'; + + return $tcaSelectHtml; + } + + /** + * Validates the additional fields' values + * + * @param array $submittedData An array containing the data submitted by the add/edit task form + * @param SchedulerModuleController $schedulerModule Reference to the scheduler backend module + * @return bool TRUE if validation was ok (or selected class is not relevant), FALSE otherwise + */ + public function validateAdditionalFields(array &$submittedData, SchedulerModuleController $schedulerModule) { + $validPeriod = $this->validateAdditionalFieldPeriod($submittedData['RecyclerCleanerPeriod'], $schedulerModule); + $validTca = $this->validateAdditionalFieldTca($submittedData['RecyclerCleanerTCA'], $schedulerModule); + + return $validPeriod && $validTca; + } + + /** + * Validates the selected Tables + * + * @param array $tca The given TCA-tables as array + * @param SchedulerModuleController $schedulerModule Reference to the scheduler backend module + * @return bool TRUE if validation was ok, FALSE otherwise + */ + protected function validateAdditionalFieldTca($tca, SchedulerModuleController $schedulerModule) { + return $this->checkTcaIsNotEmpty($tca, $schedulerModule) && $this->checkTcaIsValid($tca, $schedulerModule); + } + + /** + * Checks if the array is empty + * + * @param array $tca The given TCA-tables as array + * @param SchedulerModuleController $schedulerModule Reference to the scheduler backend module + * @return bool TRUE if validation was ok, FALSE otherwise + */ + protected function checkTcaIsNotEmpty($tca, SchedulerModuleController $schedulerModule) { + if (is_array($tca) && !empty($tca)) { + $validTca = TRUE; + } else { + $schedulerModule->addMessage( + $this->getLanguageService()->sL('LLL:EXT:recycler/locallang_tasks.xlf:cleanerTaskErrorTCAempty', TRUE), + FlashMessage::ERROR + ); + $validTca = FALSE; + } + + return $validTca; + } + + /** + * Checks if the given tables are in the TCA + * + * @param array $tca The given TCA-tables as array + * @param SchedulerModuleController $schedulerModule Reference to the scheduler backend module + * @return bool TRUE if validation was ok, FALSE otherwise + */ + protected function checkTcaIsValid(array $tca, SchedulerModuleController $schedulerModule) { + $checkTca = FALSE; + foreach ($tca as $tcaTable) { + if (!isset($GLOBALS['TCA'][$tcaTable])) { + $checkTca = FALSE; + $schedulerModule->addMessage( + sprintf($this->getLanguageService()->sL('LLL:EXT:recycler/locallang_tasks.xlf:cleanerTaskErrorTCANotSet', TRUE), $tcaTable), + FlashMessage::ERROR + ); + break; + } else { + $checkTca = TRUE; + } + } + + return $checkTca; + } + + /** + * Validates the input of period + * + * @param int $period The given period as integer + * @param SchedulerModuleController $schedulerModule Reference to the scheduler backend module + * @return bool TRUE if validation was ok, FALSE otherwise + */ + protected function validateAdditionalFieldPeriod($period, SchedulerModuleController $schedulerModule) { + if (!empty($period) && filter_var($period, FILTER_VALIDATE_INT) !== FALSE) { + $validPeriod = TRUE; + } else { + $schedulerModule->addMessage( + $this->getLanguageService()->sL('LLL:EXT:recycler/locallang_tasks.xlf:cleanerTaskErrorPeriod', TRUE), + FlashMessage::ERROR + ); + $validPeriod = FALSE; + } + + return $validPeriod; + } + + /** + * Takes care of saving the additional fields' values in the task's object + * + * @param array $submittedData An array containing the data submitted by the add/edit task form + * @param AbstractTask $task Reference to the scheduler backend module + * @return void + * @throws \InvalidArgumentException + */ + public function saveAdditionalFields(array $submittedData, AbstractTask $task) { + if (!$task instanceof CleanerTask) { + throw new \InvalidArgumentException( + 'Expected a task of type \TYPO3\CMS\Recycler\Task\CleanerTask, but got ' . get_class($task), + 1329219449 + ); + } + + $task->setTcaTables($submittedData['RecyclerCleanerTCA']); + $task->setPeriod($submittedData['RecyclerCleanerPeriod']); + } + + /** + * @return \TYPO3\CMS\Lang\LanguageService + */ + protected function getLanguageService() { + return $GLOBALS['LANG']; + } + +} diff --git a/typo3/sysext/recycler/Classes/Task/CleanerTask.php b/typo3/sysext/recycler/Classes/Task/CleanerTask.php new file mode 100644 index 000000000000..a43621cee91f --- /dev/null +++ b/typo3/sysext/recycler/Classes/Task/CleanerTask.php @@ -0,0 +1,232 @@ +<?php +namespace TYPO3\CMS\Recycler\Task; + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * A task that should be run regularly that deletes deleted + * datasets from the DB. + * + * @author Philipp Bergsmann <p.bergsmann@opendo.at> + */ +class CleanerTask extends \TYPO3\CMS\Scheduler\Task\AbstractTask { + + /** + * @var int The time period, after which the rows are deleted + */ + protected $period = 0; + + /** + * @var array The tables to clean + */ + protected $tcaTables = array(); + + /** + * @var \TYPO3\CMS\Core\Database\DatabaseConnection + */ + protected $databaseConnection = NULL; + + /** + * The main method of the task. Iterates through + * the tables and calls the cleaning function + * + * @return bool Returns TRUE on successful execution, FALSE on error + */ + public function execute() { + $success = TRUE; + $tables = $this->getTcaTables(); + foreach ($tables as $table) { + if (!$this->cleanTable($table)) { + $success = FALSE; + } + } + + return $success; + } + + /** + * Executes the delete-query for the given table + * + * @param string $tableName + * @return bool + */ + protected function cleanTable($tableName) { + $queryParts = array(); + if (isset($GLOBALS['TCA'][$tableName]['ctrl']['delete'])) { + $queryParts[] = $GLOBALS['TCA'][$tableName]['ctrl']['delete'] . ' = 1'; + if ($GLOBALS['TCA'][$tableName]['ctrl']['tstamp']) { + $dateBefore = $this->getPeriodAsTimestamp(); + $queryParts[] = $GLOBALS['TCA'][$tableName]['ctrl']['tstamp'] . ' < ' . $dateBefore; + } + $where = implode(' AND ', $queryParts); + + $this->checkFileResourceFieldsBeforeDeletion($tableName, $where); + + $this->getDatabaseConnection()->exec_DELETEquery($tableName, $where); + } + + return $this->getDatabaseConnection()->sql_error() === ''; + } + + /** + * Returns the information shown in the task-list + * + * @return string Information-text fot the scheduler task-list + */ + public function getAdditionalInformation() { + $message = ''; + + $message .= sprintf( + $this->getLanguageService()->sL('LLL:EXT:recycler/locallang_tasks.xlf:cleanerTaskDescriptionTables'), + implode(', ', $this->getTcaTables()) + ); + + $message .= '; '; + + $message .= sprintf( + $this->getLanguageService()->sL('LLL:EXT:recycler/locallang_tasks.xlf:cleanerTaskDescriptionDays'), + $this->getPeriod() + ); + + return $message; + } + + /** + * Sets the period after which a row is deleted + * + * @param int $period + */ + public function setPeriod($period) { + $this->period = (int)$period; + } + + /** + * Returns the period after which a row is deleted + * + * @return int + */ + public function getPeriod() { + return $this->period; + } + + /** + * @return int + */ + public function getPeriodAsTimestamp() { + return strtotime('-' . $this->getPeriod() . ' days'); + } + + /** + * Sets the TCA-tables which are cleaned + * + * @param array $tcaTables + */ + public function setTcaTables($tcaTables = array()) { + $this->tcaTables = $tcaTables; + } + + /** + * Returns the TCA-tables which are cleaned + * + * @return array + */ + public function getTcaTables() { + return $this->tcaTables; + } + + /** + * @param \TYPO3\CMS\Core\Database\DatabaseConnection + */ + public function setDatabaseConnection($databaseConnection) { + $this->databaseConnection = $databaseConnection; + } + + /** + * Checks if the table has fields for uploaded files and removes those files. + * + * @param string $table + * @param string $where + * @return void + */ + protected function checkFileResourceFieldsBeforeDeletion($table, $where) { + $fieldList = $this->getFileResourceFields($table); + if (!empty($fieldList)) { + $this->deleteFilesForTable($table, $where, $fieldList); + } + } + + /** + * Removes all files from the given field list in the table. + * + * @param string $table + * @param string $where + * @param array $fieldList + * @return void + */ + protected function deleteFilesForTable($table, $where, array $fieldList) { + $rows = $this->getDatabaseConnection()->exec_SELECTgetRows( + implode(',', $fieldList), + $table, + $where + ); + foreach ($rows as $row) { + foreach ($fieldList as $fieldName) { + $uploadDir = PATH_site . $GLOBALS['TCA'][$table]['columns'][$fieldName]['config']['uploadfolder'] . '/'; + $fileList = GeneralUtility::trimExplode(',', $row[$fieldName]); + foreach ($fileList as $fileName) { + @unlink($uploadDir . $fileName); + } + } + } + } + + /** + * Checks the $TCA for fields that can list file resources. + * + * @param string $table + * @return array + */ + protected function getFileResourceFields($table) { + $result = array(); + if (isset($GLOBALS['TCA'][$table]['columns'])) { + foreach ($GLOBALS['TCA'][$table]['columns'] as $fieldName => $fieldConfiguration) { + if ($fieldConfiguration['config']['type'] === 'group' + && $fieldConfiguration['config']['internal_type'] === 'file' + ) { + $result[] = $fieldName; + break; + } + } + } + return $result; + } + + /** + * @return \TYPO3\CMS\Core\Database\DatabaseConnection + */ + protected function getDatabaseConnection() { + if ($this->databaseConnection === NULL) { + $this->databaseConnection = $GLOBALS['TYPO3_DB']; + } + return $this->databaseConnection; + } + + /** + * @return \TYPO3\CMS\Lang\LanguageService + */ + protected function getLanguageService() { + return $GLOBALS['LANG']; + } +} diff --git a/typo3/sysext/recycler/Resources/Private/Language/locallang_tasks.xlf b/typo3/sysext/recycler/Resources/Private/Language/locallang_tasks.xlf new file mode 100644 index 000000000000..5271f5c63de1 --- /dev/null +++ b/typo3/sysext/recycler/Resources/Private/Language/locallang_tasks.xlf @@ -0,0 +1,35 @@ +<?xml version="1.0" encoding="UTF-8"?> +<xliff version="1.0" xmlns:t3="http://typo3.org/schemas/xliff"> + <file t3:id="1424776442" source-language="en" datatype="plaintext" original="messages" date="2011-10-17T20:22:35Z" product-name="recycler"> + <header /> + <body> + <trans-unit id="cleanerTaskTitle" xml:space="preserve"> + <source>Remove deleted records</source> + </trans-unit> + <trans-unit id="cleanerTaskDescription" xml:space="preserve"> + <source>Deletes rows from the database which are flagged "deleted"</source> + </trans-unit> + <trans-unit id="cleanerTaskPeriod" xml:space="preserve"> + <source>Delete entries older than (in days)</source> + </trans-unit> + <trans-unit id="cleanerTaskTCA" xml:space="preserve"> + <source>Tables</source> + </trans-unit> + <trans-unit id="cleanerTaskDescriptionTables" xml:space="preserve"> + <source>Tables: %s</source> + </trans-unit> + <trans-unit id="cleanerTaskDescriptionDays" xml:space="preserve"> + <source>Older than %d days(s)</source> + </trans-unit> + <trans-unit id="cleanerTaskErrorPeriod" xml:space="preserver"> + <source>The period has to be an integer and greater than zero.</source> + </trans-unit> + <trans-unit id="cleanerTaskErrorTCAempty" xml:space="preserver"> + <source>You have to select at least one table.</source> + </trans-unit> + <trans-unit id="cleanerTaskErrorTCANotSet" xml:space="preserver"> + <source>The table "%s" is not in the TCA.</source> + </trans-unit> + </body> + </file> +</xliff> diff --git a/typo3/sysext/recycler/Tests/Unit/Task/CleanerFieldProviderTest.php b/typo3/sysext/recycler/Tests/Unit/Task/CleanerFieldProviderTest.php new file mode 100644 index 000000000000..74284ac08c91 --- /dev/null +++ b/typo3/sysext/recycler/Tests/Unit/Task/CleanerFieldProviderTest.php @@ -0,0 +1,152 @@ +<?php +namespace TYPO3\CMS\Recycler\Tests\Unit\Task; + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +use TYPO3\CMS\Core\Messaging\FlashMessage; +use TYPO3\CMS\Lang\LanguageService; +use TYPO3\CMS\Recycler\Task\CleanerFieldProvider; +use TYPO3\CMS\Recycler\Task\CleanerTask; +use TYPO3\CMS\Scheduler\Controller\SchedulerModuleController; + +/** + * Testcase + */ +class CleanerFieldProviderTest extends \TYPO3\CMS\Core\Tests\UnitTestCase { + + /** + * @var CleanerFieldProvider + */ + protected $subject = NULL; + + /** + * Sets up an instance of \TYPO3\CMS\Recycler\Task\CleanerFieldProvider + */ + public function setUp() { + $languageServiceMock = $this->getMock(LanguageService::class, array('sL'), array(), '', FALSE); + $languageServiceMock->expects($this->any())->method('sL')->will($this->returnValue('titleTest')); + $this->subject = $this->getMock(CleanerFieldProvider::class, array('getLanguageService')); + $this->subject->expects($this->any())->method('getLanguageService')->willReturn($languageServiceMock); + } + + /** + * @param array $mockedMethods + * @return \PHPUnit_Framework_MockObject_MockObject|SchedulerModuleController + */ + protected function getScheduleModuleControllerMock($mockedMethods = array()) { + $languageServiceMock = $this->getMock(LanguageService::class, array('sL'), array(), '', FALSE); + $languageServiceMock->expects($this->any())->method('sL')->will($this->returnValue('titleTest')); + + $mockedMethods = array_merge(array('getLanguageService'), $mockedMethods); + $scheduleModuleMock = $this->getMock(SchedulerModuleController::class, $mockedMethods, array(), '', FALSE); + $scheduleModuleMock->expects($this->any())->method('getLanguageService')->willReturn($languageServiceMock); + + return $scheduleModuleMock; + } + + /** + * @return array + */ + public function validateAdditionalFieldsLogsPeriodErrorDataProvider() { + return array( + array('abc'), + array($this->getMockBuilder(CleanerTask::class)->disableOriginalConstructor()->getMock()), + array(NULL), + array(''), + array(0), + array('1234abc') + ); + } + + /** + * @param mixed $period + * @test + * @dataProvider validateAdditionalFieldsLogsPeriodErrorDataProvider + */ + public function validateAdditionalFieldsLogsPeriodError($period) { + $submittedData = array( + 'RecyclerCleanerPeriod' => $period, + 'RecyclerCleanerTCA' => array('pages') + ); + + $scheduleModuleControllerMock = $this->getScheduleModuleControllerMock(array('addMessage')); + $scheduleModuleControllerMock->expects($this->atLeastOnce()) + ->method('addMessage') + ->with($this->equalTo('titleTest'), FlashMessage::ERROR); + + $this->subject->validateAdditionalFields($submittedData, $scheduleModuleControllerMock); + } + + /** + * @return array + */ + public function validateAdditionalFieldsDataProvider() { + return array( + array('abc'), + array($this->getMockBuilder(CleanerTask::class)->disableOriginalConstructor()->getMock()), + array(NULL), + array(123) + ); + } + + /** + * @param mixed $table + * @test + * @dataProvider validateAdditionalFieldsDataProvider + */ + public function validateAdditionalFieldsLogsTableError($table) { + $submittedData = array( + 'RecyclerCleanerPeriod' => 14, + 'RecyclerCleanerTCA' => $table + ); + + $this->subject->validateAdditionalFields($submittedData, $this->getScheduleModuleControllerMock()); + } + + /** + * @test + */ + public function validateAdditionalFieldsIsTrueIfValid() { + $submittedData = array( + 'RecyclerCleanerPeriod' => 14, + 'RecyclerCleanerTCA' => array('pages') + ); + + $scheduleModuleControllerMock = $this->getScheduleModuleControllerMock(); + $GLOBALS['TCA']['pages'] = array('foo' => 'bar'); + $this->assertTrue($this->subject->validateAdditionalFields($submittedData, $scheduleModuleControllerMock)); + } + + /** + * @test + */ + public function saveAdditionalFieldsSavesFields() { + $submittedData = array( + 'RecyclerCleanerPeriod' => 14, + 'RecyclerCleanerTCA' => array('pages') + ); + + $taskMock = $this->getMock(CleanerTask::class); + + $taskMock->expects($this->once()) + ->method('setTcaTables') + ->with($this->equalTo(array('pages'))); + + $taskMock->expects($this->once()) + ->method('setPeriod') + ->with($this->equalTo(14)); + + $this->subject->saveAdditionalFields($submittedData, $taskMock); + } +} diff --git a/typo3/sysext/recycler/Tests/Unit/Task/CleanerTaskTest.php b/typo3/sysext/recycler/Tests/Unit/Task/CleanerTaskTest.php new file mode 100644 index 000000000000..f2f40961b741 --- /dev/null +++ b/typo3/sysext/recycler/Tests/Unit/Task/CleanerTaskTest.php @@ -0,0 +1,111 @@ +<?php +namespace TYPO3\CMS\Recycler\Tests\Unit\Task; + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +use TYPO3\CMS\Core\Database\DatabaseConnection; +use TYPO3\CMS\Recycler\Task\CleanerTask; + +/** + * Testcase + */ +class CleanerTaskTest extends \TYPO3\CMS\Core\Tests\UnitTestCase { + + /** + * @var \PHPUnit_Framework_MockObject_MockObject|CleanerTask + */ + protected $subject = NULL; + + /** + * sets up an instance of \TYPO3\CMS\Recycler\Task\CleanerTask + */ + public function setUp() { + $this->subject = $this->getMock(CleanerTask::class, array('dummy'), array(), '', FALSE); + } + + /** + * @test + */ + public function getPeriodCanBeSet() { + $period = 14; + $this->subject->setPeriod($period); + + $this->assertEquals($period, $this->subject->getPeriod()); + } + + /** + * @test + */ + public function getTcaTablesCanBeSet() { + $tables = array('pages', 'tt_content'); + $this->subject->setTcaTables($tables); + + $this->assertEquals($tables, $this->subject->getTcaTables()); + } + + /** + * @test + */ + public function taskBuildsCorrectQuery() { + $GLOBALS['TCA']['pages']['ctrl']['delete'] = 'deleted'; + $GLOBALS['TCA']['pages']['ctrl']['tstamp'] = 'tstamp'; + + /** @var \PHPUnit_Framework_MockObject_MockObject|CleanerTask $subject */ + $subject = $this->getMock(CleanerTask::class, array('getPeriodAsTimestamp'), array(), '', FALSE); + + $tables = array('pages'); + $subject->setTcaTables($tables); + + $period = 14; + $subject->setPeriod($period); + $periodAsTimestamp = strtotime('-' . $period . ' days'); + $subject->expects($this->once())->method('getPeriodAsTimestamp')->willReturn($periodAsTimestamp); + + $dbMock = $this->getMock(DatabaseConnection::class); + $dbMock->expects($this->once()) + ->method('exec_DELETEquery') + ->with($this->equalTo('pages'), $this->equalTo('deleted = 1 AND tstamp < ' . $periodAsTimestamp)); + + $dbMock->expects($this->once()) + ->method('sql_error') + ->will($this->returnValue('')); + + $subject->setDatabaseConnection($dbMock); + + $this->assertTrue($subject->execute()); + } + + /** + * @test + */ + public function taskFailsOnError() { + $GLOBALS['TCA']['pages']['ctrl']['delete'] = 'deleted'; + $GLOBALS['TCA']['pages']['ctrl']['tstamp'] = 'tstamp'; + + $tables = array('pages'); + $this->subject->setTcaTables($tables); + + $period = 14; + $this->subject->setPeriod($period); + + $dbMock = $this->getMock(DatabaseConnection::class); + $dbMock->expects($this->once()) + ->method('sql_error') + ->willReturn(1049); + + $this->subject->setDatabaseConnection($dbMock); + + $this->assertFalse($this->subject->execute()); + } +} diff --git a/typo3/sysext/recycler/ext_localconf.php b/typo3/sysext/recycler/ext_localconf.php index 7ec4801a7ee7..3fcbcc7d6aae 100644 --- a/typo3/sysext/recycler/ext_localconf.php +++ b/typo3/sysext/recycler/ext_localconf.php @@ -4,3 +4,11 @@ defined('TYPO3_MODE') or die(); if (TYPO3_MODE === 'BE') { \TYPO3\CMS\Core\Utility\ExtensionManagementUtility::registerAjaxHandler('RecyclerAjaxController::dispatch', \TYPO3\CMS\Recycler\Controller\RecyclerAjaxController::class . '->dispatch'); } +$GLOBALS['TYPO3_CONF_VARS']['BE']['AJAX']['RecyclerAjaxController::init'] = \TYPO3\CMS\Recycler\Task\CleanerTask::class . '->init'; + +$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['scheduler']['tasks'][\TYPO3\CMS\Recycler\Task\CleanerTask::class] = array( + 'extension' => $_EXTKEY, + 'title' => 'LLL:EXT:' . $_EXTKEY . '/locallang_tasks.xlf:cleanerTaskTitle', + 'description' => 'LLL:EXT:' . $_EXTKEY . '/locallang_tasks.xlf:cleanerTaskDescription', + 'additionalFields' => \TYPO3\CMS\Recycler\Task\CleanerFieldProvider::class +); \ No newline at end of file diff --git a/typo3/sysext/scheduler/Classes/Scheduler.php b/typo3/sysext/scheduler/Classes/Scheduler.php index 26dd67b0da81..34b8b428bcce 100644 --- a/typo3/sysext/scheduler/Classes/Scheduler.php +++ b/typo3/sysext/scheduler/Classes/Scheduler.php @@ -14,7 +14,10 @@ namespace TYPO3\CMS\Scheduler; * The TYPO3 project - inspiring people to share! */ +use TYPO3\CMS\Core\Registry; use TYPO3\CMS\Core\Utility\CommandUtility; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\MathUtility; /** * TYPO3 Scheduler. This class handles scheduling and execution of tasks. @@ -51,10 +54,10 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface { /** * Adds a task to the pool * - * @param \TYPO3\CMS\Scheduler\Task\AbstractTask $task The object representing the task to add + * @param Task\AbstractTask $task The object representing the task to add * @return bool TRUE if the task was successfully added, FALSE otherwise */ - public function addTask(\TYPO3\CMS\Scheduler\Task\AbstractTask $task) { + public function addTask(Task\AbstractTask $task) { $taskUid = $task->getTaskUid(); if (empty($taskUid)) { $fields = array( @@ -64,9 +67,9 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface { 'task_group' => $task->getTaskGroup(), 'serialized_task_object' => 'RESERVED' ); - $result = $GLOBALS['TYPO3_DB']->exec_INSERTquery('tx_scheduler_task', $fields); + $result = $this->getDatabaseConnection()->exec_INSERTquery('tx_scheduler_task', $fields); if ($result) { - $task->setTaskUid($GLOBALS['TYPO3_DB']->sql_insert_id()); + $task->setTaskUid($this->getDatabaseConnection()->sql_insert_id()); $task->save(); $result = TRUE; } else { @@ -86,12 +89,13 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface { */ protected function cleanExecutionArrays() { $tstamp = $GLOBALS['EXEC_TIME']; + $db = $this->getDatabaseConnection(); // Select all tasks with executions // NOTE: this cleanup is done for disabled tasks too, // to avoid leaving old executions lying around - $res = $GLOBALS['TYPO3_DB']->exec_SELECTquery('uid, serialized_executions, serialized_task_object', 'tx_scheduler_task', 'serialized_executions <> \'\''); + $res = $db->exec_SELECTquery('uid, serialized_executions, serialized_task_object', 'tx_scheduler_task', 'serialized_executions <> \'\''); $maxDuration = $this->extConf['maxLifetime'] * 60; - while ($row = $GLOBALS['TYPO3_DB']->sql_fetch_assoc($res)) { + while ($row = $db->sql_fetch_assoc($res)) { $executions = array(); if ($serialized_executions = unserialize($row['serialized_executions'])) { foreach ($serialized_executions as $task) { @@ -110,20 +114,22 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface { } else { $value = serialize($executions); } - $GLOBALS['TYPO3_DB']->exec_UPDATEquery('tx_scheduler_task', 'uid = ' . (int)$row['uid'], array('serialized_executions' => $value)); + $db->exec_UPDATEquery('tx_scheduler_task', 'uid = ' . (int)$row['uid'], array('serialized_executions' => $value)); } } - $GLOBALS['TYPO3_DB']->sql_free_result($res); + $db->sql_free_result($res); } /** * This method executes the given task and properly marks and records that execution * It is expected to return FALSE if the task was barred from running or if it was not saved properly * - * @param \TYPO3\CMS\Scheduler\Task\AbstractTask $task The task to execute + * @param Task\AbstractTask $task The task to execute * @return bool Whether the task was saved successfully to the database or not + * @throws FailedExecutionException + * @throws \Exception */ - public function executeTask(\TYPO3\CMS\Scheduler\Task\AbstractTask $task) { + public function executeTask(Task\AbstractTask $task) { // Trigger the saving of the task, as this will calculate its next execution time // This should be calculated all the time, even if the execution is skipped // (in case it is skipped, this pushes back execution to the next possible date) @@ -149,7 +155,7 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface { // Execute task $successfullyExecuted = $task->execute(); if (!$successfullyExecuted) { - throw new \TYPO3\CMS\Scheduler\FailedExecutionException('Task failed to execute successfully. Class: ' . get_class($task) . ', UID: ' . $task->getTaskUid(), 1250596541); + throw new FailedExecutionException('Task failed to execute successfully. Class: ' . get_class($task) . ', UID: ' . $task->getTaskUid(), 1250596541); } } catch (\Exception $e) { // Store exception, so that it can be saved to database @@ -180,23 +186,24 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface { if ($type !== 'manual' && $type !== 'cli-by-id') { $type = 'cron'; } - /** @var $registry \TYPO3\CMS\Core\Registry */ - $registry = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(\TYPO3\CMS\Core\Registry::class); + /** @var Registry $registry */ + $registry = GeneralUtility::makeInstance(Registry::class); $runInformation = array('start' => $GLOBALS['EXEC_TIME'], 'end' => time(), 'type' => $type); $registry->set('tx_scheduler', 'lastRun', $runInformation); } /** * Removes a task completely from the system. + * * @todo find a way to actually kill the existing jobs * - * @param \TYPO3\CMS\Scheduler\Task\AbstractTask $task The object representing the task to delete + * @param Task\AbstractTask $task The object representing the task to delete * @return bool TRUE if task was successfully deleted, FALSE otherwise */ - public function removeTask(\TYPO3\CMS\Scheduler\Task\AbstractTask $task) { + public function removeTask(Task\AbstractTask $task) { $taskUid = $task->getTaskUid(); if (!empty($taskUid)) { - $result = $GLOBALS['TYPO3_DB']->exec_DELETEquery('tx_scheduler_task', 'uid = ' . $taskUid); + $result = $this->getDatabaseConnection()->exec_DELETEquery('tx_scheduler_task', 'uid = ' . $taskUid); } else { $result = FALSE; } @@ -209,10 +216,10 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface { /** * Updates a task in the pool * - * @param \TYPO3\CMS\Scheduler\Task\AbstractTask $task Scheduler task object + * @param Task\AbstractTask $task Scheduler task object * @return bool False if submitted task was not of proper class */ - public function saveTask(\TYPO3\CMS\Scheduler\Task\AbstractTask $task) { + public function saveTask(Task\AbstractTask $task) { $taskUid = $task->getTaskUid(); if (!empty($taskUid)) { try { @@ -230,7 +237,7 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface { 'task_group' => $task->getTaskGroup(), 'serialized_task_object' => serialize($task) ); - $result = $GLOBALS['TYPO3_DB']->exec_UPDATEquery('tx_scheduler_task', 'uid = ' . $taskUid, $fields); + $result = $this->getDatabaseConnection()->exec_UPDATEquery('tx_scheduler_task', 'uid = ' . $taskUid, $fields); } else { $result = FALSE; } @@ -246,7 +253,7 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface { * If there are no due tasks the method throws an exception. * * @param int $uid Primary key of a task - * @return \TYPO3\CMS\Scheduler\Task\AbstractTask The fetched task object + * @return Task\AbstractTask The fetched task object * @throws \OutOfBoundsException * @throws \UnexpectedValueException */ @@ -269,16 +276,17 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface { ); } - $res = $GLOBALS['TYPO3_DB']->exec_SELECT_queryArray($queryArray); + $db = $this->getDatabaseConnection(); + $res = $db->exec_SELECT_queryArray($queryArray); if ($res === FALSE) { throw new \UnexpectedValueException('Query could not be executed. Possible defect in tables tx_scheduler_task or tx_scheduler_task_group', 1422044826); } // If there are no available tasks, thrown an exception - if ($GLOBALS['TYPO3_DB']->sql_num_rows($res) == 0) { + if ($db->sql_num_rows($res) == 0) { throw new \OutOfBoundsException('No task', 1247827244); } else { - $row = $GLOBALS['TYPO3_DB']->sql_fetch_assoc($res); - /** @var $task \TYPO3\CMS\Scheduler\Task\AbstractTask */ + $row = $db->sql_fetch_assoc($res); + /** @var $task Task\AbstractTask */ $task = unserialize($row['serialized_task_object']); if ($this->isValidTaskObject($task)) { // The task is valid, return it @@ -286,11 +294,11 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface { } else { // Forcibly set the disable flag to 1 in the database, // so that the task does not come up again and again for execution - $GLOBALS['TYPO3_DB']->exec_UPDATEquery('tx_scheduler_task', 'uid = ' . $row['uid'], array('disable' => 1)); + $db->exec_UPDATEquery('tx_scheduler_task', 'uid = ' . $row['uid'], array('disable' => 1)); // Throw an exception to raise the problem throw new \UnexpectedValueException('Could not unserialize task', 1255083671); } - $GLOBALS['TYPO3_DB']->sql_free_result($res); + $db->sql_free_result($res); } return $task; } @@ -305,13 +313,14 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface { * @throws \OutOfBoundsException */ public function fetchTaskRecord($uid) { - $res = $GLOBALS['TYPO3_DB']->exec_SELECTquery('*', 'tx_scheduler_task', 'uid = ' . (int)$uid); + $db = $this->getDatabaseConnection(); + $res = $db->exec_SELECTquery('*', 'tx_scheduler_task', 'uid = ' . (int)$uid); // If the task is not found, throw an exception - if ($GLOBALS['TYPO3_DB']->sql_num_rows($res) == 0) { + if ($db->sql_num_rows($res) == 0) { throw new \OutOfBoundsException('No task', 1247827245); } else { - $row = $GLOBALS['TYPO3_DB']->sql_fetch_assoc($res); - $GLOBALS['TYPO3_DB']->sql_free_result($res); + $row = $db->sql_fetch_assoc($res); + $db->sql_free_result($res); } return $row; } @@ -336,10 +345,11 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface { } $whereClause .= 'disable = 0'; } - $res = $GLOBALS['TYPO3_DB']->exec_SELECTquery('serialized_task_object', 'tx_scheduler_task', $whereClause); + $db = $this->getDatabaseConnection(); + $res = $db->exec_SELECTquery('serialized_task_object', 'tx_scheduler_task', $whereClause); if ($res) { - while ($row = $GLOBALS['TYPO3_DB']->sql_fetch_assoc($res)) { - /** @var $task Task */ + while ($row = $db->sql_fetch_assoc($res)) { + /** @var Task\AbstractTask $task */ $task = unserialize($row['serialized_task_object']); // Add the task to the list only if it is valid if ($this->isValidTaskObject($task)) { @@ -347,7 +357,7 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface { $tasks[] = $task; } } - $GLOBALS['TYPO3_DB']->sql_free_result($res); + $db->sql_free_result($res); } return $tasks; } @@ -365,7 +375,7 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface { * @return bool TRUE if object is a task, FALSE otherwise */ public function isValidTaskObject($task) { - return $task instanceof \TYPO3\CMS\Scheduler\Task\AbstractTask; + return $task instanceof Task\AbstractTask; } /** @@ -395,11 +405,11 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface { if ((int)$this->extConf['useAtdaemon'] !== 1) { return FALSE; } - /** @var $registry \TYPO3\CMS\Core\Registry */ - $registry = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(\TYPO3\CMS\Core\Registry::class); + /** @var $registry Registry */ + $registry = GeneralUtility::makeInstance(Registry::class); // Get at job id from registry and remove at job $atJobId = $registry->get('tx_scheduler', 'atJobId'); - if (\TYPO3\CMS\Core\Utility\MathUtility::canBeInterpretedAsInteger($atJobId)) { + if (MathUtility::canBeInterpretedAsInteger($atJobId)) { shell_exec('atrm ' . (int)$atJobId . ' 2>&1'); } // Can not use fetchTask() here because if tasks have just executed @@ -408,7 +418,7 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface { $nextExecution = FALSE; foreach ($tasks as $task) { try { - /** @var $task \TYPO3\CMS\Scheduler\Task\AbstractTask */ + /** @var $task Task\AbstractTask */ $tempNextExecution = $task->getNextDueExecution(); if ($nextExecution === FALSE || $tempNextExecution < $nextExecution) { $nextExecution = $tempNextExecution; @@ -431,12 +441,12 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface { $output = shell_exec($cmd); $outputParts = ''; foreach (explode(LF, $output) as $outputLine) { - if (\TYPO3\CMS\Core\Utility\GeneralUtility::isFirstPartOfStr($outputLine, 'job')) { + if (GeneralUtility::isFirstPartOfStr($outputLine, 'job')) { $outputParts = explode(' ', $outputLine, 3); break; } } - if ($outputParts[0] === 'job' && \TYPO3\CMS\Core\Utility\MathUtility::canBeInterpretedAsInteger($outputParts[1])) { + if ($outputParts[0] === 'job' && MathUtility::canBeInterpretedAsInteger($outputParts[1])) { $atJobId = (int)$outputParts[1]; $registry->set('tx_scheduler', 'atJobId', $atJobId); } @@ -444,4 +454,10 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface { return TRUE; } + /** + * @return \TYPO3\CMS\Core\Database\DatabaseConnection + */ + protected function getDatabaseConnection() { + return $GLOBALS['TYPO3_DB']; + } } -- GitLab