Completed
Pull Request — master (#189)
by Valentin
02:37
created

Validator::hasCondition()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 10
c 0
b 0
f 0
cc 2
eloc 5
nc 2
nop 1
rs 9.4285
1
<?php
2
/**
3
 * Spiral Framework.
4
 *
5
 * @license   MIT
6
 * @author    Anton Titov (Wolfy-J)
7
 */
8
9
namespace Spiral\Validation;
10
11
use Interop\Container\ContainerInterface;
12
use Psr\Log\LoggerAwareInterface;
13
use Spiral\Core\Component;
14
use Spiral\Core\Exceptions\ScopeException;
15
use Spiral\Core\Traits\SaturateTrait;
16
use Spiral\Debug\Traits\LoggerTrait;
17
use Spiral\Models\AccessorInterface;
18
use Spiral\Models\EntityInterface;
19
use Spiral\Translator\Traits\TranslatorTrait;
20
use Spiral\Validation\Configs\ValidatorConfig;
21
use Spiral\Validation\Exceptions\ValidationException;
22
23
/**
24
 * Validator is default implementation of ValidatorInterface. Class support functional rules with
25
 * user parameters. In addition, part of validation rules moved into validation checkers used to
26
 * simplify adding new rules, checkers are resolved using container and can be rebinded in
27
 * application.
28
 *
29
 * Examples:
30
 *      "status" => [
31
 *           ["notEmpty"],
32
 *           ["string::shorter", 10, "error" => "Your string is too short."],
33
 *           [["MyClass", "myMethod"], "error" => "Custom validation failed."]
34
 *      ],
35
 *      "email" => [
36
 *           ["notEmpty", "error" => "Please enter your email address."],
37
 *           ["email", "error" => "Email is not valid."]
38
 *      ],
39
 *      "pin" => [
40
 *           ["string::regexp", "/[0-9]{5}/", "error" => "Invalid pin format, if you don't know your
41
 *                                                           pin, please skip this field."]
42
 *      ],
43
 *      "flag" => ["notEmpty", "boolean"]
44
 *
45
 * In cases where you don't need custom message or check parameters you can use simplified
46
 * rule syntax:
47
 *      "flag" => ["notEmpty", "boolean"]
48
 */
49
class Validator extends Component implements ValidatorInterface, LoggerAwareInterface
50
{
51
    use LoggerTrait, TranslatorTrait, SaturateTrait;
52
53
    /**
54
     * Return from validation rule to stop any future field validations. Internal contract.
55
     */
56
    const STOP_VALIDATION = -99;
57
58
    /**
59
     * @invisible
60
     * @var ValidatorConfig
61
     */
62
    private $config = null;
63
64
    /**
65
     * @var array|\ArrayAccess
66
     */
67
    private $data = [];
68
69
    /**
70
     * Validation rules, see class title for description.
71
     *
72
     * @var array
73
     */
74
    private $rules = [];
75
76
    /**
77
     * Error messages raised while validation.
78
     *
79
     * @var array
80
     */
81
    private $errors = [];
82
83
    /**
84
     * Errors provided from outside.
85
     *
86
     * @var array
87
     */
88
    private $registeredErrors = [];
89
90
    /**
91
     * If rule has no definer error message this text will be used instead. Localizable.
92
     *
93
     * @invisible
94
     * @var string
95
     */
96
    protected $defaultMessage = "[[Condition '{condition}' does not meet.]]";
97
98
    /**
99
     * @invisible
100
     * @var ContainerInterface
101
     */
102
    protected $container = null;
103
104
    /**
105
     * Validation context. Not validated.
106
     *
107
     * @var mixed
108
     */
109
    protected $context;
110
111
    /**
112
     * {@inheritdoc}
113
     *
114
     * @param array              $rules     Validation rules.
115
     * @param array|\ArrayAccess $data      Data or model to be validated.
116
     * @param ValidatorConfig    $config    Saturated using shared container
117
     * @param ContainerInterface $container Saturated using shared container
118
     *
119
     * @throws ScopeException
120
     */
121
    public function __construct(
122
        array $rules = [],
123
        $data = [],
124
        ValidatorConfig $config = null,
125
        ContainerInterface $container = null
126
    ) {
127
        $this->data = $data;
128
        $this->rules = $rules;
129
130
        $this->config = $this->saturate($config, ValidatorConfig::class);
131
        $this->container = $this->saturate($container, ContainerInterface::class);
132
    }
133
134
    /**
135
     * {@inheritdoc}
136
     */
137
    public function setRules(array $rules): ValidatorInterface
138
    {
139
        if ($this->rules == $rules) {
140
            return $this;
141
        }
142
143
        $this->rules = $rules;
144
        $this->reset();
145
146
        return $this;
147
    }
148
149
    /**
150
     * {@inheritdoc}
151
     */
152
    public function setData($data): ValidatorInterface
153
    {
154
        $data = $this->extractData($data);
155
        if ($this->data === $data) {
156
            return $this;
157
        }
158
159
        $this->data = $data;
160
        $this->reset();
161
162
        return $this;
163
    }
164
165
    /**
166
     * {@inheritdoc}
167
     */
168
    public function getData()
169
    {
170
        return $this->data;
171
    }
172
173
    /**
174
     * {@inheritdoc}
175
     */
176
    public function isValid(): bool
177
    {
178
        $this->validate();
179
180
        return empty($this->errors) && empty($this->registeredErrors);
181
    }
182
183
    /**
184
     * {@inheritdoc}
185
     */
186
    public function registerError(string $field, string $error): ValidatorInterface
187
    {
188
        $this->registeredErrors[$field] = $error;
189
190
        return $this;
191
    }
192
193
    /**
194
     * {@inheritdoc}
195
     */
196
    public function flushRegistered(): ValidatorInterface
197
    {
198
        $this->registeredErrors = [];
199
200
        return $this;
201
    }
202
203
    /**
204
     * {@inheritdoc}
205
     */
206
    public function hasErrors(): bool
207
    {
208
        return !$this->isValid();
209
    }
210
211
    /**
212
     * {@inheritdoc}
213
     */
214
    public function getErrors(): array
215
    {
216
        $this->validate();
217
218
        return $this->registeredErrors + $this->errors;
219
    }
220
221
    /**
222
     * {@inheritdoc}
223
     */
224
    public function setContext($context)
225
    {
226
        $this->context = $context;
227
    }
228
229
    /**
230
     * {@inheritdoc}
231
     */
232
    public function getContext()
233
    {
234
        return $this->context;
235
    }
236
237
    /**
238
     * Receive field from context data or return default value.
239
     *
240
     * @param string $field
241
     * @param mixed  $default
242
     *
243
     * @return mixed
244
     */
245
    public function getValue(string $field, $default = null)
246
    {
247
        $value = isset($this->data[$field]) ? $this->data[$field] : $default;
248
249
        return ($value instanceof EntityInterface || $value instanceof AccessorInterface)
250
            ? $value->packValue()
0 ignored issues
show
Bug introduced by
The method packValue does only exist in Spiral\Models\AccessorInterface, but not in Spiral\Models\EntityInterface.

It seems like the method you are trying to call exists only in some of the possible types.

Let’s take a look at an example:

class A
{
    public function foo() { }
}

class B extends A
{
    public function bar() { }
}

/**
 * @param A|B $x
 */
function someFunction($x)
{
    $x->foo(); // This call is fine as the method exists in A and B.
    $x->bar(); // This method only exists in B and might cause an error.
}

Available Fixes

  1. Add an additional type-check:

    /**
     * @param A|B $x
     */
    function someFunction($x)
    {
        $x->foo();
    
        if ($x instanceof B) {
            $x->bar();
        }
    }
    
  2. Only allow a single type to be passed if the variable comes from a parameter:

    function someFunction(B $x) { /** ... */ }
    
Loading history...
251
            : $value;
252
    }
253
254
    /**
255
     * Reset validation state.
256
     */
257
    public function reset()
258
    {
259
        $this->errors = [];
260
        $this->registeredErrors = [];
261
    }
262
263
    /**
264
     * Validate context data with set of validation rules.
265
     */
266
    protected function validate()
267
    {
268
        $this->errors = [];
269
        foreach ($this->rules as $field => $rules) {
270
271
            foreach ($rules as $rule) {
272
                if (isset($this->errors[$field])) {
273
                    //We are validating field till first error
274
                    continue;
275
                }
276
277
                //Condition is either rule itself or first array element
278
                $condition = is_string($rule) ? $rule : $rule[0];
279
                $arguments = is_string($rule) ? [] : $this->fetchArguments($rule);
280
281
                if (empty($this->getValue($field)) && !$this->config->emptyCondition($condition)) {
282
                    //There is no need to validate empty field except for special conditions
283
                    break;
284
                }
285
286
                if ($this->skipUnderEmptyCondition($rule)) {
287
                    continue;
288
                }
289
290
                $result = $this->check($field, $this->getValue($field), $condition, $arguments);
291
292
                if ($result === true) {
293
                    //No errors
294
                    continue;
295
                }
296
297
                if ($result === self::STOP_VALIDATION) {
298
                    //Validation has to be stopped per rule request
299
                    break;
300
                }
301
302
                if ($result instanceof CheckerInterface) {
303
                    //Failed inside checker, this is implementation agreement
304
                    if ($message = $result->getMessage($condition[1])) {
305
306
                        //Checker provides it's own message for condition
307
                        $this->addMessage(
308
                            $field,
309
                            is_string($rule) ? $message : $this->fetchMessage($rule, $message),
310
                            $condition,
311
                            $arguments
312
                        );
313
314
                        continue;
315
                    }
316
                }
317
318
                //Default message
319
                $message = $this->say($this->defaultMessage);
320
321
                //Recording error message
322
                $this->addMessage(
323
                    $field,
324
                    is_string($rule) ? $message : $this->fetchMessage($rule, $message),
325
                    $condition,
326
                    $arguments
327
                );
328
            }
329
        }
330
    }
331
332
    /**
333
     * Check field with given condition. Can return instance of Checker (data is not valid) to
334
     * clarify error.
335
     *
336
     * @param string $field
337
     * @param mixed  $value
338
     * @param mixed  $condition Reference, can be altered if alias exists.
339
     * @param array  $arguments Rule arguments if any.
340
     *
341
     * @return bool|CheckerInterface
342
     * @throws ValidationException
343
     */
344
    protected function check(string $field, $value, &$condition, array $arguments = [])
345
    {
346
        //Supports both class::func and class:func
0 ignored issues
show
Unused Code Comprehensibility introduced by
39% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
347
        $condition = str_replace('::', ':', $this->config->resolveAlias($condition));
348
349
        try {
350
            if (!is_array($condition) && strpos($condition, ':')) {
351
                $condition = explode(':', $condition);
352
                if ($this->hasChecker($condition[0])) {
353
                    $checker = $this->getChecker($condition[0]);
354
                    $result = $checker->check($condition[1], $value, $arguments);
355
356
                    if ($result === false) {
357
                        //To let validation() method know that message should be handled via Checker
358
                        return $checker;
359
                    }
360
361
                    return $result;
362
                }
363
            }
364
365
            if (is_array($condition)) {
366
                //We are going to resolve class using constructor
367
                $condition[0] = is_object($condition[0])
368
                    ? $condition[0]
369
                    : $this->container->get($condition[0]);
370
            }
371
372
            //Value always coming first
373
            array_unshift($arguments, $value);
374
375
            return call_user_func_array($condition, $arguments);
376
        } catch (\Error $e) {
377
            throw new ValidationException("Invalid rule definition", $e->getCode(), $e);
378
        } catch (\Throwable $e) {
379
            $this->logException($field, func_get_arg(2), $e);
380
381
            return false;
382
        }
383
    }
384
385
    /**
386
     * Does validation config has alias defined for a given checker name or class exists
387
     *
388
     * @param string $name
389
     *
390
     * @return bool
391
     */
392
    protected function hasChecker(string $name): bool
393
    {
394
        if ($this->config->hasChecker($name)) {
395
            return true;
396
        }
397
398
        if (class_exists($name)) {
399
            $checker = $this->container->get($name);
400
401
            return $checker instanceof CheckerInterface;
402
        }
403
404
        return false;
405
    }
406
407
    /**
408
     * Get or create instance of validation checker.
409
     *
410
     * @param string $name
411
     *
412
     * @return CheckerInterface
413
     * @throws ValidationException
414
     */
415
    protected function getChecker(string $name): CheckerInterface
416
    {
417
        if (!$this->hasChecker($name)) {
418
            throw new ValidationException(
419
                "Unable to create validation checker defined by '{$name}' name"
420
            );
421
        }
422
423
        /** @var string $name */
424
        $name = $this->config->hasChecker($name) ? $this->config->checkerClass($name) : $name;
425
426
        return $this->container->get($name)->withValidator($this);
427
    }
428
429
    /**
430
     * Fetch validation rule arguments from rule definition.
431
     *
432
     * @param array $rule
433
     *
434
     * @return array
435
     */
436
    private function fetchArguments(array $rule): array
437
    {
438
        unset($rule[0], $rule['message'], $rule['error']);
439
440
        return array_values($rule);
441
    }
442
443
    /**
444
     * Fetch error message from rule definition or use default message. Method will check "message"
445
     * and "error" properties of definition.
446
     *
447
     * @param array  $rule
448
     * @param string $message Default message to use.
449
     *
450
     * @return string
451
     */
452
    private function fetchMessage(array $rule, string $message): string
453
    {
454
        if (isset($rule['message'])) {
455
            return $rule['message'];
456
        }
457
458
        if (isset($rule['error'])) {
459
            return $rule['error'];
460
        }
461
462
        return $message;
463
    }
464
465
    /**
466
     * Register error message for specified field. Rule definition will be interpolated into
467
     * message.
468
     *
469
     * @param string $field
470
     * @param string $message
471
     * @param mixed  $condition
472
     * @param array  $arguments
473
     */
474
    private function addMessage(string $field, string $message, $condition, array $arguments = [])
475
    {
476
        if (is_array($condition)) {
477
            if (is_object($condition[0])) {
478
                $condition[0] = get_class($condition[0]);
479
            }
480
481
            $condition = join('::', $condition);
482
        }
483
484
        $this->errors[$field] = \Spiral\interpolate(
485
            $message,
486
            compact('field', 'condition') + $arguments
487
        );
488
    }
489
490
    /**
491
     * @param string     $field
492
     * @param array      $condition
493
     * @param \Throwable $e
494
     */
495
    protected function logException(string $field, $condition, \Throwable $e)
496
    {
497
        if (is_array($condition)) {
498
            if (is_object($condition[0])) {
499
                $condition[0] = get_class($condition[0]);
500
            }
501
502
            $condition = join('::', $condition);
503
        }
504
505
        $this->logger()->error(
506
            "Condition '{condition}' failed with '{e}' while checking '{field}' field.",
507
            compact('condition', 'field') + ['e' => $e->getMessage()]
508
        );
509
    }
510
511
    /**
512
     * @param array|\ArrayAccess|EntityInterface $data
513
     *
514
     * @return array
515
     */
516
    private function extractData($data): array
517
    {
518
        if ($data instanceof EntityInterface) {
519
            return $data->getFields();
520
        }
521
522
        //Under consideration, might not be required
523
        if ($data instanceof \ArrayAccess && $data instanceof \Traversable) {
524
            $result = [];
525
            foreach ($data as $key => $value) {
526
                $result[$key] = $value;
527
            }
528
529
            return $result;
530
        }
531
532
        return $data;
533
    }
534
535
    /**
536
     * Does rule have condition.
537
     *
538
     * @param $rule
539
     *
540
     * @return bool
541
     */
542
    protected function skipUnderEmptyCondition($rule)
543
    {
544
        if (is_array($rule) && !empty($rule['condition']) && $this->hasCondition($rule['condition'])) {
545
            $condition = $this->getCondition($rule['condition']);
546
            if (!$condition->isMet()) {
547
                return true;
548
            }
549
        }
550
551
        return false;
552
    }
553
554
    /**
555
     * Does checker condition class exist.
556
     *
557
     * @param string $name
558
     *
559
     * @return bool
560
     */
561
    protected function hasCondition(string $name): bool
562
    {
563
        if (class_exists($name)) {
564
            $condition = $this->container->get($name);
565
566
            return $condition instanceof CheckerConditionInterface;
567
        }
568
569
        return false;
570
    }
571
572
    /**
573
     * Get or create instance of validation checker condition.
574
     *
575
     * @param string $name
576
     *
577
     * @return CheckerConditionInterface
578
     * @throws ValidationException
579
     */
580
    protected function getCondition(string $name): CheckerConditionInterface
581
    {
582
        if (!$this->hasCondition($name)) {
583
            throw new ValidationException(
584
                "Unable to create validation checker condition defined by '{$name}' name"
585
            );
586
        }
587
588
        return $this->container->get($name)->withValidator($this);
589
    }
590
}