1<?php
2
3declare(strict_types=1);
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
18namespace TYPO3\CMS\Extbase\Persistence\Generic\Mapper;
19
20use TYPO3\CMS\Core\Cache\CacheManager;
21use TYPO3\CMS\Core\Core\Environment;
22use TYPO3\CMS\Core\Database\Query\QueryHelper;
23use TYPO3\CMS\Core\DataHandling\TableColumnSubType;
24use TYPO3\CMS\Core\DataHandling\TableColumnType;
25use TYPO3\CMS\Core\Information\Typo3Version;
26use TYPO3\CMS\Core\SingletonInterface;
27use TYPO3\CMS\Core\Utility\GeneralUtility;
28use TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface;
29use TYPO3\CMS\Extbase\Object\ObjectManagerInterface;
30use TYPO3\CMS\Extbase\Persistence\ClassesConfiguration;
31use TYPO3\CMS\Extbase\Persistence\ClassesConfigurationFactory;
32use TYPO3\CMS\Extbase\Persistence\Generic\Exception\InvalidClassException;
33use TYPO3\CMS\Extbase\Persistence\Generic\Exception\UnsupportedRelationException;
34use TYPO3\CMS\Extbase\Reflection\ClassSchema\Exception\NoSuchPropertyException;
35use TYPO3\CMS\Extbase\Reflection\ReflectionService;
36
37/**
38 * A factory for a data map to map a single table configured in $TCA on a domain object.
39 * @internal only to be used within Extbase, not part of TYPO3 Core API.
40 */
41class DataMapFactory implements SingletonInterface
42{
43    /**
44     * @var \TYPO3\CMS\Extbase\Reflection\ReflectionService
45     */
46    protected $reflectionService;
47
48    /**
49     * @var \TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface
50     */
51    protected $configurationManager;
52
53    /**
54     * @var \TYPO3\CMS\Extbase\Object\ObjectManagerInterface
55     */
56    protected $objectManager;
57
58    /**
59     * @var \TYPO3\CMS\Core\Cache\CacheManager
60     */
61    protected $cacheManager;
62
63    /**
64     * @var \TYPO3\CMS\Core\Cache\Frontend\FrontendInterface
65     */
66    protected $dataMapCache;
67
68    /**
69     * Runtime cache for data maps, to reduce number of calls to cache backend.
70     *
71     * @var array
72     */
73    protected $dataMaps = [];
74
75    /**
76     * @var ClassesConfiguration
77     */
78    private $classesConfiguration;
79
80    /**
81     * @param \TYPO3\CMS\Extbase\Reflection\ReflectionService $reflectionService
82     * @param \TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface $configurationManager
83     * @param \TYPO3\CMS\Extbase\Object\ObjectManagerInterface $objectManager
84     * @param \TYPO3\CMS\Core\Cache\CacheManager $cacheManager
85     */
86    public function __construct(
87        ReflectionService $reflectionService,
88        ConfigurationManagerInterface $configurationManager,
89        ObjectManagerInterface $objectManager,
90        CacheManager $cacheManager,
91        ClassesConfigurationFactory $classesConfigurationFactory
92    ) {
93        $this->reflectionService = $reflectionService;
94        $this->configurationManager = $configurationManager;
95        $this->objectManager = $objectManager;
96        $this->cacheManager = $cacheManager;
97
98        $this->dataMapCache = $this->cacheManager->getCache('extbase');
99        $this->classesConfiguration = $classesConfigurationFactory->createClassesConfiguration();
100    }
101
102    /**
103     * Builds a data map by adding column maps for all the configured columns in the $TCA.
104     * It also resolves the type of values the column is holding and the typo of relation the column
105     * represents.
106     *
107     * @param string $className The class name you want to fetch the Data Map for
108     * @return \TYPO3\CMS\Extbase\Persistence\Generic\Mapper\DataMap The data map
109     */
110    public function buildDataMap(string $className): DataMap
111    {
112        $className = ltrim($className, '\\');
113        if (isset($this->dataMaps[$className])) {
114            return $this->dataMaps[$className];
115        }
116        $cacheIdentifierClassName = str_replace('\\', '', $className);
117        $cacheIdentifier = 'DataMap_' . $cacheIdentifierClassName . '_' . sha1((string)(new Typo3Version()) . Environment::getProjectPath());
118        $dataMap = $this->dataMapCache->get($cacheIdentifier);
119        if ($dataMap === false) {
120            $dataMap = $this->buildDataMapInternal($className);
121            $this->dataMapCache->set($cacheIdentifier, $dataMap);
122        }
123        $this->dataMaps[$className] = $dataMap;
124        return $dataMap;
125    }
126
127    /**
128     * Builds a data map by adding column maps for all the configured columns in the $TCA.
129     * It also resolves the type of values the column is holding and the typo of relation the column
130     * represents.
131     *
132     * @param string $className The class name you want to fetch the Data Map for
133     * @throws \TYPO3\CMS\Extbase\Persistence\Generic\Exception\InvalidClassException
134     * @return \TYPO3\CMS\Extbase\Persistence\Generic\Mapper\DataMap The data map
135     */
136    protected function buildDataMapInternal(string $className): DataMap
137    {
138        if (!class_exists($className)) {
139            throw new InvalidClassException(
140                'Could not find class definition for name "' . $className . '". This could be caused by a mis-spelling of the class name in the class definition.',
141                1476045117
142            );
143        }
144        $recordType = null;
145        $subclasses = [];
146        $tableName = $this->resolveTableName($className);
147        $fieldNameToPropertyNameMapping = [];
148        if ($this->classesConfiguration->hasClass($className)) {
149            $classSettings = $this->classesConfiguration->getConfigurationFor($className);
150            $subclasses = $this->classesConfiguration->getSubClasses($className);
151            if (isset($classSettings['recordType']) && $classSettings['recordType'] !== '') {
152                $recordType = $classSettings['recordType'];
153            }
154            if (isset($classSettings['tableName']) && $classSettings['tableName'] !== '') {
155                $tableName = $classSettings['tableName'];
156            }
157            foreach ($classSettings['properties'] ?? [] as $propertyName => $propertyDefinition) {
158                $fieldNameToPropertyNameMapping[$propertyDefinition['fieldName']] = $propertyName;
159            }
160        }
161        /** @var \TYPO3\CMS\Extbase\Persistence\Generic\Mapper\DataMap $dataMap */
162        $dataMap = $this->objectManager->get(DataMap::class, $className, $tableName, $recordType, $subclasses);
163        $dataMap = $this->addMetaDataColumnNames($dataMap, $tableName);
164
165        foreach ($this->getColumnsDefinition($tableName) as $columnName => $columnDefinition) {
166            $propertyName = $fieldNameToPropertyNameMapping[$columnName]
167                ?? GeneralUtility::underscoredToLowerCamelCase($columnName);
168
169            // @todo: shall we really create column maps for non existing properties?
170            // @todo: check why this could happen in the first place. TCA definitions for non existing model properties?
171            $columnMap = $this->createColumnMap($columnName, $propertyName);
172            try {
173                $property = $this->reflectionService->getClassSchema($className)->getProperty($propertyName);
174                [$type, $elementType] = [$property->getType(), $property->getElementType()];
175            } catch (NoSuchPropertyException $e) {
176                [$type, $elementType] = [null, null];
177            }
178            $columnMap = $this->setType($columnMap, $columnDefinition['config']);
179            $columnMap = $this->setRelations($columnMap, $columnDefinition['config'], $type, $elementType);
180            $columnMap = $this->setFieldEvaluations($columnMap, $columnDefinition['config']);
181            $dataMap->addColumnMap($columnMap);
182        }
183        return $dataMap;
184    }
185
186    /**
187     * Resolve the table name for the given class name
188     *
189     * @param string $className
190     * @return string The table name
191     */
192    protected function resolveTableName(string $className): string
193    {
194        $className = ltrim($className, '\\');
195        $classNameParts = explode('\\', $className);
196        // Skip vendor and product name for core classes
197        if (strpos($className, 'TYPO3\\CMS\\') === 0) {
198            $classPartsToSkip = 2;
199        } else {
200            $classPartsToSkip = 1;
201        }
202        $tableName = 'tx_' . strtolower(implode('_', array_slice($classNameParts, $classPartsToSkip)));
203
204        return $tableName;
205    }
206
207    /**
208     * Returns the TCA ctrl section of the specified table; or NULL if not set
209     *
210     * @param string $tableName An optional table name to fetch the columns definition from
211     * @return array|null The TCA columns definition
212     */
213    protected function getControlSection(string $tableName): ?array
214    {
215        return (isset($GLOBALS['TCA'][$tableName]['ctrl']) && is_array($GLOBALS['TCA'][$tableName]['ctrl']))
216            ? $GLOBALS['TCA'][$tableName]['ctrl']
217            : null;
218    }
219
220    /**
221     * Returns the TCA columns array of the specified table
222     *
223     * @param string $tableName An optional table name to fetch the columns definition from
224     * @return array The TCA columns definition
225     */
226    protected function getColumnsDefinition(string $tableName): array
227    {
228        return is_array($GLOBALS['TCA'][$tableName]['columns']) ? $GLOBALS['TCA'][$tableName]['columns'] : [];
229    }
230
231    /**
232     * @param DataMap $dataMap
233     * @param string $tableName
234     * @return DataMap
235     */
236    protected function addMetaDataColumnNames(DataMap $dataMap, string $tableName): DataMap
237    {
238        $controlSection = $GLOBALS['TCA'][$tableName]['ctrl'];
239        $dataMap->setPageIdColumnName('pid');
240        if (isset($controlSection['tstamp'])) {
241            $dataMap->setModificationDateColumnName($controlSection['tstamp']);
242        }
243        if (isset($controlSection['crdate'])) {
244            $dataMap->setCreationDateColumnName($controlSection['crdate']);
245        }
246        if (isset($controlSection['cruser_id'])) {
247            $dataMap->setCreatorColumnName($controlSection['cruser_id']);
248        }
249        if (isset($controlSection['delete'])) {
250            $dataMap->setDeletedFlagColumnName($controlSection['delete']);
251        }
252        if (isset($controlSection['languageField'])) {
253            $dataMap->setLanguageIdColumnName($controlSection['languageField']);
254        }
255        if (isset($controlSection['transOrigPointerField'])) {
256            $dataMap->setTranslationOriginColumnName($controlSection['transOrigPointerField']);
257        }
258        if (isset($controlSection['transOrigDiffSourceField'])) {
259            $dataMap->setTranslationOriginDiffSourceName($controlSection['transOrigDiffSourceField']);
260        }
261        if (isset($controlSection['type'])) {
262            $dataMap->setRecordTypeColumnName($controlSection['type']);
263        }
264        if (isset($controlSection['rootLevel'])) {
265            $dataMap->setRootLevel($controlSection['rootLevel']);
266        }
267        if (isset($controlSection['is_static'])) {
268            $dataMap->setIsStatic($controlSection['is_static']);
269        }
270        if (isset($controlSection['enablecolumns']['disabled'])) {
271            $dataMap->setDisabledFlagColumnName($controlSection['enablecolumns']['disabled']);
272        }
273        if (isset($controlSection['enablecolumns']['starttime'])) {
274            $dataMap->setStartTimeColumnName($controlSection['enablecolumns']['starttime']);
275        }
276        if (isset($controlSection['enablecolumns']['endtime'])) {
277            $dataMap->setEndTimeColumnName($controlSection['enablecolumns']['endtime']);
278        }
279        if (isset($controlSection['enablecolumns']['fe_group'])) {
280            $dataMap->setFrontEndUserGroupColumnName($controlSection['enablecolumns']['fe_group']);
281        }
282        return $dataMap;
283    }
284
285    /**
286     * Set the table column type
287     *
288     * @param ColumnMap $columnMap
289     * @param array $columnConfiguration
290     * @return ColumnMap
291     */
292    protected function setType(ColumnMap $columnMap, array $columnConfiguration): ColumnMap
293    {
294        // todo: this method should only be called with proper arguments which means that the TCA integrity check should
295        // todo: take place outside this method.
296
297        $tableColumnType = $columnConfiguration['type'] ?? null;
298        $columnMap->setType(TableColumnType::cast($tableColumnType));
299        $tableColumnSubType = $columnConfiguration['internal_type'] ?? null;
300        $columnMap->setInternalType(TableColumnSubType::cast($tableColumnSubType));
301
302        return $columnMap;
303    }
304
305    /**
306     * This method tries to determine the type of type of relation to other tables and sets it based on
307     * the $TCA column configuration
308     *
309     * @param ColumnMap $columnMap The column map
310     * @param array|null $columnConfiguration The column configuration from $TCA
311     * @param string|null $type
312     * @param string|null $elementType
313     * @return ColumnMap
314     */
315    protected function setRelations(ColumnMap $columnMap, ?array $columnConfiguration, ?string $type, ?string $elementType): ColumnMap
316    {
317        if (isset($columnConfiguration)) {
318            if (isset($columnConfiguration['MM'])) {
319                $columnMap = $this->setManyToManyRelation($columnMap, $columnConfiguration);
320            } elseif ($elementType !== null) {
321                $columnMap = $this->setOneToManyRelation($columnMap, $columnConfiguration);
322            } elseif ($type !== null && strpbrk($type, '_\\') !== false) {
323                // @todo: check the strpbrk function call. Seems to be a check for Tx_Foo_Bar style class names
324                $columnMap = $this->setOneToOneRelation($columnMap, $columnConfiguration);
325            } elseif (
326                isset($columnConfiguration['type'], $columnConfiguration['renderType'])
327                && $columnConfiguration['type'] === 'select'
328                && (
329                    $columnConfiguration['renderType'] !== 'selectSingle'
330                    || (isset($columnConfiguration['maxitems']) && $columnConfiguration['maxitems'] > 1)
331                )
332            ) {
333                $columnMap->setTypeOfRelation(ColumnMap::RELATION_HAS_MANY);
334            } elseif (
335                isset($columnConfiguration['type']) && $columnConfiguration['type'] === 'group'
336                && (!isset($columnConfiguration['maxitems']) || $columnConfiguration['maxitems'] > 1)
337            ) {
338                $columnMap->setTypeOfRelation(ColumnMap::RELATION_HAS_MANY);
339            } else {
340                $columnMap->setTypeOfRelation(ColumnMap::RELATION_NONE);
341            }
342        } else {
343            $columnMap->setTypeOfRelation(ColumnMap::RELATION_NONE);
344        }
345        return $columnMap;
346    }
347
348    /**
349     * Sets field evaluations based on $TCA column configuration.
350     *
351     * @param ColumnMap $columnMap The column map
352     * @param array|null $columnConfiguration The column configuration from $TCA
353     * @return ColumnMap
354     */
355    protected function setFieldEvaluations(ColumnMap $columnMap, array $columnConfiguration = null): ColumnMap
356    {
357        // todo: this method should only be called with proper arguments which means that the TCA integrity check should
358        // todo: take place outside this method.
359
360        if (!empty($columnConfiguration['eval'])) {
361            $fieldEvaluations = GeneralUtility::trimExplode(',', $columnConfiguration['eval'], true);
362            $dateTimeTypes = QueryHelper::getDateTimeTypes();
363
364            if (!empty(array_intersect($dateTimeTypes, $fieldEvaluations)) && !empty($columnConfiguration['dbType'])) {
365                $columnMap->setDateTimeStorageFormat($columnConfiguration['dbType']);
366            }
367        }
368
369        return $columnMap;
370    }
371
372    /**
373     * This method sets the configuration for a 1:1 relation based on
374     * the $TCA column configuration
375     *
376     * @param ColumnMap $columnMap The column map
377     * @param array|null $columnConfiguration The column configuration from $TCA
378     * @return ColumnMap
379     */
380    protected function setOneToOneRelation(ColumnMap $columnMap, array $columnConfiguration = null): ColumnMap
381    {
382        // todo: this method should only be called with proper arguments which means that the TCA integrity check should
383        // todo: take place outside this method.
384
385        $columnMap->setTypeOfRelation(ColumnMap::RELATION_HAS_ONE);
386        $columnMap->setChildTableName($columnConfiguration['foreign_table']);
387        // todo: don't update column map if value(s) isn't/aren't set.
388        $columnMap->setChildSortByFieldName($columnConfiguration['foreign_sortby'] ?? null);
389        $columnMap->setParentKeyFieldName($columnConfiguration['foreign_field'] ?? null);
390        $columnMap->setParentTableFieldName($columnConfiguration['foreign_table_field'] ?? null);
391        if (is_array($columnConfiguration['foreign_match_fields'])) {
392            $columnMap->setRelationTableMatchFields($columnConfiguration['foreign_match_fields']);
393        }
394        return $columnMap;
395    }
396
397    /**
398     * This method sets the configuration for a 1:n relation based on
399     * the $TCA column configuration
400     *
401     * @param ColumnMap $columnMap The column map
402     * @param array|null $columnConfiguration The column configuration from $TCA
403     * @return ColumnMap
404     */
405    protected function setOneToManyRelation(ColumnMap $columnMap, array $columnConfiguration = null): ColumnMap
406    {
407        // todo: this method should only be called with proper arguments which means that the TCA integrity check should
408        // todo: take place outside this method.
409
410        $columnMap->setTypeOfRelation(ColumnMap::RELATION_HAS_MANY);
411        $columnMap->setChildTableName($columnConfiguration['foreign_table']);
412        // todo: don't update column map if value(s) isn't/aren't set.
413        $columnMap->setChildSortByFieldName($columnConfiguration['foreign_sortby'] ?? null);
414        $columnMap->setParentKeyFieldName($columnConfiguration['foreign_field'] ?? null);
415        $columnMap->setParentTableFieldName($columnConfiguration['foreign_table_field'] ?? null);
416        if (is_array($columnConfiguration['foreign_match_fields'] ?? null)) {
417            $columnMap->setRelationTableMatchFields($columnConfiguration['foreign_match_fields']);
418        }
419        return $columnMap;
420    }
421
422    /**
423     * This method sets the configuration for a m:n relation based on
424     * the $TCA column configuration
425     *
426     * @param ColumnMap $columnMap The column map
427     * @param array|null $columnConfiguration The column configuration from $TCA
428     * @throws \TYPO3\CMS\Extbase\Persistence\Generic\Exception\UnsupportedRelationException
429     * @return ColumnMap
430     */
431    protected function setManyToManyRelation(ColumnMap $columnMap, array $columnConfiguration = null): ColumnMap
432    {
433        // todo: this method should only be called with proper arguments which means that the TCA integrity check should
434        // todo: take place outside this method.
435
436        if (isset($columnConfiguration['MM'])) {
437            $columnMap->setTypeOfRelation(ColumnMap::RELATION_HAS_AND_BELONGS_TO_MANY);
438            $columnMap->setChildTableName($columnConfiguration['foreign_table']);
439            // todo: don't update column map if value(s) isn't/aren't set.
440            $columnMap->setRelationTableName($columnConfiguration['MM']);
441            if (isset($columnConfiguration['MM_match_fields']) && is_array($columnConfiguration['MM_match_fields'])) {
442                $columnMap->setRelationTableMatchFields($columnConfiguration['MM_match_fields']);
443            }
444            if (isset($columnConfiguration['MM_insert_fields']) && is_array($columnConfiguration['MM_insert_fields'])) {
445                $columnMap->setRelationTableInsertFields($columnConfiguration['MM_insert_fields']);
446            }
447            // todo: don't update column map if value(s) isn't/aren't set.
448            if (!empty($columnConfiguration['MM_opposite_field'])) {
449                $columnMap->setParentKeyFieldName('uid_foreign');
450                $columnMap->setChildKeyFieldName('uid_local');
451                $columnMap->setChildSortByFieldName('sorting_foreign');
452            } else {
453                $columnMap->setParentKeyFieldName('uid_local');
454                $columnMap->setChildKeyFieldName('uid_foreign');
455                $columnMap->setChildSortByFieldName('sorting');
456            }
457        } else {
458            // todo: this else part is actually superfluous because \TYPO3\CMS\Extbase\Persistence\Generic\Mapper\DataMapFactory::setRelations
459            // todo: only calls this method if $columnConfiguration['MM'] is set.
460
461            throw new UnsupportedRelationException('The given information to build a many-to-many-relation was not sufficient. Check your TCA definitions. mm-relations with IRRE must have at least a defined "MM" or "foreign_selector".', 1268817963);
462        }
463        $relationTableName = $columnMap->getRelationTableName();
464        if ($relationTableName !== null && $this->getControlSection($relationTableName) !== null) {
465            $columnMap->setRelationTablePageIdColumnName('pid');
466        }
467        return $columnMap;
468    }
469
470    /**
471     * Creates the ColumnMap object for the given columnName and propertyName
472     *
473     * @param string $columnName
474     * @param string $propertyName
475     *
476     * @return ColumnMap
477     */
478    protected function createColumnMap(string $columnName, string $propertyName): ColumnMap
479    {
480        return $this->objectManager->get(ColumnMap::class, $columnName, $propertyName);
481    }
482}
483