[TASK] Use name-resolution instead of strings where possible: 3
[Packages/TYPO3.CMS.git] / typo3 / sysext / extbase / Classes / Validation / ValidatorResolver.php
1 <?php
2 namespace TYPO3\CMS\Extbase\Validation;
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\Core\Utility\ClassNamingUtility;
18 use TYPO3\CMS\Core\Utility\GeneralUtility;
19 use TYPO3\CMS\Extbase\Validation\Exception\NoSuchValidatorException;
20 use TYPO3\CMS\Extbase\Validation\Validator\ConjunctionValidator;
21
22 /**
23 * Validator resolver to automatically find a appropriate validator for a given subject
24 */
25 class ValidatorResolver implements \TYPO3\CMS\Core\SingletonInterface {
26
27 /**
28 * Match validator names and options
29 * @todo: adjust [a-z0-9_:.\\\\] once Tx_Extbase_Foo syntax is outdated.
30 *
31 * @var string
32 */
33 const PATTERN_MATCH_VALIDATORS = '/
34 (?:^|,\s*)
35 (?P<validatorName>[a-z0-9_:.\\\\]+)
36 \s*
37 (?:\(
38 (?P<validatorOptions>(?:\s*[a-z0-9]+\s*=\s*(?:
39 "(?:\\\\"|[^"])*"
40 |\'(?:\\\\\'|[^\'])*\'
41 |(?:\s|[^,"\']*)
42 )(?:\s|,)*)*)
43 \))?
44 /ixS';
45
46 /**
47 * Match validator options (to parse actual options)
48 * @var string
49 */
50 const PATTERN_MATCH_VALIDATOROPTIONS = '/
51 \s*
52 (?P<optionName>[a-z0-9]+)
53 \s*=\s*
54 (?P<optionValue>
55 "(?:\\\\"|[^"])*"
56 |\'(?:\\\\\'|[^\'])*\'
57 |(?:\s|[^,"\']*)
58 )
59 /ixS';
60
61 /**
62 * @var \TYPO3\CMS\Extbase\Object\ObjectManagerInterface
63 * @inject
64 */
65 protected $objectManager;
66
67 /**
68 * @var \TYPO3\CMS\Extbase\Reflection\ReflectionService
69 * @inject
70 */
71 protected $reflectionService;
72
73 /**
74 * @var array
75 */
76 protected $baseValidatorConjunctions = array();
77
78 /**
79 * Get a validator for a given data type. Returns a validator implementing
80 * the \TYPO3\CMS\Extbase\Validation\Validator\ValidatorInterface or NULL if no validator
81 * could be resolved.
82 *
83 * @param string $validatorType Either one of the built-in data types or fully qualified validator class name
84 * @param array $validatorOptions Options to be passed to the validator
85 * @return \TYPO3\CMS\Extbase\Validation\Validator\ValidatorInterface Validator or NULL if none found.
86 */
87 public function createValidator($validatorType, array $validatorOptions = array()) {
88 try {
89 /**
90 * todo: remove throwing Exceptions in resolveValidatorObjectName
91 */
92 $validatorObjectName = $this->resolveValidatorObjectName($validatorType);
93
94 $validator = $this->objectManager->get($validatorObjectName, $validatorOptions);
95
96 if (!($validator instanceof \TYPO3\CMS\Extbase\Validation\Validator\ValidatorInterface)) {
97 throw new Exception\NoSuchValidatorException('The validator "' . $validatorObjectName . '" does not implement TYPO3\CMS\Extbase\Validation\Validator\ValidatorInterface!', 1300694875);
98 }
99
100 return $validator;
101 } catch (NoSuchValidatorException $e) {
102 GeneralUtility::devLog($e->getMessage(), 'extbase', GeneralUtility::SYSLOG_SEVERITY_INFO);
103 return NULL;
104 }
105 }
106
107 /**
108 * Resolves and returns the base validator conjunction for the given data type.
109 *
110 * If no validator could be resolved (which usually means that no validation is necessary),
111 * NULL is returned.
112 *
113 * @param string $targetClassName The data type to search a validator for. Usually the fully qualified object name
114 * @return \TYPO3\CMS\Extbase\Validation\Validator\ConjunctionValidator The validator conjunction or NULL
115 */
116 public function getBaseValidatorConjunction($targetClassName) {
117 if (!array_key_exists($targetClassName, $this->baseValidatorConjunctions)) {
118 $this->buildBaseValidatorConjunction($targetClassName, $targetClassName);
119 }
120
121 return $this->baseValidatorConjunctions[$targetClassName];
122 }
123
124 /**
125 * Detects and registers any validators for arguments:
126 * - by the data type specified in the param annotations
127 * - additional validators specified in the validate annotations of a method
128 *
129 * @param string $className
130 * @param string $methodName
131 * @param array $methodParameters Optional pre-compiled array of method parameters
132 * @param array $methodValidateAnnotations Optional pre-compiled array of validate annotations (as array)
133 * @return ConjunctionValidator[] An Array of ValidatorConjunctions for each method parameters.
134 * @throws \TYPO3\CMS\Extbase\Validation\Exception\InvalidValidationConfigurationException
135 * @throws \TYPO3\CMS\Extbase\Validation\Exception\NoSuchValidatorException
136 * @throws \TYPO3\CMS\Extbase\Validation\Exception\InvalidTypeHintException
137 */
138 public function buildMethodArgumentsValidatorConjunctions($className, $methodName, array $methodParameters = NULL, array $methodValidateAnnotations = NULL) {
139 /** @var ConjunctionValidator[] $validatorConjunctions */
140 $validatorConjunctions = array();
141
142 if ($methodParameters === NULL) {
143 $methodParameters = $this->reflectionService->getMethodParameters($className, $methodName);
144 }
145 if (count($methodParameters) === 0) {
146 return $validatorConjunctions;
147 }
148
149 foreach ($methodParameters as $parameterName => $methodParameter) {
150 /** @var ConjunctionValidator $validatorConjunction */
151 $validatorConjunction = $this->createValidator(\TYPO3\CMS\Extbase\Validation\Validator\ConjunctionValidator::class);
152
153 if (!array_key_exists('type', $methodParameter)) {
154 throw new Exception\InvalidTypeHintException('Missing type information, probably no @param annotation for parameter "$' . $parameterName . '" in ' . $className . '->' . $methodName . '()', 1281962564);
155 }
156
157 // @todo: remove check for old underscore model name syntax once it's possible
158 if (strpbrk($methodParameter['type'], '_\\') === FALSE) {
159 $typeValidator = $this->createValidator($methodParameter['type']);
160 } elseif (preg_match('/[\\_]Model[\\_]/', $methodParameter['type']) !== FALSE) {
161 $possibleValidatorClassName = str_replace(array('\\Model\\', '_Model_'), array('\\Validator\\', '_Validator_'), $methodParameter['type']) . 'Validator';
162 $typeValidator = $this->createValidator($possibleValidatorClassName);
163 } else {
164 $typeValidator = NULL;
165 }
166
167 if ($typeValidator !== NULL) {
168 $validatorConjunction->addValidator($typeValidator);
169 }
170 $validatorConjunctions[$parameterName] = $validatorConjunction;
171 }
172
173 if ($methodValidateAnnotations === NULL) {
174 $validateAnnotations = $this->getMethodValidateAnnotations($className, $methodName);
175 $methodValidateAnnotations = array_map(function($validateAnnotation) {
176 return array(
177 'type' => $validateAnnotation['validatorName'],
178 'options' => $validateAnnotation['validatorOptions'],
179 'argumentName' => $validateAnnotation['argumentName'],
180 );
181 }, $validateAnnotations);
182 }
183
184 foreach ($methodValidateAnnotations as $annotationParameters) {
185 $newValidator = $this->createValidator($annotationParameters['type'], $annotationParameters['options']);
186 if ($newValidator === NULL) {
187 throw new Exception\NoSuchValidatorException('Invalid validate annotation in ' . $className . '->' . $methodName . '(): Could not resolve class name for validator "' . $annotationParameters['type'] . '".', 1239853109);
188 }
189 if (isset($validatorConjunctions[$annotationParameters['argumentName']])) {
190 $validatorConjunctions[$annotationParameters['argumentName']]->addValidator($newValidator);
191 } elseif (strpos($annotationParameters['argumentName'], '.') !== FALSE) {
192 $objectPath = explode('.', $annotationParameters['argumentName']);
193 $argumentName = array_shift($objectPath);
194 $validatorConjunctions[$argumentName]->addValidator($this->buildSubObjectValidator($objectPath, $newValidator));
195 } else {
196 throw new Exception\InvalidValidationConfigurationException('Invalid validate annotation in ' . $className . '->' . $methodName . '(): Validator specified for argument name "' . $annotationParameters['argumentName'] . '", but this argument does not exist.', 1253172726);
197 }
198 }
199
200 return $validatorConjunctions;
201 }
202
203 /**
204 * Builds a chain of nested object validators by specification of the given
205 * object path.
206 *
207 * @param array $objectPath The object path
208 * @param \TYPO3\CMS\Extbase\Validation\Validator\ValidatorInterface $propertyValidator The validator which should be added to the property specified by objectPath
209 * @return \TYPO3\CMS\Extbase\Validation\Validator\GenericObjectValidator
210 */
211 protected function buildSubObjectValidator(array $objectPath, \TYPO3\CMS\Extbase\Validation\Validator\ValidatorInterface $propertyValidator) {
212 $rootObjectValidator = $this->objectManager->get(\TYPO3\CMS\Extbase\Validation\Validator\GenericObjectValidator::class, array());
213 $parentObjectValidator = $rootObjectValidator;
214
215 while (count($objectPath) > 1) {
216 $subObjectValidator = $this->objectManager->get(\TYPO3\CMS\Extbase\Validation\Validator\GenericObjectValidator::class, array());
217 $subPropertyName = array_shift($objectPath);
218 $parentObjectValidator->addPropertyValidator($subPropertyName, $subObjectValidator);
219 $parentObjectValidator = $subObjectValidator;
220 }
221
222 $parentObjectValidator->addPropertyValidator(array_shift($objectPath), $propertyValidator);
223
224 return $rootObjectValidator;
225 }
226
227 /**
228 * Builds a base validator conjunction for the given data type.
229 *
230 * The base validation rules are those which were declared directly in a class (typically
231 * a model) through some validate annotations on properties.
232 *
233 * If a property holds a class for which a base validator exists, that property will be
234 * checked as well, regardless of a validate annotation
235 *
236 * Additionally, if a custom validator was defined for the class in question, it will be added
237 * to the end of the conjunction. A custom validator is found if it follows the naming convention
238 * "Replace '\Model\' by '\Validator\' and append 'Validator'".
239 *
240 * Example: $targetClassName is TYPO3\Foo\Domain\Model\Quux, then the validator will be found if it has the
241 * name TYPO3\Foo\Domain\Validator\QuuxValidator
242 *
243 * @param string $indexKey The key to use as index in $this->baseValidatorConjunctions; calculated from target class name and validation groups
244 * @param string $targetClassName The data type to build the validation conjunction for. Needs to be the fully qualified class name.
245 * @param array $validationGroups The validation groups to build the validator for
246 * @return void
247 * @throws \TYPO3\CMS\Extbase\Validation\Exception\NoSuchValidatorException
248 * @throws \InvalidArgumentException
249 */
250 protected function buildBaseValidatorConjunction($indexKey, $targetClassName, array $validationGroups = array()) {
251 $conjunctionValidator = new \TYPO3\CMS\Extbase\Validation\Validator\ConjunctionValidator();
252 $this->baseValidatorConjunctions[$indexKey] = $conjunctionValidator;
253 if (class_exists($targetClassName)) {
254 // Model based validator
255 /** @var \TYPO3\CMS\Extbase\Validation\Validator\GenericObjectValidator $objectValidator */
256 $objectValidator = $this->objectManager->get(\TYPO3\CMS\Extbase\Validation\Validator\GenericObjectValidator::class, array());
257 foreach ($this->reflectionService->getClassPropertyNames($targetClassName) as $classPropertyName) {
258 $classPropertyTagsValues = $this->reflectionService->getPropertyTagsValues($targetClassName, $classPropertyName);
259
260 if (!isset($classPropertyTagsValues['var'])) {
261 throw new \InvalidArgumentException(sprintf('There is no @var annotation for property "%s" in class "%s".', $classPropertyName, $targetClassName), 1363778104);
262 }
263 try {
264 $parsedType = \TYPO3\CMS\Extbase\Utility\TypeHandlingUtility::parseType(trim(implode('', $classPropertyTagsValues['var']), ' \\'));
265 } catch (\TYPO3\CMS\Extbase\Utility\Exception\InvalidTypeException $exception) {
266 throw new \InvalidArgumentException(sprintf(' @var annotation of ' . $exception->getMessage(), 'class "' . $targetClassName . '", property "' . $classPropertyName . '"'), 1315564744, $exception);
267 }
268 $propertyTargetClassName = $parsedType['type'];
269 if (\TYPO3\CMS\Extbase\Utility\TypeHandlingUtility::isCollectionType($propertyTargetClassName) === TRUE) {
270 $collectionValidator = $this->createValidator(\TYPO3\CMS\Extbase\Validation\Validator\CollectionValidator::class, array('elementType' => $parsedType['elementType'], 'validationGroups' => $validationGroups));
271 $objectValidator->addPropertyValidator($classPropertyName, $collectionValidator);
272 } elseif (class_exists($propertyTargetClassName) && !\TYPO3\CMS\Extbase\Utility\TypeHandlingUtility::isCoreType($propertyTargetClassName) && $this->objectManager->isRegistered($propertyTargetClassName) && $this->objectManager->getScope($propertyTargetClassName) === \TYPO3\CMS\Extbase\Object\Container\Container::SCOPE_PROTOTYPE) {
273 $validatorForProperty = $this->getBaseValidatorConjunction($propertyTargetClassName, $validationGroups);
274 if (count($validatorForProperty) > 0) {
275 $objectValidator->addPropertyValidator($classPropertyName, $validatorForProperty);
276 }
277 }
278
279 $validateAnnotations = array();
280 // @todo: Resolve annotations via reflectionService once its available
281 if (isset($classPropertyTagsValues['validate']) && is_array($classPropertyTagsValues['validate'])) {
282 foreach ($classPropertyTagsValues['validate'] as $validateValue) {
283 $parsedAnnotations = $this->parseValidatorAnnotation($validateValue);
284
285 foreach ($parsedAnnotations['validators'] as $validator) {
286 array_push($validateAnnotations, array(
287 'argumentName' => $parsedAnnotations['argumentName'],
288 'validatorName' => $validator['validatorName'],
289 'validatorOptions' => $validator['validatorOptions']
290 ));
291 }
292 }
293 }
294
295 foreach ($validateAnnotations as $validateAnnotation) {
296 // @todo: Respect validationGroups
297 $newValidator = $this->createValidator($validateAnnotation['validatorName'], $validateAnnotation['validatorOptions']);
298 if ($newValidator === NULL) {
299 throw new Exception\NoSuchValidatorException('Invalid validate annotation in ' . $targetClassName . '::' . $classPropertyName . ': Could not resolve class name for validator "' . $validateAnnotation->type . '".', 1241098027);
300 }
301 $objectValidator->addPropertyValidator($classPropertyName, $newValidator);
302 }
303 }
304
305 if (count($objectValidator->getPropertyValidators()) > 0) {
306 $conjunctionValidator->addValidator($objectValidator);
307 }
308 }
309
310 $this->addCustomValidators($targetClassName, $conjunctionValidator);
311 }
312
313 /**
314 * This adds custom validators to the passed $conjunctionValidator.
315 *
316 * A custom validator is found if it follows the naming convention "Replace '\Model\' by '\Validator\' and
317 * append 'Validator'". If found, it will be added to the $conjunctionValidator.
318 *
319 * In addition canValidate() will be called on all implementations of the ObjectValidatorInterface to find
320 * all validators that could validate the target. The one with the highest priority will be added as well.
321 * If multiple validators have the same priority, which one will be added is not deterministic.
322 *
323 * @param string $targetClassName
324 * @param ConjunctionValidator $conjunctionValidator
325 * @return NULL|Validator\ObjectValidatorInterface
326 */
327 protected function addCustomValidators($targetClassName, ConjunctionValidator &$conjunctionValidator) {
328
329 $addedValidatorClassName = NULL;
330 // @todo: get rid of ClassNamingUtility usage once we dropped underscored class name support
331 $possibleValidatorClassName = ClassNamingUtility::translateModelNameToValidatorName($targetClassName);
332
333 $customValidator = $this->createValidator($possibleValidatorClassName);
334 if ($customValidator !== NULL) {
335 $conjunctionValidator->addValidator($customValidator);
336 $addedValidatorClassName = get_class($customValidator);
337 }
338
339 // @todo: find polytype validator for class
340 }
341
342 /**
343 * Parses the validator options given in @validate annotations.
344 *
345 * @param string $validateValue
346 * @return array
347 */
348 protected function parseValidatorAnnotation($validateValue) {
349 $matches = array();
350 if ($validateValue[0] === '$') {
351 $parts = explode(' ', $validateValue, 2);
352 $validatorConfiguration = array('argumentName' => ltrim($parts[0], '$'), 'validators' => array());
353 preg_match_all(self::PATTERN_MATCH_VALIDATORS, $parts[1], $matches, PREG_SET_ORDER);
354 } else {
355 $validatorConfiguration = array('validators' => array());
356 preg_match_all(self::PATTERN_MATCH_VALIDATORS, $validateValue, $matches, PREG_SET_ORDER);
357 }
358 foreach ($matches as $match) {
359 $validatorOptions = array();
360 if (isset($match['validatorOptions'])) {
361 $validatorOptions = $this->parseValidatorOptions($match['validatorOptions']);
362 }
363 $validatorConfiguration['validators'][] = array('validatorName' => $match['validatorName'], 'validatorOptions' => $validatorOptions);
364 }
365 return $validatorConfiguration;
366 }
367
368 /**
369 * Parses $rawValidatorOptions not containing quoted option values.
370 * $rawValidatorOptions will be an empty string afterwards (pass by ref!).
371 *
372 * @param string $rawValidatorOptions
373 * @return array An array of optionName/optionValue pairs
374 */
375 protected function parseValidatorOptions($rawValidatorOptions) {
376 $validatorOptions = array();
377 $parsedValidatorOptions = array();
378 preg_match_all(self::PATTERN_MATCH_VALIDATOROPTIONS, $rawValidatorOptions, $validatorOptions, PREG_SET_ORDER);
379 foreach ($validatorOptions as $validatorOption) {
380 $parsedValidatorOptions[trim($validatorOption['optionName'])] = trim($validatorOption['optionValue']);
381 }
382 array_walk($parsedValidatorOptions, array($this, 'unquoteString'));
383 return $parsedValidatorOptions;
384 }
385
386 /**
387 * Removes escapings from a given argument string and trims the outermost
388 * quotes.
389 *
390 * This method is meant as a helper for regular expression results.
391 *
392 * @param string &$quotedValue Value to unquote
393 * @return void
394 */
395 protected function unquoteString(&$quotedValue) {
396 switch ($quotedValue[0]) {
397 case '"':
398 $quotedValue = str_replace('\\"', '"', trim($quotedValue, '"'));
399 break;
400 case '\'':
401 $quotedValue = str_replace('\\\'', '\'', trim($quotedValue, '\''));
402 break;
403 }
404 $quotedValue = str_replace('\\\\', '\\', $quotedValue);
405 }
406
407 /**
408 * Returns an object of an appropriate validator for the given class. If no validator is available
409 * FALSE is returned
410 *
411 * @param string $validatorName Either the fully qualified class name of the validator or the short name of a built-in validator
412 *
413 * @throws Exception\NoSuchValidatorException
414 * @return string Name of the validator object
415 */
416 protected function resolveValidatorObjectName($validatorName) {
417 if (strpos($validatorName, ':') !== FALSE || strpbrk($validatorName, '_\\') === FALSE) {
418 /**
419 * Found shorthand validator, either extbase or foreign extension
420 * NotEmpty or Acme.MyPck.Ext:MyValidator
421 */
422 list($extensionName, $extensionValidatorName) = explode(':', $validatorName);
423
424 if ($validatorName !== $extensionName && strlen($extensionValidatorName) > 0) {
425 /**
426 * Shorthand custom
427 */
428 if (strpos($extensionName, '.') !== FALSE) {
429 $extensionNameParts = explode('.', $extensionName);
430 $extensionName = array_pop($extensionNameParts);
431 $vendorName = implode('\\', $extensionNameParts);
432 $possibleClassName = $vendorName . '\\' . $extensionName . '\\Validation\\Validator\\' . $extensionValidatorName;
433 } else {
434 $possibleClassName = 'Tx_' . $extensionName . '_Validation_Validator_' . $extensionValidatorName;
435 }
436 } else {
437 /**
438 * Shorthand built in
439 */
440 $possibleClassName = 'TYPO3\\CMS\\Extbase\\Validation\\Validator\\' . $this->getValidatorType($validatorName);
441 }
442 } else {
443 /**
444 * Full qualified
445 * Tx_MyExt_Validation_Validator_MyValidator or \Acme\Ext\Validation\Validator\FooValidator
446 */
447 $possibleClassName = $validatorName;
448 }
449
450 if (substr($possibleClassName, - strlen('Validator')) !== 'Validator') {
451 $possibleClassName .= 'Validator';
452 }
453
454 if (class_exists($possibleClassName)) {
455 $possibleClassNameInterfaces = class_implements($possibleClassName);
456 if (!in_array(\TYPO3\CMS\Extbase\Validation\Validator\ValidatorInterface::class, $possibleClassNameInterfaces)) {
457 // The guessed validatorname is a valid class name, but does not implement the ValidatorInterface
458 throw new NoSuchValidatorException('Validator class ' . $validatorName . ' must implement \TYPO3\CMS\Extbase\Validation\Validator\ValidatorInterface', 1365776838);
459 }
460 $resolvedValidatorName = $possibleClassName;
461 } else {
462 throw new NoSuchValidatorException('Validator class ' . $validatorName . ' does not exist', 1365799920);
463 }
464
465 return $resolvedValidatorName;
466 }
467
468 /**
469 * Used to map PHP types to validator types.
470 *
471 * @param string $type Data type to unify
472 * @return string unified data type
473 */
474 protected function getValidatorType($type) {
475 switch ($type) {
476 case 'int':
477 $type = 'Integer';
478 break;
479 case 'bool':
480 $type = 'Boolean';
481 break;
482 case 'double':
483 $type = 'Float';
484 break;
485 case 'numeric':
486 $type = 'Number';
487 break;
488 case 'mixed':
489 $type = 'Raw';
490 break;
491 default:
492 $type = ucfirst($type);
493 }
494 return $type;
495 }
496
497 /**
498 * Temporary replacement for $this->reflectionService->getMethodAnnotations()
499 *
500 * @param string $className
501 * @param string $methodName
502 *
503 * @return array
504 */
505 public function getMethodValidateAnnotations($className, $methodName) {
506 $validateAnnotations = array();
507 $methodTagsValues = $this->reflectionService->getMethodTagsValues($className, $methodName);
508 if (isset($methodTagsValues['validate']) && is_array($methodTagsValues['validate'])) {
509 foreach ($methodTagsValues['validate'] as $validateValue) {
510 $parsedAnnotations = $this->parseValidatorAnnotation($validateValue);
511
512 foreach ($parsedAnnotations['validators'] as $validator) {
513 array_push($validateAnnotations, array(
514 'argumentName' => $parsedAnnotations['argumentName'],
515 'validatorName' => $validator['validatorName'],
516 'validatorOptions' => $validator['validatorOptions']
517 ));
518 }
519 }
520 }
521
522 return $validateAnnotations;
523 }
524 }