Validation   F
last analyzed

Complexity

Total Complexity 92

Size/Duplication

Total Lines 598
Duplicated Lines 0 %

Test Coverage

Coverage 95.83%

Importance

Changes 5
Bugs 1 Features 1
Metric Value
eloc 201
c 5
b 1
f 1
dl 0
loc 598
ccs 207
cts 216
cp 0.9583
rs 2
wmc 92

33 Methods

Rating   Name   Duplication   Size   Complexity  
A getErrors() 0 6 2
A getMainMessage() 0 2 1
A isValidField() 0 3 1
A getErrorCount() 0 7 3
A createValidation() 0 2 1
A formatErrorList() 0 17 3
A getFullMessage() 0 22 6
A setMainMessage() 0 3 2
A formatFieldName() 0 5 2
A translate() 0 6 2
A errorMessages() 0 7 2
A getFieldErrors() 0 9 3
A getRawErrors() 0 4 3
A getStatus() 0 3 1
A jsonSerialize() 0 16 2
A getConcatMessage() 0 15 5
A getSummaryMessage() 0 7 3
A setMainStatus() 0 4 1
A getMainStatus() 0 3 1
A formatValue() 0 9 4
A formatErrorMessage() 0 12 3
A getMainCode() 0 2 1
B getCode() 0 18 7
A setMainCode() 0 3 1
A pluckError() 0 8 1
A formatMessage() 0 18 5
A isValid() 0 2 1
C formatField() 0 31 12
A getTranslateFieldNames() 0 2 1
A merge() 0 17 5
A getMessage() 0 2 1
A setTranslateFieldNames() 0 3 1
A addError() 0 19 5

How to fix   Complexity   

Complex Class

Complex classes like Validation 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.

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 Validation, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * @author Todd Burry <[email protected]>
4
 * @copyright 2009-2018 Vanilla Forums Inc.
5
 * @license MIT
6
 */
7
8
namespace Garden\Schema;
9
10
/**
11
 * An class for collecting validation errors.
12
 */
