Completed
Push — master ( d1cfb9...44118f )
by Todd
01:53
created

Schema::withSparseInternal()   C

Complexity

Conditions 7
Paths 7

Size

Total Lines 28
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 7.049

Importance

Changes 0
Metric Value
dl 0
loc 28
ccs 18
cts 20
cp 0.9
rs 6.7272
c 0
b 0
f 0
cc 7
eloc 17
nc 7
nop 2
crap 7.049
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 161
    public function __construct($schema = []) {
67 161
        $this->schema = $schema;
68 161
    }
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
        foreach ($path as $i => $subKey) {
110 4
            if (is_array($value) && isset($value[$subKey])) {
111 4
                $value = $value[$subKey];
112 4
            } elseif ($value instanceof Schema) {
113 1
                return $value->getField(array_slice($path, $i), $default);
114
            } else {
115
                return $default;
116
            }
117 4
        }
118 4
        return $value;
119
    }
120
121
    /**
122
     * Set a schema field.
123
     *
124
     * @param string|array $path The JSON schema path of the field with parts separated by dots.
125
     * @param mixed $value The new value.
126
     * @return $this
127
     */
128 3
    public function setField($path, $value) {
129 3
        if (is_string($path)) {
130 3
            $path = explode('.', $path);
131 3
        }
132
133 3
        $selection = &$this->schema;
134 3
        foreach ($path as $i => $subSelector) {
135 3
            if (is_array($selection)) {
136 3
                if (!isset($selection[$subSelector])) {
137 1
                    $selection[$subSelector] = [];
138 1
                }
139 3
            } elseif ($selection instanceof Schema) {
140 1
                $selection->setField(array_slice($path, $i), $value);
141 1
                return $this;
142
            } else {
143
                $selection = [$subSelector => []];
144
            }
145 3
            $selection = &$selection[$subSelector];
146 3
        }
147
148 3
        $selection = $value;
149 3
        return $this;
150
    }
151
152
    /**
153
     * Get the ID for the schema.
154
     *
155
     * @return string
156
     */
157 2
    public function getID() {
158 2
        return isset($this->schema['id']) ? $this->schema['id'] : '';
159
    }
160
161
    /**
162
     * Set the ID for the schema.
163
     *
164
     * @param string $ID The new ID.
165
     * @throws \InvalidArgumentException Throws an exception when the provided ID is not a string.
166
     * @return Schema
167
     */
168
    public function setID($ID) {
169
        if (is_string($ID)) {
170
            $this->schema['ID'] = $ID;
171
        } else {
172
            throw new \InvalidArgumentException("The ID is not a valid string.", 500);
173
        }
174
175
        return $this;
176
    }
177
178
    /**
179
     * Return the validation flags.
180
     *
181
     * @return int Returns a bitwise combination of flags.
182
     */
183 1
    public function getFlags() {
184 1
        return $this->flags;
185
    }
186
187
    /**
188
     * Set the validation flags.
189
     *
190
     * @param int $flags One or more of the **Schema::FLAG_*** constants.
191
     * @return Schema Returns the current instance for fluent calls.
192
     */
193 8
    public function setFlags($flags) {
194 8
        if (!is_int($flags)) {
195 1
            throw new \InvalidArgumentException('Invalid flags.', 500);
196
        }
197 7
        $this->flags = $flags;
198
199 7
        return $this;
200
    }
201
202
    /**
203
     * Whether or not the schema has a flag (or combination of flags).
204
     *
205
     * @param int $flag One or more of the **Schema::VALIDATE_*** constants.
206
     * @return bool Returns **true** if all of the flags are set or **false** otherwise.
207
     */
208 18
    public function hasFlag($flag) {
209 18
        return ($this->flags & $flag) === $flag;
210
    }
211
212
    /**
213
     * Set a flag.
214
     *
215
     * @param int $flag One or more of the **Schema::VALIDATE_*** constants.
216
     * @param bool $value Either true or false.
217
     * @return $this
218
     */
219 1
    public function setFlag($flag, $value) {
220 1
        if ($value) {
221 1
            $this->flags = $this->flags | $flag;
222 1
        } else {
223 1
            $this->flags = $this->flags & ~$flag;
224
        }
225 1
        return $this;
226
    }
227
228
    /**
229
     * Merge a schema with this one.
230
     *
231
     * @param Schema $schema A scheme instance. Its parameters will be merged into the current instance.
232
     * @return $this
233
     */
234 3
    public function merge(Schema $schema) {
235 3
        $this->mergeInternal($this->schema, $schema->getSchemaArray(), true, true);
236 3
        return $this;
237
    }
238
239
    /**
240
     * Add another schema to this one.
241
     *
242
     * Adding schemas together is analogous to array addition. When you add a schema it will only add missing information.
243
     *
244
     * @param Schema $schema The schema to add.
245
     * @param bool $addProperties Whether to add properties that don't exist in this schema.
246
     * @return $this
247
     */
248 3
    public function add(Schema $schema, $addProperties = false) {
249 3
        $this->mergeInternal($this->schema, $schema->getSchemaArray(), false, $addProperties);
250 3
        return $this;
251
    }
252
253
    /**
254
     * The internal implementation of schema merging.
255
     *
256
     * @param array &$target The target of the merge.
257
     * @param array $source The source of the merge.
258
     * @param bool $overwrite Whether or not to replace values.
259
     * @param bool $addProperties Whether or not to add object properties to the target.
260
     * @return array
261
     */
