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

Validation::getStatus()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

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