Completed
Push — master ( 7bebed...2fd43b )
by Lars
01:41
created

Validator   D

Complexity

Total Complexity 80

Size/Duplication

Total Lines 590
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 11

Test Coverage

Coverage 90.74%

Importance

Changes 0
Metric Value
wmc 80
lcom 1
cbo 11
dl 0
loc 590
c 0
b 0
f 0
ccs 196
cts 216
cp 0.9074
rs 4.5142

16 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 9 1
A addCustomFilter() 0 4 1
A addCustomRule() 0 4 1
B applyFilter() 0 27 6
A autoSelectRuleByInputType() 0 16 1
A getAllFilters() 0 4 1
A getAllRules() 0 4 1
C getCurrentFieldValue() 0 54 11
A getHtml() 0 4 1
A getRequiredRules() 0 4 1
A getTranslator() 0 4 1
C parseHtmlDomForRules() 0 48 8
A parseInputForFilter() 0 15 3
F parseInputForRules() 0 105 20
A setTranslator() 0 6 1
D validate() 0 137 22

How to fix   Complexity   

Complex Class

Complex classes like Validator often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Validator, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
namespace voku\HtmlFormValidator;
6
7
use Respect\Validation\Exceptions\ComponentException;
8
use Respect\Validation\Exceptions\NestedValidationException;
9
use Respect\Validation\Exceptions\ValidationException;
10
use Respect\Validation\Factory;
11
use Respect\Validation\Rules\AbstractRule;
12
use Respect\Validation\Rules\Email;
13
use Respect\Validation\Rules\HexRgbColor;
14
use Respect\Validation\Rules\Numeric;
15
use Respect\Validation\Rules\Url;
16
use Symfony\Component\CssSelector\Exception\SyntaxErrorException;
17
use voku\helper\HtmlDomParser;
18
use voku\helper\SimpleHtmlDom;
19
use voku\helper\UTF8;
20
use voku\HtmlFormValidator\Exceptions\NoValidationRule;
21
use voku\HtmlFormValidator\Exceptions\UnknownFilter;
22
use voku\HtmlFormValidator\Exceptions\UnknownValidationRule;
23
24
class Validator
25
{
26
  /**
27
   * @var HtmlDomParser
28
   */
29
  private $formDocument;
30
31
  /**
32
   * @var string[][]
33
   */
34
  private $rules = [];
35
36
  /**
37
   * @var string[][]
38
   */
39
  private $required_rules = [];
40
41
  /**
42
   * @var string[][]
43
   */
44
  private $filters = [];
45
46
  /**
47
   * @var callable[]
48
   */
49
  private $filters_custom = [];
50
51
  /**
52
   * @var callable|null
53
   */
54
  private $translator;
55
56
  /**
57
   * @var ValidatorRulesManager
58
   */
59
  private $validatorRulesManager;
60
61
  /**
62
   * @var string
63
   */
64
  private $selector;
65
66
  /**
67
   * @param string $formHTML
68
   * @param string $selector
69
   */
70 22
  public function __construct($formHTML, $selector = '')
71
  {
72 22
    $this->validatorRulesManager = new ValidatorRulesManager();
73
74 22
    $this->formDocument = HtmlDomParser::str_get_html($formHTML);
0 ignored issues
show
Unused Code introduced by
The call to HtmlDomParser::str_get_html() has too many arguments starting with $formHTML.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
75 22
    $this->selector = $selector;
76
77 22
    $this->parseHtmlDomForRules();
78 22
  }
79
80
  /**
81
   * @param string   $name   <p>A name for the "data-filter"-attribute in the dom.</p>
82
   * @param callable $filter <p>A custom filter.</p>
83
   */
84 1
  public function addCustomFilter(string $name, callable $filter)
85
  {
86 1
    $this->filters_custom[$name] = $filter;
87 1
  }
88
89
  /**
90
   * @param string              $name      <p>A name for the "data-validator"-attribute in the dom.</p>
91
   * @param string|AbstractRule $validator <p>A custom validation class.</p>
92
   */
93 3
  public function addCustomRule(string $name, $validator)
94
  {
95 3
    $this->validatorRulesManager->addCustomRule($name, $validator);
96 3
  }
97
98
  /**
99
   * @param mixed  $currentFieldData
100
   * @param string $fieldFilter
101
   *
102
   * @throws UnknownFilter
103
   *
104
   * @return mixed|string|null
105
   */
106 5
  private function applyFilter($currentFieldData, string $fieldFilter)
107
  {
108 5
    if ($currentFieldData === null) {
109 1
      return null;
110
    }
111
112 5
    if (isset($this->filters_custom[$fieldFilter])) {
113 1
      return \call_user_func($this->filters_custom[$fieldFilter], $currentFieldData);
114
    }
115
116
    switch ($fieldFilter) {
117 4
      case 'trim':
118 4
        return \trim($currentFieldData);
119 4
      case 'escape':
120 4
        return \htmlentities($currentFieldData, ENT_QUOTES | ENT_HTML5, 'UTF-8');
121
    }
122
123 4
    if (method_exists(UTF8::class, $fieldFilter)) {
124 4
      $currentFieldData = \call_user_func([UTF8::class, $fieldFilter], $currentFieldData);
125
    } else {
126
      throw new UnknownFilter(
127
          'No filter available for "' . $fieldFilter . '"'
128
      );
129
    }
130
131 4
    return $currentFieldData;
132
  }
133
134
  /**
135
   * @param string $type
136
   *
137
   * @return string|null
138
   */
139 3
  public function autoSelectRuleByInputType(string $type)
140
  {
141
    $matchingArray = [
142 3
        'email'  => Email::class,
143
        'url'    => Url::class,
144
        'color'  => HexRgbColor::class,
145
        'number' => Numeric::class,
146
147
        //
148
        // TODO@me -> take a look here
149
        // -> https://github.com/xtreamwayz/html-form-validator/blob/master/src/FormElement/Number.php
150
        //
151
    ];
152
153 3
    return $matchingArray[$type] ?? null;
154
  }
155
156
  /**
157
   * Get the filters that will be applied.
158
   *
159
   * @return string[][]
160
   */
161 1
  public function getAllFilters(): array
162
  {
163 1
    return $this->filters;
164
  }
165
166
  /**
167
   * Get the rules that will be applied.
168
   *
169
   * @return string[][]
170
   */
171 15
  public function getAllRules(): array
172
  {
173 15
    return $this->rules;
174
  }
175
176
  /**
177
   * @param array  $formValues
178
   * @param string $field
179
   *
180
   * @return mixed|null
181
   */
182 18
  private function getCurrentFieldValue(array $formValues, string $field)
183
  {
184 18
    $fieldArrayPos = UTF8::strpos($field, '[');
185 18
    if ($fieldArrayPos !== false) {
186 3
      $fieldStart = UTF8::substr($field, 0, $fieldArrayPos);
187 3
      $fieldArray = UTF8::substr($field, $fieldArrayPos);
188 3
      $fieldHelperChar = '';
189 3
      $fieldArrayTmp = preg_replace_callback(
190 3
          '/\[([^\]]+)\]/',
191 3
          function ($match) use ($fieldHelperChar) {
192 3
            return $match[1] . $fieldHelperChar;
193 3
          },
194 3
          $fieldArray
195
      );
196 3
      $fieldArrayTmp = explode($fieldHelperChar, trim($fieldArrayTmp, $fieldHelperChar));
197
198 3
      $i = 0;
199 3
      $fieldHelper = [];
200 3
      foreach ($fieldArrayTmp as $fieldArrayTmpInner) {
201 3
        $fieldHelper[$i] = $fieldArrayTmpInner;
202
203 3
        $i++;
204
      }
205
206 3
      $currentFieldValue = null;
207
208
      switch ($i) {
209 3
        case 4:
210
          if (isset($formValues[$fieldStart][$fieldHelper[0]][$fieldHelper[1]][$fieldHelper[2]][$fieldHelper[3]])) {
211
            $currentFieldValue = $formValues[$fieldStart][$fieldHelper[0]][$fieldHelper[1]][$fieldHelper[2]][$fieldHelper[3]];
212
          }
213
          break;
214 3
        case 3:
215
          if (isset($formValues[$fieldStart][$fieldHelper[0]][$fieldHelper[1]][$fieldHelper[2]])) {
216
            $currentFieldValue = $formValues[$fieldStart][$fieldHelper[0]][$fieldHelper[1]][$fieldHelper[2]];
217
          }
218
          break;
219 3
        case 2:
220 1
          if (isset($formValues[$fieldStart][$fieldHelper[0]][$fieldHelper[1]])) {
221 1
            $currentFieldValue = $formValues[$fieldStart][$fieldHelper[0]][$fieldHelper[1]];
222
          }
223 1
          break;
224 2
        case 1:
225 2
          if (isset($formValues[$fieldStart][$fieldHelper[0]])) {
226 2
            $currentFieldValue = $formValues[$fieldStart][$fieldHelper[0]];
227
          }
228 3
          break;
229
      }
230
    } else {
231 15
      $currentFieldValue = $formValues[$field] ?? null;
232
    }
233
234 18
    return $currentFieldValue;
235
  }
236
237
  /**
238
   * @return string
239
   */
240 3
  public function getHtml(): string
241
  {
242 3
    return $this->formDocument->html();
243
  }
244
245
  /**
246
   * Get the required rules that will be applied.
247
   *
248
   * @return string[][]
249
   */
250
  public function getRequiredRules(): array
251
  {
252
    return $this->required_rules;
253
  }
254
255
  /**
256
   * @return callable|null
257
   */
258 17
  public function getTranslator()
259
  {
260 17
    return $this->translator;
261
  }
262
263
  /**
264
   * Find the first form on page or via css-selector, and parse <input>-elements.
265
   *
266
   * @return bool
267
   */
268 22
  public function parseHtmlDomForRules(): bool
269
  {
270
    // init
271 22
    $this->rules = [];
272 22
    $inputForm = [];
273
274 22
    if ($this->selector) {
275 2
      $forms = $this->formDocument->find($this->selector);
276
    } else {
277 20
      $forms = $this->formDocument->find('form');
278
    }
279
280 22
    if (\count($forms) === 0) {
281 1
      return false;
282
    }
283
284
    // get the first form
285 21
    $form = $forms[0];
286
287
    // get the from-id
288 21
    if ($form->id) {
289 14
      $formHelperId = $form->id;
290
    } else {
291 7
      $formHelperId = \uniqid('html-form-validator-tmp', true);
292
    }
293
294 21
    $formTagSelector = 'input, textarea, select';
295
296
    // get the <input>-elements from the form
297 21
    $inputFromFields = $form->find($formTagSelector);
298 21
    foreach ($inputFromFields as $inputFormField) {
299 21
      $this->parseInputForRules($inputFormField, $formHelperId, $form);
300 21
      $this->parseInputForFilter($inputFormField, $formHelperId);
301
    }
302
303
    // get the <input>-elements with a matching form="id"
304 21
    if (\strpos($formHelperId, 'html-form-validator-tmp') !== 0) {
305 14
      $inputFromFieldsTmpAll = $this->formDocument->find($formTagSelector);
306 14
      foreach ($inputFromFieldsTmpAll as $inputFromFieldTmp) {
0 ignored issues
show
Bug introduced by
The expression $inputFromFieldsTmpAll of type array<integer,object<vok...leHtmlDomNodeInterface> is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
307 14
        if ($inputFromFieldTmp->form == $formHelperId) {
308 1
          $this->parseInputForRules($inputFromFieldTmp, $formHelperId);
309 14
          $this->parseInputForFilter($inputFromFieldTmp, $formHelperId);
310
        }
311
      }
312
    }
313
314 21
    return (\count($inputForm) >= 0);
315
  }
316
317
  /**
318
   * Determine if element has filter attributes, and save the given filter.
319
   *
320
   * @param SimpleHtmlDom $inputField
321
   * @param string        $formHelperId
322
   */
323 21
  private function parseInputForFilter(SimpleHtmlDom $inputField, string $formHelperId)
324
  {
325 21
    if (!$inputField->hasAttribute('data-filter')) {
326 21
      return;
327
    }
328
329 6
    $inputName = $inputField->getAttribute('name');
330 6
    $inputFilter = $inputField->getAttribute('data-filter');
331
332 6
    if (!$inputFilter) {
333 1
      $inputFilter = 'htmlentities';
334
    }
335
336 6
    $this->filters[$formHelperId][$inputName] = $inputFilter;
337 6
  }
338
339
340
  /**
341
   * Determine if element has validator attributes, and save the given rule.
342
   *
343
   * @param SimpleHtmlDom      $formField
344
   * @param string             $formHelperId
345
   * @param SimpleHtmlDom|null $form
346
   */
347 21
  private function parseInputForRules(SimpleHtmlDom $formField, string $formHelperId, SimpleHtmlDom $form = null)
348
  {
349 21
    if (!$formField->hasAttribute('data-validator')) {
350 14
      return;
351
    }
352
353 20
    $inputName = $formField->getAttribute('name');
354 20
    $inputType = $formField->getAttribute('type');
355 20
    $inputPattern = $formField->getAttribute('pattern');
356 20
    $inputRule = $formField->getAttribute('data-validator');
357
358 20
    $inputMinLength = $formField->getAttribute('minlength');
359 20
    $inputMaxLength = $formField->getAttribute('maxlength');
360
361 20
    $inputMin = $formField->getAttribute('min');
362 20
    $inputMax = $formField->getAttribute('max');
363
364 20
    if (strpos($inputRule, 'auto') !== false) {
365
366
      //
367
      // select default rule by input-type
368
      //
369
370 4
      if ($inputType) {
371 3
        $selectedRule = $this->autoSelectRuleByInputType($inputType);
372 3
        if ($selectedRule) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $selectedRule of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
373 3
          $inputRule .= '|' . $selectedRule;
374
        }
375
      }
376
377
      //
378
      // html5 pattern to regex
379
      //
380
381 4
      if ($inputPattern) {
382 1
        $inputRule .= '|regex(/' . $inputPattern . '/)';
383
      }
384
385
      //
386
      // min- / max values
387
      //
388
389 4
      if ($inputMinLength) {
390
        $inputRule .= '|minLength(' . serialize($inputMinLength) . ')';
391
      }
392
393 4
      if ($inputMaxLength) {
394
        $inputRule .= '|maxLength(' . serialize($inputMaxLength) . ')';
395
      }
396
397 4
      if ($inputMin) {
398
        $inputRule .= '|min(' . serialize($inputMin) . ')';
399
      }
400
401 4
      if ($inputMax) {
402
        $inputRule .= '|max(' . serialize($inputMax) . ')';
403
      }
404
405
    }
406
407 20
    if (strpos($inputRule, 'strict') !== false) {
408
409 3
      if ($formField->tag === 'select') {
410
411 1
        $selectableValues = [];
412 1
        foreach ($formField->getElementsByTagName('option') as $option) {
413 1
          $selectableValues[] = $option->getNode()->nodeValue;
414
        }
415 1
        $inputRule .= '|in(' . serialize($selectableValues) . ')';
416
417
      } else if (
418
          (
419 2
              $inputType == 'checkbox'
420
              ||
421 2
              $inputType == 'radio'
422
          )
423
          &&
424 2
          $form) {
425
426 2
        $selectableValues = [];
427
428
        try {
429 2
          $formFieldNames = $form->find('[name=' . $formField->name . ']');
430
        } catch (SyntaxErrorException $syntaxErrorException) {
431
          $formFieldNames = null;
432
          // TODO@me -> can the symfony CssSelectorConverter use array-name-attributes?
433
        }
434
435 2
        if ($formFieldNames) {
436 2
          foreach ($formFieldNames as $formFieldName) {
0 ignored issues
show
Bug introduced by
The expression $formFieldNames of type array<integer,object<vok...leHtmlDomNodeInterface> is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
437 2
            $selectableValues[] = $formFieldName->value;
438
          }
439
        }
440
441 2
        $inputRule .= '|in(' . serialize($selectableValues) . ')';
442
443
      }
444
    }
445
446 20
    if ($formField->hasAttribute('required')) {
447 18
      $this->required_rules[$formHelperId][$inputName] = $inputRule;
448
    }
449
450 20
    $this->rules[$formHelperId][$inputName] = $inputRule;
451 20
  }
