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

Validation::setConcatMainMessage()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

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