262 6
    private function mergeInternal(array &$target, array $source, $overwrite = true, $addProperties = true) {
263
        // We need to do a fix for required properties here.
264 6
        if (isset($target['properties']) && !empty($source['required'])) {
265 4
            $required = isset($target['required']) ? $target['required'] : [];
266
267 4
            if (isset($source['required']) && $addProperties) {
268 3
                $newProperties = array_diff(array_keys($source['properties']), array_keys($target['properties']));
269 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...
270
271 3
                $required = array_merge($required, $newRequired);
272 3
            }
273 4
        }
274
275
276 6
        foreach ($source as $key => $val) {
277 6
            if (is_array($val) && array_key_exists($key, $target) && is_array($target[$key])) {
278 6
                if ($key === 'properties' && !$addProperties) {
279
                    // We just want to merge the properties that exist in the destination.
280 1
                    foreach ($val as $name => $prop) {
281 1
                        if (isset($target[$key][$name])) {
282 1
                            $this->mergeInternal($target[$key][$name], $prop, $overwrite, $addProperties);
283 1
                        }
284 1
                    }
285 6
                } elseif (isset($val[0]) || isset($target[$key][0])) {
286 4
                    if ($overwrite) {
287
                        // This is a numeric array, so just do a merge.
288 2
                        $merged = array_merge($target[$key], $val);
289 2
                        if (is_string($merged[0])) {
290 2
                            $merged = array_keys(array_flip($merged));
291 2
                        }
292 2
                        $target[$key] = $merged;
293 2
                    }
294 4
                } else {
295 3
                    $target[$key] = $this->mergeInternal($target[$key], $val, $overwrite, $addProperties);
296
                }
297 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...
298
                // Do nothing, we aren't replacing.
299 3
            } else {
300 5
                $target[$key] = $val;
301
            }
302 6
        }
303
304 6
        if (isset($required)) {
305 4
            if (empty($required)) {
306 1
                unset($target['required']);
307 1
            } else {
308 4
                $target['required'] = $required;
309
            }
310 4
        }
311
312 6
        return $target;
313
    }
314
315
//    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...
316
317
    /**
318
     * Returns the internal schema array.
319
     *
320
     * @return array
321
     * @see Schema::jsonSerialize()
322
     */
323 15
    public function getSchemaArray() {
324 15
        return $this->schema;
325
    }
326
327
    /**
328
     * Parse a short schema and return the associated schema.
329
     *
330
     * @param array $arr The schema array.
331
     * @param mixed ...$args Constructor arguments for the schema instance.
332
     * @return static Returns a new schema.
333
     */
334 156
    public static function parse(array $arr, ...$args) {
335 156
        $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...
336 156
        $schema->schema = $schema->parseInternal($arr);
337 156
        return $schema;
338
    }
339
340
    /**
341
     * Parse a schema in short form into a full schema array.
342
     *
343
     * @param array $arr The array to parse into a schema.
344
     * @return array The full schema array.
345
     * @throws \InvalidArgumentException Throws an exception when an item in the schema is invalid.
346
     */
347 156
    protected function parseInternal(array $arr) {
348 156
        if (empty($arr)) {
349
            // An empty schema validates to anything.
350 7
            return [];
351 150
        } elseif (isset($arr['type'])) {
352
            // This is a long form schema and can be parsed as the root.
353
            return $this->parseNode($arr);
354
        } else {
355
            // Check for a root schema.
356 150
            $value = reset($arr);
357 150
            $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...
358 150
            if (is_int($key)) {
359 94
                $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...
360 94
                $value = null;
361 94
            }
362 150
            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...
363 150
            if (empty($name)) {
364 55
                return $this->parseNode($param, $value);
365
            }
366
        }
367
368
        // If we are here then this is n object schema.
369 98
        list($properties, $required) = $this->parseProperties($arr);
370
371
        $result = [
372 98
            'type' => 'object',
373 98
            'properties' => $properties,
374
            'required' => $required
375 98
        ];
376
377 98
        return array_filter($result);
378
    }
379
380
    /**
381
     * Parse a schema node.
382
     *
383
     * @param array $node The node to parse.
384
     * @param mixed $value Additional information from the node.
385
     * @return array Returns a JSON schema compatible node.
386
     */
387 150
    private function parseNode($node, $value = null) {
388 150
        if (is_array($value)) {
389
            // The value describes a bit more about the schema.
390 54
            switch ($node['type']) {
391 54
                case 'array':
392 8
                    if (isset($value['items'])) {
393
                        // The value includes array schema information.
394 1
                        $node = array_replace($node, $value);
395 1
                    } else {
396 7
                        $node['items'] = $this->parseInternal($value);
397
                    }
398 8
                    break;
399 47
                case 'object':
400
                    // The value is a schema of the object.
401 11
                    if (isset($value['properties'])) {
402
                        list($node['properties']) = $this->parseProperties($value['properties']);
403
                    } else {
404 11
                        list($node['properties'], $required) = $this->parseProperties($value);
405 11
                        if (!empty($required)) {
406 11
                            $node['required'] = $required;
407 11
                        }
408
                    }
409 11
                    break;
410 36
                default:
411 36
                    $node = array_replace($node, $value);
412 36
                    break;
413 54
            }
414 150
        } elseif (is_string($value)) {
415 92
            if ($node['type'] === 'array' && $arrType = $this->getType($value)) {
416 5
                $node['items'] = ['type' => $arrType];
417 92
            } elseif (!empty($value)) {
418 23
                $node['description'] = $value;
419 23
            }
420 115
        } elseif ($value === null) {
421
            // Parse child elements.
422 24
            if ($node['type'] === 'array' && isset($node['items'])) {
423
                // The value includes array schema information.
424
                $node['items'] = $this->parseInternal($node['items']);
425 24
            } elseif ($node['type'] === 'object' && isset($node['properties'])) {
426
                list($node['properties']) = $this->parseProperties($node['properties']);
427
428
            }
429 24
        }
430
431 150
        if (is_array($node) && $node['type'] === null) {
432 3
            unset($node['type']);
433 3
        }
434
435 150
        return $node;
436
    }
