Completed
Pull Request — master (#33)
by Todd
01:46
created

Schema::withSparse()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 0
Metric Value
dl 0
loc 4
ccs 3
cts 3
cp 1
rs 10
c 0
b 0
f 0
cc 1
eloc 3
nc 1
nop 0
crap 1
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 176
    public function __construct($schema = []) {
77 176
        $this->schema = $schema;
78 176
    }
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
        } 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
        }
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 1
            } elseif ($value instanceof Schema) {
123 1
                return $value->getField(array_slice($path, $i), $default);
124
            } else {
125 5
                return $default;
126
            }
127
        }
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
        }
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 3
                    $selection[$subSelector] = [];
148
                }
149 1
            } 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
        }
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
        } 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 12
    public function hasFlag($flag) {
219 12
        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
        } 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 4
    public function merge(Schema $schema) {
245 4
        $this->mergeInternal($this->schema, $schema->getSchemaArray(), true, true);
246 4
        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 4
    public function add(Schema $schema, $addProperties = false) {
259 4
        $this->mergeInternal($this->schema, $schema->getSchemaArray(), false, $addProperties);
260 4
        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 7
    private function mergeInternal(array &$target, array $source, $overwrite = true, $addProperties = true) {
273
        // We need to do a fix for required properties here.
274 7
        if (isset($target['properties']) && !empty($source['required'])) {
275 5
            $required = isset($target['required']) ? $target['required'] : [];
276
277 5
            if (isset($source['required']) && $addProperties) {
278 4
                $newProperties = array_diff(array_keys($source['properties']), array_keys($target['properties']));
279 4
                $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 4
                $required = array_merge($required, $newRequired);
282
            }
283
        }
284
285
286 7
        foreach ($source as $key => $val) {
287 7
            if (is_array($val) && array_key_exists($key, $target) && is_array($target[$key])) {
288 7
                if ($key === 'properties' && !$addProperties) {
289
                    // We just want to merge the properties that exist in the destination.
290 2
                    foreach ($val as $name => $prop) {
291 2
                        if (isset($target[$key][$name])) {
292 2
                            $targetProp = &$target[$key][$name];
293
294 2
                            if (is_array($targetProp) && is_array($prop)) {
295 2
                                $this->mergeInternal($targetProp, $prop, $overwrite, $addProperties);
296 1
                            } elseif (is_array($targetProp) && $prop instanceof Schema) {
297
                                $this->mergeInternal($targetProp, $prop->getSchemaArray(), $overwrite, $addProperties);
298 1
                            } elseif ($overwrite) {
299 2
                                $target[$key][$name] = $prop;
300
                            }
301
                        }
302
                    }
303 7
                } elseif (isset($val[0]) || isset($target[$key][0])) {
304 5
                    if ($overwrite) {
305
                        // This is a numeric array, so just do a merge.
306 3
                        $merged = array_merge($target[$key], $val);
307 3
                        if (is_string($merged[0])) {
308 3
                            $merged = array_keys(array_flip($merged));
309
                        }
310 5
                        $target[$key] = $merged;
311
                    }
312
                } else {
313 7
                    $target[$key] = $this->mergeInternal($target[$key], $val, $overwrite, $addProperties);
314
                }
315 7
            } 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...
316
                // Do nothing, we aren't replacing.
317
            } else {
318 7
                $target[$key] = $val;
319
            }
320
        }
321
322 7
        if (isset($required)) {
323 5
            if (empty($required)) {
324 1
                unset($target['required']);
325
            } else {
326 5
                $target['required'] = $required;
327
            }
328
        }
329
330 7
        return $target;
331
    }
332
333
//    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...
334
335
    /**
336
     * Returns the internal schema array.
337
     *
338
     * @return array
339
     * @see Schema::jsonSerialize()
340
     */
341 10
    public function getSchemaArray() {
342 10
        return $this->schema;
343
    }
344
345
    /**
346
     * Parse a short schema and return the associated schema.
347
     *
348
     * @param array $arr The schema array.
349
     * @param mixed ...$args Constructor arguments for the schema instance.
350
     * @return static Returns a new schema.
351
     */
352 145
    public static function parse(array $arr, ...$args) {
353 145
        $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...
354 145
        $schema->schema = $schema->parseInternal($arr);
355 145
        return $schema;
356
    }
357
358
    /**
359
     * Parse a schema in short form into a full schema array.
360
     *
361
     * @param array $arr The array to parse into a schema.
362
     * @return array The full schema array.
363
     * @throws \InvalidArgumentException Throws an exception when an item in the schema is invalid.
364
     */
365 145
    protected function parseInternal(array $arr) {
366 145
        if (empty($arr)) {
367
            // An empty schema validates to anything.
368 7
            return [];
369 139
        } elseif (isset($arr['type'])) {
370
            // This is a long form schema and can be parsed as the root.
371
            return $this->parseNode($arr);
372
        } else {
373
            // Check for a root schema.
374 139
            $value = reset($arr);
375 139
            $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...
376 139
            if (is_int($key)) {
377 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...
378 83
                $value = null;
379
            }
380 139
            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...
381 139
            if (empty($name)) {
382 48
                return $this->parseNode($param, $value);
383
            }
384
        }
385
386
        // If we are here then this is n object schema.
387 94
        list($properties, $required) = $this->parseProperties($arr);
388
389
        $result = [
390 94
            'type' => 'object',
391 94
            'properties' => $properties,
392 94
            'required' => $required
393
        ];
394
395 94
        return array_filter($result);
396
    }
397
398
    /**
399
     * Parse a schema node.
400
     *
401
     * @param array $node The node to parse.
402
     * @param mixed $value Additional information from the node.
403
     * @return array Returns a JSON schema compatible node.
404
     */
405 139
    private function parseNode($node, $value = null) {
406 139
        if (is_array($value)) {
407
            // The value describes a bit more about the schema.
408 59
            switch ($node['type']) {
409 59
                case 'array':
410 11
                    if (isset($value['items'])) {
411
                        // The value includes array schema information.
412 4
                        $node = array_replace($node, $value);
413
                    } else {
414 7
                        $node['items'] = $this->parseInternal($value);
415
                    }
416 11
                    break;
417 49
                case 'object':
418
                    // The value is a schema of the object.
419 12
                    if (isset($value['properties'])) {
420
                        list($node['properties']) = $this->parseProperties($value['properties']);
421
                    } else {
422 12
                        list($node['properties'], $required) = $this->parseProperties($value);
423 12
                        if (!empty($required)) {
424 12
                            $node['required'] = $required;
425
                        }
426
                    }
427 12
                    break;
428
                default:
429 37
                    $node = array_replace($node, $value);
430 59
                    break;
431
            }
432 100
        } elseif (is_string($value)) {
433 80
            if ($node['type'] === 'array' && $arrType = $this->getType($value)) {
434 6
                $node['items'] = ['type' => $arrType];
435 76
            } elseif (!empty($value)) {
436 80
                $node['description'] = $value;
437
            }
438 25
        } elseif ($value === null) {
439
            // Parse child elements.
440 21
            if ($node['type'] === 'array' && isset($node['items'])) {
441
                // The value includes array schema information.
442
                $node['items'] = $this->parseInternal($node['items']);
443 21
            } elseif ($node['type'] === 'object' && isset($node['properties'])) {
444
                list($node['properties']) = $this->parseProperties($node['properties']);
445
446
            }
447
        }
448
449 139
        if (is_array($node)) {
450 138
            if (!empty($node['allowNull'])) {
451 1
                $node['type'] = array_merge((array)$node['type'], ['null']);
452
            }
453 138
            unset($node['allowNull']);
454
455 138
            if ($node['type'] === null || $node['type'] === []) {
456 4
                unset($node['type']);
457
            }
458
        }
459
460 139
        return $node;
461
    }
462
463
    /**
464
     * Parse the schema for an object's properties.
465
     *
466
     * @param array $arr An object property schema.
467
     * @return array Returns a schema array suitable to be placed in the **properties** key of a schema.
468
     */
469 94
    private function parseProperties(array $arr) {
470 94
        $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...
471 94
        $requiredProperties = [];
472 94
        foreach ($arr as $key => $value) {
473
            // Fix a schema specified as just a value.
474 94
            if (is_int($key)) {
475 67
                if (is_string($value)) {
476 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...
477 67
                    $value = '';
478
                } else {
479
                    throw new \InvalidArgumentException("Schema at position $key is not a valid parameter.", 500);
480
                }
481
            }
482
483
            // The parameter is defined in the key.
484 94
            list($name, $param, $required) = $this->parseShortParam($key, $value);
485
486 94
            $node = $this->parseNode($param, $value);
487
488 94
            $properties[$name] = $node;
489 94
            if ($required) {
490 94
                $requiredProperties[] = $name;
491
            }
492
        }
493 94
        return array($properties, $requiredProperties);
494
    }
495
496
    /**
497
     * Parse a short parameter string into a full array parameter.
498
     *
499
     * @param string $key The short parameter string to parse.
500
     * @param array $value An array of other information that might help resolve ambiguity.
501
     * @return array Returns an array in the form `[string name, array param, bool required]`.
502
     * @throws \InvalidArgumentException Throws an exception if the short param is not in the correct format.
503
     */
504 139
    public function parseShortParam($key, $value = []) {
505
        // Is the parameter optional?
506 139
        if (substr($key, -1) === '?') {
507 63
            $required = false;
508 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...
509
        } else {
510 99
            $required = true;
511
        }
512
513
        // Check for a type.
514 139
        $parts = explode(':', $key);
515 139
        $name = $parts[0];
516 139
        $types = [];
517
518 139
        if (!empty($parts[1])) {
519 134
            $shortTypes = explode('|', $parts[1]);
520 134
            foreach ($shortTypes as $alias) {
521 134
                $found = $this->getType($alias);
522 134
                if ($found === null) {
523
                    throw new \InvalidArgumentException("Unknown type '$alias'", 500);
524
                } else {
525 134
                    $types[] = $found;
526
                }
527
            }
528
        }
529
530 139
        if ($value instanceof Schema) {
531 5
            if (count($types) === 1 && $types[0] === 'array') {
532 1
                $param = ['type' => $types[0], 'items' => $value];
533
            } else {
534 5
                $param = $value;
535
            }
536 138
        } elseif (isset($value['type'])) {
537 3
            $param = $value;
538
539 3
            if (!empty($types) && $types !== (array)$param['type']) {
540
                $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...
541
                $paramTypesStr = implode('|', (array)$param['type']);
542
543 3
                throw new \InvalidArgumentException("Type mismatch between $typesStr and {$paramTypesStr} for field $name.", 500);
544
            }
545
        } else {
546 135
            if (empty($types) && !empty($parts[1])) {
547
                throw new \InvalidArgumentException("Invalid type {$parts[1]} for field $name.", 500);
548
            }
549 135
            if (empty($types)) {
550 4
                $param = ['type' => null];
551
            } else {
552 134
                $param = ['type' => count($types) === 1 ? $types[0] : $types];
553
            }
554
555
            // Parsed required strings have a minimum length of 1.
556 135
            if (in_array('string', $types) && !empty($name) && $required && (!isset($value['default']) || $value['default'] !== '')) {
557 38
                $param['minLength'] = 1;
558
            }
559
        }
560
561 139
        return [$name, $param, $required];
562
    }
563
564
    /**
565
     * Add a custom filter to change data before validation.
566
     *
567
     * @param string $fieldname The name of the field to filter, if any.
568
     *
569
     * If you are adding a filter to a deeply nested field then separate the path with dots.
570
     * @param callable $callback The callback to filter the field.
571
     * @return $this
572
     */
573 1
    public function addFilter($fieldname, callable $callback) {
574 1
        $this->filters[$fieldname][] = $callback;
575 1
        return $this;
576
    }
577
578
    /**
579
     * Add a custom validator to to validate the schema.
580
     *
581
     * @param string $fieldname The name of the field to validate, if any.
582
     *
583
     * If you are adding a validator to a deeply nested field then separate the path with dots.
584
     * @param callable $callback The callback to validate with.
585
     * @return Schema Returns `$this` for fluent calls.
586
     */
587 4
    public function addValidator($fieldname, callable $callback) {
588 4
        $this->validators[$fieldname][] = $callback;
589 4
        return $this;
590
    }
591
592
    /**
593
     * Require one of a given set of fields in the schema.
594
     *
595
     * @param array $required The field names to require.
596
     * @param string $fieldname The name of the field to attach to.
597
     * @param int $count The count of required items.
598
     * @return Schema Returns `$this` for fluent calls.
599
     */
600 3
    public function requireOneOf(array $required, $fieldname = '', $count = 1) {
601 3
        $result = $this->addValidator(
602 3
            $fieldname,
603 3
            function ($data, ValidationField $field) use ($required, $count) {
604
                // This validator does not apply to sparse validation.
605 3
                if ($field->isSparse()) {
606 1
                    return true;
607
                }
608
609 2
                $hasCount = 0;
610 2
                $flattened = [];
611
612 2
                foreach ($required as $name) {
613 2
                    $flattened = array_merge($flattened, (array)$name);
614
615 2
                    if (is_array($name)) {
616
                        // This is an array of required names. They all must match.
617 1
                        $hasCountInner = 0;
618 1
                        foreach ($name as $nameInner) {
619 1
                            if (array_key_exists($nameInner, $data)) {
620 1
                                $hasCountInner++;
621
                            } else {
622 1
                                break;
623
                            }
624
                        }
625 1
                        if ($hasCountInner >= count($name)) {
626 1
                            $hasCount++;
627
                        }
628 2
                    } elseif (array_key_exists($name, $data)) {
629 1
                        $hasCount++;
630
                    }
631
632 2
                    if ($hasCount >= $count) {
633 2
                        return true;
634
                    }
635
                }
636
637 2
                if ($count === 1) {
638 1
                    $message = 'One of {required} are required.';
639
                } else {
640 1
                    $message = '{count} of {required} are required.';
641
                }
642
643 2
                $field->addError('missingField', [
644 2
                    'messageCode' => $message,
645 2
                    'required' => $required,
646 2
                    'count' => $count
647
                ]);
648 2
                return false;
649 3
            }
650
        );
651
652 3
        return $result;
653
    }
654
655
    /**
656
     * Validate data against the schema.
657
     *
658
     * @param mixed $data The data to validate.
659
     * @param bool $sparse Whether or not this is a sparse validation.
660
     * @return mixed Returns a cleaned version of the data.
661
     * @throws ValidationException Throws an exception when the data does not validate against the schema.
662
     */
663 145
    public function validate($data, $sparse = false) {
664 145
        $field = new ValidationField($this->createValidation(), $this->schema, '', $sparse);
665
666 145
        $clean = $this->validateField($data, $field, $sparse);
667
668 143
        if (Invalid::isInvalid($clean) && $field->isValid()) {
669
            // This really shouldn't happen, but we want to protect against seeing the invalid object.
670
            $field->addError('invalid', ['messageCode' => '{field} is invalid.', 'status' => 422]);
671
        }
672
673 143
        if (!$field->getValidation()->isValid()) {
674 54
            throw new ValidationException($field->getValidation());
675
        }
676
677 103
        return $clean;
678
    }
679
680
    /**
681
     * Validate data against the schema and return the result.
682
     *
683
     * @param mixed $data The data to validate.
684
     * @param bool $sparse Whether or not to do a sparse validation.
685
     * @return bool Returns true if the data is valid. False otherwise.
686
     */
687 21
    public function isValid($data, $sparse = false) {
688
        try {
689 21
            $this->validate($data, $sparse);
690 17
            return true;
691 12
        } catch (ValidationException $ex) {
692 12
            return false;
693
        }
694
    }
695
696
    /**
697
     * Validate a field.
698
     *
699
     * @param mixed $value The value to validate.
700
     * @param ValidationField $field A validation object to add errors to.
701
     * @param bool $sparse Whether or not this is a sparse validation.
702
     * @return mixed|Invalid Returns a clean version of the value with all extra fields stripped out or invalid if the value
703
     * is completely invalid.
704
     */
705 145
    protected function validateField($value, ValidationField $field, $sparse = false) {
706 145
        $result = $value = $this->filterField($value, $field);
707
708 145
        if ($field->getField() instanceof Schema) {
709
            try {
710 5
                $result = $field->getField()->validate($value, $sparse);
711 2
            } catch (ValidationException $ex) {
712
                // The validation failed, so merge the validations together.
713 5
                $field->getValidation()->merge($ex->getValidation(), $field->getName());
714
            }
715 145
        } elseif (($value === null || ($value === '' && !$field->hasType('string'))) && $field->hasType('null')) {
716 7
            $result = null;
717
        } else {
718
            // Validate the field's type.
719 144
            $type = $field->getType();
720 144
            if (is_array($type)) {
721 24
                $result = $this->validateMultipleTypes($value, $type, $field, $sparse);
722
            } else {
723 121
                $result = $this->validateSingleType($value, $type, $field, $sparse);
724
            }
725 144
            if (Invalid::isValid($result)) {
726 142
                $result = $this->validateEnum($result, $field);
727
            }
728
        }
729
730
        // Validate a custom field validator.
731 145
        if (Invalid::isValid($result)) {
732 143
            $this->callValidators($result, $field);
733
        }
734
735 145
        return $result;
736
    }
737
738
    /**
739
     * Validate an array.
740
     *
741
     * @param mixed $value The value to validate.
742
     * @param ValidationField $field The validation results to add.
743
     * @param bool $sparse Whether or not this is a sparse validation.
744
     * @return array|Invalid Returns an array or invalid if validation fails.
745
     */
746 20
    protected function validateArray($value, ValidationField $field, $sparse = false) {
747 20
        if ((!is_array($value) || (count($value) > 0 && !array_key_exists(0, $value))) && !$value instanceof \Traversable) {
748 5
            $field->addTypeError('array');
749 5
            return Invalid::value();
750
        } else {
751 16
            if ((null !== $minItems = $field->val('minItems')) && count($value) < $minItems) {
752 1
                $field->addError(
753 1
                    'minItems',
754
                    [
755 1
                        'messageCode' => '{field} must contain at least {minItems} {minItems,plural,item}.',
756 1
                        'minItems' => $minItems,
757 1
                        'status' => 422
758
                    ]
759
                );
760
            }
761 16
            if ((null !== $maxItems = $field->val('maxItems')) && count($value) > $maxItems) {
762 1
                $field->addError(
763 1
                    'maxItems',
764
                    [
765 1
                        'messageCode' => '{field} must contain no more than {maxItems} {maxItems,plural,item}.',
766 1
                        'maxItems' => $maxItems,
767 1
                        'status' => 422
768
                    ]
769
                );
770
            }
771
772 16
            if ($field->val('items') !== null) {
773 12
                $result = [];
774
775
                // Validate each of the types.
776 12
                $itemValidation = new ValidationField(
777 12
                    $field->getValidation(),
778 12
                    $field->val('items'),
779 12
                    '',
780 12
                    $sparse
781
                );
782
783 12
                $count = 0;
784 12
                foreach ($value as $i => $item) {
785 12
                    $itemValidation->setName($field->getName()."[{$i}]");
786 12
                    $validItem = $this->validateField($item, $itemValidation, $sparse);
787 12
                    if (Invalid::isValid($validItem)) {
788 12
                        $result[] = $validItem;
789
                    }
790 12
                    $count++;
791
                }
792
793 12
                return empty($result) && $count > 0 ? Invalid::value() : $result;
794
            } else {
795
                // Cast the items into a proper numeric array.
796 4
                $result = is_array($value) ? array_values($value) : iterator_to_array($value);
797 4
                return $result;
798
            }
799
        }
800
    }
801
802
    /**
803
     * Validate a boolean value.
804
     *
805
     * @param mixed $value The value to validate.
806
     * @param ValidationField $field The validation results to add.
807
     * @return bool|Invalid Returns the cleaned value or invalid if validation fails.
808
     */
809 28
    protected function validateBoolean($value, ValidationField $field) {
810 28
        $value = $value === null ? $value : filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
811 28
        if ($value === null) {
812 4
            $field->addTypeError('boolean');
813 4
            return Invalid::value();
814
        }
815
816 25
        return $value;
817
    }
818
819
    /**
820
     * Validate a date time.
821
     *
822
     * @param mixed $value The value to validate.
823
     * @param ValidationField $field The validation results to add.
824
     * @return \DateTimeInterface|Invalid Returns the cleaned value or **null** if it isn't valid.
825
     */
826 11
    protected function validateDatetime($value, ValidationField $field) {
827 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...
828
            // do nothing, we're good
829 10
        } elseif (is_string($value) && $value !== '' && !is_numeric($value)) {
830
            try {
831 7
                $dt = new \DateTimeImmutable($value);
832 6
                if ($dt) {
833 6
                    $value = $dt;
834
                } else {
835 6
                    $value = null;
836
                }
837 1
            } catch (\Exception $ex) {
838 7
                $value = Invalid::value();
839
            }
840 3
        } elseif (is_int($value) && $value > 0) {
841 1
            $value = new \DateTimeImmutable('@'.(string)round($value));
842
        } else {
843 2
            $value = Invalid::value();
844
        }
845
846 11
        if (Invalid::isInvalid($value)) {
847 3
            $field->addTypeError('datetime');
848
        }
849 11
        return $value;
850
    }
