Passed
Push — master ( 009802...5d11c8 )
by Todd
29s
created

Validation   F

Complexity

Total Complexity 74

Size/Duplication

Total Lines 449
Duplicated Lines 0 %

Test Coverage

Coverage 92.9%

Importance

Changes 0
Metric Value
dl 0
loc 449
ccs 157
cts 169
cp 0.929
rs 2.48
c 0
b 0
f 0
wmc 74

24 Methods

Rating   Name   Duplication   Size   Complexity  
A getErrors() 0 6 2
A setConcatMainMessage() 0 3 1
A isValid() 0 2 1
A getMainMessage() 0 2 1
A isValidField() 0 3 1
C formatField() 0 31 12
A getErrorCount() 0 7 3
A setMainMessage() 0 3 1
A translate() 0 6 2
A formatMessage() 0 9 2
C getErrorMessage() 0 29 12
A getFieldErrors() 0 9 3
A getTranslateFieldNames() 0 2 1
A getRawErrors() 0 4 3
B getStatus() 0 18 7
A merge() 0 17 5
A getMessage() 0 8 3
A getConcatMessage() 0 13 3
A setMainStatus() 0 3 1
A setTranslateFieldNames() 0 3 1
B addError() 0 33 6
A getMainStatus() 0 2 1
A formatError() 0 8 1
A concatMainMessage() 0 2 1

How to fix   Complexity   

Complex Class

Complex classes like Validation often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Validation, and based on these observations, apply Extract Interface, too.

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