437
438
    /**
439
     * Parse the schema for an object's properties.
440
     *
441
     * @param array $arr An object property schema.
442
     * @return array Returns a schema array suitable to be placed in the **properties** key of a schema.
443
     */
444 98
    private function parseProperties(array $arr) {
445 98
        $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...
446 98
        $requiredProperties = [];
447 98
        foreach ($arr as $key => $value) {
448
            // Fix a schema specified as just a value.
449 98
            if (is_int($key)) {
450 74
                if (is_string($value)) {
451 74
                    $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...
452 74
                    $value = '';
453 74
                } else {
454
                    throw new \InvalidArgumentException("Schema at position $key is not a valid parameter.", 500);
455
                }
456 74
            }
457
458
            // The parameter is defined in the key.
459 98
            list($name, $param, $required) = $this->parseShortParam($key, $value);
460
461 98
            $node = $this->parseNode($param, $value);
462
463 98
            $properties[$name] = $node;
464 98
            if ($required) {
465 52
                $requiredProperties[] = $name;
466 52
            }
467 98
        }
468 98
        return array($properties, $requiredProperties);
469
    }
470
471
    /**
472
     * Parse a short parameter string into a full array parameter.
473
     *
474
     * @param string $key The short parameter string to parse.
475
     * @param array $value An array of other information that might help resolve ambiguity.
476
     * @return array Returns an array in the form `[string name, array param, bool required]`.
477
     * @throws \InvalidArgumentException Throws an exception if the short param is not in the correct format.
478
     */
479 150
    public function parseShortParam($key, $value = []) {
480
        // Is the parameter optional?
481 150
        if (substr($key, -1) === '?') {
482 67
            $required = false;
483 67
            $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...
484 67
        } else {
485 104
            $required = true;
486
        }
487
488
        // Check for a type.
489 150
        $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...
490 150
        $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...
491 150
        $allowNull = false;
492 150
        if (!empty($parts[1])) {
493 149
            $types = explode('|', $parts[1]);
494 149
            foreach ($types as $alias) {
495 149
                $found = $this->getType($alias);
496 149
                if ($found === null) {
497
                    throw new \InvalidArgumentException("Unknown type '$alias'", 500);
498 149
                } elseif ($found === 'null') {
499 11
                    $allowNull = true;
500 11
                } else {
501 149
                    $type = $found;
502
                }
503 149
            }
504 149
        } else {
505 5
            $type = null;
506
        }
507
508 150
        if ($value instanceof Schema) {
509 3
            if ($type === 'array') {
510 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...
511 1
            } else {
512 2
                $param = $value;
513
            }
514 150
        } elseif (isset($value['type'])) {
515
            $param = $value;
516
517
            if (!empty($type) && $type !== $param['type']) {
518
                throw new \InvalidArgumentException("Type mismatch between $type and {$param['type']} for field $name.", 500);
519
            }
520
        } else {
521 150
            if (empty($type) && !empty($parts[1])) {
522
                throw new \InvalidArgumentException("Invalid type {$parts[1]} for field $name.", 500);
523
            }
524 150
            $param = ['type' => $type];
525
526
            // Parsed required strings have a minimum length of 1.
527 150
            if ($type === 'string' && !empty($name) && $required && (!isset($value['default']) || $value['default'] !== '')) {
528 33
                $param['minLength'] = 1;
529 33
            }
530
        }
531 150
        if ($allowNull) {
532 11
            $param['allowNull'] = true;
533 11
        }
534
535 150
        return [$name, $param, $required];
536
    }
537
538
    /**
539
     * Add a custom validator to to validate the schema.
540
     *
541
     * @param string $fieldname The name of the field to validate, if any.
542
     *
543
     * If you are adding a validator to a deeply nested field then separate the path with dots.
544
     * @param callable $callback The callback to validate with.
545
     * @return Schema Returns `$this` for fluent calls.
546
     */
547 2
    public function addValidator($fieldname, callable $callback) {
548 2
        $this->validators[$fieldname][] = $callback;
549 2
        return $this;
550
    }
551
552
    /**
553
     * Require one of a given set of fields in the schema.
554
     *
555
     * @param array $required The field names to require.
556
     * @param string $fieldname The name of the field to attach to.
557
     * @param int $count The count of required items.
558
     * @return Schema Returns `$this` for fluent calls.
559
     */