851
852
    /**
853
     * Validate a float.
854
     *
855
     * @param mixed $value The value to validate.
856
     * @param ValidationField $field The validation results to add.
857
     * @return float|Invalid Returns a number or **null** if validation fails.
858
     */
859 10
    protected function validateNumber($value, ValidationField $field) {
860 10
        $result = filter_var($value, FILTER_VALIDATE_FLOAT);
861 10
        if ($result === false) {
862 3
            $field->addTypeError('number');
863 3
            return Invalid::value();
864
        }
865 7
        return $result;
866
    }
867
    /**
868
     * Validate and integer.
869
     *
870
     * @param mixed $value The value to validate.
871
     * @param ValidationField $field The validation results to add.
872
     * @return int|Invalid Returns the cleaned value or **null** if validation fails.
873
     */
874 35
    protected function validateInteger($value, ValidationField $field) {
875 35
        $result = filter_var($value, FILTER_VALIDATE_INT);
876
877 35
        if ($result === false) {
878 8
            $field->addTypeError('integer');
879 8
            return Invalid::value();
880
        }
881 31
        return $result;
882
    }
883
884
    /**
885
     * Validate an object.
886
     *
887
     * @param mixed $value The value to validate.
888
     * @param ValidationField $field The validation results to add.
889
     * @param bool $sparse Whether or not this is a sparse validation.
890
     * @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...
891
     */
