Passed
Push — master ( 4a7680...42d70b )
by Todd
33s
created

Validation   F

Complexity

Total Complexity 73

Size/Duplication

Total Lines 431
Duplicated Lines 0 %

Test Coverage

Coverage 88.48%

Importance

Changes 0
Metric Value
eloc 155
dl 0
loc 431
ccs 146
cts 165
cp 0.8848
rs 2.56
c 0
b 0
f 0
wmc 73

22 Methods

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