560 1
    public function requireOneOf(array $required, $fieldname = '', $count = 1) {
561 1
        $result = $this->addValidator(
562 1
            $fieldname,
563
            function ($data, ValidationField $field) use ($required, $count) {
564 1
                $hasCount = 0;
565 1
                $flattened = [];
566
567 1
                foreach ($required as $name) {
568 1
                    $flattened = array_merge($flattened, (array)$name);
569
570 1
                    if (is_array($name)) {
571
                        // This is an array of required names. They all must match.
572 1
                        $hasCountInner = 0;
573 1
                        foreach ($name as $nameInner) {
574 1
                            if (isset($data[$nameInner]) && $data[$nameInner]) {
575 1
                                $hasCountInner++;
576 1
                            } else {
577 1
                                break;
578
                            }
579 1
                        }
580 1
                        if ($hasCountInner >= count($name)) {
581 1
                            $hasCount++;
582 1
                        }
583 1
                    } elseif (isset($data[$name]) && $data[$name]) {
584 1
                        $hasCount++;
585 1
                    }
586
587 1
                    if ($hasCount >= $count) {
588 1
                        return true;
589
                    }
590 1
                }
591
592 1
                if ($count === 1) {
593 1
                    $message = 'One of {required} are required.';
594 1
                } else {
595
                    $message = '{count} of {required} are required.';
596
                }
597
598 1
                $field->addError('missingField', [
599 1
                    'messageCode' => $message,
600 1
                    'required' => $required,
601
                    'count' => $count
602 1
                ]);
603 1
                return false;
604
            }
605 1
        );
606
607 1
        return $result;
608
    }
609
610
    /**
611
     * Validate data against the schema.
612
     *
613
     * @param mixed $data The data to validate.
614
     * @param bool $sparse Whether or not this is a sparse validation.
615
     * @return mixed Returns a cleaned version of the data.
616
     * @throws ValidationException Throws an exception when the data does not validate against the schema.
617
     */
618 126
    public function validate($data, $sparse = false) {
619 126
        $field = new ValidationField($this->createValidation(), $this->schema, '', $sparse);
620
621 126
        $clean = $this->validateField($data, $field, $sparse);
622
623 124
        if (Invalid::isInvalid($clean) && $field->isValid()) {
624
            // This really shouldn't happen, but we want to protect against seeing the invalid object.
625
            $field->addError('invalid', ['messageCode' => '{field} is invalid.', 'status' => 422]);
626
        }
627
628 124
        if (!$field->getValidation()->isValid()) {
629 55
            throw new ValidationException($field->getValidation());
630
        }
631
632 80
        return $clean;
633
    }
634
635
    /**
636
     * Validate data against the schema and return the result.
637
     *
638
     * @param mixed $data The data to validate.
639
     * @param bool $sparse Whether or not to do a sparse validation.
640
     * @return bool Returns true if the data is valid. False otherwise.
641
     */
642 33
    public function isValid($data, $sparse = false) {
643
        try {
644 33
            $this->validate($data, $sparse);
645 23
            return true;
646 16
        } catch (ValidationException $ex) {
647 16
            return false;
648
        }
649
    }
650
651
    /**
652
     * Validate a field.
653
     *
654
     * @param mixed $value The value to validate.
655
     * @param ValidationField $field A validation object to add errors to.
656
     * @param bool $sparse Whether or not this is a sparse validation.
657
     * @return mixed|Invalid Returns a clean version of the value with all extra fields stripped out or invalid if the value
658
     * is completely invalid.
659
     */
660 126
    protected function validateField($value, ValidationField $field, $sparse = false) {
661 126
        $result = $value;
662
663 126
        if ($field->getField() instanceof Schema) {
664
            try {
665 3
                $result = $field->getField()->validate($value, $sparse);
666 3
            } catch (ValidationException $ex) {
667
                // The validation failed, so merge the validations together.
668 1
                $field->getValidation()->merge($ex->getValidation(), $field->getName());
669
            }
670 126
        } elseif (($value === null || ($value === '' && $field->getType() !== 'string')) && $field->val('allowNull', false)) {
671 11
            $result = null;
672 11
        } else {
673
            // Validate the field's type.
674 126
            $type = $field->getType();
675
            switch ($type) {
676 126
                case 'boolean':
677 21
                    $result = $this->validateBoolean($value, $field);
678 21
                    break;
679 113
                case 'integer':
680 24
                    $result = $this->validateInteger($value, $field);
681 24
                    break;
682 111
                case 'number':
683 8
                    $result = $this->validateNumber($value, $field);
684 8
                    break;
685 110
                case 'string':
686 55
                    $result = $this->validateString($value, $field);
687 55
                    break;
688 93
                case 'timestamp':
689 7
                    $result = $this->validateTimestamp($value, $field);
690 7
                    break;
691 92
                case 'datetime':
692 8
                    $result = $this->validateDatetime($value, $field);
693 8
                    break;
694 89
                case 'array':
695 14
                    $result = $this->validateArray($value, $field, $sparse);
696 14
                    break;
697 87
                case 'object':
698 86
                    $result = $this->validateObject($value, $field, $sparse);
699 84
                    break;
700 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...
701
                    // No type was specified so we are valid.
702 2
                    $result = $value;
703 2
                    break;
704
                default:
705
                    throw new \InvalidArgumentException("Unrecognized type $type.", 500);
706
            }
707 126
            if (Invalid::isValid($result)) {
708 124
                $result = $this->validateEnum($result, $field);
709 124
            }
710
        }
711
712
        // Validate a custom field validator.
713 126
        if (Invalid::isValid($result)) {
714 124
            $this->callValidators($result, $field);
715 124
        }
716
717 126
        return $result;
718
    }
