Completed
Pull Request — master (#11)
by Todd
01:48
created

Schema::getID()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 2

Importance

Changes 0
Metric Value
dl 0
loc 3
ccs 2
cts 2
cp 1
rs 10
c 0
b 0
f 0
cc 2
eloc 2
nc 2
nop 0
crap 2
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 {
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
    private $schema = [];
42
43
    /**
44
     * @var int A bitwise combination of the various **Schema::FLAG_*** constants.
45
     */
46
    private $flags = 0;
47
48
    /**
49
     * @var array An array of callbacks that will custom validate the schema.
50
     */
51
    private $validators = [];
52
53
    /**
54
     * @var string|Validation The name of the class or an instance that will be cloned.
55
     */
56
    private $validationClass = Validation::class;
57
58
59
    /// Methods ///
60
61
    /**
62
     * Initialize an instance of a new {@link Schema} class.
63
     *
64
     * @param array $schema The array schema to validate against.
65
     */
66 155
    public function __construct($schema = []) {
67 155
        $this->schema = $schema;
68 155
    }
69
70
    /**
71
     * Grab the schema's current description.
72
     *
73
     * @return string
74
     */
75 1
    public function getDescription() {
76 1
        return isset($this->schema['description']) ? $this->schema['description'] : '';
77
    }
78
79
    /**
80
     * Set the description for the schema.
81
     *
82
     * @param string $description The new description.
83
     * @throws \InvalidArgumentException Throws an exception when the provided description is not a string.
84
     * @return Schema
85
     */
86 2
    public function setDescription($description) {
87 2
        if (is_string($description)) {
88 1
            $this->schema['description'] = $description;
89 1
        } else {
90 1
            throw new \InvalidArgumentException("The description is not a valid string.", 500);
91
        }
92
93 1
        return $this;
94
    }
95
96
    /**
97
     * Get a schema field.
98
     *
99
     * @param string|array $path The JSON schema path of the field with parts separated by dots.
100
     * @param mixed $default The value to return if the field isn't found.
101
     * @return mixed Returns the field value or `$default`.
102
     */
103 4
    public function getField($path, $default = null) {
104 4
        if (is_string($path)) {
105 4
            $path = explode('.', $path);
106 4
        }
107
108 4
        $value = $this->schema;
109 4
        for ($i = 0; $i < count($path); ++$i) {
0 ignored issues
show
Performance Best Practice introduced by
It seems like you are calling the size function count() as part of the test condition. You might want to compute the size beforehand, and not on each iteration.

If the size of the collection does not change during the iteration, it is generally a good practice to compute it beforehand, and not on each iteration:

for ($i=0; $i<count($array); $i++) { // calls count() on each iteration
}

// Better
for ($i=0, $c=count($array); $i<$c; $i++) { // calls count() just once
}
Loading history...
110 4
            $subKey = $path[$i];
111
112 4
            if (is_array($value) && isset($value[$subKey])) {
113 4
                $value = $value[$subKey];
114 4
            } elseif ($value instanceof Schema) {
115 1
                return $value->getField(array_slice($path, $i), $default);
116
            } else {
117
                return $default;
118
            }
119 4
        }
120 4
        return $value;
121
    }
122
123
    /**
124
     * Set a schema field.
125
     *
126
     * @param string|array $path The JSON schema path of the field with parts separated by dots.
127
     * @param mixed $value The new value.
128
     * @return $this
129
     */
130 3
    public function setField($path, $value) {
131 3
        if (is_string($path)) {
132 3
            $path = explode('.', $path);
133 3
        }
134
135 3
        $selection = &$this->schema;
136 3
        foreach ($path as $i => $subSelector) {
137 3
            if (is_array($selection)) {
138 3
                if (!isset($selection[$subSelector])) {
139 1
                    $selection[$subSelector] = [];
140 1
                }
141 3
            } elseif ($selection instanceof Schema) {
142 1
                $selection->setField(array_slice($path, $i), $value);
143 1
                return $this;
144
            } else {
145
                $selection = [$subSelector => []];
146
            }
147 3
            $selection = &$selection[$subSelector];
148 3
        }
149
150 3
        $selection = $value;
151 3
        return $this;
152
    }
153
154
    /**
155
     * Get the ID for the schema.
156
     *
157
     * @return string
158
     */
159 2
    public function getID() {
160 2
        return isset($this->schema['id']) ? $this->schema['id'] : '';
161
    }
162
163
    /**
164
     * Set the ID for the schema.
165
     *
166
     * @param string $ID The new ID.
167
     * @throws \InvalidArgumentException Throws an exception when the provided ID is not a string.
168
     * @return Schema
169
     */
170
    public function setID($ID) {
171
        if (is_string($ID)) {
172
            $this->schema['ID'] = $ID;
173
        } else {
174
            throw new \InvalidArgumentException("The ID is not a valid string.", 500);
175
        }
176
177
        return $this;
178
    }
179
180
    /**
181
     * Return the validation flags.
182
     *
183
     * @return int Returns a bitwise combination of flags.
184
     */
185 1
    public function getFlags() {
186 1
        return $this->flags;
187
    }
188
189
    /**
190
     * Set the validation flags.
191
     *
192
     * @param int $flags One or more of the **Schema::FLAG_*** constants.
193
     * @return Schema Returns the current instance for fluent calls.
194
     */
195 8
    public function setFlags($flags) {
196 8
        if (!is_int($flags)) {
197 1
            throw new \InvalidArgumentException('Invalid flags.', 500);
198
        }
199 7
        $this->flags = $flags;
200
201 7
        return $this;
202
    }
203
204
    /**
205
     * Whether or not the schema has a flag (or combination of flags).
206
     *
207
     * @param int $flag One or more of the **Schema::VALIDATE_*** constants.
208
     * @return bool Returns **true** if all of the flags are set or **false** otherwise.
209
     */
210 8
    public function hasFlag($flag) {
211 8
        return ($this->flags & $flag) === $flag;
212
    }
213
214
    /**
215
     * Set a flag.
216
     *
217
     * @param int $flag One or more of the **Schema::VALIDATE_*** constants.
218
     * @param bool $value Either true or false.
219
     * @return $this
220
     */
221 1
    public function setFlag($flag, $value) {
222 1
        if ($value) {
223 1
            $this->flags = $this->flags | $flag;
224 1
        } else {
225 1
            $this->flags = $this->flags & ~$flag;
226
        }
227 1
        return $this;
228
    }
229
230
    /**
231
     * Merge a schema with this one.
232
     *
233
     * @param Schema $schema A scheme instance. Its parameters will be merged into the current instance.
234
     * @return $this
235
     */
236 3
    public function merge(Schema $schema) {
237 3
        $this->mergeInternal($this->schema, $schema->getSchemaArray(), true, true);
238 3
        return $this;
239
    }
240
241
    /**
242
     * Add another schema to this one.
243
     *
244
     * Adding schemas together is analogous to array addition. When you add a schema it will only add missing information.
245
     *
246
     * @param Schema $schema The schema to add.
247
     * @param bool $addProperties Whether to add properties that don't exist in this schema.
248
     * @return $this
249
     */
250 3
    public function add(Schema $schema, $addProperties = false) {
251 3
        $this->mergeInternal($this->schema, $schema->getSchemaArray(), false, $addProperties);
252 3
        return $this;
253
    }
254
255
    /**
256
     * The internal implementation of schema merging.
257
     *
258
     * @param array &$target The target of the merge.
259
     * @param array $source The source of the merge.
260
     * @param bool $overwrite Whether or not to replace values.
261
     * @param bool $addProperties Whether or not to add object properties to the target.
262
     * @return array
263
     */
264 6
    private function mergeInternal(array &$target, array $source, $overwrite = true, $addProperties = true) {
265
        // We need to do a fix for required properties here.
266 6
        if (isset($target['properties']) && !empty($source['required'])) {
267 4
            $required = isset($target['required']) ? $target['required'] : [];
268
269 4
            if (isset($source['required']) && $addProperties) {
270 3
                $newProperties = array_diff(array_keys($source['properties']), array_keys($target['properties']));
271 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...
272
273 3
                $required = array_merge($required, $newRequired);
274 3
            }
275 4
        }
276
277
278 6
        foreach ($source as $key => $val) {
279 6
            if (is_array($val) && array_key_exists($key, $target) && is_array($target[$key])) {
280 6
                if ($key === 'properties' && !$addProperties) {
281
                    // We just want to merge the properties that exist in the destination.
282 1
                    foreach ($val as $name => $prop) {
283 1
                        if (isset($target[$key][$name])) {
284 1
                            $this->mergeInternal($target[$key][$name], $prop, $overwrite, $addProperties);
285 1
                        }
286 1
                    }
287 6
                } elseif (isset($val[0]) || isset($target[$key][0])) {
288 4
                    if ($overwrite) {
289
                        // This is a numeric array, so just do a merge.
290 2
                        $merged = array_merge($target[$key], $val);
291 2
                        if (is_string($merged[0])) {
292 2
                            $merged = array_keys(array_flip($merged));
293 2
                        }
294 2
                        $target[$key] = $merged;
295 2
                    }
296 4
                } else {
297 3
                    $target[$key] = $this->mergeInternal($target[$key], $val, $overwrite, $addProperties);
298
                }
299 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...
300
                // Do nothing, we aren't replacing.
301 3
            } else {
302 5
                $target[$key] = $val;
303
            }
304 6
        }
305
306 6
        if (isset($required)) {
307 4
            if (empty($required)) {
308 1
                unset($target['required']);
309 1
            } else {
310 4
                $target['required'] = $required;
311
            }
312 4
        }
313
314 6
        return $target;
315
    }
316
317
//    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...
318
319
    /**
320
     * Returns the internal schema array.
321
     *
322
     * @return array
323
     * @see Schema::jsonSerialize()
324
     */
325 15
    public function getSchemaArray() {
326 15
        return $this->schema;
327
    }
328
329
    /**
330
     * Parse a short schema and return the associated schema.
331
     *
332
     * @param array $arr The schema array.
333
     * @param mixed ...$args Constructor arguments for the schema instance.
334
     * @return static Returns a new schema.
335
     */
336 150
    public static function parse(array $arr, ...$args) {
337 150
        $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...
338 150
        $schema->schema = $schema->parseInternal($arr);
339 150
        return $schema;
340
    }
341
342
    /**
343
     * Parse a schema in short form into a full schema array.
344
     *
345
     * @param array $arr The array to parse into a schema.
346
     * @return array The full schema array.
347
     * @throws \InvalidArgumentException Throws an exception when an item in the schema is invalid.
348
     */
349 150
    protected function parseInternal(array $arr) {
350 150
        if (empty($arr)) {
351
            // An empty schema validates to anything.
352 7
            return [];
353 144
        } elseif (isset($arr['type'])) {
354
            // This is a long form schema and can be parsed as the root.
355
            return $this->parseNode($arr);
356
        } else {
357
            // Check for a root schema.
358 144
            $value = reset($arr);
359 144
            $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...
360 144
            if (is_int($key)) {
361 88
                $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...
362 88
                $value = null;
363 88
            }
364 144
            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...
365 144
            if (empty($name)) {
366 55
                return $this->parseNode($param, $value);
367
            }
368
        }
369
370
        // If we are here then this is n object schema.
371 92
        list($properties, $required) = $this->parseProperties($arr);
372
373
        $result = [
374 92
            'type' => 'object',
375 92
            'properties' => $properties,
376
            'required' => $required
377 92
        ];
378
379 92
        return array_filter($result);
380
    }
381
382
    /**
383
     * Parse a schema node.
384
     *
385
     * @param array $node The node to parse.
386
     * @param mixed $value Additional information from the node.
387
     * @return array Returns a JSON schema compatible node.
388
     */
389 144
    private function parseNode($node, $value = null) {
390 144
        if (is_array($value)) {
391
            // The value describes a bit more about the schema.
392 54
            switch ($node['type']) {
393 54
                case 'array':
394 8
                    if (isset($value['items'])) {
395
                        // The value includes array schema information.
396 1
                        $node = array_replace($node, $value);
397 1
                    } else {
398 7
                        $node['items'] = $this->parseInternal($value);
399
                    }
400 8
                    break;
401 47
                case 'object':
402
                    // The value is a schema of the object.
403 11
                    if (isset($value['properties'])) {
404
                        list($node['properties']) = $this->parseProperties($value['properties']);
405
                    } else {
406 11
                        list($node['properties'], $required) = $this->parseProperties($value);
407 11
                        if (!empty($required)) {
408 11
                            $node['required'] = $required;
409 11
                        }
410
                    }
411 11
                    break;
412 36
                default:
413 36
                    $node = array_replace($node, $value);
414 36
                    break;
415 54
            }
416 144
        } elseif (is_string($value)) {
417 86
            if ($node['type'] === 'array' && $arrType = $this->getType($value)) {
418 5
                $node['items'] = ['type' => $arrType];
419 86
            } elseif (!empty($value)) {
420 23
                $node['description'] = $value;
421 23
            }
422 109
        } elseif ($value === null) {
423
            // Parse child elements.
424 24
            if ($node['type'] === 'array' && isset($node['items'])) {
425
                // The value includes array schema information.
426
                $node['items'] = $this->parseInternal($node['items']);
427 24
            } elseif ($node['type'] === 'object' && isset($node['properties'])) {
428
                list($node['properties']) = $this->parseProperties($node['properties']);
429
430
            }
431 24
        }
432
433 144
        if (is_array($node) && $node['type'] === null) {
434 3
            unset($node['type']);
435 3
        }
436
437 144
        return $node;
438
    }
439
440
    /**
441
     * Parse the schema for an object's properties.
442
     *
443
     * @param array $arr An object property schema.
444
     * @return array Returns a schema array suitable to be placed in the **properties** key of a schema.
445
     */
446 92
    private function parseProperties(array $arr) {
447 92
        $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...
448 92
        $requiredProperties = [];
449 92
        foreach ($arr as $key => $value) {
450
            // Fix a schema specified as just a value.
451 92
            if (is_int($key)) {
452 68
                if (is_string($value)) {
453 68
                    $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...
454 68
                    $value = '';
455 68
                } else {
456
                    throw new \InvalidArgumentException("Schema at position $key is not a valid parameter.", 500);
457
                }
458 68
            }
459
460
            // The parameter is defined in the key.
461 92
            list($name, $param, $required) = $this->parseShortParam($key, $value);
462
463 92
            $node = $this->parseNode($param, $value);
464
465 92
            $properties[$name] = $node;
466 92
            if ($required) {
467 50
                $requiredProperties[] = $name;
468 50
            }
469 92
        }
470 92
        return array($properties, $requiredProperties);
471
    }
472
473
    /**
474
     * Parse a short parameter string into a full array parameter.
475
     *
476
     * @param string $key The short parameter string to parse.
477
     * @param array $value An array of other information that might help resolve ambiguity.
478
     * @return array Returns an array in the form `[string name, array param, bool required]`.
479
     * @throws \InvalidArgumentException Throws an exception if the short param is not in the correct format.
480
     */
481 144
    public function parseShortParam($key, $value = []) {
482
        // Is the parameter optional?
483 144
        if (substr($key, -1) === '?') {
484 63
            $required = false;
485 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...
486 63
        } else {
487 102
            $required = true;
488
        }
489
490
        // Check for a type.
491 144
        $parts = explode(':', $key);
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...
492 144
        $name = $parts[0];
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...
493 144
        $allowNull = false;
494 144
        if (!empty($parts[1])) {
495 143
            $types = explode('|', $parts[1]);
496 143
            foreach ($types as $alias) {
497 143
                $found = $this->getType($alias);
498 143
                if ($found === null) {
499
                    throw new \InvalidArgumentException("Unknown type '$alias'", 500);
500 143
                } elseif ($found === 'null') {
501 9
                    $allowNull = true;
502 9
                } else {
503 143
                    $type = $found;
504
                }
505 143
            }
506 143
        } else {
507 5
            $type = null;
508
        }
509
510 144
        if ($value instanceof Schema) {
511 3
            if ($type === 'array') {
512 1
                $param = ['type' => $type, 'items' => $value];
0 ignored issues
show
Bug introduced by
The variable $type does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
513 1
            } else {
514 2
                $param = $value;
515
            }
516 144
        } elseif (isset($value['type'])) {
517
            $param = $value;
518
519
            if (!empty($type) && $type !== $param['type']) {
520
                throw new \InvalidArgumentException("Type mismatch between $type and {$param['type']} for field $name.", 500);
521
            }
522
        } else {
523 144
            if (empty($type) && !empty($parts[1])) {
524
                throw new \InvalidArgumentException("Invalid type {$parts[1]} for field $name.", 500);
525
            }
526 144
            $param = ['type' => $type];
527
528
            // Parsed required strings have a minimum length of 1.
529 144
            if ($type === 'string' && !empty($name) && $required && (!isset($value['default']) || $value['default'] !== '')) {
530 33
                $param['minLength'] = 1;
531 33
            }
532
        }
533 144
        if ($allowNull) {
534 9
            $param['allowNull'] = true;
535 9
        }
536
537 144
        return [$name, $param, $required];
538
    }
539
540
    /**
541
     * Add a custom validator to to validate the schema.
542
     *
543
     * @param string $fieldname The name of the field to validate, if any.
544
     *
545
     * If you are adding a validator to a deeply nested field then separate the path with dots.
546
     * @param callable $callback The callback to validate with.
547
     * @return Schema Returns `$this` for fluent calls.
548
     */
549 2
    public function addValidator($fieldname, callable $callback) {
550 2
        $this->validators[$fieldname][] = $callback;
551 2
        return $this;
552
    }
553
554
    /**
555
     * Require one of a given set of fields in the schema.
556
     *
557
     * @param array $required The field names to require.
558
     * @param string $fieldname The name of the field to attach to.
559
     * @param int $count The count of required items.
560
     * @return Schema Returns `$this` for fluent calls.
561
     */
562 1
    public function requireOneOf(array $required, $fieldname = '', $count = 1) {
563 1
        $result = $this->addValidator(
564 1
            $fieldname,
565
            function ($data, ValidationField $field) use ($required, $count) {
566 1
                $hasCount = 0;
567 1
                $flattened = [];
568
569 1
                foreach ($required as $name) {
570 1
                    $flattened = array_merge($flattened, (array)$name);
571
572 1
                    if (is_array($name)) {
573
                        // This is an array of required names. They all must match.
574 1
                        $hasCountInner = 0;
575 1
                        foreach ($name as $nameInner) {
576 1
                            if (isset($data[$nameInner]) && $data[$nameInner]) {
577 1
                                $hasCountInner++;
578 1
                            } else {
579 1
                                break;
580
                            }
581 1
                        }
582 1
                        if ($hasCountInner >= count($name)) {
583 1
                            $hasCount++;
584 1
                        }
585 1
                    } elseif (isset($data[$name]) && $data[$name]) {
586 1
                        $hasCount++;
587 1
                    }
588
589 1
                    if ($hasCount >= $count) {
590 1
                        return true;
591
                    }
592 1
                }
593
594 1
                if ($count === 1) {
595 1
                    $message = 'One of {required} are required.';
596 1
                } else {
597
                    $message = '{count} of {required} are required.';
598
                }
599
600 1
                $field->addError('missingField', [
601 1
                    'messageCode' => $message,
602 1
                    'required' => $required,
603
                    'count' => $count
604 1
                ]);
605 1
                return false;
606
            }
607 1
        );
608
609 1
        return $result;
610
    }
611
612
    /**
613
     * Validate data against the schema.
614
     *
615
     * @param mixed $data The data to validate.
616
     * @param bool $sparse Whether or not this is a sparse validation.
617
     * @return mixed Returns a cleaned version of the data.
618
     * @throws ValidationException Throws an exception when the data does not validate against the schema.
619
     */
620 120
    public function validate($data, $sparse = false) {
621 120
        $field = new ValidationField($this->createValidation(), $this->schema, '', $sparse);
622
623 120
        $clean = $this->validateField($data, $field, $sparse);
624
625 118
        if (Invalid::isInvalid($clean) && $field->isValid()) {
626
            // This really shouldn't happen, but we want to protect against seeing the invalid object.
627
            $field->addError('invalid', ['messageCode' => '{field} is invalid.', 'status' => 422]);
628
        }
629
630 118
        if (!$field->getValidation()->isValid()) {
631 63
            throw new ValidationException($field->getValidation());
632
        }
633
634 74
        return $clean;
635
    }
636
637
    /**
638
     * Validate data against the schema and return the result.
639
     *
640
     * @param mixed $data The data to validate.
641
     * @param bool $sparse Whether or not to do a sparse validation.
642
     * @return bool Returns true if the data is valid. False otherwise.
643
     */
644 33
    public function isValid($data, $sparse = false) {
645
        try {
646 33
            $this->validate($data, $sparse);
647 23
            return true;
648 24
        } catch (ValidationException $ex) {
649 24
            return false;
650
        }
651
    }
652
653
    /**
654
     * Validate a field.
655
     *
656
     * @param mixed $value The value to validate.
657
     * @param ValidationField $field A validation object to add errors to.
658
     * @param bool $sparse Whether or not this is a sparse validation.
659
     * @return mixed|Invalid Returns a clean version of the value with all extra fields stripped out or invalid if the value
660
     * is completely invalid.
661
     */
662 120
    protected function validateField($value, ValidationField $field, $sparse = false) {
663 120
        $result = $value;
664 120
        if ($field->getField() instanceof Schema) {
665
            try {
666 3
                $result = $field->getField()->validate($value, $sparse);
667 3
            } catch (ValidationException $ex) {
668
                // The validation failed, so merge the validations together.
669 1
                $field->getValidation()->merge($ex->getValidation(), $field->getName());
670
            }
671 120
        } elseif ($value === null && $field->val('allowNull', false)) {
672 9
            $result = $value;
673 9
        } else {
674
            // Validate the field's type.
675 120
            $type = $field->getType();
676
            switch ($type) {
677 120
                case 'boolean':
678 21
                    $result = $this->validateBoolean($value, $field);
679 21
                    break;
680 107
                case 'integer':
681 25
                    $result = $this->validateInteger($value, $field);
682 25
                    break;
683 105
                case 'number':
684 9
                    $result = $this->validateNumber($value, $field);
685 9
                    break;
686 104
                case 'string':
687 55
                    $result = $this->validateString($value, $field);
688 55
                    break;
689 87
                case 'timestamp':
690 8
                    $result = $this->validateTimestamp($value, $field);
691 8
                    break;
692 86
                case 'datetime':
693 9
                    $result = $this->validateDatetime($value, $field);
694 9
                    break;
695 83
                case 'array':
696 15
                    $result = $this->validateArray($value, $field, $sparse);
697 15
                    break;
698 81
                case 'object':
699 80
                    $result = $this->validateObject($value, $field, $sparse);
700 78
                    break;
701 2
                case null:
0 ignored issues
show
Bug introduced by
It seems like you are loosely comparing $type of type string|null against null; this is ambiguous if the string can be empty. Consider using a strict comparison === instead.
Loading history...
702
                    // No type was specified so we are valid.
703 2
                    $result = $value;
704 2
                    break;
705
                default:
706
                    throw new \InvalidArgumentException("Unrecognized type $type.", 500);
707
            }
708 120
            if (Invalid::isValid($result)) {
709 118
                $result = $this->validateEnum($result, $field);
710 118
            }
711
        }
712
713
        // Validate a custom field validator.
714 120
        if (Invalid::isValid($result)) {
715 118
            $this->callValidators($result, $field);
716 118
        }
717
718 120
        return $result;
719
    }
720
721
    /**
722
     * Validate an array.
723
     *
724
     * @param mixed $value The value to validate.
725
     * @param ValidationField $field The validation results to add.
726
     * @param bool $sparse Whether or not this is a sparse validation.
727
     * @return array|Invalid Returns an array or invalid if validation fails.
728
     */
729 15
    protected function validateArray($value, ValidationField $field, $sparse = false) {
730 15
        if ((!is_array($value) || (count($value) > 0 && !array_key_exists(0, $value))) && !$value instanceof \Traversable) {
731 7
            $field->addTypeError('array');
732 7
            return Invalid::value();
733 9
        } elseif ($field->val('items') !== null) {
734 7
            $result = [];
735
736
            // Validate each of the types.
737 7
            $itemValidation = new ValidationField(
738 7
                $field->getValidation(),
739 7
                $field->val('items'),
740 7
                '',
741
                $sparse
742 7
            );
743
744 7
            $count = 0;
745 7
            foreach ($value as $i => $item) {
746 7
                $itemValidation->setName($field->getName()."[{$i}]");
747 7
                $validItem = $this->validateField($item, $itemValidation, $sparse);
748 7
                if (Invalid::isValid($validItem)) {
749 7
                    $result[] = $validItem;
750 7
                }
751 7
                $count++;
752 7
            }
753
754 7
            return empty($result) && $count > 0 ? Invalid::value() : $result;
755
        } else {
756
            // Cast the items into a proper numeric array.
757 2
            $result = is_array($value) ? array_values($value) : iterator_to_array($value);
758 2
            return $result;
759
        }
760
    }
761
762
    /**
763
     * Validate a boolean value.
764
     *
765
     * @param mixed $value The value to validate.
766
     * @param ValidationField $field The validation results to add.
767
     * @return bool|Invalid Returns the cleaned value or invalid if validation fails.
768
     */
769 21
    protected function validateBoolean($value, ValidationField $field) {
770 21
        $value = $value === null ? $value : filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
771 21
        if ($value === null) {
772 5
            $field->addTypeError('boolean');
773 5
            return Invalid::value();
774
        }
775 17
        return $value;
776
    }
777
778
    /**
779
     * Validate a date time.
780
     *
781
     * @param mixed $value The value to validate.
782
     * @param ValidationField $field The validation results to add.
783
     * @return \DateTimeInterface|Invalid Returns the cleaned value or **null** if it isn't valid.
784
     */
785 13
    protected function validateDatetime($value, ValidationField $field) {
786 13
        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...
787
            // do nothing, we're good
788 13
        } elseif (is_string($value) && $value !== '') {
789
            try {
790 7
                $dt = new \DateTimeImmutable($value);
791 5
                if ($dt) {
792 5
                    $value = $dt;
793 5
                } else {
794
                    $value = null;
795
                }
796 7
            } catch (\Exception $ex) {
797 2
                $value = Invalid::value();
798
            }
799 11
        } elseif (is_int($value) && $value > 0) {
800 1
            $value = new \DateTimeImmutable('@'.(string)round($value));
801 1
        } else {
802 3
            $value = Invalid::value();
803
        }
804
805 13
        if (Invalid::isInvalid($value)) {
806 5
            $field->addTypeError('datetime');
807 5
        }
808 13
        return $value;
809
    }
810
811
    /**
812
     * Validate a float.
813
     *
814
     * @param mixed $value The value to validate.
815
     * @param ValidationField $field The validation results to add.
816
     * @return float|Invalid Returns a number or **null** if validation fails.
817
     */
818 9
    protected function validateNumber($value, ValidationField $field) {
819 9
        $result = filter_var($value, FILTER_VALIDATE_FLOAT);
820 9
        if ($result === false) {
821 5
            $field->addTypeError('number');
822 5
            return Invalid::value();
823
        }
824 4
        return $result;
825
    }
826
827
    /**
828
     * Validate and integer.
829
     *
830
     * @param mixed $value The value to validate.
831
     * @param ValidationField $field The validation results to add.
832
     * @return int|Invalid Returns the cleaned value or **null** if validation fails.
833
     */
834 25
    protected function validateInteger($value, ValidationField $field) {
835 25
        $result = filter_var($value, FILTER_VALIDATE_INT);
836
837 25
        if ($result === false) {
838 9
            $field->addTypeError('integer');
839 9
            return Invalid::value();
840
        }
841 19
        return $result;
842
    }
843
844
    /**
845
     * Validate an object.
846
     *
847
     * @param mixed $value The value to validate.
848
     * @param ValidationField $field The validation results to add.
849
     * @param bool $sparse Whether or not this is a sparse validation.
850
     * @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 Invalid|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...
851
     */
852 80
    protected function validateObject($value, ValidationField $field, $sparse = false) {
853 80
        if (!$this->isArray($value) || isset($value[0])) {
854 7
            $field->addTypeError('object');
855 7
            return Invalid::value();
856 80
        } elseif (is_array($field->val('properties'))) {
857
            // Validate the data against the internal schema.
858 79
            $value = $this->validateProperties($value, $field, $sparse);
859 78
        } elseif (!is_array($value)) {
860
            $value = $this->toArray($value);
861
        }
862 78
        return $value;
863
    }
864
865
    /**
866
     * Validate data against the schema and return the result.
867
     *
868
     * @param array|\ArrayAccess $data The data to validate.
869
     * @param ValidationField $field This argument will be filled with the validation result.
870
     * @param bool $sparse Whether or not this is a sparse validation.
871
     * @return array|Invalid Returns a clean array with only the appropriate properties and the data coerced to proper types.
872
     * or invalid if there are no valid properties.
873
     */
874 79
    protected function validateProperties($data, ValidationField $field, $sparse = false) {
875 79
        $properties = $field->val('properties', []);
876 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...
877
878 79
        if (is_array($data)) {
879 78
            $keys = array_keys($data);
880 78
        } else {
881 1
            $keys = array_keys(iterator_to_array($data));
882
        }
883 79
        $keys = array_combine(array_map('strtolower', $keys), $keys);
884
885 79
        $propertyField = new ValidationField($field->getValidation(), [], null, $sparse);
886
887
        // Loop through the schema fields and validate each one.
888 79
        $clean = [];
889 79
        foreach ($properties as $propertyName => $property) {
890
            $propertyField
891 79
                ->setField($property)
892 79
                ->setName(ltrim($field->getName().".$propertyName", '.'));
893
894 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...
895 79
            $isRequired = isset($required[$propertyName]);
896
897
            // First check for required fields.
898 79
            if (!array_key_exists($lName, $keys)) {
899 23
                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...
900
                    // Sparse validation can leave required fields out.
901 23
                } elseif ($propertyField->hasVal('default')) {
902 2
                    $clean[$propertyName] = $propertyField->val('default');
903 23
                } elseif ($isRequired) {
904 6
                    $propertyField->addError('missingField', ['messageCode' => '{field} is required.']);
905 6
                }
906 23
            } else {
907 77
                $clean[$propertyName] = $this->validateField($data[$keys[$lName]], $propertyField, $sparse);
908
            }
909
910 79
            unset($keys[$lName]);
911 79
        }
912
913
        // Look for extraneous properties.
914 79
        if (!empty($keys)) {
915 7
            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...
916 2
                $msg = sprintf("%s has unexpected field(s): %s.", $field->getName() ?: 'value', implode(', ', $keys));
917 2
                trigger_error($msg, E_USER_NOTICE);
918
            }
919
920 5
            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...
921 2
                $field->addError('invalid', [
922 2
                    'messageCode' => '{field} has {extra,plural,an unexpected field,unexpected fields}: {extra}.',
923 2
                    'extra' => array_values($keys),
924
                    'status' => 422
925 2
                ]);
926 2
            }