892 83
    protected function validateObject($value, ValidationField $field, $sparse = false) {
893 83
        if (!$this->isArray($value) || isset($value[0])) {
894 5
            $field->addTypeError('object');
895 5
            return Invalid::value();
896 83
        } elseif (is_array($field->val('properties'))) {
897
            // Validate the data against the internal schema.
898 80
            $value = $this->validateProperties($value, $field, $sparse);
899 3
        } elseif (!is_array($value)) {
900 3
            $value = $this->toObjectArray($value);
901
        }
902 81
        return $value;
903
    }
904
905
    /**
906
     * Validate data against the schema and return the result.
907
     *
908
     * @param array|\ArrayAccess $data The data to validate.
909
     * @param ValidationField $field This argument will be filled with the validation result.
910
     * @param bool $sparse Whether or not this is a sparse validation.
911
     * @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...
912
     * or invalid if there are no valid properties.
913
     */
914 80
    protected function validateProperties($data, ValidationField $field, $sparse = false) {
915 80
        $properties = $field->val('properties', []);
916 80
        $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...
917
918 80
        if (is_array($data)) {
919 76
            $keys = array_keys($data);
920 76
            $clean = [];
921
        } else {
922 4
            $keys = array_keys(iterator_to_array($data));
923 4
            $class = get_class($data);
924 4
            $clean = new $class;
925
926 4
            if ($clean instanceof \ArrayObject) {
927 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...
928 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...
929
            }
930
        }
931 80
        $keys = array_combine(array_map('strtolower', $keys), $keys);
932
933 80
        $propertyField = new ValidationField($field->getValidation(), [], null, $sparse);
934
935
        // Loop through the schema fields and validate each one.
936 80
        foreach ($properties as $propertyName => $property) {
937
            $propertyField
938 80
                ->setField($property)
939 80
                ->setName(ltrim($field->getName().".$propertyName", '.'));
940
941 80
            $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...
942 80
            $isRequired = isset($required[$propertyName]);
943
944
            // First check for required fields.
945 80
            if (!array_key_exists($lName, $keys)) {
946 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...
947
                    // Sparse validation can leave required fields out.
948 18
                } elseif ($propertyField->hasVal('default')) {
949 2
                    $clean[$propertyName] = $propertyField->val('default');
950 16
                } elseif ($isRequired) {
951 19
                    $propertyField->addError('missingField', ['messageCode' => '{field} is required.']);
952
                }
953
            } else {
954 77
                $value = $data[$keys[$lName]];
955
956 77
                if (in_array($value, [null, ''], true) && !$isRequired && !$propertyField->hasType('null')) {
957 5
                    if ($propertyField->getType() !== 'string' || $value === null) {
958 2
                        continue;
959
                    }
960
                }
961
962 75
                $clean[$propertyName] = $this->validateField($value, $propertyField, $sparse);
963
            }
964
965 78
            unset($keys[$lName]);
966
        }