719
720
    /**
721
     * Validate an array.
722
     *
723
     * @param mixed $value The value to validate.
724
     * @param ValidationField $field The validation results to add.
725
     * @param bool $sparse Whether or not this is a sparse validation.
726
     * @return array|Invalid Returns an array or invalid if validation fails.
727
     */
728 14
    protected function validateArray($value, ValidationField $field, $sparse = false) {
729 14
        if ((!is_array($value) || (count($value) > 0 && !array_key_exists(0, $value))) && !$value instanceof \Traversable) {
730 6
            $field->addTypeError('array');
731 6
            return Invalid::value();
732 9
        } elseif ($field->val('items') !== null) {
733 7
            $result = [];
734
735
            // Validate each of the types.
736 7
            $itemValidation = new ValidationField(
737 7
                $field->getValidation(),
738 7
                $field->val('items'),
739 7
                '',
740
                $sparse
741 7
            );
742
743 7
            $count = 0;
744 7
            foreach ($value as $i => $item) {
745 7
                $itemValidation->setName($field->getName()."[{$i}]");
746 7
                $validItem = $this->validateField($item, $itemValidation, $sparse);
747 7
                if (Invalid::isValid($validItem)) {
748 7
                    $result[] = $validItem;
749 7
                }
750 7
                $count++;
751 7
            }
752
753 7
            return empty($result) && $count > 0 ? Invalid::value() : $result;
754
        } else {
755
            // Cast the items into a proper numeric array.
756 2
            $result = is_array($value) ? array_values($value) : iterator_to_array($value);
757 2
            return $result;
758
        }
759
    }
760
761
    /**
762
     * Validate a boolean value.
763
     *
764
     * @param mixed $value The value to validate.
765
     * @param ValidationField $field The validation results to add.
766
     * @return bool|Invalid Returns the cleaned value or invalid if validation fails.
767
     */
768 21
    protected function validateBoolean($value, ValidationField $field) {
769 21
        $value = $value === null ? $value : filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
770 21
        if ($value === null) {
771 4
            $field->addTypeError('boolean');
772 4
            return Invalid::value();
773
        }
774 18
        return $value;
775
    }
776
777
    /**
778
     * Validate a date time.
779
     *
780
     * @param mixed $value The value to validate.
781
     * @param ValidationField $field The validation results to add.
782
     * @return \DateTimeInterface|Invalid Returns the cleaned value or **null** if it isn't valid.
783
     */
784 12
    protected function validateDatetime($value, ValidationField $field) {
785 12
        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...
786
            // do nothing, we're good
787 12
        } elseif (is_string($value) && $value !== '') {
788
            try {
789 7
                $dt = new \DateTimeImmutable($value);
790 5
                if ($dt) {
791 5
                    $value = $dt;
792 5
                } else {
793
                    $value = null;
794
                }
795 7
            } catch (\Exception $ex) {
796 2
                $value = Invalid::value();
797
            }
798 10
        } elseif (is_int($value) && $value > 0) {
799 1
            $value = new \DateTimeImmutable('@'.(string)round($value));
800 1
        } else {
801 2
            $value = Invalid::value();
802
        }
803
804 12
        if (Invalid::isInvalid($value)) {
805 4
            $field->addTypeError('datetime');
806 4
        }
807 12
        return $value;
808
    }
809
810
    /**
811
     * Validate a float.
812
     *
813
     * @param mixed $value The value to validate.
814
     * @param ValidationField $field The validation results to add.
815
     * @return float|Invalid Returns a number or **null** if validation fails.
816
     */
817 8
    protected function validateNumber($value, ValidationField $field) {
818 8
        $result = filter_var($value, FILTER_VALIDATE_FLOAT);
819 8
        if ($result === false) {
820 4
            $field->addTypeError('number');
821 4
            return Invalid::value();
822
        }
823 4
        return $result;
824
    }
825
826
    /**
827
     * Validate and integer.
828
     *
829
     * @param mixed $value The value to validate.
830
     * @param ValidationField $field The validation results to add.
831
     * @return int|Invalid Returns the cleaned value or **null** if validation fails.
832
     */
833 24
    protected function validateInteger($value, ValidationField $field) {
834 24
        $result = filter_var($value, FILTER_VALIDATE_INT);
835
836 24
        if ($result === false) {
837 8
            $field->addTypeError('integer');
838 8
            return Invalid::value();
839
        }
840 19
        return $result;
841
    }
842
843
    /**
844
     * Validate an object.
845
     *
846
     * @param mixed $value The value to validate.
847
     * @param ValidationField $field The validation results to add.
848
     * @param bool $sparse Whether or not this is a sparse validation.
849
     * @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...
850
     */
851 86
    protected function validateObject($value, ValidationField $field, $sparse = false) {
852 86
        if (!$this->isArray($value) || isset($value[0])) {
853 6
            $field->addTypeError('object');
854 6
            return Invalid::value();
855 86
        } elseif (is_array($field->val('properties'))) {
856
            // Validate the data against the internal schema.
857 85
            $value = $this->validateProperties($value, $field, $sparse);
858 84
        } elseif (!is_array($value)) {
859
            $value = $this->toArray($value);
860
        }
861 84
        return $value;
862
    }