927 5
        }
928
929 77
        return $clean;
930
    }
931
932
    /**
933
     * Validate a string.
934
     *
935
     * @param mixed $value The value to validate.
936
     * @param ValidationField $field The validation results to add.
937
     * @return string|Invalid Returns the valid string or **null** if validation fails.
938
     */
939 55
    protected function validateString($value, ValidationField $field) {
940 55
        if (is_string($value) || is_numeric($value)) {
941 52
            $value = $result = (string)$value;
942 52
        } else {
943 6
            $field->addTypeError('string');
944 6
            return Invalid::value();
945
        }
946
947 52
        $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...
948 52
        if (($minLength = $field->val('minLength', 0)) > 0 && mb_strlen($value) < $minLength) {
949 4
            if (!empty($field->getName()) && $minLength === 1) {
950 2
                $field->addError('missingField', ['messageCode' => '{field} is required.', 'status' => 422]);
951 2
            } else {
952 2
                $field->addError(
953 2
                    'minLength',
954
                    [
955 2
                        'messageCode' => '{field} should be at least {minLength} {minLength,plural,character} long.',
956 2
                        'minLength' => $minLength,
957
                        'status' => 422
958 2
                    ]
959 2
                );
960
            }
961 4
        }
962 52
        if (($maxLength = $field->val('maxLength', 0)) > 0 && mb_strlen($value) > $maxLength) {
963 1
            $field->addError(
964 1
                'maxLength',
965
                [
966 1
                    'messageCode' => '{field} is {overflow} {overflow,plural,characters} too long.',
967 1
                    'maxLength' => $maxLength,
968 1
                    'overflow' => mb_strlen($value) - $maxLength,
969
                    'status' => 422
970 1
                ]
971 1
            );
972 1
        }
973 52
        if ($pattern = $field->val('pattern')) {
974 4
            $regex = '`'.str_replace('`', preg_quote('`', '`'), $pattern).'`';
975
976 4
            if (!preg_match($regex, $value)) {
977 2
                $field->addError(
978 2
                    'invalid',
979
                    [
980 2
                        'messageCode' => '{field} is in the incorrect format.',
981
                        'status' => 422
982 2
                    ]
983 2
                );
984 2
            }
985 4
        }
986 52
        if ($format = $field->val('format')) {
987 15
            $type = $format;
988
            switch ($format) {
989 15
                case 'date-time':
990 4
                    $result = $this->validateDatetime($result, $field);
991 4
                    if ($result instanceof \DateTimeInterface) {
992 4
                        $result = $result->format(\DateTime::RFC3339);
993 4
                    }
994 4
                    break;
995 11
                case 'email':
996 1
                    $result = filter_var($result, FILTER_VALIDATE_EMAIL);
997 1
                    break;
998 10
                case 'ipv4':
999 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...
1000 1
                    $result = filter_var($result, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
1001 1
                    break;
1002 9
                case 'ipv6':
1003 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...
1004 1
                    $result = filter_var($result, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
1005 1
                    break;
1006 8
                case 'ip':
1007 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...
1008 1
                    $result = filter_var($result, FILTER_VALIDATE_IP);
1009 1
                    break;
1010 7
                case 'uri':
1011 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...
1012 7
                    $result = filter_var($result, FILTER_VALIDATE_URL, FILTER_FLAG_HOST_REQUIRED | FILTER_FLAG_SCHEME_REQUIRED);
1013 7
                    break;
1014
                default:
1015
                    trigger_error("Unrecognized format '$format'.", E_USER_NOTICE);
1016
            }
1017 15
            if ($result === false) {
1018 5
                $field->addTypeError($type);
1019 5
            }
1020 15
        }
1021
1022 52
        if ($field->isValid()) {
1023 44
            return $result;
1024
        } else {
1025 12
            return Invalid::value();
1026
        }
1027
    }
1028
1029
    /**
1030
     * Validate a unix timestamp.
1031
     *
1032
     * @param mixed $value The value to validate.
1033
     * @param ValidationField $field The field being validated.
1034
     * @return int|Invalid Returns a valid timestamp or invalid if the value doesn't validate.
1035
     */
1036 8
    protected function validateTimestamp($value, ValidationField $field) {
1037 8
        if (is_numeric($value) && $value > 0) {
1038 2
            $result = (int)$value;
1039 8
        } elseif (is_string($value) && $ts = strtotime($value)) {
1040 1
            $result = $ts;
1041 1
        } else {
1042 5
            $field->addTypeError('timestamp');
1043 5
            $result = Invalid::value();
1044
        }
1045 8
        return $result;
1046
    }
1047
1048
    /**
1049
     * Validate a null value.
1050
     *
1051
     * @param mixed $value The value to validate.
1052
     * @param ValidationField $field The error collector for the field.
1053
     * @return null|Invalid Returns **null** or invalid.
1054
     */
1055
    protected function validateNull($value, ValidationField $field) {
1056
        if ($value === null) {
1057
            return null;
1058
        }
1059
        $field->addError('invalid', ['messageCode' => '{field} should be null.', 'status' => 422]);
1060
        return Invalid::value();
1061
    }
1062
1063
    /**
1064
     * Validate a value against an enum.
1065
     *
1066
     * @param mixed $value The value to test.
1067
     * @param ValidationField $field The validation object for adding errors.
1068
     * @return mixed|Invalid Returns the value if it is one of the enumerated values or invalid otherwise.
1069
     */
1070 118
    protected function validateEnum($value, ValidationField $field) {
1071 118
        $enum = $field->val('enum');
1072 118
        if (empty($enum)) {
1073 117
            return $value;
1074
        }
1075
1076 1
        if (!in_array($value, $enum, true)) {
1077 1
            $field->addError(
1078 1
                'invalid',
1079
                [
1080 1
                    'messageCode' => '{field} must be one of: {enum}.',
1081 1
                    'enum' => $enum,
1082
                    'status' => 422
1083 1
                ]
1084 1
            );
1085 1
            return Invalid::value();
1086
        }
1087 1
        return $value;
1088
    }
1089
1090
    /**
1091
     * Call all of the validators attached to a field.
1092
     *
1093
     * @param mixed $value The field value being validated.
1094
     * @param ValidationField $field The validation object to add errors.
1095
     */
1096 118
    protected function callValidators($value, ValidationField $field) {
1097 118
        $valid = true;
1098
1099
        // Strip array references in the name except for the last one.
1100 118
        $key = preg_replace(['`\[\d+\]$`', '`\[\d+\]`'], ['[]', ''], $field->getName());
1101 118
        if (!empty($this->validators[$key])) {
1102 2
            foreach ($this->validators[$key] as $validator) {
1103 2
                $r = call_user_func($validator, $value, $field);
1104
1105 2
                if ($r === false || Invalid::isInvalid($r)) {
1106 1
                    $valid = false;
1107 1
                }
1108 2
            }
1109 2
        }
1110
1111
        // Add an error on the field if the validator hasn't done so.
1112 118
        if (!$valid && $field->isValid()) {
1113
            $field->addError('invalid', ['messageCode' => '{field} is invalid.', 'status' => 422]);
1114
        }
1115 118
    }
1116
1117
    /**
1118
     * Specify data which should be serialized to JSON.
1119
     *
1120
     * This method specifically returns data compatible with the JSON schema format.
1121
     *
1122
     * @return mixed Returns data which can be serialized by **json_encode()**, which is a value of any type other than a resource.
1123
     * @link http://php.net/manual/en/jsonserializable.jsonserialize.php
1124
     * @link http://json-schema.org/
1125
     */
1126
    public function jsonSerialize() {
1127 14
        $fix = function ($schema) use (&$fix) {
1128 14
            if ($schema instanceof Schema) {
1129 1
                return $schema->jsonSerialize();
1130
            }
1131
1132 14
            if (!empty($schema['type'])) {
1133
                // Swap datetime and timestamp to other types with formats.
1134 13
                if ($schema['type'] === 'datetime') {
1135 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...
1136 3
                    $schema['format'] = 'date-time';
1137 13
                } elseif ($schema['type'] === 'timestamp') {
1138 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...
1139 3
                    $schema['format'] = 'timestamp';
1140 3
                }
1141 13
            }
1142
1143 14
            if (!empty($schema['items'])) {
1144 4
                $schema['items'] = $fix($schema['items']);
1145 4
            }
1146 14
            if (!empty($schema['properties'])) {
1147 10
                $properties = [];
1148 10
                foreach ($schema['properties'] as $key => $property) {
1149 10
                    $properties[$key] = $fix($property);
1150 10
                }
1151 10
                $schema['properties'] = $properties;
1152 10
            }
1153
1154 14
            return $schema;
1155 14
        };
1156
1157 14
        $result = $fix($this->schema);
1158
1159 14
        return $result;
1160
    }
1161
1162
    /**
1163
     * Look up a type based on its alias.
1164
     *
1165
     * @param string $alias The type alias or type name to lookup.
1166
     * @return mixed
1167
     */
1168 143
    protected function getType($alias) {
1169 143
        if (isset(self::$types[$alias])) {
1170
            return $alias;
1171
        }
1172 143
        foreach (self::$types as $type => $aliases) {
1173 143
            if (in_array($alias, $aliases, true)) {
1174 143
                return $type;
1175
            }
1176 143
        }
1177 9
        return null;
1178
    }
1179
1180
    /**
1181
     * Get the class that's used to contain validation information.
1182
     *
1183
     * @return Validation|string Returns the validation class.
1184
     */
1185 120
    public function getValidationClass() {
1186 120
        return $this->validationClass;
1187
    }
1188
1189
    /**
1190
     * Set the class that's used to contain validation information.
1191
     *
1192
     * @param Validation|string $class Either the name of a class or a class that will be cloned.
1193
     * @return $this
1194
     */
1195 1
    public function setValidationClass($class) {
1196 1
        if (!is_a($class, Validation::class, true)) {
1197
            throw new \InvalidArgumentException("$class must be a subclass of ".Validation::class, 500);
1198
        }
1199
1200 1
        $this->validationClass = $class;
1201 1
        return $this;
1202
    }
1203
1204
    /**
1205
     * Create a new validation instance.
1206
     *
1207
     * @return Validation Returns a validation object.
1208
     */
1209 120
    protected function createValidation() {
1210 120
        $class = $this->getValidationClass();
1211
1212 120
        if ($class instanceof Validation) {
1213 1
            $result = clone $class;
1214 1
        } else {
1215 120
            $result = new $class;
1216
        }
1217 120
        return $result;
1218
    }
1219
1220
    /**
1221
     * Check whether or not a value is an array or accessible like an array.
1222
     *
1223
     * @param mixed $value The value to check.
1224
     * @return bool Returns **true** if the value can be used like an array or **false** otherwise.
1225
     */
1226 80
    private function isArray($value) {
1227 80
        return is_array($value) || ($value instanceof \ArrayAccess && $value instanceof \Traversable);
1228
    }
1229
1230
    /**
1231
     * Cast a value to an array.
1232
     *
1233
     * @param \Traversable $value The value to convert.
1234
     * @return array Returns an array.
1235
     */
1236
    private function toArray(\Traversable $value) {
1237
        if ($value instanceof \ArrayObject) {
1238
            return $value->getArrayCopy();
1239
        }
1240
        return iterator_to_array($value);
1241
    }
1242
1243
    /**
1244
     * Return a sparse version of this schema.
1245
     *
1246
     * A sparse schema has no required properties.
1247
     *
1248
     * @return Schema Returns a new sparse schema.
1249
     */
1250 2
    public function withSparse() {
1251 2
        $sparseSchema = $this->withSparseInternal($this, new \SplObjectStorage());
1252 2
        return $sparseSchema;
1253
    }
1254
1255
    /**
1256
     * The internal implementation of `Schema::withSparse()`.
1257
     *
1258
     * @param array|Schema $schema The schema to make sparse.
1259
     * @param \SplObjectStorage $schemas Collected sparse schemas that have already been made.
1260
     * @return mixed
1261
     */
1262 2
    private function withSparseInternal($schema, \SplObjectStorage $schemas) {
1263 2
        if ($schema instanceof Schema) {
1264 2
            if ($schemas->contains($schema)) {
1265 1
                return $schemas[$schema];
1266
            } else {
1267 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...
1268 2
                $sparseSchema->schema = $schema->withSparseInternal($schema->schema, $schemas);
1269 2
                if ($id = $sparseSchema->getID()) {
1270
                    $sparseSchema->setID($id.'Sparse');
1271
                }
1272
1273 2
                return $sparseSchema;
1274
            }
1275
        }
1276
1277 2
        unset($schema['required']);
1278
1279 2
        if (isset($schema['items'])) {
1280 1
            $schema['items'] = $this->withSparseInternal($schema['items'], $schemas);
1281 1
        }
1282 2
        if (isset($schema['properties'])) {
1283 2
            foreach ($schema['properties'] as $name => &$property) {
1284 2
                $property = $this->withSparseInternal($property, $schemas);
1285 2
            }
1286 2
        }
1287
1288 2
        return $schema;
1289
    }
1290
}
1291