13
class Validation implements \JsonSerializable {
14
    /**
15
     * @var array
16
     */
17
    private $errors = [];
18
19
    /**
20
     * @var string
21
     */
22
    private $mainMessage = '';
23
24
    /**
25
     * @var int
26
     */
27
    private $mainCode = 0;
28
29
    /**
30
     * @var bool Whether or not fields should be translated.
31
     */
32
    private $translateFieldNames = false;
33
34
    /**
35
     * Create a new `Validation` object.
36
     *
37
     * This method is meant as a convenience to be passed to `Schema::setValidationFactory()`.
38
     *
39
     * @return Validation Returns a new instance.
40
     */
41 234
    public static function createValidation() {
42 234
        return new static();
43
    }
44
45
    /**
46
     * Get or set the error status code.
47
     *
48
     * The status code is an http response code and should be of the 4xx variety.
49
     *
50
     * @return int Returns the current status code.
51
     * @deprecated
52
     */
53 1
    public function getStatus(): int {
54 1
        trigger_error("Validation::getStatus() is deprecated. Use Validation::getCode() instead.", E_USER_DEPRECATED);
55 1
        return $this->getCode();
56
    }
57
58
    /**
59
     * Get the error code.
60
     *
61
     * The code is an HTTP response code and should be of the 4xx variety.
62
     *
63
     * @return int Returns an error code.
64
     */
65 99
    public function getCode(): int {
66 99
        if ($status = $this->getMainCode()) {
67 2
            return $status;
68
        }
69
70 97
        if ($this->isValid()) {
71 5
            return 200;
72
        }
73
74
        // There was no status so loop through the errors and look for the highest one.
75 92
        $max = 0;
76 92
        foreach ($this->getRawErrors() as $error) {
77 92
            if (isset($error['code']) && $error['code'] > $max) {
78 92
                $max = $error['code'];
79
            }
80
        }
81
82 92
        return $max ?: 400;
83
    }
84
85
    /**
86
     * Get the main error number.
87
     *
88
     * @return int Returns an HTTP response code or zero to indicate it should be calculated.
89
     */
90 100
    public function getMainCode(): int {
91 100
        return $this->mainCode;
92
    }
93
94
    /**
95
     * Set the main error number.
96
     *
97
     * @param int $status An HTTP response code or zero.
98
     * @return $this
99
     */
100 1
    public function setMainCode(int $status) {
101 1
        $this->mainCode = $status;
102 1
        return $this;
103
    }
104
105
    /**
106
     * Check whether or not the validation is free of errors.
107
     *
108
     * @return bool Returns true if there are no errors, false otherwise.
109
     */
110 242
    public function isValid(): bool {
111 242
        return empty($this->errors);
112
    }
113
114
    /**
115
     * Gets all of the errors as a flat array.
116
     *
117
     * The errors are internally stored indexed by field. This method flattens them for final error returns.
118
     *
119
     * @return \Traversable Returns all of the errors.
120
     */
121 108
    protected function getRawErrors() {
122 108
        foreach ($this->errors as $field => $errors) {
123 103
            foreach ($errors as $error) {
124 103
                yield $field => $error;
125
            }
126
        }
127 108
    }
128
129
    /**
130
     * Get the message for this exception.
131
     *
132
     * @return string Returns the exception message.
133
     */
134 3
    public function getMessage(): string {
135 3
        return $this->getFullMessage();
136
    }
137
138
    /**
139
     * Get the full error message separated by field.
140
     *
141
     * @return string Returns the error message.
142
     */
143 93
    public function getFullMessage(): string {
144 93
        $paras = [];
145
146 93
        if (!empty($this->getMainMessage())) {
147 2
            $paras[] = $this->getMainMessage();
148 91
        } elseif ($this->getErrorCount() === 0) {
149 1
            return '';
150
        }
151
152 92
        if (isset($this->errors[''])) {
153 38
            $paras[] = $this->formatErrorList('', $this->errors['']);
154
        }
155
156 92
        foreach ($this->errors as $field => $errors) {
157 91
            if ($field === '') {
158 38
                continue;
159
            }
160 61
            $paras[] = $this->formatErrorList($field, $errors);
161
        }
162
163 92
        $result = implode("\n\n", $paras);
164 92
        return $result;
165
    }
166
167
    /**
168
     * Get the main error message.
169
     *
170
     * If set, this message will be returned as the error message. Otherwise the message will be set from individual
171
     * errors.
172
     *
173
     * @return string Returns the main message.
174
     */
175 103
    public function getMainMessage() {
176 103
        return $this->mainMessage;
177
    }
178
179
    /**
180
     * Set the main error message.
181
     *
182
     * @param string $message The new message.
183
     * @param bool $translate Whether or not to translate the message.
184
     * @return $this
185
     */
186 4
    public function setMainMessage(string $message, bool $translate = true) {
187 4
        $this->mainMessage = $translate ? $this->translate($message) : $message;
188 4
        return $this;
189
    }
190
191
    /**
192
     * Get the error count, optionally for a particular field.
193
     *
194
     * @param string|null $field The name of a field or an empty string for all errors.
195
     * @return int Returns the error count.
196
     */
197 143
    public function getErrorCount($field = null) {
198 143
        if ($field === null) {
199 93
            return iterator_count($this->getRawErrors());
200 66
        } elseif (empty($this->errors[$field])) {
201 64
            return 0;
202
        } else {
203 8
            return count($this->errors[$field]);
204
        }
205
    }
206
207
    /**
208
     * Format a field's errors.
209
     *
210
     * @param string $field The field name.
211
     * @param array $errors The field's errors.
212
     * @return string Returns the error messages, translated and formatted.
213
     */
214 91
    private function formatErrorList(string $field, array $errors) {
215 91
        if (empty($field)) {
216 38
            $fieldName = '';
217 38
            $colon = '%s%s';
218 38
            $sep = "\n";
219
        } else {
220 61
            $fieldName = $this->formatFieldName($field);
221 61
            $colon = $this->translate('%s: %s');
222 61
            $sep = "\n  ";
223 61
            if (count($errors) > 1) {
224 1
                $colon = rtrim(sprintf($colon, '%s', "")).$sep.'%s';
225
            }
226
        }
227
228 91
        $messages = $this->errorMessages($field, $errors);
229 91
        $result = sprintf($colon, $fieldName, implode($sep, $messages));
230 91
        return $result;
231
    }
232
233
    /**
234
     * Format the name of a field.
235
     *
236
     * @param string $field The field name to format.
237
     * @return string Returns the formatted field name.
238
     */
239 57
    protected function formatFieldName(string $field): string {
240 57
        if ($this->getTranslateFieldNames()) {
241 1
            return $this->translate($field);
242
        } else {
243 56
            return $field;
244
        }
245
    }
246
247
    /**
248
     * Translate a string.
249
     *
250
     * This method doesn't do any translation itself, but is meant for subclasses wanting to add translation ability to
251
     * this class.
252
     *
253
     * @param string $str The string to translate.
254
     * @return string Returns the translated string.
255
     */
256 107
    protected function translate(string $str): string {
257 107
        if (substr($str, 0, 1) === '@') {
258
            // This is a literal string that bypasses translation.
259 1
            return substr($str, 1);
260
        } else {
261 106
            return $str;
262
        }
263
    }
264
265
    /**
266
     * Format an array of error messages.
267
     *
268
     * @param string $field The name of the field.
269
     * @param array $errors The errors array from a field.
270
     * @return array Returns the error array.
271
     */
272 91
    private function errorMessages(string $field, array $errors): array {
273 91
        $messages = [];
274
275 91
        foreach ($errors as $error) {
276 91
            $messages[] = $this->formatErrorMessage($error + ['field' => $field]);
277
        }
278 91
        return $messages;
279
    }
280
281
    /**
282
     * Whether or not fields should be translated.
283
     *
284
     * @return bool Returns **true** if field names are translated or **false** otherwise.
285
     */
286 63
    public function getTranslateFieldNames() {
287 63
        return $this->translateFieldNames;
288
    }
289
290
    /**
291
     * Set whether or not fields should be translated.
292
     *
293
     * @param bool $translate Whether or not fields should be translated.
294
     * @return $this
295
     */
296 3
    public function setTranslateFieldNames($translate) {
297 3
        $this->translateFieldNames = $translate;
298 3
        return $this;
299
    }
300
301
    /**
302
     * Get the error message for an error row.
303
     *
304
     * @param array $error The error row.
305
     * @return string Returns a formatted/translated error message.
306
     */
307 102
    private function formatErrorMessage(array $error) {
308 102
        if (isset($error['messageCode'])) {
309 83
            $messageCode = $error['messageCode'];
310 21
        } elseif (isset($error['message'])) {
311
            return $error['message'];
312
        } else {
313 21
            $messageCode = $error['error'];
314
        }
315
316
        // Massage the field name for better formatting.
317 102
        $msg = $this->formatMessage($messageCode, $error);
318 102
        return $msg;
319
    }
320
321
    /**
322
     * Expand and translate a message format against an array of values.
323
     *
324
     * @param string $format The message format.
325
     * @param array $context The context arguments to apply to the message.
326
     * @return string Returns a formatted string.
327
     */
328 102
    private function formatMessage($format, $context = []) {
329 102
        $format = $this->translate($format);
330
331 102
        $msg = preg_replace_callback('`({[^{}]+})`', function ($m) use ($context) {
332 67
            $args = array_filter(array_map('trim', explode(',', trim($m[1], '{}'))));
333 67
            $field = array_shift($args);
334
335
            switch ($field) {
336 67
                case 'value':
337 35
                    return $this->formatValue($context[$field] ?? null);
338 40
                case 'field':
339 4
                    $field = $context['field'] ?: 'value';
340 4
                    return $this->formatFieldName($field);
341
                default:
342 37
                    return $this->formatField(isset($context[$field]) ? $context[$field] : null, $args);
343
            }
344 102
        }, $format);
345 102
        return $msg;
346
    }
347
348
    /**
349
     * Format a value for output in a message.
350
     *
351
     * @param mixed $value The value to format.
352
     * @return string Returns the formatted value.
353
     */
354 35
    protected function formatValue($value): string {
355 35
        if (is_string($value) && mb_strlen($value) > 20) {
356
            $value = mb_substr($value, 0, 20).'…';
357
        }
358
359 35
        if (is_scalar($value)) {
360 35
            return json_encode($value);
361
        } else {
362
            return $this->translate('value');
363
        }
364
    }
365
366
    /**
367
     * Translate an argument being placed in an error message.
368
     *
369
     * @param mixed $value The argument to translate.
370
     * @param array $args Formatting arguments.
371
     * @return string Returns the translated string.
372
     */
373 37
    private function formatField($value, array $args = []) {
374 37
        if ($value === null) {
375 3
            $r = $this->translate('null');
376 35
        } elseif ($value === true) {
377
            $r = $this->translate('true');
378 35
        } elseif ($value === false) {
379
            $r = $this->translate('false');
380 35
        } elseif (is_string($value)) {
381 20
            $r = $this->translate($value);
382 21
        } elseif (is_numeric($value)) {
383 16
            $r = $value;
384 6
        } elseif (is_array($value)) {
385 6
            $argArray = array_map([$this, 'formatField'], $value);
386 6
            $r = implode(', ', $argArray);
387
        } elseif ($value instanceof \DateTimeInterface) {
388
            $r = $value->format('c');
389
        } else {
390
            $r = $value;
391
        }
392
393 37
        $format = array_shift($args);
394
        switch ($format) {
395 37
            case 'plural':
396 13
                $singular = array_shift($args);
397 13
                $plural = array_shift($args) ?: $singular.'s';
398 13
                $count = is_array($value) ? count($value) : $value;
399 13
                $r = $count == 1 ? $singular : $plural;
400 13
                break;
401
        }
402
403 37
        return (string)$r;
404
    }
405
406
    /**
407
     * Gets all of the errors as a flat array.
408
     *
409
     * The errors are internally stored indexed by field. This method flattens them for final error returns.
410
     *
411
     * @return array Returns all of the errors.
412
     */
413 3
    public function getErrors(): array {
414 3
        $result = [];
415 3
        foreach ($this->getRawErrors() as $field => $error) {
416 3
            $result[] = $this->pluckError(['field' => $field] + $error);
417
        }
418 3
        return $result;
419
    }
420
421
    /**
422
     * Format a raw error row for consumption.
423
     *
424
     * @param array $error The error to format.
425
     * @return array Returns the error stripped of default values.
426
     */
427 15
    private function pluckError(array $error) {
428 15
        $row = array_intersect_key(
429 15
            $error,
430 15
            ['field' => 1, 'error' => 1, 'code' => 1]
431
        );
432
433 15
        $row['message'] = $this->formatErrorMessage($error);
434 15
        return $row;
435
    }
436
437
    /**
438
     * Get the errors for a specific field.
439
     *
440
     * @param string $field The full path to the field.
441
     * @return array Returns an array of errors.
442
     */
443 6
    public function getFieldErrors(string $field): array {
444 6
        if (empty($this->errors[$field])) {
445
            return [];
446
        } else {
447 6
            $result = [];
448 6
            foreach ($this->errors[$field] as $error) {
449 6
                $result[] = $this->pluckError($error + ['field' => $field]);
450
            }
451 6
            return $result;
452
        }
453
    }
454
455
    /**
456
     * Check whether or not a particular field is has errors.
457
     *
458
     * @param string $field The name of the field to check for validity.
459
     * @return bool Returns true if the field has no errors, false otherwise.
460
     */
461 129
    public function isValidField(string $field): bool {
462 129
        $result = empty($this->errors[$field]);
463 129
        return $result;
464
    }
465
466
    /**
467
     * Merge another validation object with this one.
468
     *
469
     * @param Validation $validation The validation object to merge.
470
     * @param string $name The path to merge to. Use this parameter when the validation object is meant to be a subset of this one.
471
     * @return $this
472
     */
473 2
    public function merge(Validation $validation, $name = '') {
474 2
        $paths = $validation->errors;
475
476 2
        foreach ($paths as $path => $errors) {
477 2
            foreach ($errors as $error) {
478 2
                if (strlen($name) > 0) {
479
                    // We are merging a sub-schema error that did not occur on a particular property of the sub-schema.
480 2
                    if ($path === '') {
481 1
                        $fullPath = $name;
482
                    } else {
483 1
                        $fullPath = "{$name}/{$path}";
484
                    }
485 2
                    $this->addError($fullPath, $error['error'], $error);
486
                }
487
            }
488
        }
489 2
        return $this;
490
    }
491
492
    /**
493
     * Add an error.
494
     *
495
     * @param string $field The name and path of the field to add or an empty string if this is a global error.
496
     * @param string $error The message code.
497
     * @param array $options An array of additional information to add to the error entry or a numeric error code.
498
     *
499
     * - **messageCode**: A specific message translation code for the final error.
500
     * - **number**: An error number for the error.
501
     * - Error specific fields can be added to format a custom error message.
502
     * @return $this
503
     */
504 108
    public function addError(string $field, string $error, $options = []) {
505 108
        if (empty($error)) {
506 1
            throw new \InvalidArgumentException('The error code cannot be empty.', 500);
507 107
        } elseif (!in_array(gettype($options), ['integer', 'array'], true)) {
508 1
            throw new \InvalidArgumentException('$options must be an integer or array.', 500);
509
        }
510 106
        if (is_int($options)) {
0 ignored issues
show
introduced by
The condition is_int($options) is always false.
Loading history...
511 1
            trigger_error('Passing an integer for $options in Validation::addError() is deprecated.', E_USER_DEPRECATED);
512 1
            $options = ['code' => $options];
513 105
        } elseif (isset($options['status'])) {
514 1
            trigger_error('Validation::addError() expects $options[\'number\'], not $options[\'status\'].', E_USER_DEPRECATED);
515 1
            $options['code'] = $options['status'];
516 1
            unset($options['status']);
517
        }
518
519 106
        $row = ['error' => $error] + $options;
520 106
        $this->errors[$field][] = $row;
521
522 106
        return $this;
523
    }
524
525
    /**
526
     * Get the main error number.
527
     *
528
     * @return int Returns an HTTP response code or zero to indicate it should be calculated.
529
     * @deprecated
530
     */
531 1
    public function getMainStatus(): int {
532 1
        trigger_error("Validation::getMainStatus() is deprecated. Use Validation::getMainCode() instead.", E_USER_DEPRECATED);
533 1
        return $this->mainCode;
534
    }
535
536
    /**
537
     * Set the main error number.
538
     *
539
     * @param int $status An HTTP response code or zero.
540
     * @return $this
541
     * @deprecated
542
     */
543 2
    public function setMainStatus(int $status) {
544 2
        trigger_error("Validation::setMainStatus() is deprecated. Use Validation::getMainCode() instead.", E_USER_DEPRECATED);
545 2
        $this->mainCode = $status;
546 2
        return $this;
547
    }
548
549
    /**
550
     * Generate a global error string by concatenating field errors.
551
     *
552
     * @param string|null $field The name of a field to concatenate errors for.
553
     * @param string $sep The error message separator.
554
     * @param bool $punctuate Whether or not to automatically add punctuation to errors if they don't have it already.
555
     * @return string Returns an error message.
556
     */
557 5
    public function getConcatMessage($field = null, string $sep = ' ', bool $punctuate = true): string {
558 5
        $sentence = $this->translate('%s.');
559
560 5
        $errors = $field === null ? $this->getRawErrors() : ($this->errors[$field] ?? []);
561
562
        // Generate the message by concatenating all of the errors together.
563 5
        $messages = [];
564 5
        foreach ($errors as $field => $error) {
565 5
            $message = $this->formatErrorMessage($error + ['field' => $field]);
566 5
            if ($punctuate && preg_match('`\PP$`u', $message)) {
567 1
                $message = sprintf($sentence, $message);
568
            }
569 5
            $messages[] = $message;
570
        }
571 5
        return implode($sep, $messages);
572
    }
573
574
    /**
575
     * Specify data which should be serialized to JSON.
576
     *
577
     * @return mixed Data which can be serialized by <b>json_encode</b>,
578
     * which is a value of any type other than a resource.
579
     * @link http://php.net/manual/en/jsonserializable.jsonserialize.php
580
     */
581 10
    public function jsonSerialize() {
582 10
        $errors = [];
583
584 10
        foreach ($this->getRawErrors() as $field => $error) {
585 6
            $errors[$field][] = array_intersect_key(
586 6
                $this->pluckError($error + ['field' => $field]),
587 6
                ['error' => 1, 'message' => 1, 'code' => 1]
588
            );
589
        }
590
591
        $result = [
592 10
            'message' => $this->getSummaryMessage(),
593 10
            'code' => $this->getCode(),
594 10
            'errors' => $errors,
595
        ];
596 10
        return $result;
597
    }
598
599
    /**
600
     * Get just the summary message for the validation.
601
     *
602
     * @return string Returns the message.
603
     */
604 10
    public function getSummaryMessage(): string {
605 10
        if ($main = $this->getMainMessage()) {
606 4
            return $main;
607 6
        } elseif ($this->isValid()) {
608 2
            return $this->translate('Validation succeeded.');
609
        } else {
610 4
            return $this->translate('Validation failed.');
611
        }
612
    }
613
}
614