[BUGFIX] Properly handle flexform related exceptions
[Packages/TYPO3.CMS.git] / typo3 / sysext / core / Classes / Configuration / FlexForm / FlexFormTools.php
1 <?php
2 declare(strict_types=1);
3 namespace TYPO3\CMS\Core\Configuration\FlexForm;
4
5 /*
6 * This file is part of the TYPO3 CMS project.
7 *
8 * It is free software; you can redistribute it and/or modify it under
9 * the terms of the GNU General Public License, either version 2
10 * of the License, or any later version.
11 *
12 * For the full copyright and license information, please read the
13 * LICENSE.txt file that was distributed with this source code.
14 *
15 * The TYPO3 project - inspiring people to share!
16 */
17
18 use TYPO3\CMS\Backend\Utility\BackendUtility;
19 use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidCombinedPointerFieldException;
20 use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidIdentifierException;
21 use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidParentRowException;
22 use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidParentRowLoopException;
23 use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidParentRowRootException;
24 use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidPointerFieldValueException;
25 use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidSinglePointerFieldException;
26 use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidTcaException;
27 use TYPO3\CMS\Core\Database\ConnectionPool;
28 use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
29 use TYPO3\CMS\Core\Utility\GeneralUtility;
30 use TYPO3\CMS\Core\Utility\MathUtility;
31
32 /**
33 * Contains functions for manipulating flex form data
34 */
35 class FlexFormTools
36 {
37 /**
38 * If set, section indexes are re-numbered before processing
39 *
40 * @var bool
41 */
42 public $reNumberIndexesOfSectionData = false;
43
44 /**
45 * Options for array2xml() for flexform.
46 * This will map the weird keys from the internal array to tags that could potentially be checked with a DTD/schema
47 *
48 * @var array
49 */
50 public $flexArray2Xml_options = [
51 'parentTagMap' => [
52 'data' => 'sheet',
53 'sheet' => 'language',
54 'language' => 'field',
55 'el' => 'field',
56 'field' => 'value',
57 'field:el' => 'el',
58 'el:_IS_NUM' => 'section',
59 'section' => 'itemType'
60 ],
61 'disableTypeAttrib' => 2
62 ];
63
64 /**
65 * Reference to object called
66 *
67 * @var object
68 */
69 public $callBackObj = null;
70
71 /**
72 * Used for accumulation of clean XML
73 *
74 * @var array
75 */
76 public $cleanFlexFormXML = [];
77
78 /**
79 * The method locates a specific data structure from given TCA and row combination
80 * and returns an identifier string that can be handed around, and can be resolved
81 * to a single data structure later without giving $row and $tca data again.
82 *
83 * Note: The returned syntax is meant to only specify the target location of the data structure.
84 * It SHOULD NOT be abused and enriched with data from the record that is dealt with. For
85 * instance, it is now allowed to add source record specific date like the uid or the pid!
86 * If that is done, it is up to the hook consumer to take care of possible side effects, eg. if
87 * the data handler copies or moves records around and those references change.
88 *
89 * This method gets: Source data that influences the target location of a data structure
90 * This method returns: Target specification of the data structure
91 *
92 * This method is "paired" with method getFlexFormDataStructureByIdentifier() that
93 * will resolve the returned syntax again and returns the data structure itself.
94 *
95 * Both methods can be extended via hooks to return and accept additional
96 * identifier strings if needed, and to transmit further information within the identifier strings.
97 *
98 * Note that the TCA for data structure definitions MUST NOT be overridden by
99 * 'columnsOverrides' or by parent TCA in an inline relation! This would create a huge mess.
100 *
101 * Note: This method and the resolving methods belowe are well unit tested and document all
102 * nasty details this way.
103 *
104 * @param array $fieldTca Full TCA of the field in question that has type=flex set
105 * @param string $tableName The table name of the TCA field
106 * @param string $fieldName The field name
107 * @param array $row The data row
108 * @return string Identifier string
109 * @throws \RuntimeException If TCA is misconfigured
110 * @throws InvalidParentRowException in getDataStructureIdentifierFromRecord
111 * @throws InvalidParentRowLoopException in getDataStructureIdentifierFromRecord
112 * @throws InvalidParentRowRootException in getDataStructureIdentifierFromRecord
113 * @throws InvalidPointerFieldValueException in getDataStructureIdentifierFromRecord
114 * @throws InvalidTcaException in getDataStructureIdentifierFromRecord
115 */
116 public function getDataStructureIdentifier(array $fieldTca, string $tableName, string $fieldName, array $row): string
117 {
118 $dataStructureIdentifier = null;
119 // Hook to inject an own logic to point to a data structure elsewhere.
120 // A hook has to implement method getDataStructureIdentifierPreProcess() to be called here.
121 // All hooks are called in a row, each MUST return an array, and the FIRST one that
122 // returns a non-empty array is used as final identifier.
123 // It is important to restrict hooks as much as possible to give other hooks a chance to kick in.
124 // The returned identifier is later given to parseFlexFormDataStructureByIdentifier() and a hook in there MUST
125 // be used to handle this identifier again.
126 // Warning: If adding source record details like the uid or pid here, this may turn out to be fragile.
127 // Be sure to test scenarios like workspaces and data handler copy/move well, additionally, this may
128 // break in between different core versions.
129 // It is probably a good idea to return at least something like [ 'type' => 'myExtension', ... ], see
130 // the core internal 'tca' and 'record' return values below
131 if (!empty($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS'][self::class]['flexParsing'])
132 && is_array($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS'][self::class]['flexParsing'])) {
133 $hookClasses = $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS'][self::class]['flexParsing'];
134 foreach ($hookClasses as $hookClass) {
135 $hookInstance = GeneralUtility::makeInstance($hookClass);
136 if (method_exists($hookClass, 'getDataStructureIdentifierPreProcess')) {
137 $dataStructureIdentifier = $hookInstance->getDataStructureIdentifierPreProcess(
138 $fieldTca,
139 $tableName,
140 $fieldName,
141 $row
142 );
143 if (!is_array($dataStructureIdentifier)) {
144 throw new \RuntimeException(
145 'Hook class ' . $hookClass . ' method getDataStructureIdentifierPreProcess must return an array',
146 1478096535
147 );
148 }
149 if (!empty($dataStructureIdentifier)) {
150 // Early break at first hook that returned something!
151 break;
152 }
153 }
154 }
155 }
156
157 // If hooks didn't return something, kick in core logic
158 if (empty($dataStructureIdentifier)) {
159 $tcaDataStructureArray = $fieldTca['config']['ds'] ?? null;
160 $tcaDataStructurePointerField = $fieldTca['config']['ds_pointerField'] ?? null;
161 if (!is_array($tcaDataStructureArray) && $tcaDataStructurePointerField) {
162 // "ds" is not an array, but "ds_pointerField" is set -> data structure is found in different table
163 $dataStructureIdentifier = $this->getDataStructureIdentifierFromRecord(
164 $fieldTca,
165 $tableName,
166 $fieldName,
167 $row
168 );
169 } elseif (is_array($tcaDataStructureArray)) {
170 $dataStructureIdentifier = $this->getDataStructureIdentifierFromTcaArray(
171 $fieldTca,
172 $tableName,
173 $fieldName,
174 $row
175 );
176 } else {
177 throw new \RuntimeException(
178 'TCA misconfiguration in table "' . $tableName . '" field "' . $fieldName . '" config section:'
179 . ' The field is configured as type="flex" and no "ds_pointerField" is defined and "ds" is not an array.'
180 . ' Either configure a default data structure in [\'ds\'][\'default\'] or add a "ds_pointerField" lookup mechanism'
181 . ' that specifies the data structure',
182 1463826960
183 );
184 }
185 }
186
187 // Second hook to manipulate identifier again. This can be used to add additional data to
188 // identifiers. Be careful here, especially if stuff from the source record like uid or pid
189 // is added! This may easily lead to issues with data handler details like copy or move records,
190 // localization and version overlays. Test this very well!
191 // Multiple hooks may add information to the same identifier here - take care to namespace array keys.
192 // Information added here can be later used in parseDataStructureByIdentifier post process hook again.
193 if (!empty($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS'][self::class]['flexParsing'])
194 && is_array($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS'][self::class]['flexParsing'])) {
195 $hookClasses = $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS'][self::class]['flexParsing'];
196 foreach ($hookClasses as $hookClass) {
197 $hookInstance = GeneralUtility::makeInstance($hookClass);
198 if (method_exists($hookClass, 'getDataStructureIdentifierPostProcess')) {
199 $dataStructureIdentifier = $hookInstance->getDataStructureIdentifierPostProcess(
200 $fieldTca,
201 $tableName,
202 $fieldName,
203 $row,
204 $dataStructureIdentifier
205 );
206 if (!is_array($dataStructureIdentifier) || empty($dataStructureIdentifier)) {
207 throw new \RuntimeException(
208 'Hook class ' . $hookClass . ' method getDataStructureIdentifierPostProcess must return a non empty array',
209 1478350835
210 );
211 }
212 }
213 }
214 }
215
216 return json_encode($dataStructureIdentifier);
217 }
218
219 /**
220 * The data structure is located in a record. This method resolves the record and
221 * returns an array to identify that record.
222 *
223 * The example setup below looks in current row for a tx_templavoila_ds value. If not found,
224 * it will search the rootline (the table is a tree, typically pages) until a value in
225 * tx_templavoila_next_ds or tx_templavoila_ds is found. That value should then be an
226 * integer, that points to a record in tx_templavoila_datastructure, and then the data
227 * structure is found in field dataprot:
228 *
229 * fieldTca = [
230 * 'config' => [
231 * 'type' => 'flex',
232 * 'ds_pointerField' => 'tx_templavoila_ds',
233 * 'ds_pointerField_searchParent' => 'pid',
234 * 'ds_pointerField_searchParent_subField' => 'tx_templavoila_next_ds',
235 * 'ds_tableField' => 'tx_templavoila_datastructure:dataprot',
236 * ]
237 * ]
238 *
239 * More simple scenario without tree traversal and having a valid data structure directly
240 * located in field theFlexDataStructureField.
241 *
242 * fieldTca = [
243 * 'config' => [
244 * 'type' => 'flex',
245 * 'ds_pointerField' => 'theFlexDataStructureField',
246 * ]
247 * ]
248 *
249 * Example return array:
250 * [
251 * 'type' => 'record',
252 * 'tableName' => 'tx_templavoila_datastructure',
253 * 'uid' => 42,
254 * 'fieldName' => 'dataprot',
255 * ];
256 *
257 * @param array $fieldTca Full TCA of the field in question that has type=flex set
258 * @param string $tableName The table name of the TCA field
259 * @param string $fieldName The field name
260 * @param array $row The data row
261 * @return array Identifier as array, see example above
262 * @throws InvalidParentRowException
263 * @throws InvalidParentRowLoopException
264 * @throws InvalidParentRowRootException
265 * @throws InvalidPointerFieldValueException
266 * @throws InvalidTcaException
267 */
268 protected function getDataStructureIdentifierFromRecord(array $fieldTca, string $tableName, string $fieldName, array $row): array
269 {
270 $pointerFieldName = $finalPointerFieldName = $fieldTca['config']['ds_pointerField'];
271 if (!array_key_exists($pointerFieldName, $row)) {
272 // Pointer field does not exist in row at all -> throw
273 throw new InvalidTcaException(
274 'No data structure for field "' . $fieldName . '" in table "' . $tableName . '" found, no "ds" array'
275 . ' configured and given row does not have a field with ds_pointerField name "' . $pointerFieldName . '".',
276 1464115059
277 );
278 }
279 $pointerValue = $row[$pointerFieldName];
280 // If set, this is typically set to "pid"
281 $parentFieldName = $fieldTca['config']['ds_pointerField_searchParent'] ?? null;
282 $pointerSubFieldName = $fieldTca['config']['ds_pointerField_searchParent_subField'] ?? null;
283 if (!$pointerValue && $parentFieldName) {
284 // Fetch rootline until a valid pointer value is found
285 $handledUids = [];
286 while (!$pointerValue) {
287 $handledUids[$row['uid']] = 1;
288 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($tableName);
289 $queryBuilder->getRestrictions()
290 ->removeAll()
291 ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
292 $queryBuilder->select('uid', $parentFieldName, $pointerFieldName);
293 if (!empty($pointerSubFieldName)) {
294 $queryBuilder->addSelect($pointerSubFieldName);
295 }
296 $queryStatement = $queryBuilder->from($tableName)
297 ->where(
298 $queryBuilder->expr()->eq(
299 'uid',
300 $queryBuilder->createNamedParameter($row[$parentFieldName], \PDO::PARAM_INT)
301 )
302 )
303 ->execute();
304 if ($queryStatement->rowCount() !== 1) {
305 throw new InvalidParentRowException(
306 'The data structure for field "' . $fieldName . '" in table "' . $tableName . '" has to be looked up'
307 . ' in field "' . $pointerFieldName . '". That field had no valid value, so a lookup in parent record'
308 . ' with uid "' . $row[$parentFieldName] . '" was done. This row however does not exist or was deleted.',
309 1463833794
310 );
311 }
312 $row = $queryStatement->fetch();
313 if (isset($handledUids[$row[$parentFieldName]])) {
314 // Row has been fetched before already -> loop detected!
315 throw new InvalidParentRowLoopException(
316 'The data structure for field "' . $fieldName . '" in table "' . $tableName . '" has to be looked up'
317 . ' in field "' . $pointerFieldName . '". That field had no valid value, so a lookup in parent record'
318 . ' with uid "' . $row[$parentFieldName] . '" was done. A loop of records was detected, the tree is broken.',
319 1464110956
320 );
321 }
322 BackendUtility::workspaceOL($tableName, $row);
323 BackendUtility::fixVersioningPid($tableName, $row, true);
324 // New pointer value: This is the "subField" value if given, else the field value
325 // ds_pointerField_searchParent_subField is the "template on next level" structure from templavoila
326 if ($pointerSubFieldName && $row[$pointerSubFieldName]) {
327 $finalPointerFieldName = $pointerSubFieldName;
328 $pointerValue = $row[$pointerSubFieldName];
329 } else {
330 $pointerValue = $row[$pointerFieldName];
331 }
332 if (!$pointerValue && ((int)$row[$parentFieldName] === 0 || $row[$parentFieldName] === null)) {
333 // If on root level and still no valid pointer found -> exception
334 throw new InvalidParentRowRootException(
335 'The data structure for field "' . $fieldName . '" in table "' . $tableName . '" has to be looked up'
336 . ' in field "' . $pointerFieldName . '". That field had no valid value, so a lookup in parent record'
337 . ' with uid "' . $row[$parentFieldName] . '" was done. Root node with uid "' . $row['uid'] . '"'
338 . ' was fetched and still no valid pointer field value was found.',
339 1464112555
340 );
341 }
342 }
343 }
344 if (!$pointerValue) {
345 // Still no valid pointer value -> exception, This still can be a data integrity issue, so throw a catchable exception
346 throw new InvalidPointerFieldValueException(
347 'No data structure for field "' . $fieldName . '" in table "' . $tableName . '" found, no "ds" array'
348 . ' configured and data structure could be found by resolving parents. This is probably a TCA misconfiguration.',
349 1464114011
350 );
351 }
352 // Ok, finally we have the field value. This is now either a data structure directly, or a pointer to a file,
353 // or the value can be interpreted as integer (is an uid) and "ds_tableField" is set, so this is the table, uid and field
354 // where the final data structure can be found.
355 if (MathUtility::canBeInterpretedAsInteger($pointerValue)) {
356 if (!isset($fieldTca['config']['ds_tableField'])) {
357 throw new InvalidTcaException(
358 'Invalid data structure pointer for field "' . $fieldName . '" in table "' . $tableName . '", the value'
359 . 'resolved to "' . $pointerValue . '" . which is an integer, so "ds_tableField" must be configured',
360 1464115639
361 );
362 }
363 if (substr_count($fieldTca['config']['ds_tableField'], ':') !== 1) {
364 // ds_tableField must be of the form "table:field"
365 throw new InvalidTcaException(
366 'Invalid TCA configuration for field "' . $fieldName . '" in table "' . $tableName . '", the setting'
367 . '"ds_tableField" must be of the form "tableName:fieldName"',
368 1464116002
369 );
370 }
371 list($foreignTableName, $foreignFieldName) = GeneralUtility::trimExplode(':', $fieldTca['config']['ds_tableField']);
372 $dataStructureIdentifier = [
373 'type' => 'record',
374 'tableName' => $foreignTableName,
375 'uid' => (int)$pointerValue,
376 'fieldName' => $foreignFieldName,
377 ];
378 } else {
379 $dataStructureIdentifier = [
380 'type' => 'record',
381 'tableName' => $tableName,
382 'uid' => (int)$row['uid'],
383 'fieldName' => $finalPointerFieldName,
384 ];
385 }
386 return $dataStructureIdentifier;
387 }
388
389 /**
390 * Find matching data structure in TCA ds array.
391 *
392 * Data structure is defined in 'ds' config array.
393 * Also, there can be a ds_pointerField
394 *
395 * fieldTca = [
396 * 'config' => [
397 * 'type' => 'flex',
398 * 'ds' => [
399 * 'aName' => '<T3DataStructure>...' OR 'FILE:...'
400 * ],
401 * 'ds_pointerField' => 'optionalSetting,upToTwoCommaSeparatedFieldNames',
402 * ]
403 * ]
404 *
405 * This method returns an array of the form:
406 * [
407 * 'type' => 'Tca:',
408 * 'tableName' => $tableName,
409 * 'fieldName' => $fieldName,
410 * 'dataStructureKey' => $key,
411 * ];
412 *
413 * Example:
414 * [
415 * 'type' => 'Tca:',
416 * 'tableName' => 'tt_content',
417 * 'fieldName' => 'pi_flexform',
418 * 'dataStructureKey' => 'powermail_pi1,list',
419 * ];
420 *
421 * @param array $fieldTca Full TCA of the field in question that has type=flex set
422 * @param string $tableName The table name of the TCA field
423 * @param string $fieldName The field name
424 * @param array $row The data row
425 * @return array Identifier as array, see example above
426 * @throws InvalidCombinedPointerFieldException
427 * @throws InvalidSinglePointerFieldException
428 * @throws InvalidTcaException
429 */
430 protected function getDataStructureIdentifierFromTcaArray(array $fieldTca, string $tableName, string $fieldName, array $row): array
431 {
432 $dataStructureIdentifier = [
433 'type' => 'tca',
434 'tableName' => $tableName,
435 'fieldName' => $fieldName,
436 'dataStructureKey' => null,
437 ];
438 $tcaDataStructurePointerField = $fieldTca['config']['ds_pointerField'] ?? null;
439 if ($tcaDataStructurePointerField === null) {
440 // No ds_pointerField set -> use 'default' as ds array key if exists.
441 if (isset($fieldTca['config']['ds']['default'])) {
442 $dataStructureIdentifier['dataStructureKey'] = 'default';
443 } else {
444 // A tca is configured as flex without ds_pointerField. A 'default' key must exist, otherwise
445 // this is a configuration error.
446 // May happen with an unloaded extension -> catchable
447 throw new InvalidTcaException(
448 'TCA misconfiguration in table "' . $tableName . '" field "' . $fieldName . '" config section:'
449 . ' The field is configured as type="flex" and no "ds_pointerField" is defined. Either configure'
450 . ' a default data structure in [\'ds\'][\'default\'] or add a "ds_pointerField" lookup mechanism'
451 . ' that specifies the data structure',
452 1463652560
453 );
454 }
455 } else {
456 // ds_pointerField is set, it can be a comma separated list of two fields, explode it.
457 $pointerFieldArray = GeneralUtility::trimExplode(',', $tcaDataStructurePointerField, true);
458 // Obvious configuration error, either one or two fields must be declared
459 $pointerFieldsCount = count($pointerFieldArray);
460 if ($pointerFieldsCount !== 1 && $pointerFieldsCount !== 2) {
461 // If it's there, it must be correct -> not catchable
462 throw new \RuntimeException(
463 'TCA misconfiguration in table "' . $tableName . '" field "' . $fieldName . '" config section:'
464 . ' ds_pointerField must be either a single field name, or a comma separated list of two fields,'
465 . ' the invalid configuration string provided was: "' . $tcaDataStructurePointerField . '"',
466 1463577497
467 );
468 }
469 // Verify first field exists in row array. If not, this is a hard error: Any extension that sets a
470 // ds_pointerField to some field name should take care that field does exist, too. They are a pair,
471 // so there shouldn't be a situation where the field does not exist. Throw an exception if that is violated.
472 if (!isset($row[$pointerFieldArray[0]])) {
473 // If it's declared, it must exist -> not catchable
474 throw new \RuntimeException(
475 'TCA misconfiguration in table "' . $tableName . '" field "' . $fieldName . '" config section:'
476 . ' ds_pointerField "' . $pointerFieldArray[0] . '" points to a field name that does not exist.',
477 1463578899
478 );
479 }
480 // Similar situation for the second field: If it is set, the field must exist.
481 if (isset($pointerFieldArray[1]) && !isset($row[$pointerFieldArray[1]])) {
482 // If it's declared, it must exist -> not catchable
483 throw new \RuntimeException(
484 'TCA misconfiguration in table "' . $tableName . '" field "' . $fieldName . '" config section:'
485 . ' Second part "' . $pointerFieldArray[1] . '" of ds_pointerField with full value "'
486 . $tcaDataStructurePointerField . '" points to a field name that does not exist.',
487 1463578900
488 );
489 }
490 if ($pointerFieldsCount === 1) {
491 if (isset($fieldTca['config']['ds'][$row[$pointerFieldArray[0]]])) {
492 // Field value points directly to an existing key in tca ds
493 $dataStructureIdentifier['dataStructureKey'] = $row[$pointerFieldArray[0]];
494 } elseif (isset($fieldTca['config']['ds']['default'])) {
495 // Field value does not exit in tca ds, fall back to default key if exists
496 $dataStructureIdentifier['dataStructureKey'] = 'default';
497 } else {
498 // The value of the ds_pointerField field points to a key in the ds array that does
499 // not exists, and there is no fallback either. This can happen if an extension brings
500 // new flex form definitions and that extension is unloaded later. "Old" records of the
501 // extension could then still point to the no longer existing key in ds. We throw a
502 // specific exception here to give controllers an opportunity to catch this case.
503 throw new InvalidSinglePointerFieldException(
504 'Field value of field "' . $pointerFieldArray[0] . '" of database record with uid "'
505 . $row['uid'] . '" from table "' . $tableName . '" points to a "ds" key ' . $row[$pointerFieldArray[0]]
506 . ' but this key does not exist and there is no "default" fallback.',
507 1463653197
508 );
509 }
510 } else {
511 // Two comma separated field names
512 if (isset($fieldTca['config']['ds'][$row[$pointerFieldArray[0]] . ',' . $row[$pointerFieldArray[1]]])) {
513 // firstValue,secondValue
514 $dataStructureIdentifier['dataStructureKey'] = $row[$pointerFieldArray[0]] . ',' . $row[$pointerFieldArray[1]];
515 } elseif (isset($fieldTca['config']['ds'][$row[$pointerFieldArray[1]] . ',*'])) {
516 // secondValue,* ?!
517 // @deprecated since TYPO3 v8, will be removed in TYPO3 v9 - just remove this elseif together with two unit tests
518 // This case is a wrong implementation - it matches "secondFieldValue,*", but it
519 // should match "*,secondFieldValue" only. Since this bug has been in the code for ages, it
520 // still works in v8 but is deprecated now.
521 // Try to log a meaningful deprecation message though, so devs can adapt
522 GeneralUtility::deprecationLog(
523 'TCA field "' . $fieldName . '" of table "' . $tableName . '" has a registered data structure'
524 . ' with name "' . $row[$pointerFieldArray[1]] . ',*". The ds_pointerField is set to "'
525 . $tcaDataStructurePointerField . '", with the matching value "' . $row[$pointerFieldArray[1]] . '"'
526 . ' for field "' . $pointerFieldArray[1] . '". This should be the other way round, so the name'
527 . ' should be: "*,' . $row[$pointerFieldArray[1]] . '" in the ds TCA array. Please change that'
528 . ' until TYPO3 v9, this matching code will be removed then.'
529 );
530 $dataStructureIdentifier['dataStructureKey'] = $row[$pointerFieldArray[1]] . ',*';
531 } elseif (isset($fieldTca['config']['ds'][$row[$pointerFieldArray[0]] . ',*'])) {
532 // firstValue,*
533 $dataStructureIdentifier['dataStructureKey'] = $row[$pointerFieldArray[0]] . ',*';
534 } elseif (isset($fieldTca['config']['ds']['*,' . $row[$pointerFieldArray[1]]])) {
535 // *,secondValue
536 $dataStructureIdentifier['dataStructureKey'] = '*,' . $row[$pointerFieldArray[1]];
537 } elseif (isset($fieldTca['config']['ds'][$row[$pointerFieldArray[0]]])) {
538 // firstValue
539 $dataStructureIdentifier['dataStructureKey'] = $row[$pointerFieldArray[0]];
540 } elseif (isset($fieldTca['config']['ds']['default'])) {
541 // Fall back to default
542 $dataStructureIdentifier['dataStructureKey'] = 'default';
543 } else {
544 // No ds_pointerField value could be determined and 'default' does not exist as
545 // fallback. This is the same case as the above scenario, throw a
546 // InvalidCombinedPointerFieldException here, too.
547 throw new InvalidCombinedPointerFieldException(
548 'Field combination of fields "' . $pointerFieldArray[0] . '" and "' . $pointerFieldArray[1] . '" of database'
549 . 'record with uid "' . $row['uid'] . '" from table "' . $tableName . '" with values "' . $row[$pointerFieldArray[0]] . '"'
550 . ' and "' . $row[$pointerFieldArray[1]] . '" could not be resolved to any registered data structure and '
551 . ' no "default" fallback exists.',
552 1463678524
553 );
554 }
555 }
556 }
557 return $dataStructureIdentifier;
558 }
559
560 /**
561 * Parse a data structure identified by $identifier to the final data structure array.
562 * This method is called after getDataStructureIdentifier(), finds the data structure
563 * and returns it.
564 *
565 * Hooks allow to manipulate the find logic and to post process the data structure array.
566 *
567 * Note that the TCA for data structure definitions MUST NOT be overridden by
568 * 'columnsOverrides' or by parent TCA in an inline relation! This would create a huge mess.
569 *
570 * After the data structure definition is found, the method resolves:
571 * * FILE:EXT: prefix of the data structure itself - the ds is in a file
572 * * FILE:EXT: prefix for sheets - if single sheets are in files
573 * * EXT: prefix for sheets - if single sheets are in files (slightly different b/w compat syntax)
574 * * Create an sDEF sheet if the data structure has non, yet.
575 *
576 * After that method is run, the data structure is fully resolved to an array,
577 * and same base normalization is done: If the ds did not contain a sheet,
578 * it will have one afterwards as "sDEF"
579 *
580 * This method gets: Target specification of the data structure.
581 * This method returns: The normalized data structure parsed to an array.
582 *
583 * Read the unit tests for nasty details.
584 *
585 * @param string $identifier String to find the data structure location
586 * @return array Parsed and normalized data structure
587 * @throws InvalidIdentifierException
588 */
589 public function parseDataStructureByIdentifier(string $identifier): array
590 {
591 // Throw an exception for an empty string. This might be a valid use case for new
592 // records in some situations, so this is catchable to give callers a chance to deal with that.
593 if (empty($identifier)) {
594 throw new InvalidIdentifierException(
595 'Empty string given to parseFlexFormDataStructureByIdentifier(). This exception might '
596 . ' be caught to handle some new record situations properly',
597 1478100828
598 );
599 }
600
601 $identifier = json_decode($identifier, true);
602
603 if (!is_array($identifier) || empty($identifier)) {
604 // If there is some identifier and it can't be decoded, programming error -> not catchable
605 throw new \RuntimeException(
606 'Identifier could not be decoded to an array.',
607 1478345642
608 );
609 }
610
611 $dataStructure = '';
612
613 // Hook to fetch data structure by given identifier.
614 // Method parseFlexFormDataStructureByIdentifier() must be implemented and returns either an
615 // empty string "not my business", or a string with the resolved data structure string, or FILE: reference,
616 // or a fully parsed data structure as aray.
617 // Result of the FIRST hook that gives an non-empty string is used, namespace your identifiers in
618 // a way that there is little chance they overlap (eg. prefix with extension name).
619 // If implemented, this hook should be paired with a hook in getDataStructureIdentifier() above.
620 if (!empty($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS'][self::class]['flexParsing'])
621 && is_array($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS'][self::class]['flexParsing'])
622 ) {
623 $hookClasses = $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS'][self::class]['flexParsing'];
624 foreach ($hookClasses as $hookClass) {
625 $hookInstance = GeneralUtility::makeInstance($hookClass);
626 if (method_exists($hookClass, 'parseDataStructureByIdentifierPreProcess')) {
627 $dataStructure = $hookInstance->parseDataStructureByIdentifierPreProcess($identifier);
628 if (!is_string($dataStructure) && !is_array($dataStructure)) {
629 // Programming error -> not catchable
630 throw new \RuntimeException(
631 'Hook class ' . $hookClass . ' method parseDataStructureByIdentifierPreProcess must either'
632 . ' return an empty string or a data structure string or a parsed data structure array.',
633 1478168512
634 );
635 }
636 if (!empty($dataStructure)) {
637 // Early break if a hook resolved to something!
638 break;
639 }
640 }
641 }
642 }
643
644 // If hooks didn't resolve, try own methods
645 if (empty($dataStructure)) {
646 if ($identifier['type'] === 'record') {
647 // Handle "record" type, see getDataStructureIdentifierFromRecord()
648 if (empty($identifier['tableName']) || empty($identifier['uid']) || empty($identifier['fieldName'])) {
649 throw new \RuntimeException(
650 'Incomplete "record" based identifier: ' . json_encode($identifier),
651 1478113873
652 );
653 }
654 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($identifier['tableName']);
655 $queryBuilder->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class));
656 $dataStructure = $queryBuilder
657 ->select($identifier['fieldName'])
658 ->from($identifier['tableName'])
659 ->where(
660 $queryBuilder->expr()->eq(
661 'uid',
662 $queryBuilder->createNamedParameter($identifier['uid'], \PDO::PARAM_INT)
663 )
664 )
665 ->execute()
666 ->fetchColumn(0);
667 } elseif ($identifier['type'] === 'tca') {
668 // Handle "tca" type, see getDataStructureIdentifierFromTcaArray
669 if (empty($identifier['tableName']) || empty($identifier['fieldName']) || empty($identifier['dataStructureKey'])) {
670 throw new \RuntimeException(
671 'Incomplete "tca" based identifier: ' . json_encode($identifier),
672 1478113471
673 );
674 }
675 $table = $identifier['tableName'];
676 $field = $identifier['fieldName'];
677 $dataStructureKey = $identifier['dataStructureKey'];
678 if (!isset($GLOBALS['TCA'][$table]['columns'][$field]['config']['ds'][$dataStructureKey])
679 || !is_string($GLOBALS['TCA'][$table]['columns'][$field]['config']['ds'][$dataStructureKey])
680 ) {
681 // This may happen for elements pointing to an unloaded extension -> catchable
682 throw new InvalidIdentifierException(
683 'Specified identifier ' . json_encode($identifier) . ' does not resolve to a valid'
684 . ' TCA array value',
685 1478105491
686 );
687 }
688 $dataStructure = $GLOBALS['TCA'][$table]['columns'][$field]['config']['ds'][$dataStructureKey];
689 } else {
690 throw new InvalidIdentifierException(
691 'Identifier ' . json_encode($identifier) . ' could not be resolved',
692 1478104554
693 );
694 }
695 }
696
697 // Hooks may have parse the data structure already to an array. If that is not the case, parse it now.
698 if (is_string($dataStructure)) {
699 // Resolve FILE: prefix pointing to a DS in a file
700 if (strpos(trim($dataStructure), 'FILE:') === 0) {
701 $file = GeneralUtility::getFileAbsFileName(substr(trim($dataStructure), 5));
702 if (empty($file) || !@is_file($file)) {
703 throw new \RuntimeException(
704 'Data structure file ' . $file . ' could not be resolved to an existing file',
705 1478105826
706 );
707 }
708 $dataStructure = file_get_contents($file);
709 }
710
711 // Parse main structure
712 $dataStructure = GeneralUtility::xml2array($dataStructure);
713 }
714
715 // Throw if it still is not an array, probably because GeneralUtility::xml2array() failed.
716 // This also may happen if artificial identifiers were constructed which don't resolve. The
717 // flex form "exclude" access rights systems does that -> catchable
718 if (!is_array($dataStructure)) {
719 throw new InvalidIdentifierException(
720 'Parse error: Data structure could not be resolved to a valid structure.',
721 1478106090
722 );
723 }
724
725 // Create default sheet if there is none, yet.
726 if (isset($dataStructure['ROOT']) && isset($dataStructure['sheets'])) {
727 throw new \RuntimeException(
728 'Parsed data structure has both ROOT and sheets on top level. Thats invalid.',
729 1440676540
730 );
731 }
732 if (isset($dataStructure['ROOT']) && is_array($dataStructure['ROOT'])) {
733 $dataStructure['sheets']['sDEF']['ROOT'] = $dataStructure['ROOT'];
734 unset($dataStructure['ROOT']);
735 }
736
737 // Resolve FILE:EXT and EXT: for single sheets
738 if (isset($dataStructure['sheets']) && is_array($dataStructure['sheets'])) {
739 foreach ($dataStructure['sheets'] as $sheetName => $sheetStructure) {
740 if (!is_array($sheetStructure)) {
741 if (strpos(trim($sheetStructure), 'FILE:') === 0) {
742 $file = GeneralUtility::getFileAbsFileName(substr(trim($sheetStructure), 5));
743 } else {
744 $file = GeneralUtility::getFileAbsFileName(trim($sheetStructure));
745 }
746 if ($file && @is_file($file)) {
747 $sheetStructure = GeneralUtility::xml2array(file_get_contents($file));
748 }
749 }
750 $dataStructure['sheets'][$sheetName] = $sheetStructure;
751 }
752 }
753
754 // Hook to manipulate data structure further. This can be used to add or remove fields
755 // from given structure. Multiple hooks can be registered, all are called. They
756 // receive the parsed structure and the identifier array.
757 if (!empty($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS'][self::class]['flexParsing'])
758 && is_array($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS'][self::class]['flexParsing'])
759 ) {
760 $hookClasses = $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS'][self::class]['flexParsing'];
761 foreach ($hookClasses as $hookClass) {
762 $hookInstance = GeneralUtility::makeInstance($hookClass);
763 if (method_exists($hookClass, 'parseDataStructureByIdentifierPostProcess')) {
764 $dataStructure = $hookInstance->parseDataStructureByIdentifierPostProcess($dataStructure, $identifier);
765 if (!is_array($dataStructure)) {
766 // Programming error -> not catchable
767 throw new \RuntimeException(
768 'Hook class ' . $hookClass . ' method parseDataStructureByIdentifierPreProcess must return and array.',
769 1478350806
770 );
771 }
772 }
773 }
774 }
775
776 return $dataStructure;
777 }
778
779 /**
780 * Handler for Flex Forms
781 *
782 * @param string $table The table name of the record
783 * @param string $field The field name of the flexform field to work on
784 * @param array $row The record data array
785 * @param object $callBackObj Object in which the call back function is located
786 * @param string $callBackMethod_value Method name of call back function in object for values
787 * @return bool|string true on success, string if error happened (error string returned)
788 */
789 public function traverseFlexFormXMLData($table, $field, $row, $callBackObj, $callBackMethod_value)
790 {
791 if (!is_array($GLOBALS['TCA'][$table]) || !is_array($GLOBALS['TCA'][$table]['columns'][$field])) {
792 return 'TCA table/field was not defined.';
793 }
794 $this->callBackObj = $callBackObj;
795
796 // Get data structure. The methods may throw various exceptions, with some of them being
797 // ok in certain scenarios, for instance on new record rows. Those are ok to "eat" here
798 // and substitute with a dummy DS.
799 $dataStructureArray = [ 'sheets' => [ 'sDEF' => [] ] ];
800 try {
801 $dataStructureIdentifier = $this->getDataStructureIdentifier($GLOBALS['TCA'][$table]['columns'][$field], $table, $field, $row);
802 $dataStructureArray = $this->parseDataStructureByIdentifier($dataStructureIdentifier);
803 } catch (InvalidParentRowException $e) {
804 } catch (InvalidParentRowLoopException $e) {
805 } catch (InvalidParentRowRootException $e) {
806 } catch (InvalidPointerFieldValueException $e) {
807 } catch (InvalidIdentifierException $e) {
808 }
809
810 // Get flexform XML data
811 $editData = GeneralUtility::xml2array($row[$field]);
812 if (!is_array($editData)) {
813 return 'Parsing error: ' . $editData;
814 }
815 // Check if $dataStructureArray['sheets'] is indeed an array before loop or it will crash with runtime error
816 if (!is_array($dataStructureArray['sheets'])) {
817 return 'Data Structure ERROR: sheets is defined but not an array for table ' . $table . (isset($row['uid']) ? ' and uid ' . $row['uid'] : '');
818 }
819 // Traverse languages:
820 foreach ($dataStructureArray['sheets'] as $sheetKey => $sheetData) {
821 // Render sheet:
822 if (is_array($sheetData['ROOT']) && is_array($sheetData['ROOT']['el'])) {
823 $PA['vKeys'] = ['DEF'];
824 $PA['lKey'] = 'lDEF';
825 $PA['callBackMethod_value'] = $callBackMethod_value;
826 $PA['table'] = $table;
827 $PA['field'] = $field;
828 $PA['uid'] = $row['uid'];
829 // Render flexform:
830 $this->traverseFlexFormXMLData_recurse($sheetData['ROOT']['el'], $editData['data'][$sheetKey]['lDEF'], $PA, 'data/' . $sheetKey . '/lDEF');
831 } else {
832 return 'Data Structure ERROR: No ROOT element found for sheet "' . $sheetKey . '".';
833 }
834 }
835 return true;
836 }
837
838 /**
839 * Recursively traversing flexform data according to data structure and element data
840 *
841 * @param array $dataStruct (Part of) data structure array that applies to the sub section of the flexform data we are processing
842 * @param array $editData (Part of) edit data array, reflecting current part of data structure
843 * @param array $PA Additional parameters passed.
844 * @param string $path Telling the "path" to the element in the flexform XML
845 * @return array
846 */
847 public function traverseFlexFormXMLData_recurse($dataStruct, $editData, &$PA, $path = '')
848 {
849 if (is_array($dataStruct)) {
850 foreach ($dataStruct as $key => $value) {
851 // The value of each entry must be an array.
852 if (is_array($value)) {
853 if ($value['type'] === 'array') {
854 // Array (Section) traversal
855 if ($value['section']) {
856 $cc = 0;
857 if (is_array($editData[$key]['el'])) {
858 if ($this->reNumberIndexesOfSectionData) {
859 $temp = [];
860 $c3 = 0;
861 foreach ($editData[$key]['el'] as $v3) {
862 $temp[++$c3] = $v3;
863 }
864 $editData[$key]['el'] = $temp;
865 }
866 foreach ($editData[$key]['el'] as $k3 => $v3) {
867 if (is_array($v3)) {
868 $cc = $k3;
869 $theType = key($v3);
870 $theDat = $v3[$theType];
871 $newSectionEl = $value['el'][$theType];
872 if (is_array($newSectionEl)) {
873 $this->traverseFlexFormXMLData_recurse([$theType => $newSectionEl], [$theType => $theDat], $PA, $path . '/' . $key . '/el/' . $cc);
874 }
875 }
876 }
877 }
878 } else {
879 // Array traversal
880 if (is_array($editData) && is_array($editData[$key])) {
881 $this->traverseFlexFormXMLData_recurse($value['el'], $editData[$key]['el'], $PA, $path . '/' . $key . '/el');
882 }
883 }
884 } elseif (is_array($value['TCEforms']['config'])) {
885 // Processing a field value:
886 foreach ($PA['vKeys'] as $vKey) {
887 $vKey = 'v' . $vKey;
888 // Call back
889 if ($PA['callBackMethod_value'] && is_array($editData) && is_array($editData[$key])) {
890 $this->executeCallBackMethod($PA['callBackMethod_value'], [$value, $editData[$key][$vKey], $PA, $path . '/' . $key . '/' . $vKey, $this]);
891 }
892 }
893 }
894 }
895 }
896 }
897 }
898
899 /**
900 * Execute method on callback object
901 *
902 * @param string $methodName Method name to call
903 * @param array $parameterArray Parameters
904 * @return mixed Result of callback object
905 */
906 protected function executeCallBackMethod($methodName, array $parameterArray)
907 {
908 return call_user_func_array([$this->callBackObj, $methodName], $parameterArray);
909 }
910
911 /***********************************
912 *
913 * Processing functions
914 *
915 ***********************************/
916 /**
917 * Cleaning up FlexForm XML to hold only the values it may according to its Data Structure. Also the order of tags will follow that of the data structure.
918 * BE CAREFUL: DO not clean records in workspaces unless IN the workspace! The Data Structure might resolve falsely on a workspace record when cleaned from Live workspace.
919 *
920 * @param string $table Table name
921 * @param string $field Field name of the flex form field in which the XML is found that should be cleaned.
922 * @param array $row The record
923 * @return string Clean XML from FlexForm field
924 */
925 public function cleanFlexFormXML($table, $field, $row)
926 {
927 // New structure:
928 $this->cleanFlexFormXML = [];
929 // Create and call iterator object:
930 $flexObj = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Configuration\FlexForm\FlexFormTools::class);
931 $flexObj->reNumberIndexesOfSectionData = true;
932 $flexObj->traverseFlexFormXMLData($table, $field, $row, $this, 'cleanFlexFormXML_callBackFunction');
933 return $this->flexArray2Xml($this->cleanFlexFormXML, true);
934 }
935
936 /**
937 * Call back function for \TYPO3\CMS\Core\Configuration\FlexForm\FlexFormTools class
938 * Basically just setting the value in a new array (thus cleaning because only values that are valid are visited!)
939 *
940 * @param array $dsArr Data structure for the current value
941 * @param mixed $data Current value
942 * @param array $PA Additional configuration used in calling function
943 * @param string $path Path of value in DS structure
944 * @param FlexFormTools $pObj caller
945 */
946 public function cleanFlexFormXML_callBackFunction($dsArr, $data, $PA, $path, $pObj)
947 {
948 // Just setting value in our own result array, basically replicating the structure:
949 $pObj->setArrayValueByPath($path, $this->cleanFlexFormXML, $data);
950 }
951
952 /***********************************
953 *
954 * Multi purpose functions
955 *
956 ***********************************/
957 /**
958 * Get a value from a multi-dimensional array by giving a path "../../.." pointing to the element
959 *
960 * @param string $pathArray The path pointing to the value field, eg. test/2/title to access $array['test'][2]['title']
961 * @param array $array Array to get value from. Passed by reference so the value returned can be used to change the value in the array!
962 * @return mixed Value returned
963 */
964 public function &getArrayValueByPath($pathArray, &$array)
965 {
966 if (!is_array($pathArray)) {
967 $pathArray = explode('/', $pathArray);
968 }
969 if (is_array($array) && !empty($pathArray)) {
970 $key = array_shift($pathArray);
971 if (isset($array[$key])) {
972 if (empty($pathArray)) {
973 return $array[$key];
974 }
975 return $this->getArrayValueByPath($pathArray, $array[$key]);
976 }
977 return null;
978 }
979 }
980
981 /**
982 * Set a value in a multi-dimensional array by giving a path "../../.." pointing to the element
983 *
984 * @param string $pathArray The path pointing to the value field, eg. test/2/title to access $array['test'][2]['title']
985 * @param array $array Array to set value in. Passed by reference so the value returned can be used to change the value in the array!
986 * @param mixed $value Value to set
987 * @return mixed Value returned
988 */
989 public function setArrayValueByPath($pathArray, &$array, $value)
990 {
991 if (isset($value)) {
992 if (!is_array($pathArray)) {
993 $pathArray = explode('/', $pathArray);
994 }
995 if (is_array($array) && !empty($pathArray)) {
996 $key = array_shift($pathArray);
997 if (empty($pathArray)) {
998 $array[$key] = $value;
999 return true;
1000 }
1001 if (!isset($array[$key])) {
1002 $array[$key] = [];
1003 }
1004 return $this->setArrayValueByPath($pathArray, $array[$key], $value);
1005 }
1006 }
1007 }
1008
1009 /**
1010 * Convert FlexForm data array to XML
1011 *
1012 * @param array $array Array to output in <T3FlexForms> XML
1013 * @param bool $addPrologue If set, the XML prologue is returned as well.
1014 * @return string XML content.
1015 */
1016 public function flexArray2Xml($array, $addPrologue = false)
1017 {
1018 if ($GLOBALS['TYPO3_CONF_VARS']['BE']['flexformForceCDATA']) {
1019 $this->flexArray2Xml_options['useCDATA'] = 1;
1020 }
1021 $output = GeneralUtility::array2xml($array, '', 0, 'T3FlexForms', 4, $this->flexArray2Xml_options);
1022 if ($addPrologue) {
1023 $output = '<?xml version="1.0" encoding="utf-8" standalone="yes" ?>' . LF . $output;
1024 }
1025 return $output;
1026 }
1027 }