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