1<?php
2
3/*
4 * This file is part of the TYPO3 CMS project.
5 *
6 * It is free software; you can redistribute it and/or modify it under
7 * the terms of the GNU General Public License, either version 2
8 * of the License, or any later version.
9 *
10 * For the full copyright and license information, please read the
11 * LICENSE.txt file that was distributed with this source code.
12 *
13 * The TYPO3 project - inspiring people to share!
14 */
15
16namespace TYPO3\CMS\Workspaces\Hook;
17
18use Doctrine\DBAL\Exception as DBALException;
19use Doctrine\DBAL\Platforms\PostgreSQL94Platform as PostgreSQLPlatform;
20use Doctrine\DBAL\Platforms\SQLServer2012Platform as SQLServerPlatform;
21use TYPO3\CMS\Backend\Utility\BackendUtility;
22use TYPO3\CMS\Core\Cache\CacheManager;
23use TYPO3\CMS\Core\Context\Context;
24use TYPO3\CMS\Core\Context\WorkspaceAspect;
25use TYPO3\CMS\Core\Database\Connection;
26use TYPO3\CMS\Core\Database\ConnectionPool;
27use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
28use TYPO3\CMS\Core\Database\RelationHandler;
29use TYPO3\CMS\Core\DataHandling\DataHandler;
30use TYPO3\CMS\Core\Localization\LanguageService;
31use TYPO3\CMS\Core\SysLog\Action\Database as DatabaseAction;
32use TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification;
33use TYPO3\CMS\Core\Type\Bitmask\Permission;
34use TYPO3\CMS\Core\Utility\ArrayUtility;
35use TYPO3\CMS\Core\Utility\GeneralUtility;
36use TYPO3\CMS\Core\Versioning\VersionState;
37use TYPO3\CMS\Workspaces\DataHandler\CommandMap;
38use TYPO3\CMS\Workspaces\Notification\StageChangeNotification;
39use TYPO3\CMS\Workspaces\Service\StagesService;
40use TYPO3\CMS\Workspaces\Service\WorkspaceService;
41
42/**
43 * Contains some parts for staging, versioning and workspaces
44 * to interact with the TYPO3 Core Engine
45 * @internal This is a specific hook implementation and is not considered part of the Public TYPO3 API.
46 */
47class DataHandlerHook
48{
49    /**
50     * For accumulating information about workspace stages raised
51     * on elements so a single mail is sent as notification.
52     *
53     * @var array
54     */
55    protected $notificationEmailInfo = [];
56
57    /**
58     * Contains remapped IDs.
59     *
60     * @var array
61     */
62    protected $remappedIds = [];
63
64    /****************************
65     *****  Cmdmap  Hooks  ******
66     ****************************/
67    /**
68     * hook that is called before any cmd of the commandmap is executed
69     *
70     * @param DataHandler $dataHandler reference to the main DataHandler object
71     */
72    public function processCmdmap_beforeStart(DataHandler $dataHandler)
73    {
74        // Reset notification array
75        $this->notificationEmailInfo = [];
76        // Resolve dependencies of version/workspaces actions:
77        $dataHandler->cmdmap = $this->getCommandMap($dataHandler)->process()->get();
78    }
79
80    /**
81     * hook that is called when no prepared command was found
82     *
83     * @param string $command the command to be executed
84     * @param string $table the table of the record
85     * @param int $id the ID of the record
86     * @param mixed $value the value containing the data
87     * @param bool $commandIsProcessed can be set so that other hooks or
88     * @param DataHandler $dataHandler reference to the main DataHandler object
89     */
90    public function processCmdmap($command, $table, $id, $value, &$commandIsProcessed, DataHandler $dataHandler)
91    {
92        // custom command "version"
93        if ($command !== 'version') {
94            return;
95        }
96        $commandIsProcessed = true;
97        $action = (string)$value['action'];
98        $comment = $value['comment'] ?? '';
99        $notificationAlternativeRecipients = $value['notificationAlternativeRecipients'] ?? [];
100        switch ($action) {
101            case 'new':
102                $dataHandler->versionizeRecord($table, $id, $value['label']);
103                break;
104            case 'swap':
105            case 'publish':
106                $this->version_swap(
107                    $table,
108                    $id,
109                    $value['swapWith'],
110                    $dataHandler,
111                    $comment,
112                    $notificationAlternativeRecipients
113                );
114                break;
115            case 'clearWSID':
116            case 'flush':
117                $dataHandler->discard($table, (int)$id);
118                break;
119            case 'setStage':
120                $elementIds = GeneralUtility::intExplode(',', (string)$id, true);
121                foreach ($elementIds as $elementId) {
122                    $this->version_setStage(
123                        $table,
124                        $elementId,
125                        $value['stageId'],
126                        $comment,
127                        $dataHandler,
128                        $notificationAlternativeRecipients
129                    );
130                }
131                break;
132            default:
133                // Do nothing
134        }
135    }
136
137    /**
138     * hook that is called AFTER all commands of the commandmap was
139     * executed
140     *
141     * @param DataHandler $dataHandler reference to the main DataHandler object
142     */
143    public function processCmdmap_afterFinish(DataHandler $dataHandler)
144    {
145        // Empty accumulation array
146        $emailNotificationService = GeneralUtility::makeInstance(StageChangeNotification::class);
147        $this->sendStageChangeNotification(
148            $this->notificationEmailInfo,
149            $emailNotificationService,
150            $dataHandler
151        );
152
153        // Reset notification array
154        $this->notificationEmailInfo = [];
155        // Reset remapped IDs
156        $this->remappedIds = [];
157
158        $this->flushWorkspaceCacheEntriesByWorkspaceId((int)$dataHandler->BE_USER->workspace);
159    }
160
161    protected function sendStageChangeNotification(
162        array $accumulatedNotificationInformation,
163        StageChangeNotification $notificationService,
164        DataHandler $dataHandler
165    ): void {
166        foreach ($accumulatedNotificationInformation as $groupedNotificationInformation) {
167            $emails = (array)$groupedNotificationInformation['recipients'];
168            if (empty($emails)) {
169                continue;
170            }
171            $workspaceRec = $groupedNotificationInformation['shared'][0];
172            if (!is_array($workspaceRec)) {
173                continue;
174            }
175            $notificationService->notifyStageChange(
176                $workspaceRec,
177                (int)$groupedNotificationInformation['shared'][1],
178                $groupedNotificationInformation['elements'],
179                $groupedNotificationInformation['shared'][2],
180                $emails,
181                $dataHandler->BE_USER
182            );
183
184            if ($dataHandler->enableLogging) {
185                [$elementTable, $elementUid] = reset($groupedNotificationInformation['elements']);
186                $propertyArray = $dataHandler->getRecordProperties($elementTable, $elementUid);
187                $pid = $propertyArray['pid'];
188                $dataHandler->log($elementTable, $elementUid, DatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::MESSAGE, 'Notification email for stage change was sent to "' . implode('", "', array_column($emails, 'email')) . '"', -1, [], $dataHandler->eventPid($elementTable, $elementUid, $pid));
189            }
190        }
191    }
192
193    /**
194     * hook that is called when an element shall get deleted
195     *
196     * @param string $table the table of the record
197     * @param int $id the ID of the record
198     * @param array $record The accordant database record
199     * @param bool $recordWasDeleted can be set so that other hooks or
200     * @param DataHandler $dataHandler reference to the main DataHandler object
201     */
202    public function processCmdmap_deleteAction($table, $id, array $record, &$recordWasDeleted, DataHandler $dataHandler)
203    {
204        // only process the hook if it wasn't processed
205        // by someone else before
206        if ($recordWasDeleted) {
207            return;
208        }
209        $recordWasDeleted = true;
210        // For Live version, try if there is a workspace version because if so, rather "delete" that instead
211        // Look, if record is an offline version, then delete directly:
212        if ((int)($record['t3ver_oid'] ?? 0) === 0) {
213            if ($wsVersion = BackendUtility::getWorkspaceVersionOfRecord($dataHandler->BE_USER->workspace, $table, $id)) {
214                $record = $wsVersion;
215                $id = $record['uid'];
216            }
217        }
218        $recordVersionState = VersionState::cast($record['t3ver_state'] ?? 0);
219        // Look, if record is an offline version, then delete directly:
220        if ((int)($record['t3ver_oid'] ?? 0) > 0) {
221            if (BackendUtility::isTableWorkspaceEnabled($table)) {
222                // In Live workspace, delete any. In other workspaces there must be match.
223                if ($dataHandler->BE_USER->workspace == 0 || (int)$record['t3ver_wsid'] == $dataHandler->BE_USER->workspace) {
224                    $liveRec = BackendUtility::getLiveVersionOfRecord($table, $id, 'uid,t3ver_state');
225                    // Processing can be skipped if a delete placeholder shall be published
226                    // during the current request. Thus it will be deleted later on...
227                    $liveRecordVersionState = VersionState::cast($liveRec['t3ver_state']);
228                    if ($recordVersionState->equals(VersionState::DELETE_PLACEHOLDER) && !empty($liveRec['uid'])
229                        && !empty($dataHandler->cmdmap[$table][$liveRec['uid']]['version']['action'])
230                        && !empty($dataHandler->cmdmap[$table][$liveRec['uid']]['version']['swapWith'])
231                        && $dataHandler->cmdmap[$table][$liveRec['uid']]['version']['action'] === 'swap'
232                        && $dataHandler->cmdmap[$table][$liveRec['uid']]['version']['swapWith'] == $id
233                    ) {
234                        return null;
235                    }
236
237                    if ($record['t3ver_wsid'] > 0 && $recordVersionState->equals(VersionState::DEFAULT_STATE)) {
238                        // Change normal versioned record to delete placeholder
239                        // Happens when an edited record is deleted
240                        GeneralUtility::makeInstance(ConnectionPool::class)
241                            ->getConnectionForTable($table)
242                            ->update(
243                                $table,
244                                ['t3ver_state' => VersionState::DELETE_PLACEHOLDER],
245                                ['uid' => $id]
246                            );
247
248                        // Delete localization overlays:
249                        $dataHandler->deleteL10nOverlayRecords($table, $id);
250                    } elseif ($record['t3ver_wsid'] == 0 || !$liveRecordVersionState->indicatesPlaceholder()) {
251                        // Delete those in WS 0 + if their live records state was not "Placeholder".
252                        $dataHandler->deleteEl($table, $id);
253                    } elseif ($recordVersionState->equals(VersionState::NEW_PLACEHOLDER)) {
254                        $placeholderRecord = BackendUtility::getLiveVersionOfRecord($table, (int)$id);
255                        $dataHandler->deleteEl($table, (int)$id);
256                        if (is_array($placeholderRecord)) {
257                            $this->softOrHardDeleteSingleRecord($table, (int)$placeholderRecord['uid']);
258                        }
259                    }
260                } else {
261                    $dataHandler->log($table, (int)$id, DatabaseAction::DELETE, 0, SystemLogErrorClassification::USER_ERROR, 'Tried to delete record from another workspace');
262                }
263            } else {
264                $dataHandler->log($table, (int)$id, DatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::USER_ERROR, 'Versioning not enabled for record with an online ID (t3ver_oid) given');
265            }
266        } elseif ($recordVersionState->equals(VersionState::NEW_PLACEHOLDER)) {
267            // If it is a new versioned record, delete it directly.
268            $dataHandler->deleteEl($table, $id);
269        } elseif ($dataHandler->BE_USER->workspaceAllowsLiveEditingInTable($table)) {
270            // Look, if record is "online" then delete directly.
271            $dataHandler->deleteEl($table, $id);
272        } else {
273            // Otherwise, try to delete by versioning:
274            $copyMappingArray = $dataHandler->copyMappingArray;
275            $dataHandler->versionizeRecord($table, $id, 'DELETED!', true);
276            // Determine newly created versions:
277            // (remove placeholders are copied and modified, thus they appear in the copyMappingArray)
278            $versionizedElements = ArrayUtility::arrayDiffKeyRecursive($dataHandler->copyMappingArray, $copyMappingArray);
279            // Delete localization overlays:
280            foreach ($versionizedElements as $versionizedTableName => $versionizedOriginalIds) {
281                foreach ($versionizedOriginalIds as $versionizedOriginalId => $_) {
282                    $dataHandler->deleteL10nOverlayRecords($versionizedTableName, $versionizedOriginalId);
283                }
284            }
285        }
286    }
287
288    /**
289     * In case a sys_workspace_stage record is deleted we do a hard reset
290     * for all existing records in that stage to avoid that any of these end up
291     * as orphan records.
292     *
293     * @param string $command
294     * @param string $table
295     * @param string $id
296     * @param string $value
297     * @param DataHandler $dataHandler
298     */
299    public function processCmdmap_postProcess($command, $table, $id, $value, DataHandler $dataHandler)
300    {
301        if ($command === 'delete') {
302            if ($table === StagesService::TABLE_STAGE) {
303                $this->resetStageOfElements((int)$id);
304            } elseif ($table === WorkspaceService::TABLE_WORKSPACE) {
305                $this->flushWorkspaceElements((int)$id);
306                $this->emitUpdateTopbarSignal();
307            }
308        }
309    }
310
311    public function processDatamap_afterAllOperations(DataHandler $dataHandler): void
312    {
313        if (isset($dataHandler->datamap[WorkspaceService::TABLE_WORKSPACE])) {
314            $this->emitUpdateTopbarSignal();
315        }
316    }
317
318    /**
319     * Hook for \TYPO3\CMS\Core\DataHandling\DataHandler::moveRecord that cares about
320     * moving records that are *not* in the live workspace
321     *
322     * @param string $table the table of the record
323     * @param int $uid the ID of the record
324     * @param int $destPid Position to move to: $destPid: >=0 then it points to
325     * @param array $propArr Record properties, like header and pid (includes workspace overlay)
326     * @param array $moveRec Record properties, like header and pid (without workspace overlay)
327     * @param int $resolvedPid The final page ID of the record
328     * @param bool $recordWasMoved can be set so that other hooks or
329     * @param DataHandler $dataHandler
330     */
331    public function moveRecord($table, $uid, $destPid, array $propArr, array $moveRec, $resolvedPid, &$recordWasMoved, DataHandler $dataHandler)
332    {
333        // Only do something in Draft workspace
334        if ($dataHandler->BE_USER->workspace === 0) {
335            return;
336        }
337        $tableSupportsVersioning = BackendUtility::isTableWorkspaceEnabled($table);
338        $recordWasMoved = true;
339        $moveRecVersionState = VersionState::cast((int)($moveRec['t3ver_state'] ?? VersionState::DEFAULT_STATE));
340        // Get workspace version of the source record, if any:
341        $versionedRecord = BackendUtility::getWorkspaceVersionOfRecord($dataHandler->BE_USER->workspace, $table, $uid, 'uid,t3ver_oid');
342        if ($tableSupportsVersioning) {
343            // Create version of record first, if it does not exist
344            if (empty($versionedRecord['uid'])) {
345                $dataHandler->versionizeRecord($table, $uid, 'MovePointer');
346                $versionedRecord = BackendUtility::getWorkspaceVersionOfRecord($dataHandler->BE_USER->workspace, $table, $uid, 'uid,t3ver_oid');
347                if ((int)$resolvedPid !== (int)$propArr['pid']) {
348                    $this->moveRecord_processFields($dataHandler, $resolvedPid, $table, $uid);
349                }
350            } elseif ($dataHandler->isRecordCopied($table, $uid) && (int)$dataHandler->copyMappingArray[$table][$uid] === (int)$versionedRecord['uid']) {
351                // If the record has been versioned before (e.g. cascaded parent-child structure), create only the move-placeholders
352                if ((int)$resolvedPid !== (int)$propArr['pid']) {
353                    $this->moveRecord_processFields($dataHandler, $resolvedPid, $table, $uid);
354                }
355            }
356        }
357        // Check workspace permissions:
358        $workspaceAccessBlocked = [];
359        // Element was in "New/Deleted/Moved" so it can be moved...
360        $recIsNewVersion = $moveRecVersionState->equals(VersionState::NEW_PLACEHOLDER) || $moveRecVersionState->indicatesPlaceholder();
361        $recordMustNotBeVersionized = $dataHandler->BE_USER->workspaceAllowsLiveEditingInTable($table);
362        $canMoveRecord = $recIsNewVersion || $tableSupportsVersioning;
363        // Workspace source check:
364        if (!$recIsNewVersion) {
365            $errorCode = $dataHandler->workspaceCannotEditRecord($table, $versionedRecord['uid'] ?: $uid);
366            if ($errorCode) {
367                $workspaceAccessBlocked['src1'] = 'Record could not be edited in workspace: ' . $errorCode . ' ';
368            } elseif (!$canMoveRecord && !$recordMustNotBeVersionized) {
369                $workspaceAccessBlocked['src2'] = 'Could not remove record from table "' . $table . '" from its page "' . $moveRec['pid'] . '" ';
370            }
371        }
372        // Workspace destination check:
373        // All records can be inserted if $recordMustNotBeVersionized is true.
374        // Only new versions can be inserted if $recordMustNotBeVersionized is FALSE.
375        if (!($recordMustNotBeVersionized || $canMoveRecord && !$recordMustNotBeVersionized)) {
376            $workspaceAccessBlocked['dest1'] = 'Could not insert record from table "' . $table . '" in destination PID "' . $resolvedPid . '" ';
377        }
378
379        if (empty($workspaceAccessBlocked)) {
380            $versionedRecordUid = (int)$versionedRecord['uid'];
381            // custom moving not needed, just behave like in live workspace (also for newly versioned records)
382            if (!$versionedRecordUid || !$tableSupportsVersioning || $recIsNewVersion) {
383                $recordWasMoved = false;
384            } else {
385                // If the move operation is done on a versioned record, which is
386                // NOT new/deleted placeholder, then mark the versioned record as "moved"
387                $this->moveRecord_moveVersionedRecord($table, (int)$uid, (int)$destPid, $versionedRecordUid, $dataHandler);
388            }
389        } else {
390            $dataHandler->log($table, $versionedRecord['uid'] ?: $uid, DatabaseAction::MOVE, 0, SystemLogErrorClassification::USER_ERROR, 'Move attempt failed due to workspace restrictions: ' . implode(' // ', $workspaceAccessBlocked));
391        }
392    }
393
394    /**
395     * Processes fields of a moved record and follows references.
396     *
397     * @param DataHandler $dataHandler Calling DataHandler instance
398     * @param int $resolvedPageId Resolved real destination page id
399     * @param string $table Name of parent table
400     * @param int $uid UID of the parent record
401     */
402    protected function moveRecord_processFields(DataHandler $dataHandler, $resolvedPageId, $table, $uid)
403    {
404        $versionedRecord = BackendUtility::getWorkspaceVersionOfRecord($dataHandler->BE_USER->workspace, $table, $uid);
405        if (empty($versionedRecord)) {
406            return;
407        }
408        foreach ($versionedRecord as $field => $value) {
409            if (empty($GLOBALS['TCA'][$table]['columns'][$field]['config'])) {
410                continue;
411            }
412            $this->moveRecord_processFieldValue(
413                $dataHandler,
414                $resolvedPageId,
415                $table,
416                $uid,
417                $value,
418                $GLOBALS['TCA'][$table]['columns'][$field]['config']
419            );
420        }
421    }
422
423    /**
424     * Processes a single field of a moved record and follows references.
425     *
426     * @param DataHandler $dataHandler Calling DataHandler instance
427     * @param int $resolvedPageId Resolved real destination page id
428     * @param string $table Name of parent table
429     * @param int $uid UID of the parent record
430     * @param string $value Value of the field of the parent record
431     * @param array $configuration TCA field configuration of the parent record
432     */
433    protected function moveRecord_processFieldValue(DataHandler $dataHandler, $resolvedPageId, $table, $uid, $value, array $configuration): void
434    {
435        $inlineFieldType = $dataHandler->getInlineFieldType($configuration);
436        $inlineProcessing = (
437            ($inlineFieldType === 'list' || $inlineFieldType === 'field')
438            && BackendUtility::isTableWorkspaceEnabled($configuration['foreign_table'])
439            && (!isset($configuration['behaviour']['disableMovingChildrenWithParent']) || !$configuration['behaviour']['disableMovingChildrenWithParent'])
440        );
441
442        if ($inlineProcessing) {
443            if ($table === 'pages') {
444                // If the inline elements are related to a page record,
445                // make sure they reside at that page and not at its parent
446                $resolvedPageId = $uid;
447            }
448
449            $dbAnalysis = $this->createRelationHandlerInstance();
450            $dbAnalysis->start($value, $configuration['foreign_table'], '', $uid, $table, $configuration);
451
452            // Moving records to a positive destination will insert each
453            // record at the beginning, thus the order is reversed here:
454            foreach ($dbAnalysis->itemArray as $item) {
455                $versionedRecord = BackendUtility::getWorkspaceVersionOfRecord($dataHandler->BE_USER->workspace, $item['table'], $item['id'], 'uid,t3ver_state');
456                if (empty($versionedRecord)) {
457                    continue;
458                }
459                $versionState = VersionState::cast($versionedRecord['t3ver_state']);
460                if ($versionState->indicatesPlaceholder()) {
461                    continue;
462                }
463                $dataHandler->moveRecord($item['table'], $item['id'], $resolvedPageId);
464            }
465        }
466    }
467
468    /****************************
469     *****  Stage Changes  ******
470     ****************************/
471    /**
472     * Setting stage of record
473     *
474     * @param string $table Table name
475     * @param int $id
476     * @param int $stageId Stage ID to set
477     * @param string $comment Comment that goes into log
478     * @param DataHandler $dataHandler DataHandler object
479     * @param array $notificationAlternativeRecipients comma separated list of recipients to notify instead of normal be_users
480     */
481    protected function version_setStage($table, $id, $stageId, string $comment, DataHandler $dataHandler, array $notificationAlternativeRecipients = [])
482    {
483        $record = BackendUtility::getRecord($table, $id);
484        if (!is_array($record)) {
485            $dataHandler->log($table, $id, DatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to set stage for record failed: No Record');
486        } elseif ($errorCode = $dataHandler->workspaceCannotEditOfflineVersion($table, $record)) {
487            $dataHandler->log($table, $id, DatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to set stage for record failed: ' . $errorCode);
488        } elseif ($dataHandler->checkRecordUpdateAccess($table, $id)) {
489            $workspaceInfo = $dataHandler->BE_USER->checkWorkspace($record['t3ver_wsid']);
490            // check if the user is allowed to the current stage, so it's also allowed to send to next stage
491            if ($dataHandler->BE_USER->workspaceCheckStageForCurrent($record['t3ver_stage'])) {
492                // Set stage of record:
493                GeneralUtility::makeInstance(ConnectionPool::class)
494                    ->getConnectionForTable($table)
495                    ->update(
496                        $table,
497                        [
498                            't3ver_stage' => $stageId,
499                        ],
500                        ['uid' => (int)$id]
501                    );
502
503                if ($dataHandler->enableLogging) {
504                    $propertyArray = $dataHandler->getRecordProperties($table, $id);
505                    $pid = $propertyArray['pid'];
506                    $dataHandler->log($table, $id, DatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::MESSAGE, 'Stage for record was changed to ' . $stageId . '. Comment was: "' . substr($comment, 0, 100) . '"', -1, [], $dataHandler->eventPid($table, $id, $pid));
507                }
508                // TEMPORARY, except 6-30 as action/detail number which is observed elsewhere!
509                $dataHandler->log($table, $id, DatabaseAction::UPDATE, 0, SystemLogErrorClassification::MESSAGE, 'Stage raised...', 30, ['comment' => $comment, 'stage' => $stageId]);
510                if ((int)$workspaceInfo['stagechg_notification'] > 0) {
511                    $this->notificationEmailInfo[$workspaceInfo['uid'] . ':' . $stageId . ':' . $comment]['shared'] = [$workspaceInfo, $stageId, $comment];
512                    $this->notificationEmailInfo[$workspaceInfo['uid'] . ':' . $stageId . ':' . $comment]['elements'][] = [$table, $id];
513                    $this->notificationEmailInfo[$workspaceInfo['uid'] . ':' . $stageId . ':' . $comment]['recipients'] = $notificationAlternativeRecipients;
514                }
515            } else {
516                $dataHandler->log($table, $id, DatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::USER_ERROR, 'The member user tried to set a stage value "' . $stageId . '" that was not allowed');
517            }
518        } else {
519            $dataHandler->log($table, $id, DatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to set stage for record failed because you do not have edit access');
520        }
521    }
522
523    /*****************************
524     *****  CMD versioning  ******
525     *****************************/
526
527    /**
528     * Publishing / Swapping (= switching) versions of a record
529     * Version from archive (future/past, called "swap version") will get the uid of the "t3ver_oid", the official element with uid = "t3ver_oid" will get the new versions old uid. PIDs are swapped also
530     *
531     * @param string $table Table name
532     * @param int $id UID of the online record to swap
533     * @param int $swapWith UID of the archived version to swap with!
534     * @param DataHandler $dataHandler DataHandler object
535     * @param string $comment Notification comment
536     * @param array $notificationAlternativeRecipients comma separated list of recipients to notify instead of normal be_users
537     */
538    protected function version_swap($table, $id, $swapWith, DataHandler $dataHandler, string $comment, $notificationAlternativeRecipients = [])
539    {
540        // Check prerequisites before start publishing
541        // Skip records that have been deleted during the current execution
542        if ($dataHandler->hasDeletedRecord($table, $id)) {
543            return;
544        }
545
546        // First, check if we may actually edit the online record
547        if (!$dataHandler->checkRecordUpdateAccess($table, $id)) {
548            $dataHandler->log(
549                $table,
550                $id,
551                DatabaseAction::PUBLISH,
552                0,
553                SystemLogErrorClassification::USER_ERROR,
554                'Error: You cannot swap versions for record %s:%d you do not have access to edit!',
555                -1,
556                [$table, $id]
557            );
558            return;
559        }
560        // Select the two versions:
561        // Currently live version, contents will be removed.
562        $curVersion = BackendUtility::getRecord($table, $id, '*');
563        // Versioned records which contents will be moved into $curVersion
564        $isNewRecord = ((int)($curVersion['t3ver_state'] ?? 0) === VersionState::NEW_PLACEHOLDER);
565        if ($isNewRecord && is_array($curVersion)) {
566            // @todo: This early return is odd. It means version_swap_processFields() and versionPublishManyToManyRelations()
567            //        below are not called for new records to be published. This is "fine" for mm since mm tables have no
568            //        t3ver_wsid and need no publish as such. For inline relation publishing, this is indirectly resolved by the
569            //        processCmdmap_beforeStart() hook, which adds additional commands for child records - a construct we
570            //        may want to avoid altogether due to its complexity. It would be easier to follow if publish here would
571            //        handle that instead.
572            $this->publishNewRecord($table, $curVersion, $dataHandler, $comment, (array)$notificationAlternativeRecipients);
573            return;
574        }
575        $swapVersion = BackendUtility::getRecord($table, $swapWith, '*');
576        if (!(is_array($curVersion) && is_array($swapVersion))) {
577            $dataHandler->log(
578                $table,
579                $id,
580                DatabaseAction::PUBLISH,
581                0,
582                SystemLogErrorClassification::SYSTEM_ERROR,
583                'Error: Either online or swap version for %s:%d->%d could not be selected!',
584                -1,
585                [$table, $id, $swapWith]
586            );
587            return;
588        }
589        $workspaceId = (int)$swapVersion['t3ver_wsid'];
590        if (!$dataHandler->BE_USER->workspacePublishAccess($workspaceId)) {
591            $dataHandler->log($table, (int)$id, DatabaseAction::PUBLISH, 0, SystemLogErrorClassification::USER_ERROR, 'User could not publish records from workspace #' . $workspaceId);
592            return;
593        }
594        $wsAccess = $dataHandler->BE_USER->checkWorkspace($workspaceId);
595        if (!($workspaceId <= 0 || !($wsAccess['publish_access'] & 1) || (int)$swapVersion['t3ver_stage'] === StagesService::STAGE_PUBLISH_ID)) {
596            $dataHandler->log($table, (int)$id, DatabaseAction::PUBLISH, 0, SystemLogErrorClassification::USER_ERROR, 'Records in workspace #' . $workspaceId . ' can only be published when in "Publish" stage.');
597            return;
598        }
599        if (!($dataHandler->doesRecordExist($table, $swapWith, Permission::PAGE_SHOW) && $dataHandler->checkRecordUpdateAccess($table, $swapWith))) {
600            $dataHandler->log($table, $swapWith, DatabaseAction::PUBLISH, 0, SystemLogErrorClassification::USER_ERROR, 'You cannot publish a record you do not have edit and show permissions for');
601            return;
602        }
603        // Check if the swapWith record really IS a version of the original!
604        if (!(((int)$swapVersion['t3ver_oid'] > 0 && (int)$curVersion['t3ver_oid'] === 0) && (int)$swapVersion['t3ver_oid'] === (int)$id)) {
605            $dataHandler->log($table, $swapWith, DatabaseAction::PUBLISH, 0, SystemLogErrorClassification::SYSTEM_ERROR, 'In offline record, either t3ver_oid was not set or the t3ver_oid didn\'t match the id of the online version as it must!');
606            return;
607        }
608        $versionState = new VersionState($swapVersion['t3ver_state']);
609
610        // Find fields to keep
611        $keepFields = $this->getUniqueFields($table);
612        // Sorting needs to be exchanged for moved records
613        if (!empty($GLOBALS['TCA'][$table]['ctrl']['sortby']) && !$versionState->equals(VersionState::MOVE_POINTER)) {
614            $keepFields[] = $GLOBALS['TCA'][$table]['ctrl']['sortby'];
615        }
616        // l10n-fields must be kept otherwise the localization
617        // will be lost during the publishing
618        if ($GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']) {
619            $keepFields[] = $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'];
620        }
621        // Swap "keepfields"
622        foreach ($keepFields as $fN) {
623            $tmp = $swapVersion[$fN];
624            $swapVersion[$fN] = $curVersion[$fN];
625            $curVersion[$fN] = $tmp;
626        }
627        // Preserve states:
628        $t3ver_state = [];
629        $t3ver_state['swapVersion'] = $swapVersion['t3ver_state'];
630        // Modify offline version to become online:
631        // Set pid for ONLINE (but not for moved records)
632        if (!$versionState->equals(VersionState::MOVE_POINTER)) {
633            $swapVersion['pid'] = (int)$curVersion['pid'];
634        }
635        // We clear this because t3ver_oid only make sense for offline versions
636        // and we want to prevent unintentional misuse of this
637        // value for online records.
638        $swapVersion['t3ver_oid'] = 0;
639        // In case of swapping and the offline record has a state
640        // (like 2 or 4 for deleting or move-pointer) we set the
641        // current workspace ID so the record is not deselected.
642        // @todo: It is odd these information are updated in $swapVersion *before* version_swap_processFields
643        //        version_swap_processFields() and versionPublishManyToManyRelations() are called. This leads
644        //        to the situation that versionPublishManyToManyRelations() needs another argument to transfer
645        //        the "from workspace" information which would usually be retrieved by accessing $swapVersion['t3ver_wsid']
646        $swapVersion['t3ver_wsid'] = 0;
647        $swapVersion['t3ver_stage'] = 0;
648        $swapVersion['t3ver_state'] = (string)new VersionState(VersionState::DEFAULT_STATE);
649        // Take care of relations in each field (e.g. IRRE):
650        if (is_array($GLOBALS['TCA'][$table]['columns'])) {
651            foreach ($GLOBALS['TCA'][$table]['columns'] as $field => $fieldConf) {
652                if (isset($fieldConf['config']) && is_array($fieldConf['config'])) {
653                    $this->version_swap_processFields($table, $fieldConf['config'], $curVersion, $swapVersion, $dataHandler);
654                }
655            }
656        }
657        $dataHandler->versionPublishManyToManyRelations($table, $curVersion, $swapVersion, $workspaceId);
658        unset($swapVersion['uid']);
659        // Modify online version to become offline:
660        unset($curVersion['uid']);
661        // Mark curVersion to contain the oid
662        $curVersion['t3ver_oid'] = (int)$id;
663        $curVersion['t3ver_wsid'] = 0;
664        // Increment lifecycle counter
665        $curVersion['t3ver_stage'] = 0;
666        $curVersion['t3ver_state'] = (string)new VersionState(VersionState::DEFAULT_STATE);
667        // Generating proper history data to prepare logging
668        $dataHandler->compareFieldArrayWithCurrentAndUnset($table, $id, $swapVersion);
669        $dataHandler->compareFieldArrayWithCurrentAndUnset($table, $swapWith, $curVersion);
670
671        // Execute swapping:
672        $sqlErrors = [];
673        $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
674
675        $platform = $connection->getDatabasePlatform();
676        $tableDetails = null;
677        if ($platform instanceof SQLServerPlatform || $platform instanceof PostgreSQLPlatform) {
678            // mssql and postgres needs to set proper PARAM_LOB and others to update fields.
679            $tableDetails = $connection->createSchemaManager()->listTableDetails($table);
680        }
681
682        try {
683            $types = [];
684
685            if ($platform instanceof SQLServerPlatform || $platform instanceof PostgreSQLPlatform) {
686                foreach ($curVersion as $columnName => $columnValue) {
687                    $types[$columnName] = $tableDetails->getColumn($columnName)->getType()->getBindingType();
688                }
689            }
690
691            $connection->update(
692                $table,
693                $swapVersion,
694                ['uid' => (int)$id],
695                $types
696            );
697        } catch (DBALException $e) {
698            $sqlErrors[] = $e->getPrevious()->getMessage();
699        }
700
701        if (empty($sqlErrors)) {
702            try {
703                $types = [];
704                if ($platform instanceof SQLServerPlatform || $platform instanceof PostgreSQLPlatform) {
705                    foreach ($curVersion as $columnName => $columnValue) {
706                        $types[$columnName] = $tableDetails->getColumn($columnName)->getType()->getBindingType();
707                    }
708                }
709
710                $connection->update(
711                    $table,
712                    $curVersion,
713                    ['uid' => (int)$swapWith],
714                    $types
715                );
716            } catch (DBALException $e) {
717                $sqlErrors[] = $e->getPrevious()->getMessage();
718            }
719        }
720
721        if (!empty($sqlErrors)) {
722            $dataHandler->log($table, $swapWith, DatabaseAction::PUBLISH, 0, SystemLogErrorClassification::SYSTEM_ERROR, 'During Swapping: SQL errors happened: ' . implode('; ', $sqlErrors));
723        } else {
724            // Update localized elements to use the live l10n_parent now
725            $this->updateL10nOverlayRecordsOnPublish($table, $id, $swapWith, $workspaceId, $dataHandler);
726            // Register swapped ids for later remapping:
727            $this->remappedIds[$table][$id] = $swapWith;
728            $this->remappedIds[$table][$swapWith] = $id;
729            if ((int)$t3ver_state['swapVersion'] === VersionState::DELETE_PLACEHOLDER) {
730                // We're publishing a delete placeholder t3ver_state = 2. This means the live record should
731                // be set to deleted. We're currently in some workspace and deal with a live record here. Thus,
732                // we temporarily set backend user workspace to 0 so all operations happen as in live.
733                $currentUserWorkspace = $dataHandler->BE_USER->workspace;
734                $dataHandler->BE_USER->workspace = 0;
735                $dataHandler->deleteEl($table, $id, true);
736                $dataHandler->BE_USER->workspace = $currentUserWorkspace;
737            }
738            $dataHandler->log($table, $id, DatabaseAction::PUBLISH, 0, SystemLogErrorClassification::MESSAGE, 'Publishing successful for table "' . $table . '" uid ' . $id . '=>' . $swapWith, -1, [], $dataHandler->eventPid($table, $id, $swapVersion['pid']));
739
740            // Set log entry for live record:
741            $propArr = $dataHandler->getRecordPropertiesFromRow($table, $swapVersion);
742            if (($propArr['t3ver_oid'] ?? 0) > 0) {
743                $label = $this->getLanguageService()->sL('LLL:EXT:workspaces/Resources/Private/Language/locallang_tcemain.xlf:version_swap.offline_record_updated');
744            } else {
745                $label = $this->getLanguageService()->sL('LLL:EXT:workspaces/Resources/Private/Language/locallang_tcemain.xlf:version_swap.online_record_updated');
746            }
747            $dataHandler->log($table, $id, DatabaseAction::UPDATE, $propArr['pid'], SystemLogErrorClassification::MESSAGE, $label, 10, [$propArr['header'], $table . ':' . $id], $propArr['event_pid']);
748            $dataHandler->setHistory($table, $id);
749            // Set log entry for offline record:
750            $propArr = $dataHandler->getRecordPropertiesFromRow($table, $curVersion);
751            if (($propArr['t3ver_oid'] ?? 0) > 0) {
752                $label = $this->getLanguageService()->sL('LLL:EXT:workspaces/Resources/Private/Language/locallang_tcemain.xlf:version_swap.offline_record_updated');
753            } else {
754                $label = $this->getLanguageService()->sL('LLL:EXT:workspaces/Resources/Private/Language/locallang_tcemain.xlf:version_swap.online_record_updated');
755            }
756            $dataHandler->log($table, $swapWith, DatabaseAction::UPDATE, $propArr['pid'], SystemLogErrorClassification::MESSAGE, $label, 10, [$propArr['header'], $table . ':' . $swapWith], $propArr['event_pid']);
757            $dataHandler->setHistory($table, $swapWith);
758
759            $stageId = StagesService::STAGE_PUBLISH_EXECUTE_ID;
760            $notificationEmailInfoKey = $wsAccess['uid'] . ':' . $stageId . ':' . $comment;
761            $this->notificationEmailInfo[$notificationEmailInfoKey]['shared'] = [$wsAccess, $stageId, $comment];
762            $this->notificationEmailInfo[$notificationEmailInfoKey]['elements'][] = [$table, $id];
763            $this->notificationEmailInfo[$notificationEmailInfoKey]['recipients'] = $notificationAlternativeRecipients;
764            // Write to log with stageId -20 (STAGE_PUBLISH_EXECUTE_ID)
765            if ($dataHandler->enableLogging) {
766                $propArr = $dataHandler->getRecordProperties($table, $id);
767                $pid = $propArr['pid'];
768                $dataHandler->log($table, $id, DatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::MESSAGE, 'Stage for record was changed to ' . $stageId . '. Comment was: "' . substr($comment, 0, 100) . '"', -1, [], $dataHandler->eventPid($table, $id, $pid));
769            }
770            $dataHandler->log($table, $id, DatabaseAction::UPDATE, 0, SystemLogErrorClassification::MESSAGE, 'Published', 30, ['comment' => $comment, 'stage' => $stageId]);
771
772            // Clear cache:
773            $dataHandler->registerRecordIdForPageCacheClearing($table, $id);
774            // If published, delete the record from the database
775            if ($table === 'pages') {
776                // Note on fifth argument false: At this point both $curVersion and $swapVersion page records are
777                // identical in DB. deleteEl() would now usually find all records assigned to our obsolete
778                // page which at the same time belong to our current version page, and would delete them.
779                // To suppress this, false tells deleteEl() to only delete the obsolete page but not its assigned records.
780                $dataHandler->deleteEl($table, $swapWith, true, true, false);
781            } else {
782                $dataHandler->deleteEl($table, $swapWith, true, true);
783            }
784
785            // Update reference index of the live record - which could have been a workspace record in case 'new'
786            $dataHandler->updateRefIndex($table, $id, 0);
787            // The 'swapWith' record has been deleted, so we can drop any reference index the record is involved in
788            $dataHandler->registerReferenceIndexRowsForDrop($table, $swapWith, (int)$dataHandler->BE_USER->workspace);
789        }
790    }
791
792    /**
793     * If an editor is doing "partial" publishing, the translated children need to be "linked" to the now pointed
794     * live record, as if the versioned record (which is deleted) would have never existed.
795     *
796     * This is related to the l10n_source and l10n_parent fields.
797     *
798     * This needs to happen before the hook calls DataHandler->deleteEl() otherwise the children get deleted as well.
799     *
800     * @param string $table the database table of the published record
801     * @param int $liveId the live version / online version of the record that was just published
802     * @param int $previouslyUsedVersionId the versioned record ID (wsid>0) which is about to be deleted
803     * @param int $workspaceId the workspace ID
804     * @param DataHandler $dataHandler
805     */
806    protected function updateL10nOverlayRecordsOnPublish(string $table, int $liveId, int $previouslyUsedVersionId, int $workspaceId, DataHandler $dataHandler): void
807    {
808        if (!BackendUtility::isTableLocalizable($table)) {
809            return;
810        }
811        if (!BackendUtility::isTableWorkspaceEnabled($table)) {
812            return;
813        }
814        $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
815        $queryBuilder = $connection->createQueryBuilder();
816        $queryBuilder->getRestrictions()->removeAll();
817
818        $l10nParentFieldName = $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'];
819        $constraints = $queryBuilder->expr()->eq(
820            $l10nParentFieldName,
821            $queryBuilder->createNamedParameter($previouslyUsedVersionId, \PDO::PARAM_INT)
822        );
823        $translationSourceFieldName = $GLOBALS['TCA'][$table]['ctrl']['translationSource'] ?? null;
824        if ($translationSourceFieldName) {
825            $constraints = $queryBuilder->expr()->orX(
826                $constraints,
827                $queryBuilder->expr()->eq(
828                    $translationSourceFieldName,
829                    $queryBuilder->createNamedParameter($previouslyUsedVersionId, \PDO::PARAM_INT)
830                )
831            );
832        }
833
834        $queryBuilder
835            ->select('uid', $l10nParentFieldName)
836            ->from($table)
837            ->where(
838                $constraints,
839                $queryBuilder->expr()->eq(
840                    't3ver_wsid',
841                    $queryBuilder->createNamedParameter($workspaceId, \PDO::PARAM_INT)
842                )
843            );
844
845        if ($translationSourceFieldName) {
846            $queryBuilder->addSelect($translationSourceFieldName);
847        }
848
849        $statement = $queryBuilder->executeQuery();
850        while ($record = $statement->fetchAssociative()) {
851            $updateFields = [];
852            $dataTypes = [\PDO::PARAM_INT];
853            if ((int)$record[$l10nParentFieldName] === $previouslyUsedVersionId) {
854                $updateFields[$l10nParentFieldName] = $liveId;
855                $dataTypes[] = \PDO::PARAM_INT;
856            }
857            if ($translationSourceFieldName && (int)$record[$translationSourceFieldName] === $previouslyUsedVersionId) {
858                $updateFields[$translationSourceFieldName] = $liveId;
859                $dataTypes[] = \PDO::PARAM_INT;
860            }
861
862            if (empty($updateFields)) {
863                continue;
864            }
865
866            $connection->update(
867                $table,
868                $updateFields,
869                ['uid' => (int)$record['uid']],
870                $dataTypes
871            );
872            $dataHandler->updateRefIndex($table, $record['uid']);
873        }
874    }
875
876    /**
877     * Processes fields of a record for the publishing/swapping process.
878     * Basically this takes care of IRRE (type "inline") child references.
879     *
880     * @param string $tableName Table name
881     * @param array $configuration TCA field configuration
882     * @param array $liveData Live record data
883     * @param array $versionData Version record data
884     * @param DataHandler $dataHandler Calling data-handler object
885     */
886    protected function version_swap_processFields($tableName, array $configuration, array $liveData, array $versionData, DataHandler $dataHandler)
887    {
888        $inlineType = $dataHandler->getInlineFieldType($configuration);
889        if ($inlineType !== 'field') {
890            return;
891        }
892        $foreignTable = $configuration['foreign_table'];
893        // Read relations that point to the current record (e.g. live record):
894        $liveRelations = $this->createRelationHandlerInstance();
895        $liveRelations->setWorkspaceId(0);
896        $liveRelations->start('', $foreignTable, '', $liveData['uid'], $tableName, $configuration);
897        // Read relations that point to the record to be swapped with e.g. draft record):
898        $versionRelations = $this->createRelationHandlerInstance();
899        $versionRelations->setUseLiveReferenceIds(false);
900        $versionRelations->start('', $foreignTable, '', $versionData['uid'], $tableName, $configuration);
901        // Update relations for both (workspace/versioning) sites:
902        if (!empty($liveRelations->itemArray)) {
903            $dataHandler->addRemapAction(
904                $tableName,
905                $liveData['uid'],
906                [$this, 'updateInlineForeignFieldSorting'],
907                [$liveData['uid'], $foreignTable, $liveRelations->tableArray[$foreignTable], $configuration, $dataHandler->BE_USER->workspace]
908            );
909        }
910        if (!empty($versionRelations->itemArray)) {
911            $dataHandler->addRemapAction(
912                $tableName,
913                $liveData['uid'],
914                [$this, 'updateInlineForeignFieldSorting'],
915                [$liveData['uid'], $foreignTable, $versionRelations->tableArray[$foreignTable], $configuration, 0]
916            );
917        }
918    }
919
920    /**
921     * When a new record in a workspace is published, there is no "replacing" the online version with
922     * the versioned record, but instead the workspace ID and the state is changed.
923     *
924     * @param string $table
925     * @param array $newRecordInWorkspace
926     * @param DataHandler $dataHandler
927     * @param string $comment
928     * @param array $notificationAlternativeRecipients
929     */
930    protected function publishNewRecord(string $table, array $newRecordInWorkspace, DataHandler $dataHandler, string $comment, array $notificationAlternativeRecipients): void
931    {
932        $id = (int)$newRecordInWorkspace['uid'];
933        $workspaceId = (int)$newRecordInWorkspace['t3ver_wsid'];
934        if (!$dataHandler->BE_USER->workspacePublishAccess($workspaceId)) {
935            $dataHandler->log($table, $id, DatabaseAction::PUBLISH, 0, SystemLogErrorClassification::USER_ERROR, 'User could not publish records from workspace #' . $workspaceId);
936            return;
937        }
938        $wsAccess = $dataHandler->BE_USER->checkWorkspace($workspaceId);
939        if (!($workspaceId <= 0 || !($wsAccess['publish_access'] & 1) || (int)$newRecordInWorkspace['t3ver_stage'] === StagesService::STAGE_PUBLISH_ID)) {
940            $dataHandler->log($table, $id, DatabaseAction::PUBLISH, 0, SystemLogErrorClassification::USER_ERROR, 'Records in workspace #' . $workspaceId . ' can only be published when in "Publish" stage.');
941            return;
942        }
943        if (!($dataHandler->doesRecordExist($table, $id, Permission::PAGE_SHOW) && $dataHandler->checkRecordUpdateAccess($table, $id))) {
944            $dataHandler->log($table, $id, DatabaseAction::PUBLISH, 0, SystemLogErrorClassification::USER_ERROR, 'You cannot publish a record you do not have edit and show permissions for');
945            return;
946        }
947
948        // Modify versioned record to become online
949        $updatedFields = [
950            't3ver_oid' => 0,
951            't3ver_wsid' => 0,
952            't3ver_stage' => 0,
953            't3ver_state' => VersionState::DEFAULT_STATE,
954        ];
955
956        try {
957            $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
958            $connection->update(
959                $table,
960                $updatedFields,
961                [
962                    'uid' => (int)$id,
963                ],
964                [
965                    \PDO::PARAM_INT,
966                    \PDO::PARAM_INT,
967                    \PDO::PARAM_INT,
968                    \PDO::PARAM_INT,
969                    \PDO::PARAM_INT,
970                ]
971            );
972        } catch (DBALException $e) {
973            $dataHandler->log($table, $id, DatabaseAction::PUBLISH, 0, SystemLogErrorClassification::SYSTEM_ERROR, 'During Publishing: SQL errors happened: ' . $e->getPrevious()->getMessage());
974        }
975
976        if ($dataHandler->enableLogging) {
977            $dataHandler->log($table, $id, DatabaseAction::PUBLISH, 0, SystemLogErrorClassification::MESSAGE, 'Publishing successful for table "' . $table . '" uid ' . $id . ' (new record)', -1, [], $dataHandler->eventPid($table, $id, $newRecordInWorkspace['pid']));
978        }
979
980        // Set log entry for record
981        $propArr = $dataHandler->getRecordPropertiesFromRow($table, $newRecordInWorkspace);
982        $label = $this->getLanguageService()->sL('LLL:EXT:workspaces/Resources/Private/Language/locallang_tcemain.xlf:version_swap.online_record_updated');
983        $dataHandler->log($table, $id, DatabaseAction::UPDATE, $propArr['pid'], SystemLogErrorClassification::MESSAGE, $label, 10, [$propArr['header'], $table . ':' . $id], $propArr['event_pid']);
984        $dataHandler->setHistory($table, $id);
985
986        $stageId = StagesService::STAGE_PUBLISH_EXECUTE_ID;
987        $notificationEmailInfoKey = $wsAccess['uid'] . ':' . $stageId . ':' . $comment;
988        $this->notificationEmailInfo[$notificationEmailInfoKey]['shared'] = [$wsAccess, $stageId, $comment];
989        $this->notificationEmailInfo[$notificationEmailInfoKey]['elements'][] = [$table, $id];
990        $this->notificationEmailInfo[$notificationEmailInfoKey]['recipients'] = $notificationAlternativeRecipients;
991        // Write to log with stageId -20 (STAGE_PUBLISH_EXECUTE_ID)
992        $dataHandler->log($table, $id, DatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::MESSAGE, 'Stage for record was changed to ' . $stageId . '. Comment was: "' . substr($comment, 0, 100) . '"', -1, [], $dataHandler->eventPid($table, $id, $newRecordInWorkspace['pid']));
993        $dataHandler->log($table, $id, DatabaseAction::UPDATE, 0, SystemLogErrorClassification::MESSAGE, 'Published', 30, ['comment' => $comment, 'stage' => $stageId]);
994
995        // Clear cache
996        $dataHandler->registerRecordIdForPageCacheClearing($table, $id);
997        // Update the reference index: Drop the references in the workspace, but update them in the live workspace
998        $dataHandler->registerReferenceIndexRowsForDrop($table, $id, $workspaceId);
999        $dataHandler->updateRefIndex($table, $id, 0);
1000        $this->updateReferenceIndexForL10nOverlays($table, $id, $workspaceId, $dataHandler);
1001
1002        // When dealing with mm relations on local side, existing refindex rows of the new workspace record
1003        // need to be re-calculated for the now live record. Scenario ManyToMany Publish createContentAndAddRelation
1004        // These calls are similar to what is done in DH->versionPublishManyToManyRelations() and can not be
1005        // used from there since publishing new records does not call that method, see @todo in version_swap().
1006        $dataHandler->registerReferenceIndexUpdateForReferencesToItem($table, $id, $workspaceId, 0);
1007        $dataHandler->registerReferenceIndexUpdateForReferencesToItem($table, $id, $workspaceId);
1008    }
1009
1010    /**
1011     * A new record was just published, but the reference index for the localized elements needs
1012     * an update too.
1013     *
1014     * @param string $table
1015     * @param int $newVersionedRecordId
1016     * @param int $workspaceId
1017     * @param DataHandler $dataHandler
1018     */
1019    protected function updateReferenceIndexForL10nOverlays(string $table, int $newVersionedRecordId, int $workspaceId, DataHandler $dataHandler): void
1020    {
1021        if (!BackendUtility::isTableLocalizable($table)) {
1022            return;
1023        }
1024        if (!BackendUtility::isTableWorkspaceEnabled($table)) {
1025            return;
1026        }
1027        $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
1028        $queryBuilder = $connection->createQueryBuilder();
1029        $queryBuilder->getRestrictions()->removeAll();
1030
1031        $l10nParentFieldName = $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'];
1032        $constraints = $queryBuilder->expr()->eq(
1033            $l10nParentFieldName,
1034            $queryBuilder->createNamedParameter($newVersionedRecordId, \PDO::PARAM_INT)
1035        );
1036        $translationSourceFieldName = $GLOBALS['TCA'][$table]['ctrl']['translationSource'] ?? null;
1037        if ($translationSourceFieldName) {
1038            $constraints = $queryBuilder->expr()->orX(
1039                $constraints,
1040                $queryBuilder->expr()->eq(
1041                    $translationSourceFieldName,
1042                    $queryBuilder->createNamedParameter($newVersionedRecordId, \PDO::PARAM_INT)
1043                )
1044            );
1045        }
1046
1047        $queryBuilder
1048            ->select('uid', $l10nParentFieldName)
1049            ->from($table)
1050            ->where(
1051                $constraints,
1052                $queryBuilder->expr()->eq(
1053                    't3ver_wsid',
1054                    $queryBuilder->createNamedParameter($workspaceId, \PDO::PARAM_INT)
1055                )
1056            );
1057
1058        if ($translationSourceFieldName) {
1059            $queryBuilder->addSelect($translationSourceFieldName);
1060        }
1061
1062        $statement = $queryBuilder->executeQuery();
1063        while ($record = $statement->fetchAssociative()) {
1064            $dataHandler->updateRefIndex($table, $record['uid']);
1065        }
1066    }
1067
1068    /**
1069     * Updates foreign field sorting values of versioned and live
1070     * parents after(!) the whole structure has been published.
1071     *
1072     * This method is used as callback function in
1073     * DataHandlerHook::version_swap_procBasedOnFieldType().
1074     * Sorting fields ("sortby") are not modified during the
1075     * workspace publishing/swapping process directly.
1076     *
1077     * @param string $parentId
1078     * @param string $foreignTableName
1079     * @param int[] $foreignIds
1080     * @param array $configuration
1081     * @param int $targetWorkspaceId
1082     * @internal
1083     */
1084    public function updateInlineForeignFieldSorting($parentId, $foreignTableName, $foreignIds, array $configuration, $targetWorkspaceId)
1085    {
1086        $remappedIds = [];
1087        // Use remapped ids (live id <-> version id)
1088        foreach ($foreignIds as $foreignId) {
1089            if (!empty($this->remappedIds[$foreignTableName][$foreignId])) {
1090                $remappedIds[] = $this->remappedIds[$foreignTableName][$foreignId];
1091            } else {
1092                $remappedIds[] = $foreignId;
1093            }
1094        }
1095
1096        $relationHandler = $this->createRelationHandlerInstance();
1097        $relationHandler->setWorkspaceId($targetWorkspaceId);
1098        $relationHandler->setUseLiveReferenceIds(false);
1099        $relationHandler->start(implode(',', $remappedIds), $foreignTableName);
1100        $relationHandler->processDeletePlaceholder();
1101        $relationHandler->writeForeignField($configuration, $parentId);
1102    }
1103
1104    /**
1105     * In case a sys_workspace_stage record is deleted we do a hard reset
1106     * for all existing records in that stage to avoid that any of these end up
1107     * as orphan records.
1108     *
1109     * @param int $stageId Elements with this stage are reset
1110     */
1111    protected function resetStageOfElements(int $stageId): void
1112    {
1113        foreach ($this->getTcaTables() as $tcaTable) {
1114            if (BackendUtility::isTableWorkspaceEnabled($tcaTable)) {
1115                $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
1116                    ->getQueryBuilderForTable($tcaTable);
1117
1118                $queryBuilder
1119                    ->update($tcaTable)
1120                    ->set('t3ver_stage', StagesService::STAGE_EDIT_ID)
1121                    ->where(
1122                        $queryBuilder->expr()->eq(
1123                            't3ver_stage',
1124                            $queryBuilder->createNamedParameter($stageId, \PDO::PARAM_INT)
1125                        ),
1126                        $queryBuilder->expr()->gt(
1127                            't3ver_wsid',
1128                            $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)
1129                        )
1130                    )
1131                    ->executeStatement();
1132            }
1133        }
1134    }
1135
1136    /**
1137     * Flushes (remove, no soft delete!) elements of a particular workspace to avoid orphan records.
1138     * This is used if an admin deletes a sys_workspace record.
1139     *
1140     * @param int $workspaceId The workspace to be flushed
1141     */
1142    protected function flushWorkspaceElements(int $workspaceId): void
1143    {
1144        $command = [];
1145        foreach ($this->getTcaTables() as $tcaTable) {
1146            if (BackendUtility::isTableWorkspaceEnabled($tcaTable)) {
1147                $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
1148                    ->getQueryBuilderForTable($tcaTable);
1149                $queryBuilder->getRestrictions()->removeAll();
1150                $result = $queryBuilder
1151                    ->select('uid')
1152                    ->from($tcaTable)
1153                    ->where(
1154                        $queryBuilder->expr()->eq(
1155                            't3ver_wsid',
1156                            $queryBuilder->createNamedParameter($workspaceId, \PDO::PARAM_INT)
1157                        ),
1158                        // t3ver_oid >= 0 basically omits placeholder records here, those would otherwise
1159                        // fail to delete later in DH->discard() and would create "can't do that" log entries.
1160                        $queryBuilder->expr()->orX(
1161                            $queryBuilder->expr()->gt(
1162                                't3ver_oid',
1163                                $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)
1164                            ),
1165                            $queryBuilder->expr()->eq(
1166                                't3ver_state',
1167                                $queryBuilder->createNamedParameter(VersionState::NEW_PLACEHOLDER, \PDO::PARAM_INT)
1168                            )
1169                        )
1170                    )
1171                    ->orderBy('uid')
1172                    ->executeQuery();
1173
1174                while (($recordId = $result->fetchOne()) !== false) {
1175                    $command[$tcaTable][$recordId]['version']['action'] = 'flush';
1176                }
1177            }
1178        }
1179        if (!empty($command)) {
1180            // Execute the command array via DataHandler to flush all records from this workspace.
1181            // Switch to target workspace temporarily, otherwise DH->discard() do not
1182            // operate on correct workspace if fetching additional records.
1183            $backendUser = $GLOBALS['BE_USER'];
1184            $savedWorkspace = $backendUser->workspace;
1185            $backendUser->workspace = $workspaceId;
1186            $context = GeneralUtility::makeInstance(Context::class);
1187            $savedWorkspaceContext = $context->getAspect('workspace');
1188            $context->setAspect('workspace', new WorkspaceAspect($workspaceId));
1189
1190            $dataHandler = GeneralUtility::makeInstance(DataHandler::class);
1191            $dataHandler->start([], $command, $backendUser);
1192            $dataHandler->process_cmdmap();
1193
1194            $backendUser->workspace = $savedWorkspace;
1195            $context->setAspect('workspace', $savedWorkspaceContext);
1196        }
1197    }
1198
1199    /**
1200     * Gets all defined TCA tables.
1201     *
1202     * @return array
1203     */
1204    protected function getTcaTables(): array
1205    {
1206        return array_keys($GLOBALS['TCA']);
1207    }
1208
1209    /**
1210     * Flushes the workspace cache for current workspace and for the virtual "all workspaces" too.
1211     *
1212     * @param int $workspaceId The workspace to be flushed in cache
1213     */
1214    protected function flushWorkspaceCacheEntriesByWorkspaceId(int $workspaceId): void
1215    {
1216        $workspacesCache = GeneralUtility::makeInstance(CacheManager::class)->getCache('workspaces_cache');
1217        $workspacesCache->flushByTag($workspaceId);
1218    }
1219
1220    /*******************************
1221     *****  helper functions  ******
1222     *******************************/
1223
1224    /**
1225     * Finds all elements for swapping versions in workspace
1226     *
1227     * @param string $table Table name of the original element to swap
1228     * @param int $id UID of the original element to swap (online)
1229     * @param int $offlineId As above but offline
1230     * @return array Element data. Key is table name, values are array with first element as online UID, second - offline UID
1231     */
1232    public function findPageElementsForVersionSwap($table, $id, $offlineId)
1233    {
1234        $rec = BackendUtility::getRecord($table, $offlineId, 't3ver_wsid');
1235        $workspaceId = (int)$rec['t3ver_wsid'];
1236        $elementData = [];
1237        if ($workspaceId === 0) {
1238            return $elementData;
1239        }
1240        // Get page UID for LIVE and workspace
1241        if ($table !== 'pages') {
1242            $rec = BackendUtility::getRecord($table, $id, 'pid');
1243            $pageId = $rec['pid'];
1244            $rec = BackendUtility::getRecord('pages', $pageId);
1245            BackendUtility::workspaceOL('pages', $rec, $workspaceId);
1246            $offlinePageId = $rec['_ORIG_uid'];
1247        } else {
1248            $pageId = $id;
1249            $offlinePageId = $offlineId;
1250        }
1251        // Traversing all tables supporting versioning:
1252        foreach ($GLOBALS['TCA'] as $table => $cfg) {
1253            if (BackendUtility::isTableWorkspaceEnabled($table) && $table !== 'pages') {
1254                $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
1255                    ->getQueryBuilderForTable($table);
1256
1257                $queryBuilder->getRestrictions()
1258                    ->removeAll()
1259                    ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
1260
1261                $statement = $queryBuilder
1262                    ->select('A.uid AS offlineUid', 'B.uid AS uid')
1263                    ->from($table, 'A')
1264                    ->from($table, 'B')
1265                    ->where(
1266                        $queryBuilder->expr()->gt(
1267                            'A.t3ver_oid',
1268                            $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)
1269                        ),
1270                        $queryBuilder->expr()->eq(
1271                            'B.pid',
1272                            $queryBuilder->createNamedParameter($pageId, \PDO::PARAM_INT)
1273                        ),
1274                        $queryBuilder->expr()->eq(
1275                            'A.t3ver_wsid',
1276                            $queryBuilder->createNamedParameter($workspaceId, \PDO::PARAM_INT)
1277                        ),
1278                        $queryBuilder->expr()->eq('A.t3ver_oid', $queryBuilder->quoteIdentifier('B.uid'))
1279                    )
1280                    ->executeQuery();
1281
1282                while ($row = $statement->fetchAssociative()) {
1283                    $elementData[$table][] = [$row['uid'], $row['offlineUid']];
1284                }
1285            }
1286        }
1287        if ($offlinePageId && $offlinePageId != $pageId) {
1288            $elementData['pages'][] = [$pageId, $offlinePageId];
1289        }
1290
1291        return $elementData;
1292    }
1293
1294    /**
1295     * Searches for all elements from all tables on the given pages in the same workspace.
1296     *
1297     * @param array $pageIdList List of PIDs to search
1298     * @param int $workspaceId Workspace ID
1299     * @param array $elementList List of found elements. Key is table name, value is array of element UIDs
1300     */
1301    public function findPageElementsForVersionStageChange(array $pageIdList, $workspaceId, array &$elementList)
1302    {
1303        if ($workspaceId == 0) {
1304            return;
1305        }
1306        // Traversing all tables supporting versioning:
1307        foreach ($GLOBALS['TCA'] as $table => $cfg) {
1308            if (BackendUtility::isTableWorkspaceEnabled($table) && $table !== 'pages') {
1309                $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
1310                    ->getQueryBuilderForTable($table);
1311
1312                $queryBuilder->getRestrictions()
1313                    ->removeAll()
1314                    ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
1315
1316                $statement = $queryBuilder
1317                    ->select('A.uid')
1318                    ->from($table, 'A')
1319                    ->from($table, 'B')
1320                    ->where(
1321                        $queryBuilder->expr()->gt(
1322                            'A.t3ver_oid',
1323                            $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)
1324                        ),
1325                        $queryBuilder->expr()->in(
1326                            'B.pid',
1327                            $queryBuilder->createNamedParameter($pageIdList, Connection::PARAM_INT_ARRAY)
1328                        ),
1329                        $queryBuilder->expr()->eq(
1330                            'A.t3ver_wsid',
1331                            $queryBuilder->createNamedParameter($workspaceId, \PDO::PARAM_INT)
1332                        ),
1333                        $queryBuilder->expr()->eq('A.t3ver_oid', $queryBuilder->quoteIdentifier('B.uid'))
1334                    )
1335                    ->groupBy('A.uid')
1336                    ->executeQuery();
1337
1338                while ($row = $statement->fetchAssociative()) {
1339                    $elementList[$table][] = $row['uid'];
1340                }
1341                if (is_array($elementList[$table])) {
1342                    // Yes, it is possible to get non-unique array even with DISTINCT above!
1343                    // It happens because several UIDs are passed in the array already.
1344                    $elementList[$table] = array_unique($elementList[$table]);
1345                }
1346            }
1347        }
1348    }
1349
1350    /**
1351     * Finds page UIDs for the element from table <code>$table</code> with UIDs from <code>$idList</code>
1352     *
1353     * @param string $table Table to search
1354     * @param array $idList List of records' UIDs
1355     * @param int $workspaceId Workspace ID. We need this parameter because user can be in LIVE but he still can publish DRAFT from ws module!
1356     * @param array $pageIdList List of found page UIDs
1357     * @param array $elementList List of found element UIDs. Key is table name, value is list of UIDs
1358     */
1359    public function findPageIdsForVersionStateChange($table, array $idList, $workspaceId, array &$pageIdList, array &$elementList)
1360    {
1361        if ($workspaceId == 0) {
1362            return;
1363        }
1364
1365        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
1366            ->getQueryBuilderForTable($table);
1367        $queryBuilder->getRestrictions()
1368            ->removeAll()
1369            ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
1370
1371        $statement = $queryBuilder
1372            ->select('B.pid')
1373            ->from($table, 'A')
1374            ->from($table, 'B')
1375            ->where(
1376                $queryBuilder->expr()->gt(
1377                    'A.t3ver_oid',
1378                    $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)
1379                ),
1380                $queryBuilder->expr()->eq(
1381                    'A.t3ver_wsid',
1382                    $queryBuilder->createNamedParameter($workspaceId, \PDO::PARAM_INT)
1383                ),
1384                $queryBuilder->expr()->in(
1385                    'A.uid',
1386                    $queryBuilder->createNamedParameter($idList, Connection::PARAM_INT_ARRAY)
1387                ),
1388                $queryBuilder->expr()->eq('A.t3ver_oid', $queryBuilder->quoteIdentifier('B.uid'))
1389            )
1390            ->groupBy('B.pid')
1391            ->executeQuery();
1392
1393        while ($row = $statement->fetchAssociative()) {
1394            $pageIdList[] = $row['pid'];
1395            // Find ws version
1396            // Note: cannot use BackendUtility::getRecordWSOL()
1397            // here because it does not accept workspace id!
1398            $rec = BackendUtility::getRecord('pages', $row[0]);
1399            BackendUtility::workspaceOL('pages', $rec, $workspaceId);
1400            if ($rec['_ORIG_uid']) {
1401                $elementList['pages'][$row[0]] = $rec['_ORIG_uid'];
1402            }
1403        }
1404        // The line below is necessary even with DISTINCT
1405        // because several elements can be passed by caller
1406        $pageIdList = array_unique($pageIdList);
1407    }
1408
1409    /**
1410     * Finds real page IDs for state change.
1411     *
1412     * @param array $idList List of page UIDs, possibly versioned
1413     */
1414    public function findRealPageIds(array &$idList): void
1415    {
1416        foreach ($idList as $key => $id) {
1417            $rec = BackendUtility::getRecord('pages', $id, 't3ver_oid');
1418            if ($rec['t3ver_oid'] > 0) {
1419                $idList[$key] = $rec['t3ver_oid'];
1420            }
1421        }
1422    }
1423
1424    /**
1425     * Moves a versioned record, which is not new or deleted.
1426     *
1427     * This is critical for a versioned record to be marked as MOVED (t3ver_state=4)
1428     *
1429     * @param string $table Table name to move
1430     * @param int $liveUid Record uid to move (online record)
1431     * @param int $destPid Position to move to: $destPid: >=0 then it points to a page-id on which to insert the record (as the first element). <0 then it points to a uid from its own table after which to insert it (works if
1432     * @param int $versionedRecordUid UID of offline version of online record
1433     * @param DataHandler $dataHandler DataHandler object
1434     * @see moveRecord()
1435     */
1436    protected function moveRecord_moveVersionedRecord(string $table, int $liveUid, int $destPid, int $versionedRecordUid, DataHandler $dataHandler): void
1437    {
1438        // If a record gets moved after a record that already has a versioned record
1439        // then the versioned record needs to be placed after the existing one
1440        $originalRecordDestinationPid = $destPid;
1441        $movedTargetRecordInWorkspace = BackendUtility::getWorkspaceVersionOfRecord($dataHandler->BE_USER->workspace, $table, abs($destPid), 'uid');
1442        if (is_array($movedTargetRecordInWorkspace) && $destPid < 0) {
1443            $destPid = -$movedTargetRecordInWorkspace['uid'];
1444        }
1445        $dataHandler->moveRecord_raw($table, $versionedRecordUid, $destPid);
1446
1447        $versionedRecord = BackendUtility::getRecord($table, $versionedRecordUid, 'uid,t3ver_state');
1448        if (!VersionState::cast($versionedRecord['t3ver_state'])->equals(VersionState::DELETE_PLACEHOLDER)) {
1449            // Update the state of this record to a move placeholder. This is allowed if the
1450            // record is a 'changed' (t3ver_state=0) record: Changing a record and moving it
1451            // around later, should switch it from 'changed' to 'moved'. Deleted placeholders
1452            // however are an 'end-state', they should not be switched to a move placeholder.
1453            // Scenario: For a live page that has a localization, the localization is first
1454            // marked as to-delete in workspace, creating a delete placeholder for that
1455            // localization. Later, the page is moved around, moving the localization along
1456            // with the default language record. The localization should then NOT be switched
1457            // from 'to-delete' to 'moved', this would loose the 'to-delete' information.
1458            GeneralUtility::makeInstance(ConnectionPool::class)
1459                ->getConnectionForTable($table)
1460                ->update(
1461                    $table,
1462                    [
1463                        't3ver_state' => (string)new VersionState(VersionState::MOVE_POINTER),
1464                    ],
1465                    [
1466                        'uid' => (int)$versionedRecordUid,
1467                    ]
1468                );
1469        }
1470
1471        // Check for the localizations of that element and move them as well
1472        $dataHandler->moveL10nOverlayRecords($table, $liveUid, $destPid, $originalRecordDestinationPid);
1473    }
1474
1475    /**
1476     * Gets an instance of the command map helper.
1477     *
1478     * @param DataHandler $dataHandler DataHandler object
1479     * @return CommandMap
1480     */
1481    public function getCommandMap(DataHandler $dataHandler): CommandMap
1482    {
1483        return GeneralUtility::makeInstance(
1484            CommandMap::class,
1485            $this,
1486            $dataHandler,
1487            $dataHandler->cmdmap,
1488            $dataHandler->BE_USER->workspace
1489        );
1490    }
1491
1492    protected function emitUpdateTopbarSignal(): void
1493    {
1494        BackendUtility::setUpdateSignal('updateTopbar');
1495    }
1496
1497    /**
1498     * Returns all fieldnames from a table which have the unique evaluation type set.
1499     *
1500     * @param string $table Table name
1501     * @return array Array of fieldnames
1502     */
1503    protected function getUniqueFields($table): array
1504    {
1505        $listArr = [];
1506        foreach ($GLOBALS['TCA'][$table]['columns'] ?? [] as $field => $configArr) {
1507            if ($configArr['config']['type'] === 'input') {
1508                $evalCodesArray = GeneralUtility::trimExplode(',', $configArr['config']['eval'] ?? '', true);
1509                if (in_array('uniqueInPid', $evalCodesArray) || in_array('unique', $evalCodesArray)) {
1510                    $listArr[] = $field;
1511                }
1512            }
1513        }
1514        return $listArr;
1515    }
1516
1517    /**
1518     * Straight db based record deletion: sets deleted = 1 for soft-delete
1519     * enabled tables, or removes row from table. Used for move placeholder
1520     * records sometimes.
1521     */
1522    protected function softOrHardDeleteSingleRecord(string $table, int $uid): void
1523    {
1524        $deleteField = $GLOBALS['TCA'][$table]['ctrl']['delete'] ?? null;
1525        if ($deleteField) {
1526            GeneralUtility::makeInstance(ConnectionPool::class)
1527                ->getConnectionForTable($table)
1528                ->update(
1529                    $table,
1530                    [$deleteField => 1],
1531                    ['uid' => $uid],
1532                    [\PDO::PARAM_INT]
1533                );
1534        } else {
1535            GeneralUtility::makeInstance(ConnectionPool::class)
1536                ->getConnectionForTable($table)
1537                ->delete(
1538                    $table,
1539                    ['uid' => $uid]
1540                );
1541        }
1542    }
1543
1544    /**
1545     * @return RelationHandler
1546     */
1547    protected function createRelationHandlerInstance(): RelationHandler
1548    {
1549        return GeneralUtility::makeInstance(RelationHandler::class);
1550    }
1551
1552    /**
1553     * @return LanguageService
1554     */
1555    protected function getLanguageService(): LanguageService
1556    {
1557        return $GLOBALS['LANG'];
1558    }
1559}
1560