Test Failed
Pull Request — master (#58)
by Todd
02:24
created

Validation::getStatus()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 1
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 0
dl 0
loc 3
ccs 1
cts 1
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 82
     * - **number**: An error number for the error.
43 82
     * - Error specific fields can be added to format a custom error message.
44
     * @return $this
45 82
     */
46
    public function addError(string $field, string $error, $options = []) {
47
        if (empty($error)) {
48
            throw new \InvalidArgumentException('The error code cannot be empty.', 500);
49 82
        } elseif (!in_array(gettype($options), ['integer', 'array'], true)) {
50 82
            throw new \InvalidArgumentException('$options must be an integer or array.', 500);
51
        }
52
        if (is_int($options)) {
0 ignored issues
show
introduced by
The condition is_int($options) is always false.
Loading history...
53 82
            trigger_error('Passing an integer for $options in Validation::addError() is deprecated.', E_USER_DEPRECATED);
54 2
            $options = ['number' => $options];
55 2
        } elseif (isset($options['status'])) {
56
            trigger_error('Validation::addError() expects $options[\'number\'], not $options[\'status\'].', E_USER_DEPRECATED);
57 82
            $options['number'] = $options['status'];
58 1
            unset($options['status']);
59 1
        }
60
61 82
        $row = ['error' => $error] + $options;
62 82
        $this->errors[$field][] = $row;
63
64
        return $this;
65 82
    }
66 82
67
    /**
68 82
     * Get the error number.
69 4
     *
70
     * The number is an HTTP response code and should be of the 4xx variety.
71 79
     *
72
     * @return int Returns an error number.
73
     */
74 82
    public function getNumber(): int {
75
        if ($status = $this->getMainNumber()) {
76 82
            return $status;
77
        }
78
79
        if ($this->isValid()) {
80
            return 200;
81
        }
82
83
        // There was no status so loop through the errors and look for the highest one.
84
        $maxNumber = 0;
85
        foreach ($this->getRawErrors() as $error) {
86 75
            if (isset($error['number']) && $error['number'] > $maxNumber) {
87 75
                $maxNumber = $error['number'];
88 1
            }
89
        }
90
91 74
        return $maxNumber?: 400;
92 1
    }
93
94
    /**
95
     * Get or set the error status code.
96 73
     *
97 73
     * The status code is an http response code and should be of the 4xx variety.
98 73
     *
99 73
     * @return int Returns the current status code.
100
     * @deprecated
101
     */
102
    public function getStatus(): int {
103 73
        trigger_error("Validation::getStatus() is deprecated. Use Validation::getNumber() instead.", E_USER_DEPRECATED);
104
        return $this->getNumber();
105
    }
106
107
    /**
108
     * Get the message for this exception.
109
     *
110
     * @return string Returns the exception message.
111 76
     */
112 76
    public function getMessage(): string {
113 1
        if ($message = $this->getMainMessage()) {
114
            return $message;
115
        }
116 75
117
        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 4
     */
127 4
    public function getErrors(): array {
128 4
        $result = [];
129 4
        foreach ($this->getRawErrors() as $field => $error) {
130
            $result[] = $this->pluckError(['field' => $field] + $error);
131 4
        }
132
        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 6
     */
141 6
    public function getFieldErrors(string $field): array {
142
        if (empty($this->errors[$field])) {
143
            return [];
144 6
        } else {
145 6
            $result = [];
146 6
            foreach ($this->errors[$field] as $error) {
147
                $result[] = $this->pluckError($error + ['field' => $field]);
148 6
            }
149
            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 103
     */
160 103
    protected function getRawErrors() {
161 79
        foreach ($this->errors as $field => $errors) {
162 79
            foreach ($errors as $error) {
163
                yield $field => $error;
164
            }
165 103
        }
166
    }
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 210
     */
173 210
    public function isValid(): bool {
174
        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 107
     */
183 107
    public function isValidField(string $field): bool {
184 107
        $result = empty($this->errors[$field]);
185
        return $result;
186
    }
187
188
    /**
189
     * Get the error count, optionally for a particular field.
190
     *
191
     * @param string $field The name of a field or an empty string for all errors.
192
     * @return int Returns the error count.
193 57
     */
194 57
    public function getErrorCount($field = '') {
195 31
        if (empty($field)) {
196 32
            return iterator_count($this->getRawErrors());
197 32
        } elseif (empty($this->errors[$field])) {
198
            return 0;
199
        } else {
200
            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 77
     */
210 77
    private function formatErrorMessage(array $error) {
211 71
        if (isset($error['messageCode'])) {
212 7
            $messageCode = $error['messageCode'];
213
        } elseif (isset($error['message'])) {
214
            return $error['message'];
215 7
        } else {
216
            $messageCode = $error['error'];
217
        }
218
219 77
        // Massage the field name for better formatting.
220 76
        $msg = $this->formatMessage($messageCode, $error);
221 76
        return $msg;
222 76
    }
223 1
224
    /**
225 76
     * Format a value for output in a message.
226 3
     *
227
     * @param mixed $value The value to format.
228
     * @return string Returns the formatted value.
229
     */
230
    protected function formatValue($value): string {
231
        if (is_string($value) && mb_strlen($value) > 20) {
232 3
            $value = mb_substr($value, 0, 20).'…';
233 2
        }
234
235
        if (is_scalar($value)) {
236 77
            return json_encode($value);
237 77
        } 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 77
     * @return string Returns a formatted string.
248 77
     */
249
    private function formatMessage($format, $context = []) {
250
        $format = $this->translate($format);
251 74
252 74
        $msg = preg_replace_callback('`({[^{}]+})`', function ($m) use ($context) {
253 74
            $args = array_filter(array_map('trim', explode(',', trim($m[1], '{}'))));
254 77
            $field = array_shift($args);
255 77
256
            switch ($field) {
257
                case 'value':
258
                    return $this->formatValue($context[$field] ?? null);
259
                case 'field':
260
                    $field = $context['field'] ?: 'value';
261
                    if ($this->getTranslateFieldNames()) {
262
                        return $this->translate($field);
263
                    } else {
264
                        return $field;
265 74
                    }
266 74
                default:
267 3
                    return $this->formatField(isset($context[$field]) ? $context[$field] : null, $args);
268 74
            }
269
        }, $format);
270 74
        return $msg;
271
    }
272 74
273 73
    /**
274 18
     * Translate an argument being placed in an error message.
275 14
     *
276 5
     * @param mixed $value The argument to translate.
277 5
     * @param array $args Formatting arguments.
278 5
     * @return string Returns the translated string.
279
     */
280
    private function formatField($value, array $args = []) {
281
        if ($value === null) {
282
            $r = $this->translate('null');
283
        } elseif ($value === true) {
284
            $r = $this->translate('true');
285 74
        } elseif ($value === false) {
286
            $r = $this->translate('false');
287 74
        } elseif (is_string($value)) {
288 10
            $r = $this->translate($value);
289 10
        } elseif (is_numeric($value)) {
290 10
            $r = $value;
291 10
        } elseif (is_array($value)) {
292 10
            $argArray = array_map([$this, 'formatField'], $value);
293
            $r = implode(', ', $argArray);
294
        } elseif ($value instanceof \DateTimeInterface) {
295 74
            $r = $value->format('c');
296
        } else {
297
            $r = $value;
298
        }
299
300
        $format = array_shift($args);
301
        switch ($format) {
302
            case 'plural':
303
                $singular = array_shift($args);
304
                $plural = array_shift($args) ?: $singular.'s';
305
                $count = is_array($value) ? count($value) : $value;
306
                $r = $count == 1 ? $singular : $plural;
307 78
                break;
308 78
        }
309
310 69
        return (string)$r;
311
    }
312 77
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
    public function translate(string $str): string {
323 2
        if (substr($str, 0, 1) === '@') {
324 2
            // This is a literal string that bypasses translation.
325
            return substr($str, 1);
326 2
        } else {
327 2
            return $str;
328 2
        }
329
    }
330 2
331 1
    /**
332
     * Merge another validation object with this one.
333 1
     *
334
     * @param Validation $validation The validation object to merge.
335 2
     * @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
    public function merge(Validation $validation, $name = '') {
339 2
        $paths = $validation->errors;
340
341
        foreach ($paths as $path => $errors) {
342
            foreach ($errors as $error) {
343
                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
                    if ($path === '') {
346
                        $fullPath = $name;
347
                    } else {
348
                        $fullPath = "{$name}/{$path}";
349
                    }
350 76
                    $this->addError($fullPath, $error['error'], $error);
351 76
                }
352
            }
353
        }
354
        return $this;
355
    }
356
357
    /**
358
     * Get the main error message.
359
     *
360 1
     * If set, this message will be returned as the error message. Otherwise the message will be set from individual
361 1
     * errors.
362 1
     *
363
     * @return string Returns the main message.
364
     */
365
    public function getMainMessage() {
366
        return $this->mainMessage;
367
    }
368
369
    /**
370 75
     * Set the main error message.
371 75
     *
372
     * @param string $message The new message.
373
     * @return $this
374
     */
375
    public function setMainMessage($message) {
376
        $this->mainMessage = $message;
377
        return $this;
378
    }
379
380 1
    /**
381 1
     * Get the main error number.
382 1
     *
383
     * @return int Returns an HTTP response code or zero to indicate it should be calculated.
384
     */
385
    public function getMainNumber(): int {
386
        return $this->mainNumber;
387
    }
388
389
    /**
390 77
     * Set the main error number.
391 77
     *
392
     * @param int $status An HTTP response code or zero.
393
     * @return $this
394
     */
395
    public function setMainNumber(int $status) {
396
        $this->mainNumber = $status;
397
        return $this;
398
    }
399
400 3
    /**
401 3
     * Get the main error number.
402 3
     *
403
     * @return int Returns an HTTP response code or zero to indicate it should be calculated.
404
     * @deprecated
405
     */
406
    public function getMainStatus(): int {
407
        trigger_error("Validation::getMainStatus() is deprecated. Use Validation::getMainNumber() instead.", E_USER_DEPRECATED);
408
        return $this->mainNumber;
409
    }
410
411 10
    /**
412 10
     * Set the main error number.
413 10
     *
414 10
     * @param int $status An HTTP response code or zero.
415 10
     * @return $this
416
     * @deprecated
417 10
     */
418 10
    public function setMainStatus(int $status) {
419
        trigger_error("Validation::setMainStatus() is deprecated. Use Validation::getMainNumber() instead.", E_USER_DEPRECATED);
420
        $this->mainNumber = $status;
421
        return $this;
422
    }
423
424
425
    /**
426
     * Whether or not fields should be translated.
427 75
     *
428 75
     * @return bool Returns **true** if field names are translated or **false** otherwise.
429
     */
430
    public function getTranslateFieldNames() {
431 75
        return $this->translateFieldNames;
432 75
    }
433 75
434
    /**
435
     * Set whether or not fields should be translated.
436
     *
437 75
     * @param bool $translate Whether or not fields should be translated.
438 75
     * @return $this
439 2
     */
440
    public function setTranslateFieldNames($translate) {
441 75
        $this->translateFieldNames = $translate;
442
        return $this;
443 75
    }
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
    private function pluckError(array $error) {
452
        $row = array_intersect_key(
453
            $error,
454
            ['field' => 1, 'error' => 1, 'number' => 1]
455
        ) + ['number' => 400];
456
457
        $row['message'] = $this->formatErrorMessage($error);
458
        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
    public function getConcatMessage($field = null): string {
468
        $sentence = $this->translate('%s.');
469
470
        $errors = $field === null ? $this->getRawErrors() : ($this->errors[$field] ?? []);
471
472
        // Generate the message by concatenating all of the errors together.
473
        $messages = [];
474
        foreach ($errors as $field => $error) {
475
            $message = $this->formatErrorMessage($error + ['field' => $field]);
476
            if (preg_match('`\PP$`u', $message)) {
477
                $message = sprintf($sentence, $message);
478
            }
479
            $messages[] = $message;
480
        }
481
        return implode(' ', $messages);
482
    }
483
}
484