Completed
Push — master ( df948c...389d06 )
by Alexandre
01:21
created

Schema::parseInternal()   B

Complexity

Conditions 5
Paths 6

Size

Total Lines 32
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 5.0035

Importance

Changes 0
Metric Value
dl 0
loc 32
ccs 18
cts 19
cp 0.9474
rs 8.439
c 0
b 0
f 0
cc 5
eloc 20
nc 6
nop 1
crap 5.0035
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
 * A class for defining and validating data schemas.
12
 */
13
class Schema implements \JsonSerializable, \ArrayAccess {
14
    /**
15
     * Trigger a notice when extraneous properties are encountered during validation.
16
     */
17
    const VALIDATE_EXTRA_PROPERTY_NOTICE = 0x1;
18
19
    /**
20
     * Throw a ValidationException when extraneous properties are encountered during validation.
21
     */
22
    const VALIDATE_EXTRA_PROPERTY_EXCEPTION = 0x2;
23
24
    /**
25
     * @var array All the known types.
26
     *
27
     * If this is ever given some sort of public access then remove the static.
28
     */
29
    private static $types = [
30
        'array' => ['a'],
31
        'object' => ['o'],
32
        'integer' => ['i', 'int'],
33
        'string' => ['s', 'str'],
34
        'number' => ['f', 'float'],
35
        'boolean' => ['b', 'bool'],
36
        'timestamp' => ['ts'],
37
        'datetime' => ['dt'],
38
        'null' => ['n']
39
    ];
40
41
    /**
42
     * @var string The regular expression to strictly determine if a string is a date.
43
     */
44
    private static $DATE_REGEX = '`^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2})?)?`i';
45
46
    private $schema = [];
47
48
    /**
49
     * @var int A bitwise combination of the various **Schema::FLAG_*** constants.
50
     */
51
    private $flags = 0;
52
53
    /**
54
     * @var array An array of callbacks that will filter data in the schema.
55
     */
56
    private $filters = [];
57
58
    /**
59
     * @var array An array of callbacks that will custom validate the schema.
60
     */
61
    private $validators = [];
62
63
    /**
64
     * @var string|Validation The name of the class or an instance that will be cloned.
65
     */
66
    private $validationClass = Validation::class;
67
68
69
    /// Methods ///
70
71
    /**
72
     * Initialize an instance of a new {@link Schema} class.
73
     *
74
     * @param array $schema The array schema to validate against.
75
     */
76 175
    public function __construct($schema = []) {
77 175
        $this->schema = $schema;
78 175
    }
79
80
    /**
81
     * Grab the schema's current description.
82
     *
83
     * @return string
84
     */
85 1
    public function getDescription() {
86 1
        return isset($this->schema['description']) ? $this->schema['description'] : '';
87
    }
88
89
    /**
90
     * Set the description for the schema.
91
     *
92
     * @param string $description The new description.
93
     * @throws \InvalidArgumentException Throws an exception when the provided description is not a string.
94
     * @return Schema
95
     */
96 2
    public function setDescription($description) {
97 2
        if (is_string($description)) {
98 1
            $this->schema['description'] = $description;
99 1
        } else {
100 1
            throw new \InvalidArgumentException("The description is not a valid string.", 500);
101
        }
102
103 1
        return $this;
104
    }
105
106
    /**
107
     * Get a schema field.
108
     *
109
     * @param string|array $path The JSON schema path of the field with parts separated by dots.
110
     * @param mixed $default The value to return if the field isn't found.
111
     * @return mixed Returns the field value or `$default`.
112
     */
113 5
    public function getField($path, $default = null) {
114 5
        if (is_string($path)) {
115 5
            $path = explode('.', $path);
116 5
        }
117
118 5
        $value = $this->schema;
119 5
        foreach ($path as $i => $subKey) {
120 5
            if (is_array($value) && isset($value[$subKey])) {
121 5
                $value = $value[$subKey];
122 5
            } elseif ($value instanceof Schema) {
123 1
                return $value->getField(array_slice($path, $i), $default);
124
            } else {
125
                return $default;
126
            }
127 5
        }
128 5
        return $value;
129
    }
130
131
    /**
132
     * Set a schema field.
133
     *
134
     * @param string|array $path The JSON schema path of the field with parts separated by dots.
135
     * @param mixed $value The new value.
136
     * @return $this
137
     */
138 3
    public function setField($path, $value) {
139 3
        if (is_string($path)) {
140 3
            $path = explode('.', $path);
141 3
        }
142
143 3
        $selection = &$this->schema;
144 3
        foreach ($path as $i => $subSelector) {
145 3
            if (is_array($selection)) {
146 3
                if (!isset($selection[$subSelector])) {
147 1
                    $selection[$subSelector] = [];
148 1
                }
149 3
            } elseif ($selection instanceof Schema) {
150 1
                $selection->setField(array_slice($path, $i), $value);
151 1
                return $this;
152
            } else {
153
                $selection = [$subSelector => []];
154
            }
155 3
            $selection = &$selection[$subSelector];
156 3
        }
157
158 3
        $selection = $value;
159 3
        return $this;
160
    }
161
162
    /**
163
     * Get the ID for the schema.
164
     *
165
     * @return string
166
     */
167 3
    public function getID() {
168 3
        return isset($this->schema['id']) ? $this->schema['id'] : '';
169
    }
170
171
    /**
172
     * Set the ID for the schema.
173
     *
174
     * @param string $id The new ID.
175
     * @throws \InvalidArgumentException Throws an exception when the provided ID is not a string.
176
     * @return Schema
177
     */
178 1
    public function setID($id) {
179 1
        if (is_string($id)) {
180 1
            $this->schema['id'] = $id;
181 1
        } else {
182
            throw new \InvalidArgumentException("The ID is not a valid string.", 500);
183
        }
184
185 1
        return $this;
186
    }
187
188
    /**
189
     * Return the validation flags.
190
     *
191
     * @return int Returns a bitwise combination of flags.
192
     */
193 1
    public function getFlags() {
194 1
        return $this->flags;
195
    }
196
197
    /**
198
     * Set the validation flags.
199
     *
200
     * @param int $flags One or more of the **Schema::FLAG_*** constants.
201
     * @return Schema Returns the current instance for fluent calls.
202
     */
203 8
    public function setFlags($flags) {
204 8
        if (!is_int($flags)) {
205 1
            throw new \InvalidArgumentException('Invalid flags.', 500);
206
        }
207 7
        $this->flags = $flags;
208
209 7
        return $this;
210
    }
211
212
    /**
213
     * Whether or not the schema has a flag (or combination of flags).
214
     *
215
     * @param int $flag One or more of the **Schema::VALIDATE_*** constants.
216
     * @return bool Returns **true** if all of the flags are set or **false** otherwise.
217
     */
218 11
    public function hasFlag($flag) {
219 11
        return ($this->flags & $flag) === $flag;
220
    }
221
222
    /**
223
     * Set a flag.
224
     *
225
     * @param int $flag One or more of the **Schema::VALIDATE_*** constants.
226
     * @param bool $value Either true or false.
227
     * @return $this
228
     */
229 1
    public function setFlag($flag, $value) {
230 1
        if ($value) {
231 1
            $this->flags = $this->flags | $flag;
232 1
        } else {
233 1
            $this->flags = $this->flags & ~$flag;
234
        }
235 1
        return $this;
236
    }
237
238
    /**
239
     * Merge a schema with this one.
240
     *
241
     * @param Schema $schema A scheme instance. Its parameters will be merged into the current instance.
242
     * @return $this
243
     */
244 3
    public function merge(Schema $schema) {
245 3
        $this->mergeInternal($this->schema, $schema->getSchemaArray(), true, true);
246 3
        return $this;
247
    }
248
249
    /**
250
     * Add another schema to this one.
251
     *
252
     * Adding schemas together is analogous to array addition. When you add a schema it will only add missing information.
253
     *
254
     * @param Schema $schema The schema to add.
255
     * @param bool $addProperties Whether to add properties that don't exist in this schema.
256
     * @return $this
257
     */
258 3
    public function add(Schema $schema, $addProperties = false) {
259 3
        $this->mergeInternal($this->schema, $schema->getSchemaArray(), false, $addProperties);
260 3
        return $this;
261
    }
262
263
    /**
264
     * The internal implementation of schema merging.
265
     *
266
     * @param array &$target The target of the merge.
267
     * @param array $source The source of the merge.
268
     * @param bool $overwrite Whether or not to replace values.
269
     * @param bool $addProperties Whether or not to add object properties to the target.
270
     * @return array
271
     */
272 6
    private function mergeInternal(array &$target, array $source, $overwrite = true, $addProperties = true) {
273
        // We need to do a fix for required properties here.
274 6
        if (isset($target['properties']) && !empty($source['required'])) {
275 4
            $required = isset($target['required']) ? $target['required'] : [];
276
277 4
            if (isset($source['required']) && $addProperties) {
278 3
                $newProperties = array_diff(array_keys($source['properties']), array_keys($target['properties']));
279 3
                $newRequired = array_intersect($source['required'], $newProperties);
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 3 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
280
281 3
                $required = array_merge($required, $newRequired);
282 3
            }
283 4
        }
284
285
286 6
        foreach ($source as $key => $val) {
287 6
            if (is_array($val) && array_key_exists($key, $target) && is_array($target[$key])) {
288 6
                if ($key === 'properties' && !$addProperties) {
289
                    // We just want to merge the properties that exist in the destination.
290 1
                    foreach ($val as $name => $prop) {
291 1
                        if (isset($target[$key][$name])) {
292 1
                            $this->mergeInternal($target[$key][$name], $prop, $overwrite, $addProperties);
293 1
                        }
294 1
                    }
295 6
                } elseif (isset($val[0]) || isset($target[$key][0])) {
296 4
                    if ($overwrite) {
297
                        // This is a numeric array, so just do a merge.
298 2
                        $merged = array_merge($target[$key], $val);
299 2
                        if (is_string($merged[0])) {
300 2
                            $merged = array_keys(array_flip($merged));
301 2
                        }
302 2
                        $target[$key] = $merged;
303 2
                    }
304 4
                } else {
305 3
                    $target[$key] = $this->mergeInternal($target[$key], $val, $overwrite, $addProperties);
306
                }
307 6
            } elseif (!$overwrite && array_key_exists($key, $target) && !is_array($val)) {
0 ignored issues
show
Unused Code introduced by
This elseif statement is empty, and could be removed.

This check looks for the bodies of elseif statements that have no statements or where all statements have been commented out. This may be the result of changes for debugging or the code may simply be obsolete.

These elseif bodies can be removed. If you have an empty elseif but statements in the else branch, consider inverting the condition.

Loading history...
308
                // Do nothing, we aren't replacing.
309 3
            } else {
310 5
                $target[$key] = $val;
311
            }
312 6
        }
313
314 6
        if (isset($required)) {
315 4
            if (empty($required)) {
316 1
                unset($target['required']);
317 1
            } else {
318 4
                $target['required'] = $required;
319
            }
320 4
        }
321
322 6
        return $target;
323
    }
324
325
//    public function overlay(Schema $schema )
0 ignored issues
show
Unused Code Comprehensibility introduced by
42% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
326
327
    /**
328
     * Returns the internal schema array.
329
     *
330
     * @return array
331
     * @see Schema::jsonSerialize()
332
     */
333 9
    public function getSchemaArray() {
334 9
        return $this->schema;
335
    }
336
337
    /**
338
     * Parse a short schema and return the associated schema.
339
     *
340
     * @param array $arr The schema array.
341
     * @param mixed ...$args Constructor arguments for the schema instance.
342
     * @return static Returns a new schema.
343
     */
344 144
    public static function parse(array $arr, ...$args) {
345 144
        $schema = new static([], ...$args);
0 ignored issues
show
Unused Code introduced by
The call to Schema::__construct() has too many arguments starting with $args.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 9 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
346 144
        $schema->schema = $schema->parseInternal($arr);
347 144
        return $schema;
348
    }
349
350
    /**
351
     * Parse a schema in short form into a full schema array.
352
     *
353
     * @param array $arr The array to parse into a schema.
354
     * @return array The full schema array.
355
     * @throws \InvalidArgumentException Throws an exception when an item in the schema is invalid.
356
     */
357 144
    protected function parseInternal(array $arr) {
358 144
        if (empty($arr)) {
359
            // An empty schema validates to anything.
360 7
            return [];
361 138
        } elseif (isset($arr['type'])) {
362
            // This is a long form schema and can be parsed as the root.
363
            return $this->parseNode($arr);
364
        } else {
365
            // Check for a root schema.
366 138
            $value = reset($arr);
367 138
            $key = key($arr);
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 3 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
368 138
            if (is_int($key)) {
369 83
                $key = $value;
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 3 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
370 83
                $value = null;
371 83
            }
372 138
            list ($name, $param) = $this->parseShortParam($key, $value);
0 ignored issues
show
Documentation introduced by
$value is of type null|false, but the function expects a array.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
373 138
            if (empty($name)) {
374 48
                return $this->parseNode($param, $value);
375
            }
376
        }
377
378
        // If we are here then this is n object schema.
379 93
        list($properties, $required) = $this->parseProperties($arr);
380
381
        $result = [
382 93
            'type' => 'object',
383 93
            'properties' => $properties,
384
            'required' => $required
385 93
        ];
386
387 93
        return array_filter($result);
388
    }
389
390
    /**
391
     * Parse a schema node.
392
     *
393
     * @param array $node The node to parse.
394
     * @param mixed $value Additional information from the node.
395
     * @return array Returns a JSON schema compatible node.
396
     */
397 138
    private function parseNode($node, $value = null) {
398 138
        if (is_array($value)) {
399
            // The value describes a bit more about the schema.
400 59
            switch ($node['type']) {
401 59
                case 'array':
402 11
                    if (isset($value['items'])) {
403
                        // The value includes array schema information.
404 4
                        $node = array_replace($node, $value);
405 4
                    } else {
406 7
                        $node['items'] = $this->parseInternal($value);
407
                    }
408 11
                    break;
409 49
                case 'object':
410
                    // The value is a schema of the object.
411 12
                    if (isset($value['properties'])) {
412
                        list($node['properties']) = $this->parseProperties($value['properties']);
413
                    } else {
414 12
                        list($node['properties'], $required) = $this->parseProperties($value);
415 12
                        if (!empty($required)) {
416 12
                            $node['required'] = $required;
417 12
                        }
418
                    }
419 12
                    break;
420 37
                default:
421 37
                    $node = array_replace($node, $value);
422 37
                    break;
423 59
            }
424 138
        } elseif (is_string($value)) {
425 79
            if ($node['type'] === 'array' && $arrType = $this->getType($value)) {
426 6
                $node['items'] = ['type' => $arrType];
427 79
            } elseif (!empty($value)) {
428 16
                $node['description'] = $value;
429 16
            }
430 99
        } elseif ($value === null) {
431
            // Parse child elements.
432 20
            if ($node['type'] === 'array' && isset($node['items'])) {
433
                // The value includes array schema information.
434
                $node['items'] = $this->parseInternal($node['items']);
435 20
            } elseif ($node['type'] === 'object' && isset($node['properties'])) {
436
                list($node['properties']) = $this->parseProperties($node['properties']);
437
438
            }
439 20
        }
440
441 138
        if (is_array($node)) {
442 137
            if (!empty($node['allowNull'])) {
443 1
                $node['type'] = array_merge((array)$node['type'], ['null']);
444 1
            }
445 137
            unset($node['allowNull']);
446
447 137
            if ($node['type'] === null || $node['type'] === []) {
448 3
                unset($node['type']);
449 3
            }
450 137
        }
451
452 138
        return $node;
453
    }
454
455
    /**
456
     * Parse the schema for an object's properties.
457
     *
458
     * @param array $arr An object property schema.
459
     * @return array Returns a schema array suitable to be placed in the **properties** key of a schema.
460
     */
461 93
    private function parseProperties(array $arr) {
462 93
        $properties = [];
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 9 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
463 93
        $requiredProperties = [];
464 93
        foreach ($arr as $key => $value) {
465
            // Fix a schema specified as just a value.
466 93
            if (is_int($key)) {
467 67
                if (is_string($value)) {
468 67
                    $key = $value;
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 3 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
469 67
                    $value = '';
470 67
                } else {
471
                    throw new \InvalidArgumentException("Schema at position $key is not a valid parameter.", 500);
472
                }
473 67
            }
474
475
            // The parameter is defined in the key.
476 93
            list($name, $param, $required) = $this->parseShortParam($key, $value);
477
478 93
            $node = $this->parseNode($param, $value);
479
480 93
            $properties[$name] = $node;
481 93
            if ($required) {
482 53
                $requiredProperties[] = $name;
483 53
            }
484 93
        }
485 93
        return array($properties, $requiredProperties);
486
    }
487
488
    /**
489
     * Parse a short parameter string into a full array parameter.
490
     *
491
     * @param string $key The short parameter string to parse.
492
     * @param array $value An array of other information that might help resolve ambiguity.
493
     * @return array Returns an array in the form `[string name, array param, bool required]`.
494
     * @throws \InvalidArgumentException Throws an exception if the short param is not in the correct format.
495
     */
496 138
    public function parseShortParam($key, $value = []) {
497
        // Is the parameter optional?
498 138
        if (substr($key, -1) === '?') {
499 63
            $required = false;
500 63
            $key = substr($key, 0, -1);
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 6 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
501 63
        } else {
502 98
            $required = true;
503
        }
504
505
        // Check for a type.
506 138
        $parts = explode(':', $key);
507 138
        $name = $parts[0];
508 138
        $types = [];
509
510 138
        if (!empty($parts[1])) {
511 133
            $shortTypes = explode('|', $parts[1]);
512 133
            foreach ($shortTypes as $alias) {
513 133
                $found = $this->getType($alias);
514 133
                if ($found === null) {
515
                    throw new \InvalidArgumentException("Unknown type '$alias'", 500);
516
                } else {
517 133
                    $types[] = $found;
518
                }
519 133
            }
520 133
        }
521
522 138
        if ($value instanceof Schema) {
523 4
            if (count($types) === 1 && $types[0] === 'array') {
524 1
                $param = ['type' => $types[0], 'items' => $value];
525 1
            } else {
526 3
                $param = $value;
527
            }
528 138
        } elseif (isset($value['type'])) {
529 3
            $param = $value;
530
531 3
            if (!empty($types) && $types !== (array)$param['type']) {
532
                $typesStr = implode('|', $types);
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 6 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
533
                $paramTypesStr = implode('|', (array)$param['type']);
534
535
                throw new \InvalidArgumentException("Type mismatch between $typesStr and {$paramTypesStr} for field $name.", 500);
536
            }
537 3
        } else {
538 134
            if (empty($types) && !empty($parts[1])) {
539
                throw new \InvalidArgumentException("Invalid type {$parts[1]} for field $name.", 500);
540
            }
541 134
            if (empty($types)) {
542 3
                $param = ['type' => null];
543 3
            } else {
544 133
                $param = ['type' => count($types) === 1 ? $types[0] : $types];
545
            }
546
547
            // Parsed required strings have a minimum length of 1.
548 134
            if (in_array('string', $types) && !empty($name) && $required && (!isset($value['default']) || $value['default'] !== '')) {
549 38
                $param['minLength'] = 1;
550 38
            }
551
        }
552
553 138
        return [$name, $param, $required];
554
    }
555
556
    /**
557
     * Add a custom filter to change data before validation.
558
     *
559
     * @param string $fieldname The name of the field to filter, if any.
560
     *
561
     * If you are adding a filter to a deeply nested field then separate the path with dots.
562
     * @param callable $callback The callback to filter the field.
563
     * @return $this
564
     */
565 1
    public function addFilter($fieldname, callable $callback) {
566 1
        $this->filters[$fieldname][] = $callback;
567 1
        return $this;
568
    }
569
570
    /**
571
     * Add a custom validator to to validate the schema.
572
     *
573
     * @param string $fieldname The name of the field to validate, if any.
574
     *
575
     * If you are adding a validator to a deeply nested field then separate the path with dots.
576
     * @param callable $callback The callback to validate with.
577
     * @return Schema Returns `$this` for fluent calls.
578
     */
579 4
    public function addValidator($fieldname, callable $callback) {
580 4
        $this->validators[$fieldname][] = $callback;
581 4
        return $this;
582
    }
583
584
    /**
585
     * Require one of a given set of fields in the schema.
586
     *
587
     * @param array $required The field names to require.
588
     * @param string $fieldname The name of the field to attach to.
589
     * @param int $count The count of required items.
590
     * @return Schema Returns `$this` for fluent calls.
591
     */
592 3
    public function requireOneOf(array $required, $fieldname = '', $count = 1) {
593 3
        $result = $this->addValidator(
594 3
            $fieldname,
595
            function ($data, ValidationField $field) use ($required, $count) {
596
                // This validator does not apply to sparse validation.
597 3
                if ($field->isSparse()) {
598 1
                    return true;
599
                }
600
601 2
                $hasCount = 0;
602 2
                $flattened = [];
603
604 2
                foreach ($required as $name) {
605 2
                    $flattened = array_merge($flattened, (array)$name);
606
607 2
                    if (is_array($name)) {
608
                        // This is an array of required names. They all must match.
609 1
                        $hasCountInner = 0;
610 1
                        foreach ($name as $nameInner) {
611 1
                            if (array_key_exists($nameInner, $data)) {
612 1
                                $hasCountInner++;
613 1
                            } else {
614 1
                                break;
615
                            }
616 1
                        }
617 1
                        if ($hasCountInner >= count($name)) {
618 1
                            $hasCount++;
619 1
                        }
620 2
                    } elseif (array_key_exists($name, $data)) {
621 1
                        $hasCount++;
622 1
                    }
623
624 2
                    if ($hasCount >= $count) {
625 1
                        return true;
626
                    }
627 2
                }
628
629 2
                if ($count === 1) {
630 1
                    $message = 'One of {required} are required.';
631 1
                } else {
632 1
                    $message = '{count} of {required} are required.';
633
                }
634
635 2
                $field->addError('missingField', [
636 2
                    'messageCode' => $message,
637 2
                    'required' => $required,
638
                    'count' => $count
639 2
                ]);
640 2
                return false;
641
            }
642 3
        );
643
644 3
        return $result;
645
    }
646
647
    /**
648
     * Validate data against the schema.
649
     *
650
     * @param mixed $data The data to validate.
651
     * @param bool $sparse Whether or not this is a sparse validation.
652
     * @return mixed Returns a cleaned version of the data.
653
     * @throws ValidationException Throws an exception when the data does not validate against the schema.
654
     */
655 144
    public function validate($data, $sparse = false) {
656 144
        $field = new ValidationField($this->createValidation(), $this->schema, '', $sparse);
657
658 144
        $clean = $this->validateField($data, $field, $sparse);
659
660 142
        if (Invalid::isInvalid($clean) && $field->isValid()) {
661
            // This really shouldn't happen, but we want to protect against seeing the invalid object.
662
            $field->addError('invalid', ['messageCode' => '{field} is invalid.', 'status' => 422]);
663
        }
664
665 142
        if (!$field->getValidation()->isValid()) {
666 54
            throw new ValidationException($field->getValidation());
667
        }
668
669 102
        return $clean;
670
    }
671
672
    /**
673
     * Validate data against the schema and return the result.
674
     *
675
     * @param mixed $data The data to validate.
676
     * @param bool $sparse Whether or not to do a sparse validation.
677
     * @return bool Returns true if the data is valid. False otherwise.
678
     */
679 21
    public function isValid($data, $sparse = false) {
680
        try {
681 21
            $this->validate($data, $sparse);
682 17
            return true;
683 12
        } catch (ValidationException $ex) {
684 12
            return false;
685
        }
686
    }
687
688
    /**
689
     * Validate a field.
690
     *
691
     * @param mixed $value The value to validate.
692
     * @param ValidationField $field A validation object to add errors to.
693
     * @param bool $sparse Whether or not this is a sparse validation.
694
     * @return mixed|Invalid Returns a clean version of the value with all extra fields stripped out or invalid if the value
695
     * is completely invalid.
696
     */
697 144
    protected function validateField($value, ValidationField $field, $sparse = false) {
698 144
        $result = $value = $this->filterField($value, $field);
699
700 144
        if ($field->getField() instanceof Schema) {
701
            try {
702 4
                $result = $field->getField()->validate($value, $sparse);
703 4
            } catch (ValidationException $ex) {
704
                // The validation failed, so merge the validations together.
705 2
                $field->getValidation()->merge($ex->getValidation(), $field->getName());
706
            }
707 144
        } elseif (($value === null || ($value === '' && !$field->hasType('string'))) && $field->hasType('null')) {
708 7
            $result = null;
709 7
        } else {
710
            // Validate the field's type.
711 143
            $type = $field->getType();
712 143
            if (is_array($type)) {
713 24
                $result = $this->validateMultipleTypes($value, $type, $field, $sparse);
714 24
            } else {
715 120
                $result = $this->validateSingleType($value, $type, $field, $sparse);
716
            }
717 143
            if (Invalid::isValid($result)) {
718 141
                $result = $this->validateEnum($result, $field);
719 141
            }
720
        }
721
722
        // Validate a custom field validator.
723 144
        if (Invalid::isValid($result)) {
724 142
            $this->callValidators($result, $field);
725 142
        }
726
727 144
        return $result;
728
    }
729
730
    /**
731
     * Validate an array.
732
     *
733
     * @param mixed $value The value to validate.
734
     * @param ValidationField $field The validation results to add.
735
     * @param bool $sparse Whether or not this is a sparse validation.
736
     * @return array|Invalid Returns an array or invalid if validation fails.
737
     */
738 20
    protected function validateArray($value, ValidationField $field, $sparse = false) {
739 20
        if ((!is_array($value) || (count($value) > 0 && !array_key_exists(0, $value))) && !$value instanceof \Traversable) {
740 5
            $field->addTypeError('array');
741 5
            return Invalid::value();
742
        } else {
743 16
            if ((null !== $minItems = $field->val('minItems')) && count($value) < $minItems) {
744 1
                $field->addError(
745 1
                    'minItems',
746
                    [
747 1
                        'messageCode' => '{field} must contain at least {minItems} {minItems,plural,item}.',
748 1
                        'minItems' => $minItems,
749
                        'status' => 422
750 1
                    ]
751 1
                );
752 1
            }
753 16
            if ((null !== $maxItems = $field->val('maxItems')) && count($value) > $maxItems) {
754 1
                $field->addError(
755 1
                    'maxItems',
756
                    [
757 1
                        'messageCode' => '{field} must contain no more than {maxItems} {maxItems,plural,item}.',
758 1
                        'maxItems' => $maxItems,
759
                        'status' => 422
760 1
                    ]
761 1
                );
762 1
            }
763
764 16
            if ($field->val('items') !== null) {
765 12
                $result = [];
766
767
                // Validate each of the types.
768 12
                $itemValidation = new ValidationField(
769 12
                    $field->getValidation(),
770 12
                    $field->val('items'),
771 12
                    '',
772
                    $sparse
773 12
                );
774
775 12
                $count = 0;
776 12
                foreach ($value as $i => $item) {
777 12
                    $itemValidation->setName($field->getName()."[{$i}]");
778 12
                    $validItem = $this->validateField($item, $itemValidation, $sparse);
779 12
                    if (Invalid::isValid($validItem)) {
780 12
                        $result[] = $validItem;
781 12
                    }
782 12
                    $count++;
783 12
                }
784
785 12
                return empty($result) && $count > 0 ? Invalid::value() : $result;
786
            } else {
787
                // Cast the items into a proper numeric array.
788 4
                $result = is_array($value) ? array_values($value) : iterator_to_array($value);
789 4
                return $result;
790
            }
791
        }
792
    }
793
794
    /**
795
     * Validate a boolean value.
796
     *
797
     * @param mixed $value The value to validate.
798
     * @param ValidationField $field The validation results to add.
799
     * @return bool|Invalid Returns the cleaned value or invalid if validation fails.
800
     */
801 27
    protected function validateBoolean($value, ValidationField $field) {
802 27
        $value = $value === null ? $value : filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
803 27
        if ($value === null) {
804 4
            $field->addTypeError('boolean');
805 4
            return Invalid::value();
806
        }
807
808 24
        return $value;
809
    }
810
811
    /**
812
     * Validate a date time.
813
     *
814
     * @param mixed $value The value to validate.
815
     * @param ValidationField $field The validation results to add.
816
     * @return \DateTimeInterface|Invalid Returns the cleaned value or **null** if it isn't valid.
817
     */
818 11
    protected function validateDatetime($value, ValidationField $field) {
819 11
        if ($value instanceof \DateTimeInterface) {
0 ignored issues
show
Unused Code introduced by
This if statement is empty and can be removed.

This check looks for the bodies of if statements that have no statements or where all statements have been commented out. This may be the result of changes for debugging or the code may simply be obsolete.

These if bodies can be removed. If you have an empty if but statements in the else branch, consider inverting the condition.

if (rand(1, 6) > 3) {
//print "Check failed";
} else {
    print "Check succeeded";
}

could be turned into

if (rand(1, 6) <= 3) {
    print "Check succeeded";
}

This is much more concise to read.

Loading history...
820
            // do nothing, we're good
821 11
        } elseif (is_string($value) && $value !== '' && !is_numeric($value)) {
822
            try {
823 7
                $dt = new \DateTimeImmutable($value);
824 6
                if ($dt) {
825 6
                    $value = $dt;
826 6
                } else {
827
                    $value = null;
828
                }
829 7
            } catch (\Exception $ex) {
830 1
                $value = Invalid::value();
831
            }
832 10
        } elseif (is_int($value) && $value > 0) {
833 1
            $value = new \DateTimeImmutable('@'.(string)round($value));
834 1
        } else {
835 2
            $value = Invalid::value();
836
        }
837
838 11
        if (Invalid::isInvalid($value)) {
839 3
            $field->addTypeError('datetime');
840 3
        }
841 11
        return $value;
842
    }
843
844
    /**
845
     * Validate a float.
846
     *
847
     * @param mixed $value The value to validate.
848
     * @param ValidationField $field The validation results to add.
849
     * @return float|Invalid Returns a number or **null** if validation fails.
850
     */
851 10
    protected function validateNumber($value, ValidationField $field) {
852 10
        $result = filter_var($value, FILTER_VALIDATE_FLOAT);
853 10
        if ($result === false) {
854 3
            $field->addTypeError('number');
855 3
            return Invalid::value();
856
        }
857 7
        return $result;
858
    }
859
    /**
860
     * Validate and integer.
861
     *
862
     * @param mixed $value The value to validate.
863
     * @param ValidationField $field The validation results to add.
864
     * @return int|Invalid Returns the cleaned value or **null** if validation fails.
865
     */
866 35
    protected function validateInteger($value, ValidationField $field) {
867 35
        $result = filter_var($value, FILTER_VALIDATE_INT);
868
869 35
        if ($result === false) {
870 8
            $field->addTypeError('integer');
871 8
            return Invalid::value();
872
        }
873 31
        return $result;
874
    }
875
876
    /**
877
     * Validate an object.
878
     *
879
     * @param mixed $value The value to validate.
880
     * @param ValidationField $field The validation results to add.
881
     * @param bool $sparse Whether or not this is a sparse validation.
882
     * @return object|Invalid Returns a clean object or **null** if validation fails.
0 ignored issues
show
Documentation introduced by
Should the return type not be array|object?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

Loading history...
883
     */
884 82
    protected function validateObject($value, ValidationField $field, $sparse = false) {
885 82
        if (!$this->isArray($value) || isset($value[0])) {
886 5
            $field->addTypeError('object');
887 5
            return Invalid::value();
888 82
        } elseif (is_array($field->val('properties'))) {
889
            // Validate the data against the internal schema.
890 79
            $value = $this->validateProperties($value, $field, $sparse);
891 80
        } elseif (!is_array($value)) {
892 3
            $value = $this->toObjectArray($value);
893 3
        }
894 80
        return $value;
895
    }
896
897
    /**
898
     * Validate data against the schema and return the result.
899
     *
900
     * @param array|\ArrayAccess $data The data to validate.
901
     * @param ValidationField $field This argument will be filled with the validation result.
902
     * @param bool $sparse Whether or not this is a sparse validation.
903
     * @return array|Invalid Returns a clean array with only the appropriate properties and the data coerced to proper types.
0 ignored issues
show
Documentation introduced by
Should the return type not be array|object?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

Loading history...
904
     * or invalid if there are no valid properties.
905
     */
906 79
    protected function validateProperties($data, ValidationField $field, $sparse = false) {
907 79
        $properties = $field->val('properties', []);
908 79
        $required = array_flip($field->val('required', []));
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 3 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
909
910 79
        if (is_array($data)) {
911 75
            $keys = array_keys($data);
912 75
            $clean = [];
913 75
        } else {
914 4
            $keys = array_keys(iterator_to_array($data));
915 4
            $class = get_class($data);
916 4
            $clean = new $class;
917
918 4
            if ($clean instanceof \ArrayObject) {
919 3
                $clean->setFlags($data->getFlags());
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface ArrayAccess as the method getFlags() does only exist in the following implementations of said interface: ArrayIterator, ArrayObject, CachingIterator, Garden\Schema\Schema, Phar, PharData, RecursiveArrayIterator, RecursiveCachingIterator.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
920 3
                $clean->setIteratorClass($data->getIteratorClass());
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface ArrayAccess as the method getIteratorClass() does only exist in the following implementations of said interface: ArrayObject.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
921 3
            }
922
        }
923 79
        $keys = array_combine(array_map('strtolower', $keys), $keys);
924
925 79
        $propertyField = new ValidationField($field->getValidation(), [], null, $sparse);
926
927
        // Loop through the schema fields and validate each one.
928 79
        foreach ($properties as $propertyName => $property) {
929
            $propertyField
930 79
                ->setField($property)
931 79
                ->setName(ltrim($field->getName().".$propertyName", '.'));
932
933 79
            $lName = strtolower($propertyName);
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 6 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
934 79
            $isRequired = isset($required[$propertyName]);
935
936
            // First check for required fields.
937 79
            if (!array_key_exists($lName, $keys)) {
938 19
                if ($sparse) {
0 ignored issues
show
Unused Code introduced by
This if statement is empty and can be removed.

This check looks for the bodies of if statements that have no statements or where all statements have been commented out. This may be the result of changes for debugging or the code may simply be obsolete.

These if bodies can be removed. If you have an empty if but statements in the else branch, consider inverting the condition.

if (rand(1, 6) > 3) {
//print "Check failed";
} else {
    print "Check succeeded";
}

could be turned into

if (rand(1, 6) <= 3) {
    print "Check succeeded";
}

This is much more concise to read.

Loading history...
939
                    // Sparse validation can leave required fields out.
940 19
                } elseif ($propertyField->hasVal('default')) {
941 2
                    $clean[$propertyName] = $propertyField->val('default');
942 18
                } elseif ($isRequired) {
943 6
                    $propertyField->addError('missingField', ['messageCode' => '{field} is required.']);
944 6
                }
945 19
            } else {
946 76
                $value = $data[$keys[$lName]];
947
948 76
                if (in_array($value, [null, ''], true) && !$isRequired && !$propertyField->hasType('null')) {
949 5
                    if ($propertyField->getType() !== 'string' || $value === null) {
950 2
                        continue;
951
                    }
952 3
                }
953
954 74
                $clean[$propertyName] = $this->validateField($value, $propertyField, $sparse);
955
            }
956
957 77
            unset($keys[$lName]);
958 79
        }
959
960
        // Look for extraneous properties.
961 79
        if (!empty($keys)) {
962 10
            if ($this->hasFlag(Schema::VALIDATE_EXTRA_PROPERTY_NOTICE)) {
0 ignored issues
show
Coding Style introduced by
As per coding style, self should be used for accessing local static members.

This check looks for accesses to local static members using the fully qualified name instead of self::.

<?php

class Certificate {
    const TRIPLEDES_CBC = 'ASDFGHJKL';

    private $key;

    public function __construct()
    {
        $this->key = Certificate::TRIPLEDES_CBC;
    }
}

While this is perfectly valid, the fully qualified name of Certificate::TRIPLEDES_CBC could just as well be replaced by self::TRIPLEDES_CBC. Referencing local members with self:: assured the access will still work when the class is renamed, makes it perfectly clear that the member is in fact local and will usually be shorter.

Loading history...
963 2
                $msg = sprintf("%s has unexpected field(s): %s.", $field->getName() ?: 'value', implode(', ', $keys));
964 2
                trigger_error($msg, E_USER_NOTICE);
965
            }
966
967 8
            if ($this->hasFlag(Schema::VALIDATE_EXTRA_PROPERTY_EXCEPTION)) {
0 ignored issues
show
Coding Style introduced by
As per coding style, self should be used for accessing local static members.

This check looks for accesses to local static members using the fully qualified name instead of self::.

<?php

class Certificate {
    const TRIPLEDES_CBC = 'ASDFGHJKL';

    private $key;

    public function __construct()
    {
        $this->key = Certificate::TRIPLEDES_CBC;
    }
}

While this is perfectly valid, the fully qualified name of Certificate::TRIPLEDES_CBC could just as well be replaced by self::TRIPLEDES_CBC. Referencing local members with self:: assured the access will still work when the class is renamed, makes it perfectly clear that the member is in fact local and will usually be shorter.

Loading history...
968 2
                $field->addError('invalid', [
969 2
                    'messageCode' => '{field} has {extra,plural,an unexpected field,unexpected fields}: {extra}.',
970 2
                    'extra' => array_values($keys),
971
                    'status' => 422
972 2
                ]);
973 2
            }
974 8
        }
975
976 77
        return $clean;
977
    }
978
979
    /**
980
     * Validate a string.
981
     *
982
     * @param mixed $value The value to validate.
983
     * @param ValidationField $field The validation results to add.
984
     * @return string|Invalid Returns the valid string or **null** if validation fails.
985
     */
986 63
    protected function validateString($value, ValidationField $field) {
987 63
        if (is_string($value) || is_numeric($value)) {
988 61
            $value = $result = (string)$value;
989 61
        } else {
990 4
            $field->addTypeError('string');
991 4
            return Invalid::value();
992
        }
993
994 61
        $errorCount = $field->getErrorCount();
0 ignored issues
show
Unused Code introduced by
$errorCount is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
995 61
        if (($minLength = $field->val('minLength', 0)) > 0 && mb_strlen($value) < $minLength) {
996 3
            if (!empty($field->getName()) && $minLength === 1) {
997 1
                $field->addError('missingField', ['messageCode' => '{field} is required.', 'status' => 422]);
998 1
            } else {
999 2
                $field->addError(
1000 2
                    'minLength',
1001
                    [
1002 2
                        'messageCode' => '{field} should be at least {minLength} {minLength,plural,character} long.',
1003 2
                        'minLength' => $minLength,
1004
                        'status' => 422
1005 2
                    ]
1006 2
                );
1007
            }
1008 3
        }
1009 61
        if (($maxLength = $field->val('maxLength', 0)) > 0 && mb_strlen($value) > $maxLength) {
1010 1
            $field->addError(
1011 1
                'maxLength',
1012
                [
1013 1
                    'messageCode' => '{field} is {overflow} {overflow,plural,characters} too long.',
1014 1
                    'maxLength' => $maxLength,
1015 1
                    'overflow' => mb_strlen($value) - $maxLength,
1016
                    'status' => 422
1017 1
                ]
1018 1
            );
1019 1
        }
1020 61
        if ($pattern = $field->val('pattern')) {
1021 4
            $regex = '`'.str_replace('`', preg_quote('`', '`'), $pattern).'`';
1022
1023 4
            if (!preg_match($regex, $value)) {
1024 2
                $field->addError(
1025 2
                    'invalid',
1026
                    [
1027 2
                        'messageCode' => '{field} is in the incorrect format.',
1028
                        'status' => 422
1029 2
                    ]
1030 2
                );
1031 2
            }
1032 4
        }
1033 61
        if ($format = $field->val('format')) {
1034 15
            $type = $format;
1035
            switch ($format) {
1036 15
                case 'date-time':
1037 4
                    $result = $this->validateDatetime($result, $field);
1038 4
                    if ($result instanceof \DateTimeInterface) {
1039 4
                        $result = $result->format(\DateTime::RFC3339);
1040 4
                    }
1041 4
                    break;
1042 11
                case 'email':
1043 1
                    $result = filter_var($result, FILTER_VALIDATE_EMAIL);
1044 1
                    break;
1045 10
                case 'ipv4':
1046 1
                    $type = 'IPv4 address';
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 3 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
1047 1
                    $result = filter_var($result, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
1048 1
                    break;
1049 9
                case 'ipv6':
1050 1
                    $type = 'IPv6 address';
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 3 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
1051 1
                    $result = filter_var($result, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
1052 1
                    break;
1053 8
                case 'ip':
1054 1
                    $type = 'IP address';
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 3 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
1055 1
                    $result = filter_var($result, FILTER_VALIDATE_IP);
1056 1
                    break;
1057 7
                case 'uri':
1058 7
                    $type = 'URI';
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 3 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
1059 7
                    $result = filter_var($result, FILTER_VALIDATE_URL, FILTER_FLAG_HOST_REQUIRED | FILTER_FLAG_SCHEME_REQUIRED);
1060 7
                    break;
1061
                default:
1062
                    trigger_error("Unrecognized format '$format'.", E_USER_NOTICE);
1063
            }
1064 15
            if ($result === false) {
1065 5
                $field->addTypeError($type);
1066 5
            }
1067 15
        }
1068
1069 61
        if ($field->isValid()) {
1070 54
            return $result;
1071
        } else {
1072 11
            return Invalid::value();
1073
        }
1074
    }
1075
1076
    /**
1077
     * Validate a unix timestamp.
1078
     *
1079
     * @param mixed $value The value to validate.
1080
     * @param ValidationField $field The field being validated.
1081
     * @return int|Invalid Returns a valid timestamp or invalid if the value doesn't validate.
1082
     */
1083 5
    protected function validateTimestamp($value, ValidationField $field) {
1084 5
        if (is_numeric($value) && $value > 0) {
1085 1
            $result = (int)$value;
1086 5
        } elseif (is_string($value) && $ts = strtotime($value)) {
1087 1
            $result = $ts;
1088 1
        } else {
1089 3
            $field->addTypeError('timestamp');
1090 3
            $result = Invalid::value();
1091
        }
1092 5
        return $result;
1093
    }
1094
1095
    /**
1096
     * Validate a null value.
1097
     *
1098
     * @param mixed $value The value to validate.
1099
     * @param ValidationField $field The error collector for the field.
1100
     * @return null|Invalid Returns **null** or invalid.
1101
     */
1102 1
    protected function validateNull($value, ValidationField $field) {
1103 1
        if ($value === null) {
1104
            return null;
1105
        }
1106 1
        $field->addError('invalid', ['messageCode' => '{field} should be null.', 'status' => 422]);
1107 1
        return Invalid::value();
1108
    }
1109
1110
    /**
1111
     * Validate a value against an enum.
1112
     *
1113
     * @param mixed $value The value to test.
1114
     * @param ValidationField $field The validation object for adding errors.
1115
     * @return mixed|Invalid Returns the value if it is one of the enumerated values or invalid otherwise.
1116
     */
1117 141
    protected function validateEnum($value, ValidationField $field) {
1118 141
        $enum = $field->val('enum');
1119 141
        if (empty($enum)) {
1120 140
            return $value;
1121
        }
1122
1123 1
        if (!in_array($value, $enum, true)) {
1124 1
            $field->addError(
1125 1
                'invalid',
1126
                [
1127 1
                    'messageCode' => '{field} must be one of: {enum}.',
1128 1
                    'enum' => $enum,
1129
                    'status' => 422
1130 1
                ]
1131 1
            );
1132 1
            return Invalid::value();
1133
        }
1134 1
        return $value;
1135
    }
1136
1137
    /**
1138
     * Call all of the filters attached to a field.
1139
     *
1140
     * @param mixed $value The field value being filtered.
1141
     * @param ValidationField $field The validation object.
1142
     * @return mixed Returns the filtered value. If there are no filters for the field then the original value is returned.
1143
     */
1144 144
    protected function callFilters($value, ValidationField $field) {
1145
        // Strip array references in the name except for the last one.
1146 144
        $key = preg_replace(['`\[\d+\]$`', '`\[\d+\]`'], ['[]', ''], $field->getName());
1147 144
        if (!empty($this->filters[$key])) {
1148 1
            foreach ($this->filters[$key] as $filter) {
1149 1
                $value = call_user_func($filter, $value, $field);
1150 1
            }
1151 1
        }
1152 144
        return $value;
1153
    }
1154
1155
    /**
1156
     * Call all of the validators attached to a field.
1157
     *
1158
     * @param mixed $value The field value being validated.
1159
     * @param ValidationField $field The validation object to add errors.
1160
     */
1161 142
    protected function callValidators($value, ValidationField $field) {
1162 142
        $valid = true;
1163
1164
        // Strip array references in the name except for the last one.
1165 142
        $key = preg_replace(['`\[\d+\]$`', '`\[\d+\]`'], ['[]', ''], $field->getName());
1166 142
        if (!empty($this->validators[$key])) {
1167 4
            foreach ($this->validators[$key] as $validator) {
1168 4
                $r = call_user_func($validator, $value, $field);
1169
1170 4
                if ($r === false || Invalid::isInvalid($r)) {
1171 2
                    $valid = false;
1172 2
                }
1173 4
            }
1174 4
        }
1175
1176
        // Add an error on the field if the validator hasn't done so.
1177 142
        if (!$valid && $field->isValid()) {
1178
            $field->addError('invalid', ['messageCode' => '{field} is invalid.', 'status' => 422]);
1179
        }
1180 142
    }
1181
1182
    /**
1183
     * Specify data which should be serialized to JSON.
1184
     *
1185
     * This method specifically returns data compatible with the JSON schema format.
1186
     *
1187
     * @return mixed Returns data which can be serialized by **json_encode()**, which is a value of any type other than a resource.
1188
     * @link http://php.net/manual/en/jsonserializable.jsonserialize.php
1189
     * @link http://json-schema.org/
1190
     */
1191
    public function jsonSerialize() {
1192 14
        $fix = function ($schema) use (&$fix) {
1193 14
            if ($schema instanceof Schema) {
1194 1
                return $schema->jsonSerialize();
1195
            }
1196
1197 14
            if (!empty($schema['type'])) {
1198
                // Swap datetime and timestamp to other types with formats.
1199 13
                if ($schema['type'] === 'datetime') {
1200 3
                    $schema['type'] = 'string';
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 3 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
1201 3
                    $schema['format'] = 'date-time';
1202 13
                } elseif ($schema['type'] === 'timestamp') {
1203 3
                    $schema['type'] = 'integer';
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 3 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
1204 3
                    $schema['format'] = 'timestamp';
1205 3
                }
1206 13
            }
1207
1208 14
            if (!empty($schema['items'])) {
1209 4
                $schema['items'] = $fix($schema['items']);
1210 4
            }
1211 14
            if (!empty($schema['properties'])) {
1212 10
                $properties = [];
1213 10
                foreach ($schema['properties'] as $key => $property) {
1214 10
                    $properties[$key] = $fix($property);
1215 10
                }
1216 10
                $schema['properties'] = $properties;
1217 10
            }
1218
1219 14
            return $schema;
1220 14
        };
1221
1222 14
        $result = $fix($this->schema);
1223
1224 14
        return $result;
1225
    }
1226
1227
    /**
1228
     * Look up a type based on its alias.
1229
     *
1230
     * @param string $alias The type alias or type name to lookup.
1231
     * @return mixed
1232
     */
1233 133
    protected function getType($alias) {
1234 133
        if (isset(self::$types[$alias])) {
1235
            return $alias;
1236
        }
1237 133
        foreach (self::$types as $type => $aliases) {
1238 133
            if (in_array($alias, $aliases, true)) {
1239 133
                return $type;
1240
            }
1241 133
        }
1242 6
        return null;
1243
    }
1244
1245
    /**
1246
     * Get the class that's used to contain validation information.
1247
     *
1248
     * @return Validation|string Returns the validation class.
1249
     */
1250 144
    public function getValidationClass() {
1251 144
        return $this->validationClass;
1252
    }
1253
1254
    /**
1255
     * Set the class that's used to contain validation information.
1256
     *
1257
     * @param Validation|string $class Either the name of a class or a class that will be cloned.
1258
     * @return $this
1259
     */
1260 1
    public function setValidationClass($class) {
1261 1
        if (!is_a($class, Validation::class, true)) {
1262
            throw new \InvalidArgumentException("$class must be a subclass of ".Validation::class, 500);
1263
        }
1264
1265 1
        $this->validationClass = $class;
1266 1
        return $this;
1267
    }
1268
1269
    /**
1270
     * Create a new validation instance.
1271
     *
1272
     * @return Validation Returns a validation object.
1273
     */
1274 144
    protected function createValidation() {
1275 144
        $class = $this->getValidationClass();
1276
1277 144
        if ($class instanceof Validation) {
1278 1
            $result = clone $class;
1279 1
        } else {
1280 144
            $result = new $class;
1281
        }
1282 144
        return $result;
1283
    }
1284
1285
    /**
1286
     * Check whether or not a value is an array or accessible like an array.
1287
     *
1288
     * @param mixed $value The value to check.
1289
     * @return bool Returns **true** if the value can be used like an array or **false** otherwise.
1290
     */
1291 82
    private function isArray($value) {
1292 82
        return is_array($value) || ($value instanceof \ArrayAccess && $value instanceof \Traversable);
1293
    }
1294
1295
    /**
1296
     * Cast a value to an array.
1297
     *
1298
     * @param \Traversable $value The value to convert.
1299
     * @return array Returns an array.
0 ignored issues
show
Documentation introduced by
Should the return type not be object|array?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

Loading history...
1300
     */
1301 3
    private function toObjectArray(\Traversable $value) {
1302 3
        $class = get_class($value);
1303 3
        if ($value instanceof \ArrayObject) {
1304 2
            return new $class($value->getArrayCopy(), $value->getFlags(), $value->getIteratorClass());
1305 1
        } elseif ($value instanceof \ArrayAccess) {
1306 1
            $r = new $class;
1307 1
            foreach ($value as $k => $v) {
1308 1
                $r[$k] = $v;
1309 1
            }
1310 1
            return $r;
1311
        }
1312
        return iterator_to_array($value);
1313
    }
1314
1315
    /**
1316
     * Return a sparse version of this schema.
1317
     *
1318
     * A sparse schema has no required properties.
1319
     *
1320
     * @return Schema Returns a new sparse schema.
1321
     */
1322 2
    public function withSparse() {
1323 2
        $sparseSchema = $this->withSparseInternal($this, new \SplObjectStorage());
1324 2
        return $sparseSchema;
1325
    }
1326
1327
    /**
1328
     * The internal implementation of `Schema::withSparse()`.
1329
     *
1330
     * @param array|Schema $schema The schema to make sparse.
1331
     * @param \SplObjectStorage $schemas Collected sparse schemas that have already been made.
1332
     * @return mixed
1333
     */
1334 2
    private function withSparseInternal($schema, \SplObjectStorage $schemas) {
1335 2
        if ($schema instanceof Schema) {
1336 2
            if ($schemas->contains($schema)) {
1337 1
                return $schemas[$schema];
1338
            } else {
1339 2
                $schemas[$schema] = $sparseSchema = new Schema();
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 5 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
1340 2
                $sparseSchema->schema = $schema->withSparseInternal($schema->schema, $schemas);
1341 2
                if ($id = $sparseSchema->getID()) {
1342
                    $sparseSchema->setID($id.'Sparse');
1343
                }
1344
1345 2
                return $sparseSchema;
1346
            }
1347
        }
1348
1349 2
        unset($schema['required']);
1350
1351 2
        if (isset($schema['items'])) {
1352 1
            $schema['items'] = $this->withSparseInternal($schema['items'], $schemas);
1353 1
        }
1354 2
        if (isset($schema['properties'])) {
1355 2
            foreach ($schema['properties'] as $name => &$property) {
1356 2
                $property = $this->withSparseInternal($property, $schemas);
1357 2
            }
1358 2
        }
1359
1360 2
        return $schema;
1361
    }
1362
1363
    /**
1364
     * Filter a field's value using built in and custom filters.
1365
     *
1366
     * @param mixed $value The original value of the field.
1367
     * @param ValidationField $field The field information for the field.
1368
     * @return mixed Returns the filtered field or the original field value if there are no filters.
1369
     */
1370 144
    private function filterField($value, ValidationField $field) {
1371
        // Check for limited support for Open API style.
1372 144
        if (!empty($field->val('style')) && is_string($value)) {
1373 8
            $doFilter = true;
1374 8
            if ($field->hasType('boolean') && in_array($value, ['true', 'false', '0', '1'], true)) {
1375 4
                $doFilter = false;
1376 8
            } elseif ($field->hasType('integer') || $field->hasType('number') && is_numeric($value)) {
1377
                $doFilter = false;
1378
            }
1379
1380 8
            if ($doFilter) {
1381 4
                switch ($field->val('style')) {
1382 4
                    case 'form':
1383 2
                        $value = explode(',', $value);
1384 2
                        break;
1385 2
                    case 'spaceDelimited':
1386 1
                        $value = explode(' ', $value);
1387 1
                        break;
1388 1
                    case 'pipeDelimited':
1389 1
                        $value = explode('|', $value);
1390 1
                        break;
1391 4
                }
1392 4
            }
1393 8
        }
1394
1395 144
        $value = $this->callFilters($value, $field);
1396
1397 144
        return $value;
1398
    }
1399
1400
    /**
1401
     * Whether a offset exists.
1402
     *
1403
     * @param mixed $offset An offset to check for.
1404
     * @return boolean true on success or false on failure.
1405
     * @link http://php.net/manual/en/arrayaccess.offsetexists.php
1406
     */
1407 5
    public function offsetExists($offset) {
1408 5
        return isset($this->schema[$offset]);
1409
    }
1410
1411
    /**
1412
     * Offset to retrieve.
1413
     *
1414
     * @param mixed $offset The offset to retrieve.
1415
     * @return mixed Can return all value types.
1416
     * @link http://php.net/manual/en/arrayaccess.offsetget.php
1417
     */
1418 2
    public function offsetGet($offset) {
1419 2
        return isset($this->schema[$offset]) ? $this->schema[$offset] : null;
1420
    }
1421
1422
    /**
1423
     * Offset to set.
1424
     *
1425
     * @param mixed $offset The offset to assign the value to.
1426
     * @param mixed $value The value to set.
1427
     * @link http://php.net/manual/en/arrayaccess.offsetset.php
1428
     */
1429 1
    public function offsetSet($offset, $value) {
1430 1
        $this->schema[$offset] = $value;
1431 1
    }
1432
1433
    /**
1434
     * Offset to unset.
1435
     *
1436
     * @param mixed $offset The offset to unset.
1437
     * @link http://php.net/manual/en/arrayaccess.offsetunset.php
1438
     */
1439 1
    public function offsetUnset($offset) {
1440 1
        unset($this->schema[$offset]);
1441 1
    }
1442
1443
    /**
1444
     * Validate a field against a single type.
1445
     *
1446
     * @param mixed $value The value to validate.
1447
     * @param string $type The type to validate against.
1448
     * @param ValidationField $field Contains field and validation information.
1449
     * @param bool $sparse Whether or not this should be a sparse validation.
1450
     * @return mixed Returns the valid value or `Invalid`.
1451
     */
1452 143
    protected function validateSingleType($value, $type, ValidationField $field, $sparse) {
1453
        switch ($type) {
1454 143
            case 'boolean':
1455 27
                $result = $this->validateBoolean($value, $field);
1456 27
                break;
1457 124
            case 'integer':
1458 35
                $result = $this->validateInteger($value, $field);
1459 35
                break;
1460 119
            case 'number':
1461 10
                $result = $this->validateNumber($value, $field);
1462 10
                break;
1463 115
            case 'string':
1464 63
                $result = $this->validateString($value, $field);
1465 63
                break;
1466 95
            case 'timestamp':
1467 5
                $result = $this->validateTimestamp($value, $field);
1468 5
                break;
1469 95
            case 'datetime':
1470 7
                $result = $this->validateDatetime($value, $field);
1471 7
                break;
1472 92
            case 'array':
1473 20
                $result = $this->validateArray($value, $field, $sparse);
1474 20
                break;
1475 83
            case 'object':
1476 82
                $result = $this->validateObject($value, $field, $sparse);
1477 80
                break;
1478 3
            case 'null':
1479 1
                $result = $this->validateNull($value, $field);
1480 1
                break;
1481 2
            case null:
1482
                // No type was specified so we are valid.
1483 2
                $result = $value;
1484 2
                break;
1485
            default:
1486
                throw new \InvalidArgumentException("Unrecognized type $type.", 500);
1487
        }
1488 143
        return $result;
1489
    }
1490
1491
    /**
1492
     * Validate a field against multiple basic types.
1493
     *
1494
     * The first validation that passes will be returned. If no type can be validated against then validation will fail.
1495
     *
1496
     * @param mixed $value The value to validate.
1497
     * @param string[] $types The types to validate against.
1498
     * @param ValidationField $field Contains field and validation information.
1499
     * @param bool $sparse Whether or not this should be a sparse validation.
1500
     * @return mixed Returns the valid value or `Invalid`.
1501
     */
1502 24
    private function validateMultipleTypes($value, array $types, ValidationField $field, $sparse) {
1503
        // First check for an exact type match.
1504 24
        switch (gettype($value)) {
1505 24
            case 'boolean':
1506 3
                if (in_array('boolean', $types)) {
1507 3
                    $singleType = 'boolean';
1508 3
                }
1509 3
                break;
1510 21
            case 'integer':
1511 5
                if (in_array('integer', $types)) {
1512 4
                    $singleType = 'integer';
1513 5
                } elseif (in_array('number', $types)) {
1514 1
                    $singleType = 'number';
1515 1
                }
1516 5
                break;
1517 16
            case 'double':
1518 3
                if (in_array('number', $types)) {
1519 3
                    $singleType = 'number';
1520 3
                } elseif (in_array('integer', $types)) {
1521
                    $singleType = 'integer';
1522
                }
1523 3
                break;
1524 13
            case 'string':
1525 10
                if (in_array('datetime', $types) && preg_match(self::$DATE_REGEX, $value)) {
1526 1
                    $singleType = 'datetime';
1527 10
                } elseif (in_array('string', $types)) {
1528 5
                    $singleType = 'string';
1529 5
                }
1530 10
                break;
1531 3
            case 'array':
1532 3
                if (in_array('array', $types) && in_array('object', $types)) {
1533
                    $singleType = isset($value[0]) || empty($value) ? 'array' : 'object';
1534 3
                } elseif (in_array('object', $types)) {
1535
                    $singleType = 'object';
1536 3
                } elseif (in_array('array', $types)) {
1537 3
                    $singleType = 'array';
1538 3
                }
1539 3
                break;
1540
            case 'NULL':
1541
                if (in_array('null', $types)) {
1542
                    $singleType = $this->validateSingleType($value, 'null', $field, $sparse);
1543
                }
1544
                break;
1545 24
        }
1546 24
        if (!empty($singleType)) {
1547 20
            return $this->validateSingleType($value, $singleType, $field, $sparse);
1548
        }
1549
1550
        // Clone the validation field to collect errors.
1551 4
        $typeValidation = new ValidationField(new Validation(), $field->getField(), '', $sparse);
1552
1553
        // Try and validate against each type.
1554 4
        foreach ($types as $type) {
1555 4
            $result = $this->validateSingleType($value, $type, $typeValidation, $sparse);
1556 4
            if (Invalid::isValid($result)) {
1557 4
                return $result;
1558
            }
1559
        }
1560
1561
        // Since we got here the value is invalid.
1562
        $field->merge($typeValidation->getValidation());
1563
        return Invalid::value();
1564
    }
1565
}
1566