[BUGFIX] Catch DatabaseRecordException when editing record with deleted relation
[Packages/TYPO3.CMS.git] / typo3 / sysext / backend / Classes / Form / FormDataProvider / TcaInline.php
1 <?php
2 namespace TYPO3\CMS\Backend\Form\FormDataProvider;
3
4 /*
5 * This file is part of the TYPO3 CMS project.
6 *
7 * It is free software; you can redistribute it and/or modify it under
8 * the terms of the GNU General Public License, either version 2
9 * of the License, or any later version.
10 *
11 * For the full copyright and license information, please read the
12 * LICENSE.txt file that was distributed with this source code.
13 *
14 * The TYPO3 project - inspiring people to share!
15 */
16
17 use TYPO3\CMS\Backend\Form\Exception\DatabaseRecordException;
18 use TYPO3\CMS\Backend\Form\FormDataCompiler;
19 use TYPO3\CMS\Backend\Form\FormDataGroup\OnTheFly;
20 use TYPO3\CMS\Backend\Form\FormDataGroup\TcaDatabaseRecord;
21 use TYPO3\CMS\Backend\Form\FormDataProviderInterface;
22 use TYPO3\CMS\Backend\Form\InlineStackProcessor;
23 use TYPO3\CMS\Backend\Utility\BackendUtility;
24 use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
25 use TYPO3\CMS\Core\Database\RelationHandler;
26 use TYPO3\CMS\Core\Log\Logger;
27 use TYPO3\CMS\Core\Log\LogManager;
28 use TYPO3\CMS\Core\Messaging\FlashMessage;
29 use TYPO3\CMS\Core\Messaging\FlashMessageService;
30 use TYPO3\CMS\Core\Utility\GeneralUtility;
31 use TYPO3\CMS\Core\Versioning\VersionState;
32 use TYPO3\CMS\Lang\LanguageService;
33
34 /**
35 * Resolve and prepare inline data.
36 */
37 class TcaInline extends AbstractDatabaseRecordProvider implements FormDataProviderInterface
38 {
39 /**
40 * Resolve inline fields
41 *
42 * @param array $result
43 * @return array
44 */
45 public function addData(array $result)
46 {
47 $result = $this->addInlineFirstPid($result);
48
49 foreach ($result['processedTca']['columns'] as $fieldName => $fieldConfig) {
50 if (!$this->isInlineField($fieldConfig)) {
51 continue;
52 }
53 $result['processedTca']['columns'][$fieldName]['children'] = [];
54 if (!$this->isUserAllowedToModify($fieldConfig)) {
55 continue;
56 }
57 if ($result['inlineResolveExistingChildren']) {
58 $result = $this->resolveRelatedRecords($result, $fieldName);
59 $result = $this->addForeignSelectorAndUniquePossibleRecords($result, $fieldName);
60 }
61 }
62
63 return $result;
64 }
65
66 /**
67 * Is column of type "inline"
68 *
69 * @param array $fieldConfig
70 * @return bool
71 */
72 protected function isInlineField($fieldConfig)
73 {
74 return !empty($fieldConfig['config']['type']) && $fieldConfig['config']['type'] === 'inline';
75 }
76
77 /**
78 * Is user allowed to modify child elements
79 *
80 * @param array $fieldConfig
81 * @return bool
82 */
83 protected function isUserAllowedToModify($fieldConfig)
84 {
85 return $this->getBackendUser()->check('tables_modify', $fieldConfig['config']['foreign_table']);
86 }
87
88 /**
89 * The "entry" pid for inline records. Nested inline records can potentially hang around on different
90 * pid's, but the entry pid is needed for AJAX calls, so that they would know where the action takes place on the page structure.
91 *
92 * @param array $result Incoming result
93 * @return array Modified result
94 * @todo: Find out when and if this is different from 'effectivePid'
95 */
96 protected function addInlineFirstPid(array $result)
97 {
98 if (is_null($result['inlineFirstPid'])) {
99 $table = $result['tableName'];
100 $row = $result['databaseRow'];
101 // If the parent is a page, use the uid(!) of the (new?) page as pid for the child records:
102 if ($table === 'pages') {
103 $liveVersionId = BackendUtility::getLiveVersionIdOfRecord('pages', $row['uid']);
104 $pid = is_null($liveVersionId) ? $row['uid'] : $liveVersionId;
105 } elseif ($row['pid'] < 0) {
106 $prevRec = BackendUtility::getRecord($table, abs($row['pid']));
107 $pid = $prevRec['pid'];
108 } else {
109 $pid = $row['pid'];
110 }
111 $result['inlineFirstPid'] = (int)$pid;
112 }
113 return $result;
114 }
115
116 /**
117 * Substitute the value in databaseRow of this inline field with an array
118 * that contains the databaseRows of currently connected records and some meta information.
119 *
120 * @param array $result Result array
121 * @param string $fieldName Current handle field name
122 * @return array Modified item array
123 */
124 protected function resolveRelatedRecords(array $result, $fieldName)
125 {
126 $childTableName = $result['processedTca']['columns'][$fieldName]['config']['foreign_table'];
127
128 // localizationMode is either "none", "keep" or "select":
129 // * none: Handled parent row is not a localized record, or if it is a localized row, this is ignored.
130 // Default language records and overlays have distinct children that are not connected to each other.
131 // * keep: Handled parent row is a localized record, but child table is either not localizable, or
132 // "keep" is explicitly set. A localized parent and its default language row share the same
133 // children records. Editing a child from a localized record will change this record for the
134 // default language row, too.
135 // * select: Handled parent row is a localized record, child table is localizable. Children records are
136 // localized overlays of a default language record. Three scenarios can happen:
137 // ** Localized child overlay and its default language row exist - show localized overlay record
138 // ** Default child language row exists but child overlay doesn't - show a "synchronize this record" button
139 // ** Localized child overlay exists but default language row does not - this dangling child is a data inconsistency
140
141 // Mode was prepared by TcaInlineConfiguration provider
142 // @deprecated: IRRE 'localizationMode' is deprecated and will be removed in TYPO3 CMS 9
143 $mode = $result['processedTca']['columns'][$fieldName]['config']['behaviour']['localizationMode'];
144 if ($mode === 'none') {
145 $connectedUids = [];
146 // A new record that has distinct children can not have children yet, fetch connected uids for existing only
147 if ($result['command'] === 'edit') {
148 $connectedUids = $this->resolveConnectedRecordUids(
149 $result['processedTca']['columns'][$fieldName]['config'],
150 $result['tableName'],
151 $result['databaseRow']['uid'],
152 $result['databaseRow'][$fieldName]
153 );
154 }
155 $result['databaseRow'][$fieldName] = implode(',', $connectedUids);
156 $connectedUids = $this->getWorkspacedUids($connectedUids, $childTableName);
157 // @todo: If inlineCompileExistingChildren must be kept, it might be better to change the data
158 // @todo: format of databaseRow for this field and separate the child compilation to an own provider?
159 if ($result['inlineCompileExistingChildren']) {
160 foreach ($connectedUids as $childUid) {
161 try {
162 $result['processedTca']['columns'][$fieldName]['children'][] = $this->compileChild($result, $fieldName, $childUid);
163 } catch (DatabaseRecordException $e) {
164 // The child could not be compiled, probably it was deleted and a dangling mm record exists
165 $this->getLogger()->warning(
166 $e->getMessage(),
167 [
168 'table' => $childTableName,
169 'uid' => $childUid,
170 'exception' => $e
171 ]
172 );
173 continue;
174 }
175 }
176 }
177 } elseif ($mode === 'keep') {
178 // Fetch connected uids of default language record
179 $connectedUids = $this->resolveConnectedRecordUids(
180 $result['processedTca']['columns'][$fieldName]['config'],
181 $result['tableName'],
182 $result['defaultLanguageRow']['uid'],
183 $result['defaultLanguageRow'][$fieldName]
184 );
185 $result['databaseRow'][$fieldName] = implode(',', $connectedUids);
186 $connectedUids = $this->getWorkspacedUids($connectedUids, $childTableName);
187 if ($result['inlineCompileExistingChildren']) {
188 foreach ($connectedUids as $childUid) {
189 $result['processedTca']['columns'][$fieldName]['children'][] = $this->compileChild($result, $fieldName, $childUid);
190 }
191 }
192 } else {
193 $connectedUidsOfLocalizedOverlay = [];
194 if ($result['command'] === 'edit') {
195 $connectedUidsOfLocalizedOverlay = $this->resolveConnectedRecordUids(
196 $result['processedTca']['columns'][$fieldName]['config'],
197 $result['tableName'],
198 $result['databaseRow']['uid'],
199 $result['databaseRow'][$fieldName]
200 );
201 }
202 $result['databaseRow'][$fieldName] = implode(',', $connectedUidsOfLocalizedOverlay);
203 if ($result['inlineCompileExistingChildren']) {
204 $tableNameWithDefaultRecords = $result['tableName'];
205 if ($tableNameWithDefaultRecords === 'pages_language_overlay') {
206 $tableNameWithDefaultRecords = 'pages';
207 }
208 $connectedUidsOfDefaultLanguageRecord = $this->resolveConnectedRecordUids(
209 $result['processedTca']['columns'][$fieldName]['config'],
210 $tableNameWithDefaultRecords,
211 $result['defaultLanguageRow']['uid'],
212 $result['defaultLanguageRow'][$fieldName]
213 );
214
215 $showPossible = $result['processedTca']['columns'][$fieldName]['config']['appearance']['showPossibleLocalizationRecords'];
216
217 // Find which records are localized, which records are not localized and which are
218 // localized but miss default language record
219 $fieldNameWithDefaultLanguageUid = $GLOBALS['TCA'][$childTableName]['ctrl']['transOrigPointerField'];
220 foreach ($connectedUidsOfLocalizedOverlay as $localizedUid) {
221 try {
222 $localizedRecord = $this->getRecordFromDatabase($childTableName, $localizedUid);
223 } catch (DatabaseRecordException $e) {
224 // The child could not be compiled, probably it was deleted and a dangling mm record exists
225 $this->getLogger()->warning(
226 $e->getMessage(),
227 [
228 'table' => $childTableName,
229 'uid' => $localizedUid,
230 'exception' => $e
231 ]
232 );
233 continue;
234 }
235 $uidOfDefaultLanguageRecord = $localizedRecord[$fieldNameWithDefaultLanguageUid];
236 if (in_array($uidOfDefaultLanguageRecord, $connectedUidsOfDefaultLanguageRecord)) {
237 // This localized child has a default language record. Remove this record from list of default language records
238 $connectedUidsOfDefaultLanguageRecord = array_diff($connectedUidsOfDefaultLanguageRecord, [$uidOfDefaultLanguageRecord]);
239 }
240 // Compile localized record
241 try {
242 $compiledChild = $this->compileChild($result, $fieldName, $localizedUid);
243 } catch (DatabaseRecordException $e) {
244 // The child could not be compiled, probably it was deleted and a dangling mm record exists
245 $this->getLogger()->warning(
246 $e->getMessage(),
247 [
248 'table' => $childTableName,
249 'uid' => $localizedUid,
250 'exception' => $e
251 ]
252 );
253 continue;
254 }
255 $result['processedTca']['columns'][$fieldName]['children'][] = $compiledChild;
256 }
257 if ($showPossible) {
258 foreach ($connectedUidsOfDefaultLanguageRecord as $defaultLanguageUid) {
259 // If there are still uids in $connectedUidsOfDefaultLanguageRecord, these are records that
260 // exist in default language, but are not localized yet. Compile and mark those
261 try {
262 $compiledChild = $this->compileChild($result, $fieldName, $defaultLanguageUid);
263 } catch (DatabaseRecordException $e) {
264 // The child could not be compiled, probably it was deleted and a dangling mm record exists
265 $this->getLogger()->warning(
266 $e->getMessage(),
267 [
268 'table' => $childTableName,
269 'uid' => $defaultLanguageUid,
270 'exception' => $e
271 ]
272 );
273 continue;
274 }
275 $compiledChild['isInlineDefaultLanguageRecordInLocalizedParentContext'] = true;
276 $result['processedTca']['columns'][$fieldName]['children'][] = $compiledChild;
277 }
278 }
279 }
280 }
281
282 return $result;
283 }
284
285 /**
286 * If there is a foreign_selector or foreign_unique configuration, fetch
287 * the list of possible records that can be connected and attach the to the
288 * inline configuration.
289 *
290 * @param array $result Result array
291 * @param string $fieldName Current handle field name
292 * @return array Modified item array
293 */
294 protected function addForeignSelectorAndUniquePossibleRecords(array $result, $fieldName)
295 {
296 if (!is_array($result['processedTca']['columns'][$fieldName]['config']['selectorOrUniqueConfiguration'])) {
297 return $result;
298 }
299
300 $selectorOrUniqueConfiguration = $result['processedTca']['columns'][$fieldName]['config']['selectorOrUniqueConfiguration'];
301 $foreignFieldName = $selectorOrUniqueConfiguration['fieldName'];
302 $selectorOrUniquePossibleRecords = [];
303
304 if ($selectorOrUniqueConfiguration['config']['type'] === 'select') {
305 // Compile child table data for this field only
306 $selectDataInput = [
307 'tableName' => $result['processedTca']['columns'][$fieldName]['config']['foreign_table'],
308 'command' => 'new',
309 // Since there is no existing record that may have a type, it does not make sense to
310 // do extra handling of pageTsConfig merged here. Just provide "parent" pageTS as is
311 'pageTsConfig' => $result['pageTsConfig'],
312 'userTsConfig' => $result['userTsConfig'],
313 'databaseRow' => $result['databaseRow'],
314 'processedTca' => [
315 'ctrl' => [],
316 'columns' => [
317 $foreignFieldName => [
318 'config' => $selectorOrUniqueConfiguration['config'],
319 ],
320 ],
321 ],
322 'inlineExpandCollapseStateArray' => $result['inlineExpandCollapseStateArray'],
323 ];
324 /** @var OnTheFly $formDataGroup */
325 $formDataGroup = GeneralUtility::makeInstance(OnTheFly::class);
326 $formDataGroup->setProviderList([ TcaSelectItems::class ]);
327 /** @var FormDataCompiler $formDataCompiler */
328 $formDataCompiler = GeneralUtility::makeInstance(FormDataCompiler::class, $formDataGroup);
329 $compilerResult = $formDataCompiler->compile($selectDataInput);
330 $selectorOrUniquePossibleRecords = $compilerResult['processedTca']['columns'][$foreignFieldName]['config']['items'];
331 }
332
333 $result['processedTca']['columns'][$fieldName]['config']['selectorOrUniquePossibleRecords'] = $selectorOrUniquePossibleRecords;
334
335 return $result;
336 }
337
338 /**
339 * Compile a full child record
340 *
341 * @param array $result Result array of parent
342 * @param string $parentFieldName Name of parent field
343 * @param int $childUid Uid of child to compile
344 * @return array Full result array
345 */
346 protected function compileChild(array $result, $parentFieldName, $childUid)
347 {
348 $parentConfig = $result['processedTca']['columns'][$parentFieldName]['config'];
349 $childTableName = $parentConfig['foreign_table'];
350
351 /** @var InlineStackProcessor $inlineStackProcessor */
352 $inlineStackProcessor = GeneralUtility::makeInstance(InlineStackProcessor::class);
353 $inlineStackProcessor->initializeByGivenStructure($result['inlineStructure']);
354 $inlineTopMostParent = $inlineStackProcessor->getStructureLevel(0);
355
356 /** @var TcaDatabaseRecord $formDataGroup */
357 $formDataGroup = GeneralUtility::makeInstance(TcaDatabaseRecord::class);
358 /** @var FormDataCompiler $formDataCompiler */
359 $formDataCompiler = GeneralUtility::makeInstance(FormDataCompiler::class, $formDataGroup);
360 $formDataCompilerInput = [
361 'command' => 'edit',
362 'tableName' => $childTableName,
363 'vanillaUid' => (int)$childUid,
364 // Give incoming returnUrl down to children so they generate a returnUrl back to
365 // the originally opening record, also see "originalReturnUrl" in inline container
366 // and FormInlineAjaxController
367 'returnUrl' => $result['returnUrl'],
368 'isInlineChild' => true,
369 'inlineStructure' => $result['inlineStructure'],
370 'inlineExpandCollapseStateArray' => $result['inlineExpandCollapseStateArray'],
371 'inlineFirstPid' => $result['inlineFirstPid'],
372 'inlineParentConfig' => $parentConfig,
373
374 // values of the current parent element
375 // it is always a string either an id or new...
376 'inlineParentUid' => $result['databaseRow']['uid'],
377 'inlineParentTableName' => $result['tableName'],
378 'inlineParentFieldName' => $parentFieldName,
379
380 // values of the top most parent element set on first level and not overridden on following levels
381 'inlineTopMostParentUid' => $result['inlineTopMostParentUid'] ?: $inlineTopMostParent['uid'],
382 'inlineTopMostParentTableName' => $result['inlineTopMostParentTableName'] ?: $inlineTopMostParent['table'],
383 'inlineTopMostParentFieldName' => $result['inlineTopMostParentFieldName'] ?: $inlineTopMostParent['field'],
384 ];
385
386 // For foreign_selector with useCombination $mainChild is the mm record
387 // and $combinationChild is the child-child. For 1:n "normal" relations,
388 // $mainChild is just the normal child record and $combinationChild is empty.
389 $mainChild = $formDataCompiler->compile($formDataCompilerInput);
390 if ($parentConfig['foreign_selector'] && $parentConfig['appearance']['useCombination']) {
391 try {
392 $mainChild['combinationChild'] = $this->compileChildChild($mainChild, $parentConfig);
393 } catch (DatabaseRecordException $e) {
394 // The child could not be compiled, probably it was deleted and a dangling mm record
395 // exists. This is a data inconsistency, we catch this exception and create a flash message
396 $message = vsprintf(
397 $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang.xlf:formEngine.databaseRecordErrorInlineChildChild'),
398 [ $e->getTableName(), $e->getUid(), $childTableName, (int)$childUid ]
399 );
400 $flashMessage = GeneralUtility::makeInstance(
401 FlashMessage::class,
402 $message,
403 '',
404 FlashMessage::ERROR
405 );
406 GeneralUtility::makeInstance(FlashMessageService::class)->getMessageQueueByIdentifier()->enqueue($flashMessage);
407 }
408 }
409 return $mainChild;
410 }
411
412 /**
413 * With useCombination set, not only content of the intermediate table, but also
414 * the connected child should be rendered in one go. Prepare this here.
415 *
416 * @param array $child Full data array of "mm" record
417 * @param array $parentConfig TCA configuration of "parent"
418 * @return array Full data array of child
419 */
420 protected function compileChildChild(array $child, array $parentConfig)
421 {
422 // foreign_selector on intermediate is probably type=select, so data provider of this table resolved that to the uid already
423 $childChildUid = $child['databaseRow'][$parentConfig['foreign_selector']][0];
424 // child-child table name is set in child tca "the selector field" foreign_table
425 $childChildTableName = $child['processedTca']['columns'][$parentConfig['foreign_selector']]['config']['foreign_table'];
426 /** @var TcaDatabaseRecord $formDataGroup */
427 $formDataGroup = GeneralUtility::makeInstance(TcaDatabaseRecord::class);
428 /** @var FormDataCompiler $formDataCompiler */
429 $formDataCompiler = GeneralUtility::makeInstance(FormDataCompiler::class, $formDataGroup);
430
431 $formDataCompilerInput = [
432 'command' => 'edit',
433 'tableName' => $childChildTableName,
434 'vanillaUid' => (int)$childChildUid,
435 'isInlineChild' => true,
436 'isInlineChildExpanded' => $child['isInlineChildExpanded'],
437 // @todo: this is the wrong inline structure, isn't it? Shouldn't it contain the part from child child, too?
438 'inlineStructure' => $child['inlineStructure'],
439 'inlineFirstPid' => $child['inlineFirstPid'],
440 // values of the top most parent element set on first level and not overridden on following levels
441 'inlineTopMostParentUid' => $child['inlineTopMostParentUid'],
442 'inlineTopMostParentTableName' => $child['inlineTopMostParentTableName'],
443 'inlineTopMostParentFieldName' => $child['inlineTopMostParentFieldName'],
444 ];
445 $childChild = $formDataCompiler->compile($formDataCompilerInput);
446 return $childChild;
447 }
448
449 /**
450 * Substitute given list of uids in child table with workspace uid if needed
451 *
452 * @param array $connectedUids List of connected uids
453 * @param string $childTableName Name of child table
454 * @return array List of uids in workspace
455 */
456 protected function getWorkspacedUids(array $connectedUids, $childTableName)
457 {
458 $backendUser = $this->getBackendUser();
459 $newConnectedUids = [];
460 foreach ($connectedUids as $uid) {
461 // Fetch workspace version of a record (if any):
462 // @todo: Needs handling
463 if ($backendUser->workspace !== 0 && BackendUtility::isTableWorkspaceEnabled($childTableName)) {
464 $workspaceVersion = BackendUtility::getWorkspaceVersionOfRecord($backendUser->workspace, $childTableName, $uid, 'uid,t3ver_state');
465 if (!empty($workspaceVersion)) {
466 $versionState = VersionState::cast($workspaceVersion['t3ver_state']);
467 if ($versionState->equals(VersionState::DELETE_PLACEHOLDER)) {
468 continue;
469 }
470 $uid = $workspaceVersion['uid'];
471 }
472 }
473 $newConnectedUids[] = $uid;
474 }
475 return $newConnectedUids;
476 }
477
478 /**
479 * Use RelationHandler to resolve connected uids.
480 *
481 * @param array $parentConfig TCA config section of parent
482 * @param string $parentTableName Name of parent table
483 * @param string $parentUid Uid of parent record
484 * @param string $parentFieldValue Database value of parent record of this inline field
485 * @return array Array with connected uids
486 * @todo: Cover with unit tests
487 */
488 protected function resolveConnectedRecordUids(array $parentConfig, $parentTableName, $parentUid, $parentFieldValue)
489 {
490 $directlyConnectedIds = GeneralUtility::trimExplode(',', $parentFieldValue);
491 if (empty($parentConfig['MM'])) {
492 $parentUid = $this->getLiveDefaultId($parentTableName, $parentUid);
493 }
494 /** @var RelationHandler $relationHandler */
495 $relationHandler = GeneralUtility::makeInstance(RelationHandler::class);
496 $relationHandler->registerNonTableValues = (bool)$parentConfig['allowedIdValues'];
497 $relationHandler->start($parentFieldValue, $parentConfig['foreign_table'], $parentConfig['MM'], $parentUid, $parentTableName, $parentConfig);
498 $foreignRecordUids = $relationHandler->getValueArray();
499 $resolvedForeignRecordUids = [];
500 foreach ($foreignRecordUids as $aForeignRecordUid) {
501 if ($parentConfig['MM'] || $parentConfig['foreign_field']) {
502 $resolvedForeignRecordUids[] = (int)$aForeignRecordUid;
503 } else {
504 foreach ($directlyConnectedIds as $id) {
505 if ((int)$aForeignRecordUid === (int)$id) {
506 $resolvedForeignRecordUids[] = (int)$aForeignRecordUid;
507 }
508 }
509 }
510 }
511 return $resolvedForeignRecordUids;
512 }
513
514 /**
515 * Gets the record uid of the live default record. If already
516 * pointing to the live record, the submitted record uid is returned.
517 *
518 * @param string $tableName
519 * @param int $uid
520 * @return int
521 * @todo: the workspace mess still must be resolved somehow
522 */
523 protected function getLiveDefaultId($tableName, $uid)
524 {
525 $liveDefaultId = BackendUtility::getLiveVersionIdOfRecord($tableName, $uid);
526 if ($liveDefaultId === null) {
527 $liveDefaultId = $uid;
528 }
529 return $liveDefaultId;
530 }
531
532 /**
533 * @return BackendUserAuthentication
534 */
535 protected function getBackendUser()
536 {
537 return $GLOBALS['BE_USER'];
538 }
539
540 /**
541 * @return LanguageService
542 */
543 protected function getLanguageService()
544 {
545 return $GLOBALS['LANG'];
546 }
547
548 /**
549 * @return Logger
550 */
551 protected function getLogger()
552 {
553 return GeneralUtility::makeInstance(LogManager::class)->getLogger(__CLASS__);
554 }
555 }