Completed
Pull Request — master (#38)
by Todd
05:08 queued 02:03
created

Validation::getConcatMessage()   A

Complexity

Conditions 5
Paths 4

Size

Total Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 5.0187

Importance

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