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