452
453
  /**
454
   * @param callable $translator
455
   *
456
   * @return Validator
457
   */
458 1
  public function setTranslator(callable $translator): Validator
459
  {
460 1
    $this->translator = $translator;
461
462 1
    return $this;
463
  }
464
465
  /**
466
   * Loop the form data through form rules.
467
   *
468
   * @param array $formValues
469
   * @param bool  $useNoValidationRuleException
470
   *
471
   * @throws UnknownValidationRule
472
   *
473
   * @return ValidatorResult
474
   */
475 20
  public function validate(array $formValues, $useNoValidationRuleException = false): ValidatorResult
476
  {
477
    if (
478 20
        $useNoValidationRuleException === true
479
        &&
480 20
        \count($this->rules) === 0
481
    ) {
482 1
      throw new NoValidationRule(
483 1
          'No rules defined in the html.'
484
      );
485
    }
486
487
    // init
488 19
    $validatorResult = new ValidatorResult($this->formDocument);
489
490 19
    foreach ($this->rules as $formHelperId => $formFields) {
491 18
      foreach ($formFields as $field => $fieldRuleOuter) {
492
493 18
        $currentFieldValue = $this->getCurrentFieldValue($formValues, $field);
494
495
        //
496
        // use the filter
497
        //
498
499 18
        if (isset($this->filters[$formHelperId][$field])) {
500 5
          $filtersOuter = $this->filters[$formHelperId][$field];
501 5
          $fieldFilters = preg_split("/\|+(?![^\(]*\))/", $filtersOuter);
502
503 5
          foreach ($fieldFilters as $fieldFilter) {
504
505 5
            if (!$fieldFilter) {
506
              continue;
507
            }
508
509 5
            $currentFieldValue = $this->applyFilter($currentFieldValue, $fieldFilter);
510
          }
511
        }
512
513
        //
514
        // save the new values into the result-object
515
        //
516
517 18
        $validatorResult->setValues($field, $currentFieldValue);
518
519
        //
520
        // skip validation, if there was no value and validation is not required
521
        //
522
523
        if (
524 18
            $currentFieldValue === null
525
            &&
526 18
            !isset($this->required_rules[$formHelperId][$field])
527
        ) {
528 3
          continue;
529
        }
530
531
        //
532
        // use the validation rules from the dom
533
        //
534
535 18
        $fieldRules = preg_split("/\|+(?![^\(?:]*\))/", $fieldRuleOuter);
536
537 18
        foreach ($fieldRules as $fieldRule) {
538
539 18
          if (!$fieldRule) {
540
            continue;
541
          }
542
543 18
          $validationClassArray = $this->validatorRulesManager->getClassViaAlias($fieldRule);
544
545 18
          if ($validationClassArray['object']) {
546 1
            $validationClass = $validationClassArray['object'];
547 18
          } else if ($validationClassArray['class']) {
548 18
            $validationClass = $validationClassArray['class'];
549
          } else {
550
            $validationClass = null;
551
          }
552
553 18
          $validationClassArgs = $validationClassArray['classArgs'] ?? null;
554
555 18
          if ($validationClass instanceof AbstractRule) {
556
557 1
            $respectValidator = $validationClass;
558
559
          } else {
560
561
            try {
562 18
              $respectValidatorFactory = new Factory();
563 18
              $respectValidatorFactory->prependRulePrefix('voku\\HtmlFormValidator\\Rules');
564
565 18
              if ($validationClassArgs !== null) {
566 9
                $respectValidator = $respectValidatorFactory->rule($validationClass, $validationClassArgs);
567
              } else {
568 18
                $respectValidator = $respectValidatorFactory->rule($validationClass);
569
              }
570
571 1
            } catch (ComponentException $componentException) {
572 1
              throw new UnknownValidationRule(
573 1
                  'No rule defined for: ' . $field . ' (rule: ' . $fieldRule . ' | class: ' . $validationClass . ')',
574 1
                  500,
575 1
                  $componentException
576
              );
577
            }
578
579
          }
580
581 17
          $hasPassed = false;
582 17
          $translator = $this->getTranslator();
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $translator is correct as $this->getTranslator() (which targets voku\HtmlFormValidator\Validator::getTranslator()) seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
583
584
          try {
585 17
            $hasPassed = $respectValidator->assert($currentFieldValue);
586 12
          } catch (NestedValidationException $nestedValidationException) {
587
588 1
            if ($translator) {
589
              $nestedValidationException->setParam('translator', $translator);
590
            }
591
592 1
            $validatorResult->setError($field, $nestedValidationException->getFullMessage());
593 11
          } catch (ValidationException $validationException) {
594
595 11
            if ($translator) {
596 1
              $validationException->setParam('translator', $translator);
597
            }
598
599 11
            $validatorResult->setError($field, $validationException->getMainMessage());
600
          }
601
602 17
          if ($hasPassed === true) {
603 17
            continue;
604
          }
605
606
        }
607
      }
608
    }
609
610 18
    return $validatorResult;
611
  }
612
613
}
614