1<?php
2namespace TYPO3\CMS\Extbase\Property;
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
17use TYPO3\CMS\Extbase\Property\Exception\TargetNotFoundException;
18use TYPO3\CMS\Extbase\Property\TypeConverter\AbstractTypeConverter;
19use TYPO3\CMS\Extbase\Utility\TypeHandlingUtility;
20
21/**
22 * The Property Mapper transforms simple types (arrays, strings, integers, floats, booleans) to objects or other simple types.
23 * It is used most prominently to map incoming HTTP arguments to objects.
24 */
25class PropertyMapper implements \TYPO3\CMS\Core\SingletonInterface
26{
27    /**
28     * @var \TYPO3\CMS\Extbase\Object\ObjectManagerInterface
29     */
30    protected $objectManager;
31
32    /**
33     * @var \TYPO3\CMS\Extbase\Property\PropertyMappingConfigurationBuilder
34     */
35    protected $configurationBuilder;
36
37    /**
38     * A multi-dimensional array which stores the Type Converters available in the system.
39     * It has the following structure:
40     * 1. Dimension: Source Type
41     * 2. Dimension: Target Type
42     * 3. Dimension: Priority
43     * Value: Type Converter instance
44     *
45     * @var array
46     */
47    protected $typeConverters = [];
48
49    /**
50     * A list of property mapping messages (errors, warnings) which have occurred on last mapping.
51     *
52     * @var \TYPO3\CMS\Extbase\Error\Result
53     */
54    protected $messages;
55
56    /**
57     * @param \TYPO3\CMS\Extbase\Object\ObjectManagerInterface $objectManager
58     * @internal only to be used within Extbase, not part of TYPO3 Core API.
59     */
60    public function injectObjectManager(\TYPO3\CMS\Extbase\Object\ObjectManagerInterface $objectManager)
61    {
62        $this->objectManager = $objectManager;
63    }
64
65    /**
66     * @param \TYPO3\CMS\Extbase\Property\PropertyMappingConfigurationBuilder $configurationBuilder
67     * @internal only to be used within Extbase, not part of TYPO3 Core API.
68     */
69    public function injectConfigurationBuilder(\TYPO3\CMS\Extbase\Property\PropertyMappingConfigurationBuilder $configurationBuilder)
70    {
71        $this->configurationBuilder = $configurationBuilder;
72    }
73
74    /**
75     * Lifecycle method, called after all dependencies have been injected.
76     * Here, the typeConverter array gets initialized.
77     *
78     * @throws Exception\DuplicateTypeConverterException
79     * @internal only to be used within Extbase, not part of TYPO3 Core API.
80     */
81    public function initializeObject()
82    {
83        foreach ($GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['extbase']['typeConverters'] as $typeConverterClassName) {
84            $typeConverter = $this->objectManager->get($typeConverterClassName);
85            foreach ($typeConverter->getSupportedSourceTypes() as $supportedSourceType) {
86                if (isset($this->typeConverters[$supportedSourceType][$typeConverter->getSupportedTargetType()][$typeConverter->getPriority()])) {
87                    throw new Exception\DuplicateTypeConverterException('There exist at least two converters which handle the conversion from "' . $supportedSourceType . '" to "' . $typeConverter->getSupportedTargetType() . '" with priority "' . $typeConverter->getPriority() . '": ' . get_class($this->typeConverters[$supportedSourceType][$typeConverter->getSupportedTargetType()][$typeConverter->getPriority()]) . ' and ' . get_class($typeConverter), 1297951378);
88                }
89                $this->typeConverters[$supportedSourceType][$typeConverter->getSupportedTargetType()][$typeConverter->getPriority()] = $typeConverter;
90            }
91        }
92    }
93
94    /**
95     * Map $source to $targetType, and return the result
96     *
97     * @param mixed $source the source data to map. MUST be a simple type, NO object allowed!
98     * @param string $targetType The type of the target; can be either a class name or a simple type.
99     * @param PropertyMappingConfigurationInterface $configuration Configuration for the property mapping. If NULL, the PropertyMappingConfigurationBuilder will create a default configuration.
100     * @throws Exception
101     * @return mixed an instance of $targetType
102     */
103    public function convert($source, $targetType, PropertyMappingConfigurationInterface $configuration = null)
104    {
105        if ($configuration === null) {
106            $configuration = $this->configurationBuilder->build();
107        }
108        $currentPropertyPath = [];
109        $this->messages = new \TYPO3\CMS\Extbase\Error\Result();
110        try {
111            $result = $this->doMapping($source, $targetType, $configuration, $currentPropertyPath);
112            if ($result instanceof \TYPO3\CMS\Extbase\Error\Error) {
113                return null;
114            }
115
116            return $result;
117        } catch (TargetNotFoundException $e) {
118            throw $e;
119        } catch (\Exception $e) {
120            throw new Exception('Exception while property mapping at property path "' . implode('.', $currentPropertyPath) . '": ' . $e->getMessage(), 1297759968, $e);
121        }
122    }
123
124    /**
125     * Get the messages of the last Property Mapping
126     *
127     * @return \TYPO3\CMS\Extbase\Error\Result
128     */
129    public function getMessages()
130    {
131        return $this->messages;
132    }
133
134    /**
135     * Internal function which actually does the property mapping.
136     *
137     * @param mixed $source the source data to map. MUST be a simple type, NO object allowed!
138     * @param string $targetType The type of the target; can be either a class name or a simple type.
139     * @param PropertyMappingConfigurationInterface $configuration Configuration for the property mapping.
140     * @param array &$currentPropertyPath The property path currently being mapped; used for knowing the context in case an exception is thrown.
141     * @throws Exception\TypeConverterException
142     * @throws Exception\InvalidPropertyMappingConfigurationException
143     * @return mixed an instance of $targetType
144     */
145    protected function doMapping($source, $targetType, PropertyMappingConfigurationInterface $configuration, &$currentPropertyPath)
146    {
147        if (is_object($source)) {
148            $targetType = $this->parseCompositeType($targetType);
149            if ($source instanceof $targetType) {
150                return $source;
151            }
152        }
153
154        if ($source === null) {
155            $source = '';
156        }
157
158        $typeConverter = $this->findTypeConverter($source, $targetType, $configuration);
159        $targetType = $typeConverter->getTargetTypeForSource($source, $targetType, $configuration);
160
161        if (!is_object($typeConverter) || !$typeConverter instanceof \TYPO3\CMS\Extbase\Property\TypeConverterInterface) {
162            throw new Exception\TypeConverterException(
163                'Type converter for "' . $source . '" -> "' . $targetType . '" not found.',
164                1476045062
165            );
166        }
167
168        $convertedChildProperties = [];
169        foreach ($typeConverter->getSourceChildPropertiesToBeConverted($source) as $sourcePropertyName => $sourcePropertyValue) {
170            $targetPropertyName = $configuration->getTargetPropertyName($sourcePropertyName);
171            if ($configuration->shouldSkip($targetPropertyName)) {
172                continue;
173            }
174
175            if (!$configuration->shouldMap($targetPropertyName)) {
176                if ($configuration->shouldSkipUnknownProperties()) {
177                    continue;
178                }
179                throw new Exception\InvalidPropertyMappingConfigurationException('It is not allowed to map property "' . $targetPropertyName . '". You need to use $propertyMappingConfiguration->allowProperties(\'' . $targetPropertyName . '\') to enable mapping of this property.', 1355155913);
180            }
181
182            $targetPropertyType = $typeConverter->getTypeOfChildProperty($targetType, $targetPropertyName, $configuration);
183
184            $subConfiguration = $configuration->getConfigurationFor($targetPropertyName);
185
186            $currentPropertyPath[] = $targetPropertyName;
187            $targetPropertyValue = $this->doMapping($sourcePropertyValue, $targetPropertyType, $subConfiguration, $currentPropertyPath);
188            array_pop($currentPropertyPath);
189            if (!($targetPropertyValue instanceof \TYPO3\CMS\Extbase\Error\Error)) {
190                $convertedChildProperties[$targetPropertyName] = $targetPropertyValue;
191            }
192        }
193        $result = $typeConverter->convertFrom($source, $targetType, $convertedChildProperties, $configuration);
194
195        if ($result instanceof \TYPO3\CMS\Extbase\Error\Error) {
196            $this->messages->forProperty(implode('.', $currentPropertyPath))->addError($result);
197        }
198
199        return $result;
200    }
201
202    /**
203     * Determine the type converter to be used. If no converter has been found, an exception is raised.
204     *
205     * @param mixed $source
206     * @param string $targetType
207     * @param PropertyMappingConfigurationInterface $configuration
208     * @throws Exception\TypeConverterException
209     * @throws Exception\InvalidTargetException
210     * @return \TYPO3\CMS\Extbase\Property\TypeConverterInterface Type Converter which should be used to convert between $source and $targetType.
211     */
212    protected function findTypeConverter($source, $targetType, PropertyMappingConfigurationInterface $configuration)
213    {
214        if ($configuration->getTypeConverter() !== null) {
215            return $configuration->getTypeConverter();
216        }
217
218        $sourceType = $this->determineSourceType($source);
219
220        if (!is_string($targetType)) {
221            throw new Exception\InvalidTargetException('The target type was no string, but of type "' . gettype($targetType) . '"', 1297941727);
222        }
223
224        $targetType = $this->parseCompositeType($targetType);
225        // This is needed to correctly convert old class names to new ones
226        // This compatibility layer will be removed with 7.0
227        $targetType = \TYPO3\CMS\Core\Core\ClassLoadingInformation::getClassNameForAlias($targetType);
228
229        $targetType = TypeHandlingUtility::normalizeType($targetType);
230
231        $converter = null;
232
233        if (TypeHandlingUtility::isSimpleType($targetType)) {
234            if (isset($this->typeConverters[$sourceType][$targetType])) {
235                $converter = $this->findEligibleConverterWithHighestPriority($this->typeConverters[$sourceType][$targetType], $source, $targetType);
236            }
237        } else {
238            $converter = $this->findFirstEligibleTypeConverterInObjectHierarchy($source, $sourceType, $targetType);
239        }
240
241        if ($converter === null) {
242            throw new Exception\TypeConverterException(
243                'No converter found which can be used to convert from "' . $sourceType . '" to "' . $targetType . '".',
244                1476044883
245            );
246        }
247
248        return $converter;
249    }
250
251    /**
252     * Tries to find a suitable type converter for the given source and target type.
253     *
254     * @param string $source The actual source value
255     * @param string $sourceType Type of the source to convert from
256     * @param string $targetClass Name of the target class to find a type converter for
257     * @return mixed Either the matching object converter or NULL
258     * @throws Exception\InvalidTargetException
259     */
260    protected function findFirstEligibleTypeConverterInObjectHierarchy($source, $sourceType, $targetClass)
261    {
262        if (!class_exists($targetClass) && !interface_exists($targetClass)) {
263            throw new Exception\InvalidTargetException('Could not find a suitable type converter for "' . $targetClass . '" because no such class or interface exists.', 1297948764);
264        }
265
266        if (!isset($this->typeConverters[$sourceType])) {
267            return null;
268        }
269
270        $convertersForSource = $this->typeConverters[$sourceType];
271        if (isset($convertersForSource[$targetClass])) {
272            $converter = $this->findEligibleConverterWithHighestPriority($convertersForSource[$targetClass], $source, $targetClass);
273            if ($converter !== null) {
274                return $converter;
275            }
276        }
277
278        foreach (class_parents($targetClass) as $parentClass) {
279            if (!isset($convertersForSource[$parentClass])) {
280                continue;
281            }
282
283            $converter = $this->findEligibleConverterWithHighestPriority($convertersForSource[$parentClass], $source, $targetClass);
284            if ($converter !== null) {
285                return $converter;
286            }
287        }
288
289        $converters = $this->getConvertersForInterfaces($convertersForSource, class_implements($targetClass));
290        $converter = $this->findEligibleConverterWithHighestPriority($converters, $source, $targetClass);
291
292        if ($converter !== null) {
293            return $converter;
294        }
295        if (isset($convertersForSource['object'])) {
296            return $this->findEligibleConverterWithHighestPriority($convertersForSource['object'], $source, $targetClass);
297        }
298        return null;
299    }
300
301    /**
302     * @param mixed $converters
303     * @param mixed $source
304     * @param string $targetType
305     * @return mixed Either the matching object converter or NULL
306     */
307    protected function findEligibleConverterWithHighestPriority($converters, $source, $targetType)
308    {
309        if (!is_array($converters)) {
310            return null;
311        }
312        krsort($converters, SORT_NUMERIC);
313        reset($converters);
314        /** @var AbstractTypeConverter $converter */
315        foreach ($converters as $converter) {
316            if ($converter->canConvertFrom($source, $targetType)) {
317                return $converter;
318            }
319        }
320        return null;
321    }
322
323    /**
324     * @param array $convertersForSource
325     * @param array $interfaceNames
326     * @return array
327     * @throws Exception\DuplicateTypeConverterException
328     */
329    protected function getConvertersForInterfaces(array $convertersForSource, array $interfaceNames)
330    {
331        $convertersForInterface = [];
332        foreach ($interfaceNames as $implementedInterface) {
333            if (isset($convertersForSource[$implementedInterface])) {
334                foreach ($convertersForSource[$implementedInterface] as $priority => $converter) {
335                    if (isset($convertersForInterface[$priority])) {
336                        throw new Exception\DuplicateTypeConverterException('There exist at least two converters which handle the conversion to an interface with priority "' . $priority . '". ' . get_class($convertersForInterface[$priority]) . ' and ' . get_class($converter), 1297951338);
337                    }
338                    $convertersForInterface[$priority] = $converter;
339                }
340            }
341        }
342        return $convertersForInterface;
343    }
344
345    /**
346     * Determine the type of the source data, or throw an exception if source was an unsupported format.
347     *
348     * @param mixed $source
349     * @throws Exception\InvalidSourceException
350     * @return string the type of $source
351     */
352    protected function determineSourceType($source)
353    {
354        if (is_string($source)) {
355            return 'string';
356        }
357        if (is_array($source)) {
358            return 'array';
359        }
360        if (is_float($source)) {
361            return 'float';
362        }
363        if (is_int($source)) {
364            return 'integer';
365        }
366        if (is_bool($source)) {
367            return 'boolean';
368        }
369        throw new Exception\InvalidSourceException('The source is not of type string, array, float, integer or boolean, but of type "' . gettype($source) . '"', 1297773150);
370    }
371
372    /**
373     * Parse a composite type like \Foo\Collection<\Bar\Entity> into
374     * \Foo\Collection
375     *
376     * @param string $compositeType
377     * @return string
378     * @internal only to be used within Extbase, not part of TYPO3 Core API.
379     */
380    public function parseCompositeType($compositeType)
381    {
382        if (strpos($compositeType, '<') !== false) {
383            $compositeType = substr($compositeType, 0, strpos($compositeType, '<'));
384        }
385        return $compositeType;
386    }
387}
388