Passed
Pull Request — master (#58)
by Todd
02:37
created

Validation::formatValue()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 9
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 4.5923

Importance

Changes 0
Metric Value
cc 4
eloc 6
nc 4
nop 1
dl 0
loc 9
ccs 4
cts 6
cp 0.6667
crap 4.5923
rs 10
c 0
b 0
f 0
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 {
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 $mainNumber = 0;
28
29
    /**
30
     * @var bool Whether or not fields should be translated.
31
     */
32
    private $translateFieldNames = false;
33
34
    /**
35
     * Add an error.
36
     *
37
     * @param string $field The name and path of the field to add or an empty string if this is a global error.
38
     * @param string $error The message code.
39
     * @param array $options An array of additional information to add to the error entry or a numeric error code.
40
     * @param mixed $value The value the failed validation.
41
     * @return $this
42
     */
43 82
    public function addError(string $field, string $error, $options = [], $value = null) {
44 82
        if (empty($error)) {
45
            throw new \InvalidArgumentException('The error code cannot be empty.', 500);
46 82
        } elseif (!in_array(gettype($options), ['integer', 'array'], true)) {
47
            throw new \InvalidArgumentException('$options must be an integer or array.', 500);
48
        }
49 82
        if (is_int($options)) {
0 ignored issues
show
introduced by
The condition is_int($options) is always false.
Loading history...
50
            trigger_error('Passing an integer for $options in Validation::addError() is deprecated.', E_USER_DEPRECATED);
51
        }
52
53 82
        $fieldKey = $field;
54 82
        $row = ['field' => null, 'code' => null, 'path' => null, 'index' => null, 'value' => $value];
55
56
        // Split the field out into a path, field, and possible index.
57 82
        if (($pos = strrpos($field, '.')) !== false) {
58 2
            $row['path'] = substr($field, 0, $pos);
59 2
            $field = substr($field, $pos + 1);
60
        }
61 82
        if (preg_match('`^([^[]+)\[(\d+)\]$`', $field, $m)) {
62 1
            $row['index'] = (int)$m[2];
63 1
            $field = $m[1];
64
        }
65 82
        $row['field'] = $field;
66 82
        $row['code'] = $error;
67
68
        $row = array_filter($row, function ($v) {
69 82
            return $v !== null;
70 82
        });
71
72 82
        if (is_int($options)) {
0 ignored issues
show
introduced by
The condition is_int($options) is always false.
Loading history...
73
            $row['number'] = $options;
74
        } else {
75 82
            if (isset($options['status'])) {
76
                trigger_error('Validation::addError() expects $options[\'number\'], not $options[\'status\'].', E_USER_DEPRECATED);
77
                $options['number'] = $options['status'];
78
                unset($options['status']);
79
            }
80
81 82
            $row += $options;
82
        }
83
84 82
        $this->errors[$fieldKey][] = $row;
85
86 82
        return $this;
87
    }
88
89
    /**
90
     * Get the error number.
91
     *
92
     * The number is an HTTP response code and should be of the 4xx variety.
93
     *
94
     * @return int Returns an error number.
95
     */
96 75
    public function getNumber(): int {
97 75
        if ($status = $this->getMainNumber()) {
98 1
            return $status;
99
        }
100
101 74
        if ($this->isValid()) {
102 1
            return 200;
103
        }
104
105
        // There was no status so loop through the errors and look for the highest one.
106 73
        $maxNumber = 0;
107 73
        foreach ($this->getRawErrors() as $error) {
108 73
            if (isset($error['number']) && $error['number'] > $maxNumber) {
109 73
                $maxNumber = $error['number'];
110
            }
111
        }
112
113 73
        return $maxNumber?: 400;
114
    }
115
116
    /**
117
     * Get or set the error status code.
118
     *
119
     * The status code is an http response code and should be of the 4xx variety.
120
     *
121
     * @return int Returns the current status code.
122
     * @deprecated
123
     */
124
    public function getStatus(): int {
125
        trigger_error("Validation::getStatus() is deprecated. Use Validation::getNumber() instead.", E_USER_DEPRECATED);
126
        return $this->getNumber();
127
    }
128
129
    /**
130
     * Get the message for this exception.
131
     *
132
     * @return string Returns the exception message.
133
     */
134 76
    public function getMessage(): string {
135 76
        if ($message = $this->getMainMessage()) {
136 1
            return $message;
137
        }
138
139 75
        return $this->getConcatMessage();
140
    }
141
142
    /**
143
     * Gets all of the errors as a flat array.
144
     *
145
     * The errors are internally stored indexed by field. This method flattens them for final error returns.
146
     *
147
     * @return array Returns all of the errors.
148
     */
149 4
    public function getErrors(): array {
150 4
        $result = [];
151 4
        foreach ($this->getRawErrors() as $error) {
152 4
            $result[] = $this->formatError($error);
153
        }
154 4
        return $result;
155
    }
156
157
    /**
158
     * Get the errors for a specific field.
159
     *
160
     * @param string $field The full path to the field.
161
     * @return array Returns an array of errors.
162
     */
163 6
    public function getFieldErrors(string $field): array {
164 6
        if (empty($this->errors[$field])) {
165
            return [];
166
        } else {
167 6
            $result = [];
168 6
            foreach ($this->errors[$field] as $error) {
169 6
                $result[] = $this->formatError($error);
170
            }
171 6
            return $result;
172
        }
173
    }
174
175
    /**
176
     * Gets all of the errors as a flat array.
177
     *
178
     * The errors are internally stored indexed by field. This method flattens them for final error returns.
179
     *
180
     * @return \Traversable Returns all of the errors.
181
     */
182 103
    protected function getRawErrors() {
183 103
        foreach ($this->errors as $errors) {
184 79
            foreach ($errors as $error) {
185 79
                yield $error;
186
            }
187
        }
188 103
    }
189
190
    /**
191
     * Check whether or not the validation is free of errors.
192
     *
193
     * @return bool Returns true if there are no errors, false otherwise.
194
     */
195 210
    public function isValid(): bool {
196 210
        return empty($this->errors);
197
    }
198
199
    /**
200
     * Check whether or not a particular field is has errors.
201
     *
202
     * @param string $field The name of the field to check for validity.
203
     * @return bool Returns true if the field has no errors, false otherwise.
204
     */
205 107
    public function isValidField(string $field): bool {
206 107
        $result = empty($this->errors[$field]);
207 107
        return $result;
208
    }
209
210
    /**
211
     * Get the error count, optionally for a particular field.
212
     *
213
     * @param string $field The name of a field or an empty string for all errors.
214
     * @return int Returns the error count.
215
     */
216 57
    public function getErrorCount($field = '') {
217 57
        if (empty($field)) {
218 31
            return iterator_count($this->getRawErrors());
219 32
        } elseif (empty($this->errors[$field])) {
220 32
            return 0;
221
        } else {
222
            return count($this->errors[$field]);
223
        }
224
    }
225
226
    /**
227
     * Get the error message for an error row.
228
     *
229
     * @param array $error The error row.
230
     * @return string Returns a formatted/translated error message.
231
     */
232 77
    private function getErrorMessage(array $error) {
233 77
        if (isset($error['messageCode'])) {
234 71
            $messageCode = $error['messageCode'];
235 7
        } elseif (isset($error['message'])) {
236
            return $error['message'];
237
        } else {
238 7
            $messageCode = $error['code'];
239
        }
240
241
        // Massage the field name for better formatting.
242 77
        if (!$this->getTranslateFieldNames()) {
243 76
            $field = (!empty($error['path']) ? ($error['path'][0] !== '[' ? '' : 'item').$error['path'].'.' : '').$error['field'];
244 76
            $field = $field ?: (isset($error['index']) ? 'item' : 'value');
245 76
            if (isset($error['index'])) {
246 1
                $field .= '['.$error['index'].']';
247
            }
248 76
            $error['field'] = '@'.$field;
249 2
        } elseif (isset($error['index'])) {
250
            if (empty($error['field'])) {
251
                $error['field'] = '@'.$this->formatMessage('item {index}', $error);
252
            } else {
253
                $error['field'] = '@'.$this->formatMessage('{field} at position {index}', $error);
254
            }
255 2
        } elseif (empty($error['field'])) {
256 1
            $error['field'] = 'value';
257
        }
258
259 77
        $msg = $this->formatMessage($messageCode, $error);
260 77
        return $msg;
261
    }
262
263
    /**
264
     * Format a value for output in a message.
265
     *
266
     * @param mixed $value The value to format.
267
     * @return string Returns the formatted value.
268
     */
269 29
    protected function formatValue($value): string {
270 29
        if (is_string($value) && mb_strlen($value) > 20) {
271
            $value = mb_substr($value, 0, 20).'…';
272
        }
273
274 29
        if (is_scalar($value)) {
275 29
            return json_encode($value);
276
        } else {
277
            return $this->translate('value');
278
        }
279
    }
280
281
    /**
282
     * Expand and translate a message format against an array of values.
283
     *
284
     * @param string $format The message format.
285
     * @param array $context The context arguments to apply to the message.
286
     * @return string Returns a formatted string.
287
     */
288 77
    private function formatMessage($format, $context = []) {
289 77
        $format = $this->translate($format);
290
291
        $msg = preg_replace_callback('`({[^{}]+})`', function ($m) use ($context) {
292 61
            $args = array_filter(array_map('trim', explode(',', trim($m[1], '{}'))));
293 61
            $field = array_shift($args);
294 61
            if ($field === 'value') {
295 29
                return $this->formatValue($context[$field] ?? null);
296
            } else {
297 35
                return $this->formatField(isset($context[$field]) ? $context[$field] : null, $args);
298
            }
299 77
        }, $format);
300 77
        return $msg;
301
    }
302
303
    /**
304
     * Translate an argument being placed in an error message.
305
     *
306
     * @param mixed $value The argument to translate.
307
     * @param array $args Formatting arguments.
308
     * @return string Returns the translated string.
309
     */
310 35
    private function formatField($value, array $args = []) {
311 35
        if ($value === null) {
312 3
            $r = $this->translate('null');
313 35
        } elseif ($value === true) {
314
            $r = $this->translate('true');
315 35
        } elseif ($value === false) {
316
            $r = $this->translate('false');
317 35
        } elseif (is_string($value)) {
318 34
            $r = $this->translate($value);
319 18
        } elseif (is_numeric($value)) {
320 14
            $r = $value;
321 5
        } elseif (is_array($value)) {
322 5
            $argArray = array_map([$this, 'formatField'], $value);
323 5
            $r = implode(', ', $argArray);
324
        } elseif ($value instanceof \DateTimeInterface) {
325
            $r = $value->format('c');
326
        } else {
327
            $r = $value;
328
        }
329
330 35
        $format = array_shift($args);
331
        switch ($format) {
332 35
            case 'plural':
333 10
                $singular = array_shift($args);
334 10
                $plural = array_shift($args) ?: $singular.'s';
335 10
                $count = is_array($value) ? count($value) : $value;
336 10
                $r = $count == 1 ? $singular : $plural;
337 10
                break;
338
        }
339
340 35
        return (string)$r;
341
    }
342
343
    /**
344
     * Translate a string.
345
     *
346
     * This method doesn't do any translation itself, but is meant for subclasses wanting to add translation ability to
347
     * this class.
348
     *
349
     * @param string $str The string to translate.
350
     * @return string Returns the translated string.
351
     */
352 78
    public function translate(string $str): string {
353 78
        if (substr($str, 0, 1) === '@') {
354
            // This is a literal string that bypasses translation.
355 31
            return substr($str, 1);
356
        } else {
357 77
            return $str;
358
        }
359
    }
360
361
    /**
362
     * Merge another validation object with this one.
363
     *
364
     * @param Validation $validation The validation object to merge.
365
     * @param string $name The path to merge to. Use this parameter when the validation object is meant to be a subset of this one.
366
     * @return $this
367
     */
368 2
    public function merge(Validation $validation, $name = '') {
369 2
        $paths = $validation->errors;
370
371 2
        foreach ($paths as $path => $errors) {
372 2
            foreach ($errors as $error) {
373 2
                if (strlen($name) > 0) {
374
                    // We are merging a sub-schema error that did not occur on a particular property of the sub-schema.
375 2
                    if ($path === '') {
376 1
                        $fullPath = $name;
377
                    } else {
378 1
                        $fullPath = "{$name}/{$path}";
379
                    }
380 2
                    $this->addError($fullPath, $error['code'], $error);
381
                }
382
            }
383
        }
384 2
        return $this;
385
    }
386
387
    /**
388
     * Get the main error message.
389
     *
390
     * If set, this message will be returned as the error message. Otherwise the message will be set from individual
391
     * errors.
392
     *
393
     * @return string Returns the main message.
394
     */
395 76
    public function getMainMessage() {
396 76
        return $this->mainMessage;
397
    }
398
399
    /**
400
     * Set the main error message.
401
     *
402
     * @param string $message The new message.
403
     * @return $this
404
     */
405 1
    public function setMainMessage($message) {
406 1
        $this->mainMessage = $message;
407 1
        return $this;
408
    }
409
410
    /**
411
     * Get the main error number.
412
     *
413
     * @return int Returns an HTTP response code or zero to indicate it should be calculated.
414
     */
415 75
    public function getMainNumber(): int {
416 75
        return $this->mainNumber;
417
    }
418
419
    /**
420
     * Set the main error number.
421
     *
422
     * @param int $status An HTTP response code or zero.
423
     * @return $this
424
     */
425 1
    public function setMainNumber(int $status) {
426 1
        $this->mainNumber = $status;
427 1
        return $this;
428
    }
429
430
    /**
431
     * Get the main error number.
432
     *
433
     * @return int Returns an HTTP response code or zero to indicate it should be calculated.
434
     * @deprecated
435
     */
436
    public function getMainStatus(): int {
437
        trigger_error("Validation::getMainStatus() is deprecated. Use Validation::getMainNumber() instead.", E_USER_DEPRECATED);
438
        return $this->mainNumber;
439
    }
440
441
    /**
442
     * Set the main error number.
443
     *
444
     * @param int $status An HTTP response code or zero.
445
     * @return $this
446
     * @deprecated
447
     */
448
    public function setMainStatus(int $status) {
449
        trigger_error("Validation::setMainStatus() is deprecated. Use Validation::getMainNumber() instead.", E_USER_DEPRECATED);
450
        $this->mainNumber = $status;
451
        return $this;
452
    }
453
454
455
    /**
456
     * Whether or not fields should be translated.
457
     *
458
     * @return bool Returns **true** if field names are translated or **false** otherwise.
459
     */
460 77
    public function getTranslateFieldNames() {
461 77
        return $this->translateFieldNames;
462
    }
463
464
    /**
465
     * Set whether or not fields should be translated.
466
     *
467
     * @param bool $translate Whether or not fields should be translated.
468
     * @return $this
469
     */
470 3
    public function setTranslateFieldNames($translate) {
471 3
        $this->translateFieldNames = $translate;
472 3
        return $this;
473
    }
474
475
    /**
476
     * Format a raw error row for consumption.
477
     *
478
     * @param array $error The error to format.
479
     * @return array Returns the error stripped of default values.
480
     */
481 10
    private function formatError(array $error) {
482 10
        $row = array_intersect_key(
483 10
            $error,
484 10
            ['field' => 1, 'path' => 1, 'index' => 1, 'code' => 1, 'number' => 1]
485 10
        ) + ['number' => 400];
486
487 10
        $row['message'] = $this->getErrorMessage($error);
488 10
        return $row;
489
    }
490
491
    /**
492
     * Generate a global error string by concatenating field errors.
493
     *
494
     * @param string|null $field The name of a field to concatenate errors for.
495
     * @return string Returns an error message.
496
     */
497 75
    public function getConcatMessage($field = null): string {
498 75
        $sentence = $this->translate('%s.');
499
500
        // Generate the message by concatenating all of the errors together.
501 75
        $messages = [];
502 75
        foreach ($this->getRawErrors() as $error) {
503 75
            if ($field !== null && $field !== $error['field']) {
504
                continue;
505
            }
506
507 75
            $message = $this->getErrorMessage($error);
508 75
            if (preg_match('`\PP$`u', $message)) {
509 2
                $message = sprintf($sentence, $message);
510
            }
511 75
            $messages[] = $message;
512
        }
513 75
        return implode(' ', $messages);
514
    }
515
}
516