Validation::formatField()   C
last analyzed

Complexity

Conditions 12
Paths 40

Size

Total Lines 31
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 20
CRAP Score 13.152

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 12
eloc 26
c 1
b 0
f 0
nc 40
nop 2
dl 0
loc 31
ccs 20
cts 25
cp 0.8
crap 13.152
rs 6.9666

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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