863
864
    /**
865
     * Validate data against the schema and return the result.
866
     *
867
     * @param array|\ArrayAccess $data The data to validate.
868
     * @param ValidationField $field This argument will be filled with the validation result.
869
     * @param bool $sparse Whether or not this is a sparse validation.
870
     * @return array|Invalid Returns a clean array with only the appropriate properties and the data coerced to proper types.
871
     * or invalid if there are no valid properties.
872
     */
873 85
    protected function validateProperties($data, ValidationField $field, $sparse = false) {
874 85
        $properties = $field->val('properties', []);
875 85
        $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...
876
877 85
        if (is_array($data)) {
878 84
            $keys = array_keys($data);
879 84
        } else {
880 1
            $keys = array_keys(iterator_to_array($data));
881
        }
882 85
        $keys = array_combine(array_map('strtolower', $keys), $keys);
883
884 85
        $propertyField = new ValidationField($field->getValidation(), [], null, $sparse);
885
886
        // Loop through the schema fields and validate each one.
887 85
        $clean = [];
888 85
        foreach ($properties as $propertyName => $property) {
889
            $propertyField
890 85
                ->setField($property)
891 85
                ->setName(ltrim($field->getName().".$propertyName", '.'));
892
893 85
            $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...
894 85
            $isRequired = isset($required[$propertyName]);
895
896
            // First check for required fields.
897 85
            if (!array_key_exists($lName, $keys)) {
898 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...
899
                    // Sparse validation can leave required fields out.
900 23
                } elseif ($propertyField->hasVal('default')) {
901 2
                    $clean[$propertyName] = $propertyField->val('default');
902 23
                } elseif ($isRequired) {
903 6
                    $propertyField->addError('missingField', ['messageCode' => '{field} is required.']);
904 6
                }
905 23
            } else {
906 83
                $value = $data[$keys[$lName]];
907
908 83
                if (in_array($value, [null, ''], true) && !$isRequired && !$propertyField->val('allowNull')) {
909 13
                    if ($propertyField->getType() !== 'string' || $value === null) {
910 10
                        continue;
911
                    }
912 3
                }
913
914 73
                $clean[$propertyName] = $this->validateField($value, $propertyField, $sparse);
915
            }
916
917 83
            unset($keys[$lName]);
918 85
        }
919
920
        // Look for extraneous properties.
921 85
        if (!empty($keys)) {
922 17
            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...
923 2
                $msg = sprintf("%s has unexpected field(s): %s.", $field->getName() ?: 'value', implode(', ', $keys));
924 2
                trigger_error($msg, E_USER_NOTICE);
925
            }
926
927 15
            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...
928 2
                $field->addError('invalid', [
929 2
                    'messageCode' => '{field} has {extra,plural,an unexpected field,unexpected fields}: {extra}.',
930 2
                    'extra' => array_values($keys),
931
                    'status' => 422
932 2
                ]);
933 2
            }
934 15
        }
935
936 83
        return $clean;
937
    }
938
939
    /**
940
     * Validate a string.
941
     *
942
     * @param mixed $value The value to validate.
943
     * @param ValidationField $field The validation results to add.
944
     * @return string|Invalid Returns the valid string or **null** if validation fails.
945
     */