967
968
        // Look for extraneous properties.
969 80
        if (!empty($keys)) {
970 11
            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...
971 2
                $msg = sprintf("%s has unexpected field(s): %s.", $field->getName() ?: 'value', implode(', ', $keys));
972 2
                trigger_error($msg, E_USER_NOTICE);
973
            }
974
975 9
            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...
976 2
                $field->addError('invalid', [
977 2
                    'messageCode' => '{field} has {extra,plural,an unexpected field,unexpected fields}: {extra}.',
978 2
                    'extra' => array_values($keys),
979 2
                    'status' => 422
980
                ]);
981
            }
982
        }
983
984 78
        return $clean;
985
    }
986
987
    /**
988
     * Validate a string.
989
     *
990
     * @param mixed $value The value to validate.
991
     * @param ValidationField $field The validation results to add.
992
     * @return string|Invalid Returns the valid string or **null** if validation fails.
993
     */
994 63
    protected function validateString($value, ValidationField $field) {
995 63
        if (is_string($value) || is_numeric($value)) {
996 61
            $value = $result = (string)$value;
997
        } else {
998 4
            $field->addTypeError('string');
999 4
            return Invalid::value();
1000
        }
1001
1002 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...
1003 61
        if (($minLength = $field->val('minLength', 0)) > 0 && mb_strlen($value) < $minLength) {
1004 3
            if (!empty($field->getName()) && $minLength === 1) {
1005 1
                $field->addError('missingField', ['messageCode' => '{field} is required.', 'status' => 422]);
1006
            } else {
1007 2
                $field->addError(
1008 2
                    'minLength',
1009
                    [
1010 2
                        'messageCode' => '{field} should be at least {minLength} {minLength,plural,character} long.',
1011 2
                        'minLength' => $minLength,
1012 2
                        'status' => 422
1013
                    ]
1014
                );
1015
            }
1016
        }
1017 61
        if (($maxLength = $field->val('maxLength', 0)) > 0 && mb_strlen($value) > $maxLength) {
1018 1
            $field->addError(
1019 1
                'maxLength',
1020
                [
1021 1
                    'messageCode' => '{field} is {overflow} {overflow,plural,characters} too long.',
1022 1
                    'maxLength' => $maxLength,
1023 1
                    'overflow' => mb_strlen($value) - $maxLength,
1024 1
                    'status' => 422
1025
                ]
1026
            );
1027
        }
1028 61
        if ($pattern = $field->val('pattern')) {
1029 4
            $regex = '`'.str_replace('`', preg_quote('`', '`'), $pattern).'`';
1030
1031 4
            if (!preg_match($regex, $value)) {
1032 2
                $field->addError(
1033 2
                    'invalid',
1034
                    [
1035 2
                        'messageCode' => '{field} is in the incorrect format.',
1036
                        'status' => 422
1037
                    ]
1038
                );
1039
            }
1040
        }
1041 61
        if ($format = $field->val('format')) {
1042 15
            $type = $format;
1043
            switch ($format) {
1044 15
                case 'date-time':
1045 4
                    $result = $this->validateDatetime($result, $field);
1046 4
                    if ($result instanceof \DateTimeInterface) {
1047 4
                        $result = $result->format(\DateTime::RFC3339);
1048
                    }
1049 4
                    break;
1050 11
                case 'email':
1051 1
                    $result = filter_var($result, FILTER_VALIDATE_EMAIL);
1052 1
                    break;
1053 10
                case 'ipv4':
1054 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...
1055 1
                    $result = filter_var($result, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
1056 1
                    break;
1057 9
                case 'ipv6':
1058 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...
1059 1
                    $result = filter_var($result, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
1060 1
                    break;
1061 8
                case 'ip':
1062 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...
1063 1
                    $result = filter_var($result, FILTER_VALIDATE_IP);
1064 1
                    break;
1065 7
                case 'uri':
1066 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...
1067 7
                    $result = filter_var($result, FILTER_VALIDATE_URL, FILTER_FLAG_HOST_REQUIRED | FILTER_FLAG_SCHEME_REQUIRED);
1068 7
                    break;
1069
                default:
1070
                    trigger_error("Unrecognized format '$format'.", E_USER_NOTICE);
1071
            }
1072 15
            if ($result === false) {
1073 5
                $field->addTypeError($type);
1074
            }
1075
        }
1076
1077 61
        if ($field->isValid()) {
1078 54
            return $result;
1079
        } else {
1080 11
            return Invalid::value();
1081
        }
1082
    }
1083
1084
    /**
1085
     * Validate a unix timestamp.
1086
     *
1087
     * @param mixed $value The value to validate.
1088
     * @param ValidationField $field The field being validated.
1089
     * @return int|Invalid Returns a valid timestamp or invalid if the value doesn't validate.
1090
     */
1091 5
    protected function validateTimestamp($value, ValidationField $field) {
1092 5
        if (is_numeric($value) && $value > 0) {
1093 1
            $result = (int)$value;
1094 4
        } elseif (is_string($value) && $ts = strtotime($value)) {
1095 1
            $result = $ts;
1096
        } else {
1097 3
            $field->addTypeError('timestamp');
1098 3
            $result = Invalid::value();
1099
        }
1100 5
        return $result;
1101
    }
1102
1103
    /**
1104
     * Validate a null value.
1105
     *
1106
     * @param mixed $value The value to validate.
1107
     * @param ValidationField $field The error collector for the field.
1108
     * @return null|Invalid Returns **null** or invalid.
1109
     */
1110 1
    protected function validateNull($value, ValidationField $field) {
1111 1
        if ($value === null) {
1112
            return null;
1113
        }
1114 1
        $field->addError('invalid', ['messageCode' => '{field} should be null.', 'status' => 422]);
1115 1
        return Invalid::value();
1116
    }
1117
1118
    /**
1119
     * Validate a value against an enum.
1120
     *
1121
     * @param mixed $value The value to test.
1122
     * @param ValidationField $field The validation object for adding errors.
1123
     * @return mixed|Invalid Returns the value if it is one of the enumerated values or invalid otherwise.
1124
     */
1125 142
    protected function validateEnum($value, ValidationField $field) {
1126 142
        $enum = $field->val('enum');
1127 142
        if (empty($enum)) {
1128 141
            return $value;
1129
        }
1130
1131 1
        if (!in_array($value, $enum, true)) {
1132 1
            $field->addError(
1133 1
                'invalid',
1134
                [
1135 1
                    'messageCode' => '{field} must be one of: {enum}.',
1136 1
                    'enum' => $enum,
1137 1
                    'status' => 422
1138
                ]
1139
            );
1140 1
            return Invalid::value();
1141
        }
1142 1
        return $value;
1143
    }
1144
1145
    /**
1146
     * Call all of the filters attached to a field.
1147
     *
1148
     * @param mixed $value The field value being filtered.
1149
     * @param ValidationField $field The validation object.
1150
     * @return mixed Returns the filtered value. If there are no filters for the field then the original value is returned.
1151
     */
1152 145
    protected function callFilters($value, ValidationField $field) {
1153
        // Strip array references in the name except for the last one.
1154 145
        $key = preg_replace(['`\[\d+\]$`', '`\[\d+\]`'], ['[]', ''], $field->getName());
1155 145
        if (!empty($this->filters[$key])) {
1156 1
            foreach ($this->filters[$key] as $filter) {
1157 1
                $value = call_user_func($filter, $value, $field);
1158
            }
1159
        }
1160 145
        return $value;
1161
    }
1162
1163
    /**
1164
     * Call all of the validators attached to a field.
1165
     *
1166
     * @param mixed $value The field value being validated.
1167
     * @param ValidationField $field The validation object to add errors.
1168
     */
1169 143
    protected function callValidators($value, ValidationField $field) {
1170 143
        $valid = true;
1171
1172
        // Strip array references in the name except for the last one.
1173 143
        $key = preg_replace(['`\[\d+\]$`', '`\[\d+\]`'], ['[]', ''], $field->getName());
1174 143
        if (!empty($this->validators[$key])) {
1175 4
            foreach ($this->validators[$key] as $validator) {
1176 4
                $r = call_user_func($validator, $value, $field);
1177
1178 4
                if ($r === false || Invalid::isInvalid($r)) {
1179 4
                    $valid = false;
1180
                }
1181
            }
1182
        }
1183
1184
        // Add an error on the field if the validator hasn't done so.
1185 143
        if (!$valid && $field->isValid()) {
1186
            $field->addError('invalid', ['messageCode' => '{field} is invalid.', 'status' => 422]);
1187
        }
1188 143
    }
1189
1190
    /**
1191
     * Specify data which should be serialized to JSON.
1192
     *
1193
     * This method specifically returns data compatible with the JSON schema format.
1194
     *
1195
     * @return mixed Returns data which can be serialized by **json_encode()**, which is a value of any type other than a resource.
1196
     * @link http://php.net/manual/en/jsonserializable.jsonserialize.php
1197
     * @link http://json-schema.org/
1198
     */
1199
    public function jsonSerialize() {
1200 14
        $fix = function ($schema) use (&$fix) {
1201 14
            if ($schema instanceof Schema) {
1202 1
                return $schema->jsonSerialize();
1203
            }
1204
1205 14
            if (!empty($schema['type'])) {
1206 13
                $types = (array)$schema['type'];
1207 13
                foreach ($types as &$type) {
1208
                    // Swap datetime and timestamp to other types with formats.
1209 13
                    if ($type === 'datetime') {
1210 3
                        $type = 'string';
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 13 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...
1211 3
                        $schema['format'] = 'date-time';
1212 12
                    } elseif ($type === 'timestamp') {
1213 3
                        $type = 'integer';
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 13 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...
1214 13
                        $schema['format'] = 'timestamp';
1215
                    }
1216
                }
1217 13
                $types = array_unique($types);
1218 13
                if (count($types) === 1) {
1219 13
                    $types = reset($types);
1220
                }
1221 13
                $schema['type'] = $types;
1222
            }
1223
1224 14
            if (!empty($schema['items'])) {
1225 4
                $schema['items'] = $fix($schema['items']);
1226
            }
1227 14
            if (!empty($schema['properties'])) {
1228 10
                $properties = [];
1229 10
                foreach ($schema['properties'] as $key => $property) {
1230 10
                    $properties[$key] = $fix($property);
1231
                }
1232 10
                $schema['properties'] = $properties;
1233
            }
1234
1235 14
            return $schema;
1236 14
        };
1237
1238 14
        $result = $fix($this->schema);
1239
1240 14
        return $result;
1241
    }
1242
1243
    /**
1244
     * Look up a type based on its alias.
1245
     *
1246
     * @param string $alias The type alias or type name to lookup.
1247
     * @return mixed
1248
     */
1249 134
    protected function getType($alias) {
1250 134
        if (isset(self::$types[$alias])) {
1251
            return $alias;
1252
        }
1253 134
        foreach (self::$types as $type => $aliases) {
1254 134
            if (in_array($alias, $aliases, true)) {
1255 134
                return $type;
1256
            }
1257
        }
1258 6
        return null;
1259
    }
1260
1261
    /**
1262
     * Get the class that's used to contain validation information.
1263
     *
1264
     * @return Validation|string Returns the validation class.
1265
     */
1266 145
    public function getValidationClass() {
1267 145
        return $this->validationClass;
1268
    }
1269
1270
    /**
1271
     * Set the class that's used to contain validation information.
1272
     *
1273
     * @param Validation|string $class Either the name of a class or a class that will be cloned.
1274
     * @return $this
1275
     */
1276 1
    public function setValidationClass($class) {
1277 1
        if (!is_a($class, Validation::class, true)) {
1278
            throw new \InvalidArgumentException("$class must be a subclass of ".Validation::class, 500);
1279
        }
1280
1281 1
        $this->validationClass = $class;
1282 1
        return $this;
1283
    }
1284
1285
    /**
1286
     * Create a new validation instance.
1287
     *
1288
     * @return Validation Returns a validation object.
1289
     */
1290 145
    protected function createValidation() {
1291 145
        $class = $this->getValidationClass();
1292
1293 145
        if ($class instanceof Validation) {
1294 1
            $result = clone $class;
1295
        } else {
1296 145
            $result = new $class;
1297
        }
1298 145
        return $result;
1299
    }
1300
1301
    /**
1302
     * Check whether or not a value is an array or accessible like an array.
1303
     *
1304
     * @param mixed $value The value to check.
1305
     * @return bool Returns **true** if the value can be used like an array or **false** otherwise.
1306
     */
1307 83
    private function isArray($value) {
1308 83
        return is_array($value) || ($value instanceof \ArrayAccess && $value instanceof \Traversable);
1309
    }
1310
1311
    /**
1312
     * Cast a value to an array.
1313
     *
1314
     * @param \Traversable $value The value to convert.
1315
     * @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...
1316
     */
1317 3
    private function toObjectArray(\Traversable $value) {
1318 3
        $class = get_class($value);
1319 3
        if ($value instanceof \ArrayObject) {
1320 2
            return new $class($value->getArrayCopy(), $value->getFlags(), $value->getIteratorClass());
1321 1
        } elseif ($value instanceof \ArrayAccess) {
1322 1
            $r = new $class;
1323 1
            foreach ($value as $k => $v) {
1324 1
                $r[$k] = $v;
1325
            }
1326 1
            return $r;
1327
        }
1328
        return iterator_to_array($value);
1329
    }
1330
1331
    /**
1332
     * Return a sparse version of this schema.
1333
     *
1334
     * A sparse schema has no required properties.
1335
     *
1336
     * @return Schema Returns a new sparse schema.
1337
     */
1338 2
    public function withSparse() {
1339 2
        $sparseSchema = $this->withSparseInternal($this, new \SplObjectStorage());
1340 2
        return $sparseSchema;
1341
    }
1342
1343
    /**
1344
     * The internal implementation of `Schema::withSparse()`.
1345
     *
1346
     * @param array|Schema $schema The schema to make sparse.
1347
     * @param \SplObjectStorage $schemas Collected sparse schemas that have already been made.
1348
     * @return mixed
1349
     */
1350 2
    private function withSparseInternal($schema, \SplObjectStorage $schemas) {
1351 2
        if ($schema instanceof Schema) {
1352 2
            if ($schemas->contains($schema)) {
1353 1
                return $schemas[$schema];
1354
            } else {
1355 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...
1356 2
                $sparseSchema->schema = $schema->withSparseInternal($schema->schema, $schemas);
1357 2
                if ($id = $sparseSchema->getID()) {
1358
                    $sparseSchema->setID($id.'Sparse');
1359
                }
1360
1361 2
                return $sparseSchema;
1362
            }
1363
        }
1364
1365 2
        unset($schema['required']);
1366
1367 2
        if (isset($schema['items'])) {
1368 1
            $schema['items'] = $this->withSparseInternal($schema['items'], $schemas);
1369
        }
1370 2
        if (isset($schema['properties'])) {
1371 2
            foreach ($schema['properties'] as $name => &$property) {
1372 2
                $property = $this->withSparseInternal($property, $schemas);
1373
            }
1374
        }
1375
1376 2
        return $schema;
1377
    }
1378
1379
    /**
1380
     * Filter a field's value using built in and custom filters.
1381
     *
1382
     * @param mixed $value The original value of the field.
1383
     * @param ValidationField $field The field information for the field.
1384
     * @return mixed Returns the filtered field or the original field value if there are no filters.
1385
     */
1386 145
    private function filterField($value, ValidationField $field) {
1387
        // Check for limited support for Open API style.
1388 145
        if (!empty($field->val('style')) && is_string($value)) {
1389 8
            $doFilter = true;
1390 8
            if ($field->hasType('boolean') && in_array($value, ['true', 'false', '0', '1'], true)) {
1391 4
                $doFilter = false;
1392 4
            } elseif ($field->hasType('integer') || $field->hasType('number') && is_numeric($value)) {
1393
                $doFilter = false;
1394
            }
1395
1396 8
            if ($doFilter) {
1397 4
                switch ($field->val('style')) {
1398 4
                    case 'form':
1399 2
                        $value = explode(',', $value);
1400 2
                        break;
1401 2
                    case 'spaceDelimited':
1402 1
                        $value = explode(' ', $value);
1403 1
                        break;
1404 1
                    case 'pipeDelimited':
1405 1
                        $value = explode('|', $value);
1406 1
                        break;
1407
                }
1408
            }
1409
        }
1410
1411 145
        $value = $this->callFilters($value, $field);
1412
1413 145
        return $value;
1414
    }
1415
1416
    /**
1417
     * Whether a offset exists.
1418
     *
1419
     * @param mixed $offset An offset to check for.
1420
     * @return boolean true on success or false on failure.
1421
     * @link http://php.net/manual/en/arrayaccess.offsetexists.php
1422
     */
1423 6
    public function offsetExists($offset) {
1424 6
        return isset($this->schema[$offset]);
1425
    }
1426
1427
    /**
1428
     * Offset to retrieve.
1429
     *
1430
     * @param mixed $offset The offset to retrieve.
1431
     * @return mixed Can return all value types.
1432
     * @link http://php.net/manual/en/arrayaccess.offsetget.php
1433
     */
1434 2
    public function offsetGet($offset) {
1435 2
        return isset($this->schema[$offset]) ? $this->schema[$offset] : null;
1436
    }
1437
1438
    /**
1439
     * Offset to set.
1440
     *
1441
     * @param mixed $offset The offset to assign the value to.
1442
     * @param mixed $value The value to set.
1443
     * @link http://php.net/manual/en/arrayaccess.offsetset.php
1444
     */
1445 1
    public function offsetSet($offset, $value) {
1446 1
        $this->schema[$offset] = $value;
1447 1
    }
1448
1449
    /**
1450
     * Offset to unset.
1451
     *
1452
     * @param mixed $offset The offset to unset.
1453
     * @link http://php.net/manual/en/arrayaccess.offsetunset.php
1454
     */
1455 1
    public function offsetUnset($offset) {
1456 1
        unset($this->schema[$offset]);
1457 1
    }
1458
1459
    /**
1460
     * Validate a field against a single type.
1461
     *
1462
     * @param mixed $value The value to validate.
1463
     * @param string $type The type to validate against.
1464
     * @param ValidationField $field Contains field and validation information.
1465
     * @param bool $sparse Whether or not this should be a sparse validation.
1466
     * @return mixed Returns the valid value or `Invalid`.
1467
     */
1468 144
    protected function validateSingleType($value, $type, ValidationField $field, $sparse) {
1469
        switch ($type) {
1470 144
            case 'boolean':
1471 28
                $result = $this->validateBoolean($value, $field);
1472 28
                break;
1473 125
            case 'integer':
1474 35
                $result = $this->validateInteger($value, $field);
1475 35
                break;
1476 120
            case 'number':
1477 10
                $result = $this->validateNumber($value, $field);
1478 10
                break;
1479 116
            case 'string':
1480 63
                $result = $this->validateString($value, $field);
1481 63
                break;
1482 96
            case 'timestamp':
1483 5
                $result = $this->validateTimestamp($value, $field);
1484 5
                break;
1485 96
            case 'datetime':
1486 7
                $result = $this->validateDatetime($value, $field);
1487 7
                break;
1488 93
            case 'array':
1489 20
                $result = $this->validateArray($value, $field, $sparse);
1490 20
                break;
1491 84
            case 'object':
1492 83
                $result = $this->validateObject($value, $field, $sparse);
1493 81
                break;
1494 3
            case 'null':
1495 1
                $result = $this->validateNull($value, $field);
1496 1
                break;
1497 2
            case null:
1498
                // No type was specified so we are valid.
1499 2
                $result = $value;
1500 2
                break;
1501
            default:
1502
                throw new \InvalidArgumentException("Unrecognized type $type.", 500);
1503
        }
1504 144
        return $result;
1505
    }
1506
1507
    /**
1508
     * Validate a field against multiple basic types.
1509
     *
1510
     * The first validation that passes will be returned. If no type can be validated against then validation will fail.
1511
     *
1512
     * @param mixed $value The value to validate.
1513
     * @param string[] $types The types to validate against.
1514
     * @param ValidationField $field Contains field and validation information.
1515
     * @param bool $sparse Whether or not this should be a sparse validation.
1516
     * @return mixed Returns the valid value or `Invalid`.
1517
     */
1518 24
    private function validateMultipleTypes($value, array $types, ValidationField $field, $sparse) {
1519
        // First check for an exact type match.
1520 24
        switch (gettype($value)) {
1521 24
            case 'boolean':
1522 3
                if (in_array('boolean', $types)) {
1523 3
                    $singleType = 'boolean';
1524
                }
1525 3
                break;
1526 21
            case 'integer':
1527 5
                if (in_array('integer', $types)) {
1528 4
                    $singleType = 'integer';
1529 1
                } elseif (in_array('number', $types)) {
1530 1
                    $singleType = 'number';
1531
                }
1532 5
                break;
1533 16
            case 'double':
1534 3
                if (in_array('number', $types)) {
1535 3
                    $singleType = 'number';
1536
                } elseif (in_array('integer', $types)) {
1537
                    $singleType = 'integer';
1538
                }
1539 3
                break;
1540 13
            case 'string':
1541 10
                if (in_array('datetime', $types) && preg_match(self::$DATE_REGEX, $value)) {
1542 1
                    $singleType = 'datetime';
1543 9
                } elseif (in_array('string', $types)) {
1544 5
                    $singleType = 'string';
1545
                }
1546 10
                break;
1547 3
            case 'array':
1548 3
                if (in_array('array', $types) && in_array('object', $types)) {
1549
                    $singleType = isset($value[0]) || empty($value) ? 'array' : 'object';
1550 3
                } elseif (in_array('object', $types)) {
1551
                    $singleType = 'object';
1552 3
                } elseif (in_array('array', $types)) {
1553 3
                    $singleType = 'array';
1554
                }
1555 3
                break;
1556
            case 'NULL':
1557
                if (in_array('null', $types)) {
1558
                    $singleType = $this->validateSingleType($value, 'null', $field, $sparse);
1559
                }
1560
                break;
1561
        }
1562 24
        if (!empty($singleType)) {
1563 20
            return $this->validateSingleType($value, $singleType, $field, $sparse);
1564
        }
1565
1566
        // Clone the validation field to collect errors.
1567 4
        $typeValidation = new ValidationField(new Validation(), $field->getField(), '', $sparse);
1568
1569
        // Try and validate against each type.
1570 4
        foreach ($types as $type) {
1571 4
            $result = $this->validateSingleType($value, $type, $typeValidation, $sparse);
1572 4
            if (Invalid::isValid($result)) {
1573 4
                return $result;
1574
            }
1575
        }
1576
1577
        // Since we got here the value is invalid.
1578
        $field->merge($typeValidation->getValidation());
1579
        return Invalid::value();
1580
    }
1581
}
1582