946 55
    protected function validateString($value, ValidationField $field) {
947 55
        if (is_string($value) || is_numeric($value)) {
948 53
            $value = $result = (string)$value;
949 53
        } else {
950 5
            $field->addTypeError('string');
951 5
            return Invalid::value();
952
        }
953
954 53
        $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...
955 53
        if (($minLength = $field->val('minLength', 0)) > 0 && mb_strlen($value) < $minLength) {
956 4
            if (!empty($field->getName()) && $minLength === 1) {
957 2
                $field->addError('missingField', ['messageCode' => '{field} is required.', 'status' => 422]);
958 2
            } else {
959 2
                $field->addError(
960 2
                    'minLength',
961
                    [
962 2
                        'messageCode' => '{field} should be at least {minLength} {minLength,plural,character} long.',
963 2
                        'minLength' => $minLength,
964
                        'status' => 422
965 2
                    ]
966 2
                );
967
            }
968 4
        }
969 53
        if (($maxLength = $field->val('maxLength', 0)) > 0 && mb_strlen($value) > $maxLength) {
970 1
            $field->addError(
971 1
                'maxLength',
972
                [
973 1
                    'messageCode' => '{field} is {overflow} {overflow,plural,characters} too long.',
974 1
                    'maxLength' => $maxLength,
975 1
                    'overflow' => mb_strlen($value) - $maxLength,
976
                    'status' => 422
977 1
                ]
978 1
            );
979 1
        }
980 53
        if ($pattern = $field->val('pattern')) {
981 4
            $regex = '`'.str_replace('`', preg_quote('`', '`'), $pattern).'`';
982
983 4
            if (!preg_match($regex, $value)) {
984 2
                $field->addError(
985 2
                    'invalid',
986
                    [
987 2
                        'messageCode' => '{field} is in the incorrect format.',
988
                        'status' => 422
989 2
                    ]
990 2
                );
991 2
            }
992 4
        }
993 53
        if ($format = $field->val('format')) {
994 15
            $type = $format;
995
            switch ($format) {
996 15
                case 'date-time':
997 4
                    $result = $this->validateDatetime($result, $field);
998 4
                    if ($result instanceof \DateTimeInterface) {
999 4
                        $result = $result->format(\DateTime::RFC3339);
1000 4
                    }
1001 4
                    break;
1002 11
                case 'email':
1003 1
                    $result = filter_var($result, FILTER_VALIDATE_EMAIL);
1004 1
                    break;
1005 10
                case 'ipv4':
1006 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...
1007 1
                    $result = filter_var($result, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
1008 1
                    break;
1009 9
                case 'ipv6':
1010 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...
1011 1
                    $result = filter_var($result, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
1012 1
                    break;
1013 8
                case 'ip':
1014 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...
1015 1
                    $result = filter_var($result, FILTER_VALIDATE_IP);
1016 1
                    break;
1017 7
                case 'uri':
1018 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...
1019 7
                    $result = filter_var($result, FILTER_VALIDATE_URL, FILTER_FLAG_HOST_REQUIRED | FILTER_FLAG_SCHEME_REQUIRED);
1020 7
                    break;
1021
                default:
1022
                    trigger_error("Unrecognized format '$format'.", E_USER_NOTICE);
1023
            }
1024 15
            if ($result === false) {
1025 5
                $field->addTypeError($type);
1026 5
            }
1027 15
        }
1028
1029 53
        if ($field->isValid()) {
1030 45
            return $result;
1031
        } else {
1032 12
            return Invalid::value();
1033
        }
1034
    }
1035
1036
    /**
1037
     * Validate a unix timestamp.
1038
     *
1039
     * @param mixed $value The value to validate.
1040
     * @param ValidationField $field The field being validated.
1041
     * @return int|Invalid Returns a valid timestamp or invalid if the value doesn't validate.
1042
     */
1043 7
    protected function validateTimestamp($value, ValidationField $field) {
1044 7
        if (is_numeric($value) && $value > 0) {
1045 2
            $result = (int)$value;
1046 7
        } elseif (is_string($value) && $ts = strtotime($value)) {
1047 1
            $result = $ts;
1048 1
        } else {
1049 4
            $field->addTypeError('timestamp');
1050 4
            $result = Invalid::value();
1051
        }
1052 7
        return $result;
1053
    }
1054
1055
    /**
1056
     * Validate a null value.
1057
     *
1058
     * @param mixed $value The value to validate.
1059
     * @param ValidationField $field The error collector for the field.
1060
     * @return null|Invalid Returns **null** or invalid.
1061
     */
1062
    protected function validateNull($value, ValidationField $field) {
1063
        if ($value === null) {
1064
            return null;
1065
        }
1066
        $field->addError('invalid', ['messageCode' => '{field} should be null.', 'status' => 422]);
1067
        return Invalid::value();
1068
    }
1069
1070
    /**
1071
     * Validate a value against an enum.
1072
     *
1073
     * @param mixed $value The value to test.
1074
     * @param ValidationField $field The validation object for adding errors.
1075
     * @return mixed|Invalid Returns the value if it is one of the enumerated values or invalid otherwise.
1076
     */
1077 124
    protected function validateEnum($value, ValidationField $field) {
1078 124
        $enum = $field->val('enum');
1079 124
        if (empty($enum)) {
1080 123
            return $value;
1081
        }
1082
1083 1
        if (!in_array($value, $enum, true)) {
1084 1
            $field->addError(
1085 1
                'invalid',
1086
                [
1087 1
                    'messageCode' => '{field} must be one of: {enum}.',
1088 1
                    'enum' => $enum,
1089
                    'status' => 422
1090 1
                ]
1091 1
            );
1092 1
            return Invalid::value();
1093
        }
1094 1
        return $value;
1095
    }
1096
1097
    /**
1098
     * Call all of the validators attached to a field.
1099
     *
1100
     * @param mixed $value The field value being validated.
1101
     * @param ValidationField $field The validation object to add errors.
1102
     */
1103 124
    protected function callValidators($value, ValidationField $field) {
1104 124
        $valid = true;
1105
1106
        // Strip array references in the name except for the last one.
1107 124
        $key = preg_replace(['`\[\d+\]$`', '`\[\d+\]`'], ['[]', ''], $field->getName());
1108 124
        if (!empty($this->validators[$key])) {
1109 2
            foreach ($this->validators[$key] as $validator) {
1110 2
                $r = call_user_func($validator, $value, $field);
1111
1112 2
                if ($r === false || Invalid::isInvalid($r)) {
1113 1
                    $valid = false;
1114 1
                }
1115 2
            }
1116 2
        }
1117
1118
        // Add an error on the field if the validator hasn't done so.
1119 124
        if (!$valid && $field->isValid()) {
1120
            $field->addError('invalid', ['messageCode' => '{field} is invalid.', 'status' => 422]);
1121
        }
1122 124
    }
1123
1124
    /**
1125
     * Specify data which should be serialized to JSON.
1126
     *
1127
     * This method specifically returns data compatible with the JSON schema format.
1128
     *
1129
     * @return mixed Returns data which can be serialized by **json_encode()**, which is a value of any type other than a resource.
1130
     * @link http://php.net/manual/en/jsonserializable.jsonserialize.php
1131
     * @link http://json-schema.org/
1132
     */
1133
    public function jsonSerialize() {
1134 14
        $fix = function ($schema) use (&$fix) {
1135 14
            if ($schema instanceof Schema) {
1136 1
                return $schema->jsonSerialize();
1137
            }
1138
1139 14
            if (!empty($schema['type'])) {
1140
                // Swap datetime and timestamp to other types with formats.
1141 13
                if ($schema['type'] === 'datetime') {
1142 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...
1143 3
                    $schema['format'] = 'date-time';
1144 13
                } elseif ($schema['type'] === 'timestamp') {
1145 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...
1146 3
                    $schema['format'] = 'timestamp';
1147 3
                }
1148 13
            }
1149
1150 14
            if (!empty($schema['items'])) {
1151 4
                $schema['items'] = $fix($schema['items']);
1152 4
            }
1153 14
            if (!empty($schema['properties'])) {
1154 10
                $properties = [];
1155 10
                foreach ($schema['properties'] as $key => $property) {
1156 10
                    $properties[$key] = $fix($property);
1157 10
                }
1158 10
                $schema['properties'] = $properties;
1159 10
            }
1160
1161 14
            return $schema;
1162 14
        };
1163
1164 14
        $result = $fix($this->schema);
1165
1166 14
        return $result;
1167
    }
1168
1169
    /**
1170
     * Look up a type based on its alias.
1171
     *
1172
     * @param string $alias The type alias or type name to lookup.
1173
     * @return mixed
1174
     */
1175 149
    protected function getType($alias) {
1176 149
        if (isset(self::$types[$alias])) {
1177
            return $alias;
1178
        }
1179 149
        foreach (self::$types as $type => $aliases) {
1180 149
            if (in_array($alias, $aliases, true)) {
1181 149
                return $type;
1182
            }
1183 149
        }
1184 9
        return null;
1185
    }
1186
1187
    /**
1188
     * Get the class that's used to contain validation information.
1189
     *
1190
     * @return Validation|string Returns the validation class.
1191
     */
1192 126
    public function getValidationClass() {
1193 126
        return $this->validationClass;
1194
    }
1195
1196
    /**
1197
     * Set the class that's used to contain validation information.
1198
     *
1199
     * @param Validation|string $class Either the name of a class or a class that will be cloned.
1200
     * @return $this
1201
     */
1202 1
    public function setValidationClass($class) {
1203 1
        if (!is_a($class, Validation::class, true)) {
1204
            throw new \InvalidArgumentException("$class must be a subclass of ".Validation::class, 500);
1205
        }
1206
1207 1
        $this->validationClass = $class;
1208 1
        return $this;
1209
    }
1210
1211
    /**
1212
     * Create a new validation instance.
1213
     *
1214
     * @return Validation Returns a validation object.
1215
     */
1216 126
    protected function createValidation() {
1217 126
        $class = $this->getValidationClass();
1218
1219 126
        if ($class instanceof Validation) {
1220 1
            $result = clone $class;
1221 1
        } else {
1222 126
            $result = new $class;
1223
        }
1224 126
        return $result;
1225
    }
1226
1227
    /**
1228
     * Check whether or not a value is an array or accessible like an array.
1229
     *
1230
     * @param mixed $value The value to check.
1231
     * @return bool Returns **true** if the value can be used like an array or **false** otherwise.
1232
     */
1233 86
    private function isArray($value) {
1234 86
        return is_array($value) || ($value instanceof \ArrayAccess && $value instanceof \Traversable);
1235
    }
1236
1237
    /**
1238
     * Cast a value to an array.
1239
     *
1240
     * @param \Traversable $value The value to convert.
1241
     * @return array Returns an array.
1242
     */
1243
    private function toArray(\Traversable $value) {
1244
        if ($value instanceof \ArrayObject) {
1245
            return $value->getArrayCopy();
1246
        }
1247
        return iterator_to_array($value);
1248
    }
1249
1250
    /**
1251
     * Return a sparse version of this schema.
1252
     *
1253
     * A sparse schema has no required properties.
1254
     *
1255
     * @return Schema Returns a new sparse schema.
1256
     */
1257 2
    public function withSparse() {
1258 2
        $sparseSchema = $this->withSparseInternal($this, new \SplObjectStorage());
1259 2
        return $sparseSchema;
1260
    }
1261
1262
    /**
1263
     * The internal implementation of `Schema::withSparse()`.
1264
     *
1265
     * @param array|Schema $schema The schema to make sparse.
1266
     * @param \SplObjectStorage $schemas Collected sparse schemas that have already been made.
1267
     * @return mixed
1268
     */
1269 2
    private function withSparseInternal($schema, \SplObjectStorage $schemas) {
1270 2
        if ($schema instanceof Schema) {
1271 2
            if ($schemas->contains($schema)) {
1272 1
                return $schemas[$schema];
1273
            } else {
1274 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...
1275 2
                $sparseSchema->schema = $schema->withSparseInternal($schema->schema, $schemas);
1276 2
                if ($id = $sparseSchema->getID()) {
1277
                    $sparseSchema->setID($id.'Sparse');
1278
                }
1279
1280 2
                return $sparseSchema;
1281
            }
1282
        }
1283
1284 2
        unset($schema['required']);
1285
1286 2
        if (isset($schema['items'])) {
1287 1
            $schema['items'] = $this->withSparseInternal($schema['items'], $schemas);
1288 1
        }
1289 2
        if (isset($schema['properties'])) {
1290 2
            foreach ($schema['properties'] as $name => &$property) {
1291 2
                $property = $this->withSparseInternal($property, $schemas);
1292 2
            }
1293 2
        }
1294
1295 2
        return $schema;
1296
    }
1297
}
1298