Completed
Pull Request — master (#61)
by Todd
02:51
created

Schema::validate()   A

Complexity

Conditions 5
Paths 8

Size

Total Lines 23
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 5

Importance

Changes 0
Metric Value
cc 5
eloc 12
nc 8
nop 2
dl 0
loc 23
ccs 13
cts 13
cp 1
crap 5
rs 9.5555
c 0
b 0
f 0
1
<?php
2
/**
3
 * @author Todd Burry <[email protected]>
4
 * @copyright 2009-2018 Vanilla Forums Inc.
5
 * @license MIT
6
 */
7
8
namespace Garden\Schema;
9
10
/**
11
 * A class for defining and validating data schemas.
12
 */
13
class Schema implements \JsonSerializable, \ArrayAccess {
14
    /**
15
     * Trigger a notice when extraneous properties are encountered during validation.
16
     */
17
    const VALIDATE_EXTRA_PROPERTY_NOTICE = 0x1;
18
19
    /**
20
     * Throw a ValidationException when extraneous properties are encountered during validation.
21
     */
22
    const VALIDATE_EXTRA_PROPERTY_EXCEPTION = 0x2;
23
24
    /**
25
     * @var array All the known types.
26
     *
27
     * If this is ever given some sort of public access then remove the static.
28
     */
29
    private static $types = [
30
        'array' => ['a'],
31
        'object' => ['o'],
32
        'integer' => ['i', 'int'],
33
        'string' => ['s', 'str'],
34
        'number' => ['f', 'float'],
35
        'boolean' => ['b', 'bool'],
36
37
        // Psuedo-types
38
        'timestamp' => ['ts'], // type: integer, format: timestamp
39
        'datetime' => ['dt'], // type: string, format: date-time
40
        'null' => ['n'], // Adds nullable: true
41
    ];
42
43
    /**
44
     * @var string The regular expression to strictly determine if a string is a date.
45
     */
46
    private static $DATE_REGEX = '`^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2})?)?`i';
47
48
    private $schema = [];
49
50
    /**
51
     * @var int A bitwise combination of the various **Schema::FLAG_*** constants.
52
     */
53
    private $flags = 0;
54
55
    /**
56
     * @var array An array of callbacks that will filter data in the schema.
57
     */
58
    private $filters = [];
59
60
    /**
61
     * @var array An array of callbacks that will custom validate the schema.
62
     */
63
    private $validators = [];
64
65
    /**
66
     * @var string|Validation The name of the class or an instance that will be cloned.
67
     * @deprecated
68
     */
69
    private $validationClass = Validation::class;
70
71
    /**
72
     * @var callable A callback is used to create validation objects.
73
     */
74
    private $validationFactory = [Validation::class, 'createValidation'];
75
76
    /**
77
     * @var callable
78
     */
79
    private $refLookup;
80
81
    /// Methods ///
82
83
    /**
84
     * Initialize an instance of a new {@link Schema} class.
85
     *
86
     * @param array $schema The array schema to validate against.
87
     * @param callable $refLookup The function used to lookup references.
88
     */
89 277
    public function __construct(array $schema = [], callable $refLookup = null) {
90 277
        $this->schema = $schema;
91
92 271
        $this->refLookup = $refLookup ?? function (/** @scrutinizer ignore-unused */
93
                string $_) {
94 1
                return null;
95 271
            };
96 277
    }
97
98
    /**
99
     * Parse a short schema and return the associated schema.
100
     *
101
     * @param array $arr The schema array.
102
     * @param mixed[] $args Constructor arguments for the schema instance.
103
     * @return static Returns a new schema.
104
     */
105 179
    public static function parse(array $arr, ...$args) {
106 179
        $schema = new static([], ...$args);
0 ignored issues
show
Bug introduced by
$args is expanded, but the parameter $refLookup of Garden\Schema\Schema::__construct() does not expect variable arguments. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

106
        $schema = new static([], /** @scrutinizer ignore-type */ ...$args);
Loading history...
107 179
        $schema->schema = $schema->parseInternal($arr);
108 177
        return $schema;
109
    }
110
111
    /**
112
     * Parse a schema in short form into a full schema array.
113
     *
114
     * @param array $arr The array to parse into a schema.
115
     * @return array The full schema array.
116
     * @throws ParseException Throws an exception when an item in the schema is invalid.
117
     */
118 179
    protected function parseInternal(array $arr): array {
119 179
        if (empty($arr)) {
120
            // An empty schema validates to anything.
121 6
            return [];
122 174
        } elseif (isset($arr['type'])) {
123
            // This is a long form schema and can be parsed as the root.
124 2
            return $this->parseNode($arr);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->parseNode($arr) could return the type ArrayAccess which is incompatible with the type-hinted return array. Consider adding an additional type-check to rule them out.
Loading history...
125
        } else {
126
            // Check for a root schema.
127 173
            $value = reset($arr);
128 173
            $key = key($arr);
129 173
            if (is_int($key)) {
130 108
                $key = $value;
131 108
                $value = null;
132
            }
133 173
            list ($name, $param) = $this->parseShortParam($key, $value);
134 171
            if (empty($name)) {
135 63
                return $this->parseNode($param, $value);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->parseNode($param, $value) could return the type ArrayAccess which is incompatible with the type-hinted return array. Consider adding an additional type-check to rule them out.
Loading history...
136
            }
137
        }
138
139
        // If we are here then this is n object schema.
140 111
        list($properties, $required) = $this->parseProperties($arr);
141
142
        $result = [
143 111
            'type' => 'object',
144 111
            'properties' => $properties,
145 111
            'required' => $required
146
        ];
147
148 111
        return array_filter($result);
149
    }
150
151
    /**
152
     * Parse a schema node.
153
     *
154
     * @param array|Schema $node The node to parse.
155
     * @param mixed $value Additional information from the node.
156
     * @return array|\ArrayAccess Returns a JSON schema compatible node.
157
     * @throws ParseException Throws an exception if there was a problem parsing the schema node.
158
     */
159 172
    private function parseNode($node, $value = null) {
160 172
        if (is_array($value)) {
161 66
            if (is_array($node['type'])) {
162
                trigger_error('Schemas with multiple types are deprecated.', E_USER_DEPRECATED);
163
            }
164
165
            // The value describes a bit more about the schema.
166 66
            switch ($node['type']) {
167 66
                case 'array':
168 11
                    if (isset($value['items'])) {
169
                        // The value includes array schema information.
170 4
                        $node = array_replace($node, $value);
0 ignored issues
show
Bug introduced by
It seems like $node can also be of type Garden\Schema\Schema; however, parameter $array of array_replace() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

170
                        $node = array_replace(/** @scrutinizer ignore-type */ $node, $value);
Loading history...
171
                    } else {
172 7
                        $node['items'] = $this->parseInternal($value);
173
                    }
174 11
                    break;
175 56
                case 'object':
176
                    // The value is a schema of the object.
177 12
                    if (isset($value['properties'])) {
178
                        list($node['properties']) = $this->parseProperties($value['properties']);
179
                    } else {
180 12
                        list($node['properties'], $required) = $this->parseProperties($value);
181 12
                        if (!empty($required)) {
182 12
                            $node['required'] = $required;
183
                        }
184
                    }
185 12
                    break;
186
                default:
187 44
                    $node = array_replace($node, $value);
188 66
                    break;
189
            }
190 132
        } elseif (is_string($value)) {
191 102
            if ($node['type'] === 'array' && $arrType = $this->getType($value)) {
192 6
                $node['items'] = ['type' => $arrType];
193 98
            } elseif (!empty($value)) {
194 102
                $node['description'] = $value;
195
            }
196 35
        } elseif ($value === null) {
197
            // Parse child elements.
198 31
            if ($node['type'] === 'array' && isset($node['items'])) {
199
                // The value includes array schema information.
200
                $node['items'] = $this->parseInternal($node['items']);
0 ignored issues
show
Bug introduced by
It seems like $node['items'] can also be of type null; however, parameter $arr of Garden\Schema\Schema::parseInternal() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

200
                $node['items'] = $this->parseInternal(/** @scrutinizer ignore-type */ $node['items']);
Loading history...
201 31
            } elseif ($node['type'] === 'object' && isset($node['properties'])) {
202 1
                list($node['properties']) = $this->parseProperties($node['properties']);
203
            }
204
        }
205
206 172
        if (is_array($node)) {
207 171
            if (!empty($node['allowNull'])) {
208 1
                $node['nullable'] = true;
209
            }
210 171
            unset($node['allowNull']);
211
212 171
            if ($node['type'] === null || $node['type'] === []) {
213 4
                unset($node['type']);
214
            }
215
        }
216
217 172
        return $node;
218
    }
219
220
    /**
221
     * Parse the schema for an object's properties.
222
     *
223
     * @param array $arr An object property schema.
224
     * @return array Returns a schema array suitable to be placed in the **properties** key of a schema.
225
     * @throws ParseException Throws an exception if a property name cannot be determined for an array item.
226
     */
227 112
    private function parseProperties(array $arr): array {
228 112
        $properties = [];
229 112
        $requiredProperties = [];
230 112
        foreach ($arr as $key => $value) {
231
            // Fix a schema specified as just a value.
232 112
            if (is_int($key)) {
233 82
                if (is_string($value)) {
234 82
                    $key = $value;
235 82
                    $value = '';
236
                } else {
237
                    throw new ParseException("Schema at position $key is not a valid parameter.", 500);
238
                }
239
            }
240
241
            // The parameter is defined in the key.
242 112
            list($name, $param, $required) = $this->parseShortParam($key, $value);
0 ignored issues
show
Bug introduced by
$value of type string is incompatible with the type array expected by parameter $value of Garden\Schema\Schema::parseShortParam(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

242
            list($name, $param, $required) = $this->parseShortParam($key, /** @scrutinizer ignore-type */ $value);
Loading history...
243
244 112
            $node = $this->parseNode($param, $value);
245
246 112
            $properties[$name] = $node;
247 112
            if ($required) {
248 112
                $requiredProperties[] = $name;
249
            }
250
        }
251 112
        return [$properties, $requiredProperties];
252
    }
253
254
    /**
255
     * Parse a short parameter string into a full array parameter.
256
     *
257
     * @param string $key The short parameter string to parse.
258
     * @param array $value An array of other information that might help resolve ambiguity.
259
     * @return array Returns an array in the form `[string name, array param, bool required]`.
260
     * @throws ParseException Throws an exception if the short param is not in the correct format.
261
     */
262 174
    public function parseShortParam(string $key, $value = []): array {
263
        // Is the parameter optional?
264 174
        if (substr($key, -1) === '?') {
265 70
            $required = false;
266 70
            $key = substr($key, 0, -1);
267
        } else {
268 126
            $required = true;
269
        }
270
271
        // Check for a type.
272 174
        if (false !== ($pos = strrpos($key, ':'))) {
273 168
            $name = substr($key, 0, $pos);
274 168
            $typeStr = substr($key, $pos + 1);
275
276
            // Kludge for names with colons that are not specifying an array of a type.
277 168
            if (isset($value['type']) && 'array' !== $this->getType($typeStr)) {
278 2
                $name = $key;
279 168
                $typeStr = '';
280
            }
281
        } else {
282 16
            $name = $key;
283 16
            $typeStr = '';
284
        }
285 174
        $types = [];
286 174
        $param = [];
287
288 174
        if (!empty($typeStr)) {
289 166
            $shortTypes = explode('|', $typeStr);
290 166
            foreach ($shortTypes as $alias) {
291 166
                $found = $this->getType($alias);
292 166
                if ($found === null) {
293 1
                    throw new ParseException("Unknown type '$alias'.", 500);
294 165
                } elseif ($found === 'datetime') {
295 9
                    $param['format'] = 'date-time';
296 9
                    $types[] = 'string';
297 157
                } elseif ($found === 'timestamp') {
298 12
                    $param['format'] = 'timestamp';
299 12
                    $types[] = 'integer';
300 151
                } elseif ($found === 'null') {
301 11
                    $nullable = true;
302
                } else {
303 165
                    $types[] = $found;
304
                }
305
            }
306
        }
307
308 173
        if ($value instanceof Schema) {
309 6
            if (count($types) === 1 && $types[0] === 'array') {
310 1
                $param += ['type' => $types[0], 'items' => $value];
311
            } else {
312 6
                $param = $value;
313
            }
314 171
        } elseif (isset($value['type'])) {
315 10
            $param = $value + $param;
316
317 10
            if (!empty($types) && $types !== (array)$param['type']) {
318
                $typesStr = implode('|', $types);
319
                $paramTypesStr = implode('|', (array)$param['type']);
320
321 10
                throw new ParseException("Type mismatch between $typesStr and {$paramTypesStr} for field $name.", 500);
322
            }
323
        } else {
324 166
            if (empty($types) && !empty($parts[1])) {
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $parts seems to never exist and therefore empty should always be true.
Loading history...
325
                throw new ParseException("Invalid type {$parts[1]} for field $name.", 500);
326
            }
327 166
            if (empty($types)) {
328 4
                $param += ['type' => null];
329
            } else {
330 165
                $param += ['type' => count($types) === 1 ? $types[0] : $types];
331
            }
332
333
            // Parsed required strings have a minimum length of 1.
334 166
            if (in_array('string', $types) && !empty($name) && $required && (!isset($value['default']) || $value['default'] !== '')) {
335 41
                $param['minLength'] = 1;
336
            }
337
        }
338
339 173
        if (!empty($nullable)) {
340 11
            $param['nullable'] = true;
341
        }
342
343 173
        if (is_array($param['type'])) {
344 1
            trigger_error('Schemas with multiple types is deprecated.', E_USER_DEPRECATED);
345
        }
346
347 172
        return [$name, $param, $required];
348
    }
349
350
    /**
351
     * Look up a type based on its alias.
352
     *
353
     * @param string $alias The type alias or type name to lookup.
354
     * @return mixed
355
     */
356 168
    private function getType($alias) {
357 168
        if (isset(self::$types[$alias])) {
358
            return $alias;
359
        }
360 168
        foreach (self::$types as $type => $aliases) {
361 168
            if (in_array($alias, $aliases, true)) {
362 168
                return $type;
363
            }
364
        }
365 12
        return null;
366
    }
367
368
    /**
369
     * Unescape a JSON reference segment.
370
     *
371
     * @param string $str The segment to unescapeRef.
372
     * @return string Returns the unescaped string.
373
     */
374 20
    public static function unescapeRef(string $str): string {
375 20
        return str_replace(['~1', '~0'], ['/', '~'], $str);
376
    }
377
378
    /**
379
     * Explode a references into its individual parts.
380
     *
381
     * @param string $ref A JSON reference.
382
     * @return string[] The individual parts of the reference.
383
     */
384 20
    public static function explodeRef(string $ref): array {
385 20
        return array_map([self::class, 'unescapeRef'], explode('/', $ref));
386
    }
387
388
    /**
389
     * Grab the schema's current description.
390
     *
391
     * @return string
392
     */
393 1
    public function getDescription(): string {
394 1
        return $this->schema['description'] ?? '';
395
    }
396
397
    /**
398
     * Set the description for the schema.
399
     *
400
     * @param string $description The new description.
401
     * @return $this
402
     */
403 1
    public function setDescription(string $description) {
404 1
        $this->schema['description'] = $description;
405 1
        return $this;
406
    }
407
408
    /**
409
     * Get the schema's title.
410
     *
411
     * @return string Returns the title.
412
     */
413 1
    public function getTitle(): string {
414 1
        return $this->schema['title'] ?? '';
415
    }
416
417
    /**
418
     * Set the schema's title.
419
     *
420
     * @param string $title The new title.
421
     */
422 1
    public function setTitle(string $title) {
423 1
        $this->schema['title'] = $title;
424 1
    }
425
426
    /**
427
     * Get a schema field.
428
     *
429
     * @param string|array $path The JSON schema path of the field with parts separated by dots.
430
     * @param mixed $default The value to return if the field isn't found.
431
     * @return mixed Returns the field value or `$default`.
432
     */
433 10
    public function getField($path, $default = null) {
434 10
        if (is_string($path)) {
435 10
            if (strpos($path, '.') !== false && strpos($path, '/') === false) {
436 1
                trigger_error('Field selectors must be separated by "/" instead of "."', E_USER_DEPRECATED);
437 1
                $path = explode('.', $path);
438
            } else {
439 9
                $path = explode('/', $path);
440
            }
441
        }
442
443 10
        $value = $this->schema;
444 10
        foreach ($path as $i => $subKey) {
445 10
            if (is_array($value) && isset($value[$subKey])) {
446 10
                $value = $value[$subKey];
447 1
            } elseif ($value instanceof Schema) {
448 1
                return $value->getField(array_slice($path, $i), $default);
449
            } else {
450 10
                return $default;
451
            }
452
        }
453 10
        return $value;
454
    }
455
456
    /**
457
     * Set a schema field.
458
     *
459
     * @param string|array $path The JSON schema path of the field with parts separated by slashes.
460
     * @param mixed $value The new value.
461
     * @return $this
462
     */
463 4
    public function setField($path, $value) {
464 4
        if (is_string($path)) {
465 4
            if (strpos($path, '.') !== false && strpos($path, '/') === false) {
466 1
                trigger_error('Field selectors must be separated by "/" instead of "."', E_USER_DEPRECATED);
467 1
                $path = explode('.', $path);
468
            } else {
469 3
                $path = explode('/', $path);
470
            }
471
        }
472
473 4
        $selection = &$this->schema;
474 4
        foreach ($path as $i => $subSelector) {
475 4
            if (is_array($selection)) {
476 4
                if (!isset($selection[$subSelector])) {
477 4
                    $selection[$subSelector] = [];
478
                }
479 1
            } elseif ($selection instanceof Schema) {
480 1
                $selection->setField(array_slice($path, $i), $value);
481 1
                return $this;
482
            } else {
483
                $selection = [$subSelector => []];
484
            }
485 4
            $selection = &$selection[$subSelector];
486
        }
487
488 4
        $selection = $value;
489 4
        return $this;
490
    }
491
492
    /**
493
     * Return the validation flags.
494
     *
495
     * @return int Returns a bitwise combination of flags.
496
     */
497 1
    public function getFlags(): int {
498 1
        return $this->flags;
499
    }
500
501
    /**
502
     * Set the validation flags.
503
     *
504
     * @param int $flags One or more of the **Schema::FLAG_*** constants.
505
     * @return Schema Returns the current instance for fluent calls.
506
     */
507 7
    public function setFlags(int $flags) {
508 7
        $this->flags = $flags;
509
510 7
        return $this;
511
    }
512
513
    /**
514
     * Set a flag.
515
     *
516
     * @param int $flag One or more of the **Schema::VALIDATE_*** constants.
517
     * @param bool $value Either true or false.
518
     * @return $this
519
     */
520 1
    public function setFlag(int $flag, bool $value) {
521 1
        if ($value) {
522 1
            $this->flags = $this->flags | $flag;
523
        } else {
524 1
            $this->flags = $this->flags & ~$flag;
525
        }
526 1
        return $this;
527
    }
528
529
    /**
530
     * Merge a schema with this one.
531
     *
532
     * @param Schema $schema A scheme instance. Its parameters will be merged into the current instance.
533
     * @return $this
534
     */
535 4
    public function merge(Schema $schema) {
536 4
        $this->mergeInternal($this->schema, $schema->getSchemaArray(), true, true);
537 4
        return $this;
538
    }
539
540
    /**
541
     * The internal implementation of schema merging.
542
     *
543
     * @param array $target The target of the merge.
544
     * @param array $source The source of the merge.
545
     * @param bool $overwrite Whether or not to replace values.
546
     * @param bool $addProperties Whether or not to add object properties to the target.
547
     * @return array
548
     */
549 7
    private function mergeInternal(array &$target, array $source, $overwrite = true, $addProperties = true) {
550
        // We need to do a fix for required properties here.
551 7
        if (isset($target['properties']) && !empty($source['required'])) {
552 5
            $required = isset($target['required']) ? $target['required'] : [];
553
554 5
            if (isset($source['required']) && $addProperties) {
555 4
                $newProperties = array_diff(array_keys($source['properties']), array_keys($target['properties']));
556 4
                $newRequired = array_intersect($source['required'], $newProperties);
557
558 4
                $required = array_merge($required, $newRequired);
559
            }
560
        }
561
562
563 7
        foreach ($source as $key => $val) {
564 7
            if (is_array($val) && array_key_exists($key, $target) && is_array($target[$key])) {
565 7
                if ($key === 'properties' && !$addProperties) {
566
                    // We just want to merge the properties that exist in the destination.
567 2
                    foreach ($val as $name => $prop) {
568 2
                        if (isset($target[$key][$name])) {
569 2
                            $targetProp = &$target[$key][$name];
570
571 2
                            if (is_array($targetProp) && is_array($prop)) {
572 2
                                $this->mergeInternal($targetProp, $prop, $overwrite, $addProperties);
573 1
                            } elseif (is_array($targetProp) && $prop instanceof Schema) {
574
                                $this->mergeInternal($targetProp, $prop->getSchemaArray(), $overwrite, $addProperties);
575 1
                            } elseif ($overwrite) {
576 2
                                $targetProp = $prop;
577
                            }
578
                        }
579
                    }
580 7
                } elseif (isset($val[0]) || isset($target[$key][0])) {
581 5
                    if ($overwrite) {
582
                        // This is a numeric array, so just do a merge.
583 3
                        $merged = array_merge($target[$key], $val);
584 3
                        if (is_string($merged[0])) {
585 3
                            $merged = array_keys(array_flip($merged));
586
                        }
587 5
                        $target[$key] = $merged;
588
                    }
589
                } else {
590 7
                    $target[$key] = $this->mergeInternal($target[$key], $val, $overwrite, $addProperties);
591
                }
592 7
            } elseif (!$overwrite && array_key_exists($key, $target) && !is_array($val)) {
593
                // Do nothing, we aren't replacing.
594
            } else {
595 7
                $target[$key] = $val;
596
            }
597
        }
598
599 7
        if (isset($required)) {
600 5
            if (empty($required)) {
601 1
                unset($target['required']);
602
            } else {
603 5
                $target['required'] = $required;
604
            }
605
        }
606
607 7
        return $target;
608
    }
609
610
    /**
611
     * Returns the internal schema array.
612
     *
613
     * @return array
614
     * @see Schema::jsonSerialize()
615
     */
616 17
    public function getSchemaArray(): array {
617 17
        return $this->schema;
618
    }
619
620
    /**
621
     * Add another schema to this one.
622
     *
623
     * Adding schemas together is analogous to array addition. When you add a schema it will only add missing information.
624
     *
625
     * @param Schema $schema The schema to add.
626
     * @param bool $addProperties Whether to add properties that don't exist in this schema.
627
     * @return $this
628
     */
629 4
    public function add(Schema $schema, $addProperties = false) {
630 4
        $this->mergeInternal($this->schema, $schema->getSchemaArray(), false, $addProperties);
631 4
        return $this;
632
    }
633
634
    /**
635
     * Add a custom filter to change data before validation.
636
     *
637
     * @param string $fieldname The name of the field to filter, if any.
638
     *
639
     * If you are adding a filter to a deeply nested field then separate the path with dots.
640
     * @param callable $callback The callback to filter the field.
641
     * @param bool $validate Whether or not the filter should also validate. If true default validation is skipped.
642
     * @return $this
643
     */
644 4
    public function addFilter(string $fieldname, callable $callback, bool $validate = false) {
645 4
        $fieldname = $this->parseFieldSelector($fieldname);
646 4
        $this->filters[$fieldname][] = [$callback, $validate];
647 4
        return $this;
648
    }
649
650
    /**
651
     * Parse a nested field name selector.
652
     *
653
     * Field selectors should be separated by "/" characters, but may currently be separated by "." characters which
654
     * triggers a deprecated error.
655
     *
656
     * @param string $field The field selector.
657
     * @return string Returns the field selector in the correct format.
658
     */
659 17
    private function parseFieldSelector(string $field): string {
660 17
        if (strlen($field) === 0) {
661 6
            return $field;
662
        }
663
664 11
        if (strpos($field, '.') !== false) {
665 1
            if (strpos($field, '/') === false) {
666 1
                trigger_error('Field selectors must be separated by "/" instead of "."', E_USER_DEPRECATED);
667
668 1
                $parts = explode('.', $field);
669 1
                $parts = @array_map([$this, 'parseFieldSelector'], $parts); // silence because error triggered already.
670
671 1
                $field = implode('/', $parts);
672
            }
673 11
        } elseif ($field === '[]') {
674 1
            trigger_error('Field selectors with item selector "[]" must be converted to "items".', E_USER_DEPRECATED);
675 1
            $field = 'items';
676 10
        } elseif (strpos($field, '/') === false && !in_array($field, ['items', 'additionalProperties'], true)) {
677 3
            trigger_error("Field selectors must specify full schema paths. ($field)", E_USER_DEPRECATED);
678 3
            $field = "/properties/$field";
679
        }
680
681 11
        if (strpos($field, '[]') !== false) {
682 1
            trigger_error('Field selectors with item selector "[]" must be converted to "/items".', E_USER_DEPRECATED);
683 1
            $field = str_replace('[]', '/items', $field);
684
        }
685
686 11
        return ltrim($field, '/');
687
    }
688
689
    /**
690
     * Require one of a given set of fields in the schema.
691
     *
692
     * @param array $required The field names to require.
693
     * @param string $fieldname The name of the field to attach to.
694
     * @param int $count The count of required items.
695
     * @return Schema Returns `$this` for fluent calls.
696
     */
697 3
    public function requireOneOf(array $required, string $fieldname = '', int $count = 1) {
698 3
        $result = $this->addValidator(
699 3
            $fieldname,
700 3
            function ($data, ValidationField $field) use ($required, $count) {
701
                // This validator does not apply to sparse validation.
702 3
                if ($field->isSparse()) {
703 1
                    return true;
704
                }
705
706 2
                $hasCount = 0;
707 2
                $flattened = [];
708
709 2
                foreach ($required as $name) {
710 2
                    $flattened = array_merge($flattened, (array)$name);
711
712 2
                    if (is_array($name)) {
713
                        // This is an array of required names. They all must match.
714 1
                        $hasCountInner = 0;
715 1
                        foreach ($name as $nameInner) {
716 1
                            if (array_key_exists($nameInner, $data)) {
717 1
                                $hasCountInner++;
718
                            } else {
719 1
                                break;
720
                            }
721
                        }
722 1
                        if ($hasCountInner >= count($name)) {
723 1
                            $hasCount++;
724
                        }
725 2
                    } elseif (array_key_exists($name, $data)) {
726 1
                        $hasCount++;
727
                    }
728
729 2
                    if ($hasCount >= $count) {
730 2
                        return true;
731
                    }
732
                }
733
734 2
                if ($count === 1) {
735 1
                    $message = 'One of {properties} are required.';
736
                } else {
737 1
                    $message = '{count} of {properties} are required.';
738
                }
739
740 2
                $field->addError('oneOfRequired', [
741 2
                    'messageCode' => $message,
742 2
                    'properties' => $required,
743 2
                    'count' => $count
744
                ]);
745 2
                return false;
746 3
            }
747
        );
748
749 3
        return $result;
750
    }
751
752
    /**
753
     * Add a custom validator to to validate the schema.
754
     *
755
     * @param string $fieldname The name of the field to validate, if any.
756
     *
757
     * If you are adding a validator to a deeply nested field then separate the path with dots.
758
     * @param callable $callback The callback to validate with.
759
     * @return Schema Returns `$this` for fluent calls.
760
     */
761 5
    public function addValidator(string $fieldname, callable $callback) {
762 5
        $fieldname = $this->parseFieldSelector($fieldname);
763 5
        $this->validators[$fieldname][] = $callback;
764 5
        return $this;
765
    }
766
767
    /**
768
     * Validate data against the schema and return the result.
769
     *
770
     * @param mixed $data The data to validate.
771
     * @param array $options Validation options. See `Schema::validate()`.
772
     * @return bool Returns true if the data is valid. False otherwise.
773
     * @throws RefNotFoundException Throws an exception when there is an unknown `$ref` in the schema.
774
     */
775 45
    public function isValid($data, $options = []) {
776
        try {
777 45
            $this->validate($data, $options);
778 31
            return true;
779 24
        } catch (ValidationException $ex) {
780 24
            return false;
781
        }
782
    }
783
784
    /**
785
     * Validate data against the schema.
786
     *
787
     * @param mixed $data The data to validate.
788
     * @param array $options Validation options.
789
     *
790
     * - **sparse**: Whether or not this is a sparse validation.
791
     * @return mixed Returns a cleaned version of the data.
792
     * @throws ValidationException Throws an exception when the data does not validate against the schema.
793
     * @throws RefNotFoundException Throws an exception when a schema `$ref` is not found.
794
     */
795 222
    public function validate($data, $options = []) {
796 222
        if (is_bool($options)) {
0 ignored issues
show
introduced by
The condition is_bool($options) is always false.
Loading history...
797 1
            trigger_error('The $sparse parameter is deprecated. Use [\'sparse\' => true] instead.', E_USER_DEPRECATED);
798 1
            $options = ['sparse' => true];
799
        }
800 222
        $options += ['sparse' => false];
801
802
803 222
        list($schema, $schemaPath) = $this->lookupSchema($this->schema, '');
804 218
        $field = new ValidationField($this->createValidation(), $schema, '', $schemaPath, $options);
805
806 218
        $clean = $this->validateField($data, $field);
807
808 216
        if (Invalid::isInvalid($clean) && $field->isValid()) {
809
            // This really shouldn't happen, but we want to protect against seeing the invalid object.
810 1
            $field->addError('invalid', ['messageCode' => 'The value is invalid.']);
811
        }
812
813 216
        if (!$field->getValidation()->isValid()) {
814 74
            throw new ValidationException($field->getValidation());
815
        }
816
817 158
        return $clean;
818
    }
819
820
    /**
821
     * Lookup a schema based on a schema node.
822
     *
823
     * The node could be a schema array, `Schema` object, or a schema reference.
824
     *
825
     * @param mixed $schema The schema node to lookup with.
826
     * @param string $schemaPath The current path of the schema.
827
     * @return array Returns an array with two elements:
828
     * - Schema|array|\ArrayAccess The schema that was found.
829
     * - string The path of the schema. This is either the reference or the `$path` parameter for inline schemas.
830
     * @throws RefNotFoundException Throws an exception when a reference could not be found.
831
     */
832 222
    private function lookupSchema($schema, string $schemaPath) {
833 222
        if ($schema instanceof Schema) {
834 6
            return [$schema, $schemaPath];
835
        } else {
836 222
            $lookup = $this->getRefLookup();
837 222
            $visited = [];
838
839 222
            while (!empty($schema['$ref'])) {
840 17
                $schemaPath = $schema['$ref'];
841
842 17
                if (isset($visited[$schemaPath])) {
843 1
                    throw new RefNotFoundException("Cyclical reference cannot be resolved. ($schemaPath)", 508);
844
                }
845 17
                $visited[$schemaPath] = true;
846
847
                try {
848 17
                    $schema = call_user_func($lookup, $schemaPath);
849 1
                } catch (\Exception $ex) {
850 1
                    throw new RefNotFoundException($ex->getMessage(), $ex->getCode(), $ex);
851
                }
852 16
                if ($schema === null) {
853 2
                    throw new RefNotFoundException("Schema reference could not be found. ($schemaPath)");
854
                }
855
            }
856 218
            return [$schema, $schemaPath];
857
        }
858
    }
859
860
    /**
861
     * Get the function used to resolve `$ref` lookups.
862
     *
863
     * @return callable Returns the current `$ref` lookup.
864
     */
865 222
    public function getRefLookup(): callable {
866 222
        return $this->refLookup;
867
    }
868
869
    /**
870
     * Set the function used to resolve `$ref` lookups.
871
     *
872
     * The function should have the following signature:
873
     *
874
     * ```php
875
     * function(string $ref): array|Schema|null {
876
     *     ...
877
     * }
878
     * ```
879
     * The function should take a string reference and return a schema array, `Schema` or **null**.
880
     *
881
     * @param callable $refLookup The new lookup function.
882
     * @return $this
883
     */
884 10
    public function setRefLookup(callable $refLookup) {
885 10
        $this->refLookup = $refLookup;
886 10
        return $this;
887
    }
888
889
    /**
890
     * Create a new validation instance.
891
     *
892
     * @return Validation Returns a validation object.
893
     */
894 218
    protected function createValidation(): Validation {
895 218
        return call_user_func($this->getValidationFactory());
896
    }
897
898
    /**
899
     * Get factory used to create validation objects.
900
     *
901
     * @return callable Returns the current factory.
902
     */
903 218
    public function getValidationFactory(): callable {
904 218
        return $this->validationFactory;
905
    }
906
907
    /**
908
     * Set the factory used to create validation objects.
909
     *
910
     * @param callable $validationFactory The new factory.
911
     * @return $this
912
     */
913 2
    public function setValidationFactory(callable $validationFactory) {
914 2
        $this->validationFactory = $validationFactory;
915 2
        $this->validationClass = null;
0 ignored issues
show
Deprecated Code introduced by
The property Garden\Schema\Schema::$validationClass has been deprecated. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

915
        /** @scrutinizer ignore-deprecated */ $this->validationClass = null;
Loading history...
916 2
        return $this;
917
    }
918
919
    /**
920
     * Validate a field.
921
     *
922
     * @param mixed $value The value to validate.
923
     * @param ValidationField $field A validation object to add errors to.
924
     * @return mixed|Invalid Returns a clean version of the value with all extra fields stripped out or invalid if the value
925
     * is completely invalid.
926
     * @throws RefNotFoundException Throws an exception when a schema `$ref` is not found.
927
     */
928 218
    protected function validateField($value, ValidationField $field) {
929 218
        $validated = false;
930 218
        $result = $value = $this->filterField($value, $field, $validated);
931
932 218
        if ($validated) {
933 2
            return $result;
934 216
        } elseif ($field->getField() instanceof Schema) {
935
            try {
936 5
                $result = $field->getField()->validate($value, $field->getOptions());
937 2
            } catch (ValidationException $ex) {
938
                // The validation failed, so merge the validations together.
939 5
                $field->getValidation()->merge($ex->getValidation(), $field->getName());
940
            }
941 216
        } elseif (($value === null || ($value === '' && !$field->hasType('string'))) && ($field->val('nullable') || $field->hasType('null'))) {
0 ignored issues
show
introduced by
Consider adding parentheses for clarity. Current Interpretation: ($value === null || $val...$field->hasType('null'), Probably Intended Meaning: $value === null || ($val...field->hasType('null'))
Loading history...
942 14
            $result = null;
943
        } else {
944
            // Validate the field's type.
945 216
            $type = $field->getType();
946 216
            if (is_array($type)) {
947 29
                $result = $this->validateMultipleTypes($value, $type, $field);
0 ignored issues
show
Deprecated Code introduced by
The function Garden\Schema\Schema::validateMultipleTypes() has been deprecated: Multiple types are being removed next version. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

947
                $result = /** @scrutinizer ignore-deprecated */ $this->validateMultipleTypes($value, $type, $field);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
948
            } else {
949 195
                $result = $this->validateSingleType($value, $type, $field);
950
            }
951 216
            if (Invalid::isValid($result)) {
952 206
                $result = $this->validateEnum($result, $field);
953
            }
954
        }
955
956
        // Validate a custom field validator.
957 216
        if (Invalid::isValid($result)) {
958 207
            $this->callValidators($result, $field);
959
        }
960
961 216
        return $result;
962
    }
963
964
    /**
965
     * Filter a field's value using built in and custom filters.
966
     *
967
     * @param mixed $value The original value of the field.
968
     * @param ValidationField $field The field information for the field.
969
     * @param bool $validated Whether or not a filter validated the value.
970
     * @return mixed Returns the filtered field or the original field value if there are no filters.
971
     */
972 218
    private function filterField($value, ValidationField $field, bool &$validated = false) {
973
        // Check for limited support for Open API style.
974 218
        if (!empty($field->val('style')) && is_string($value)) {
975 8
            $doFilter = true;
976 8
            if ($field->hasType('boolean') && in_array($value, ['true', 'false', '0', '1'], true)) {
977 4
                $doFilter = false;
978 4
            } elseif ($field->hasType('integer') || $field->hasType('number') && is_numeric($value)) {
979
                $doFilter = false;
980
            }
981
982 8
            if ($doFilter) {
983 4
                switch ($field->val('style')) {
984 4
                    case 'form':
985 2
                        $value = explode(',', $value);
986 2
                        break;
987 2
                    case 'spaceDelimited':
988 1
                        $value = explode(' ', $value);
989 1
                        break;
990 1
                    case 'pipeDelimited':
991 1
                        $value = explode('|', $value);
992 1
                        break;
993
                }
994
            }
995
        }
996
997 218
        $value = $this->callFilters($value, $field, $validated);
998
999 218
        return $value;
1000
    }
1001
1002
    /**
1003
     * Call all of the filters attached to a field.
1004
     *
1005
     * @param mixed $value The field value being filtered.
1006
     * @param ValidationField $field The validation object.
1007
     * @param bool $validated Whether or not a filter validated the field.
1008
     * @return mixed Returns the filtered value. If there are no filters for the field then the original value is returned.
1009
     */
1010 218
    private function callFilters($value, ValidationField $field, bool &$validated = false) {
1011
        // Strip array references in the name except for the last one.
1012 218
        $key = $field->getSchemaPath();
1013 218
        if (!empty($this->filters[$key])) {
1014 4
            foreach ($this->filters[$key] as list($filter, $validate)) {
1015 4
                $value = call_user_func($filter, $value, $field);
1016 4
                $validated |= $validate;
1017
1018 4
                if (Invalid::isInvalid($value)) {
1019 4
                    return $value;
1020
                }
1021
            }
1022
        }
1023 217
        return $value;
1024
    }
1025
1026
    /**
1027
     * Validate a field against multiple basic types.
1028
     *
1029
     * The first validation that passes will be returned. If no type can be validated against then validation will fail.
1030
     *
1031
     * @param mixed $value The value to validate.
1032
     * @param string[] $types The types to validate against.
1033
     * @param ValidationField $field Contains field and validation information.
1034
     * @return mixed Returns the valid value or `Invalid`.
1035
     * @throws RefNotFoundException Throws an exception when a schema `$ref` is not found.
1036
     * @deprecated Multiple types are being removed next version.
1037
     */
1038 29
    private function validateMultipleTypes($value, array $types, ValidationField $field) {
1039 29
        trigger_error('Multiple schema types are deprecated.', E_USER_DEPRECATED);
1040
1041
        // First check for an exact type match.
1042 29
        switch (gettype($value)) {
1043 29
            case 'boolean':
1044 4
                if (in_array('boolean', $types)) {
1045 4
                    $singleType = 'boolean';
1046
                }
1047 4
                break;
1048 26
            case 'integer':
1049 7
                if (in_array('integer', $types)) {
1050 5
                    $singleType = 'integer';
1051 2
                } elseif (in_array('number', $types)) {
1052 1
                    $singleType = 'number';
1053
                }
1054 7
                break;
1055 21
            case 'double':
1056 4
                if (in_array('number', $types)) {
1057 4
                    $singleType = 'number';
1058
                } elseif (in_array('integer', $types)) {
1059
                    $singleType = 'integer';
1060
                }
1061 4
                break;
1062 18
            case 'string':
1063 9
                if (in_array('datetime', $types) && preg_match(self::$DATE_REGEX, $value)) {
1064 1
                    $singleType = 'datetime';
1065 8
                } elseif (in_array('string', $types)) {
1066 4
                    $singleType = 'string';
1067
                }
1068 9
                break;
1069 10
            case 'array':
1070 10
                if (in_array('array', $types) && in_array('object', $types)) {
1071 1
                    $singleType = isset($value[0]) || empty($value) ? 'array' : 'object';
1072 9
                } elseif (in_array('object', $types)) {
1073
                    $singleType = 'object';
1074 9
                } elseif (in_array('array', $types)) {
1075 9
                    $singleType = 'array';
1076
                }
1077 10
                break;
1078 1
            case 'NULL':
1079
                if (in_array('null', $types)) {
1080
                    $singleType = $this->validateSingleType($value, 'null', $field);
1081
                }
1082
                break;
1083
        }
1084 29
        if (!empty($singleType)) {
1085 25
            return $this->validateSingleType($value, $singleType, $field);
1086
        }
1087
1088
        // Clone the validation field to collect errors.
1089 6
        $typeValidation = new ValidationField(new Validation(), $field->getField(), '', '', $field->getOptions());
1090
1091
        // Try and validate against each type.
1092 6
        foreach ($types as $type) {
1093 6
            $result = $this->validateSingleType($value, $type, $typeValidation);
1094 6
            if (Invalid::isValid($result)) {
1095 6
                return $result;
1096
            }
1097
        }
1098
1099
        // Since we got here the value is invalid.
1100
        $field->merge($typeValidation->getValidation());
1101
        return Invalid::value();
1102
    }
1103
1104
    /**
1105
     * Validate a field against a single type.
1106
     *
1107
     * @param mixed $value The value to validate.
1108
     * @param string $type The type to validate against.
1109
     * @param ValidationField $field Contains field and validation information.
1110
     * @return mixed Returns the valid value or `Invalid`.
1111
     * @throws \InvalidArgumentException Throws an exception when `$type` is not recognized.
1112
     * @throws RefNotFoundException Throws an exception when internal validation has a reference that isn't found.
1113
     */
1114 216
    protected function validateSingleType($value, string $type, ValidationField $field) {
1115
        switch ($type) {
1116 216
            case 'boolean':
1117 32
                $result = $this->validateBoolean($value, $field);
1118 32
                break;
1119 196
            case 'integer':
1120 65
                $result = $this->validateInteger($value, $field);
1121 65
                break;
1122 177
            case 'number':
1123 17
                $result = $this->validateNumber($value, $field);
1124 17
                break;
1125 168
            case 'string':
1126 89
                $result = $this->validateString($value, $field);
1127 89
                break;
1128 144
            case 'timestamp':
1129 1
                trigger_error('The timestamp type is deprecated. Use an integer with a format of timestamp instead.', E_USER_DEPRECATED);
1130 1
                $result = $this->validateTimestamp($value, $field);
1131 1
                break;
1132 144
            case 'datetime':
1133 2
                trigger_error('The datetime type is deprecated. Use a string with a format of date-time instead.', E_USER_DEPRECATED);
1134 2
                $result = $this->validateDatetime($value, $field);
1135 2
                break;
1136 143
            case 'array':
1137 38
                $result = $this->validateArray($value, $field);
1138 38
                break;
1139 123
            case 'object':
1140 121
                $result = $this->validateObject($value, $field);
1141 119
                break;
1142 6
            case 'null':
1143 1
                $result = $this->validateNull($value, $field);
1144 1
                break;
1145 5
            case '':
1146
                // No type was specified so we are valid.
1147 5
                $result = $value;
1148 5
                break;
1149
            default:
1150
                throw new \InvalidArgumentException("Unrecognized type $type.", 500);
1151
        }
1152 216
        return $result;
1153
    }
1154
1155
    /**
1156
     * Validate a boolean value.
1157
     *
1158
     * @param mixed $value The value to validate.
1159
     * @param ValidationField $field The validation results to add.
1160
     * @return bool|Invalid Returns the cleaned value or invalid if validation fails.
1161
     */
1162 32
    protected function validateBoolean($value, ValidationField $field) {
1163 32
        $value = $value === null ? $value : filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
1164 32
        if ($value === null) {
1165 4
            $field->addTypeError($value, 'boolean');
1166 4
            return Invalid::value();
1167
        }
1168
1169 29
        return $value;
1170
    }
1171
1172
    /**
1173
     * Validate and integer.
1174
     *
1175
     * @param mixed $value The value to validate.
1176
     * @param ValidationField $field The validation results to add.
1177
     * @return int|Invalid Returns the cleaned value or **null** if validation fails.
1178
     */
1179 65
    protected function validateInteger($value, ValidationField $field) {
1180 65
        if ($field->val('format') === 'timestamp') {
1181 7
            return $this->validateTimestamp($value, $field);
1182
        }
1183
1184 60
        $result = filter_var($value, FILTER_VALIDATE_INT);
1185
1186 60
        if ($result === false) {
1187 11
            $field->addTypeError($value, 'integer');
1188 11
            return Invalid::value();
1189
        }
1190
1191 53
        $result = $this->validateNumberProperties($result, $field);
1192
1193 53
        return $result;
1194
    }
1195
1196
    /**
1197
     * Validate a unix timestamp.
1198
     *
1199
     * @param mixed $value The value to validate.
1200
     * @param ValidationField $field The field being validated.
1201
     * @return int|Invalid Returns a valid timestamp or invalid if the value doesn't validate.
1202
     */
1203 8
    protected function validateTimestamp($value, ValidationField $field) {
1204 8
        if (is_numeric($value) && $value > 0) {
1205 3
            $result = (int)$value;
1206 5
        } elseif (is_string($value) && $ts = strtotime($value)) {
1207 1
            $result = $ts;
1208
        } else {
1209 4
            $field->addTypeError($value, 'timestamp');
1210 4
            $result = Invalid::value();
1211
        }
1212 8
        return $result;
1213
    }
1214
1215
    /**
1216
     * Validate specific numeric validation properties.
1217
     *
1218
     * @param int|float $value The value to test.
1219
     * @param ValidationField $field Field information.
1220
     * @return int|float|Invalid Returns the number of invalid.
1221
     */
1222 63
    private function validateNumberProperties($value, ValidationField $field) {
1223 63
        $count = $field->getErrorCount();
1224
1225 63
        if ($multipleOf = $field->val('multipleOf')) {
1226 4
            $divided = $value / $multipleOf;
1227
1228 4
            if ($divided != round($divided)) {
1229 2
                $field->addError('multipleOf', ['messageCode' => 'The value must be a multiple of {multipleOf}.', 'multipleOf' => $multipleOf]);
1230
            }
1231
        }
1232
1233 63
        if ($maximum = $field->val('maximum')) {
1234 4
            $exclusive = $field->val('exclusiveMaximum');
1235
1236 4
            if ($value > $maximum || ($exclusive && $value == $maximum)) {
1237 2
                if ($exclusive) {
1238 1
                    $field->addError('maximum', ['messageCode' => 'The value must be less than {maximum}.', 'maximum' => $maximum]);
1239
                } else {
1240 1
                    $field->addError('maximum', ['messageCode' => 'The value must be less than or equal to {maximum}.', 'maximum' => $maximum]);
1241
                }
1242
            }
1243
        }
1244
1245 63
        if ($minimum = $field->val('minimum')) {
1246 4
            $exclusive = $field->val('exclusiveMinimum');
1247
1248 4
            if ($value < $minimum || ($exclusive && $value == $minimum)) {
1249 2
                if ($exclusive) {
1250 1
                    $field->addError('minimum', ['messageCode' => 'The value must be greater than {minimum}.', 'minimum' => $minimum]);
1251
                } else {
1252 1
                    $field->addError('minimum', ['messageCode' => 'The value must be greater than or equal to {minimum}.', 'minimum' => $minimum]);
1253
                }
1254
            }
1255
        }
1256
1257 63
        return $field->getErrorCount() === $count ? $value : Invalid::value();
1258
    }
1259
1260
    /**
1261
     * Validate a float.
1262
     *
1263
     * @param mixed $value The value to validate.
1264
     * @param ValidationField $field The validation results to add.
1265
     * @return float|Invalid Returns a number or **null** if validation fails.
1266
     */
1267 17
    protected function validateNumber($value, ValidationField $field) {
1268 17
        $result = filter_var($value, FILTER_VALIDATE_FLOAT);
1269 17
        if ($result === false) {
1270 4
            $field->addTypeError($value, 'number');
1271 4
            return Invalid::value();
1272
        }
1273
1274 13
        $result = $this->validateNumberProperties($result, $field);
1275
1276 13
        return $result;
1277
    }
1278
1279
    /**
1280
     * Validate a string.
1281
     *
1282
     * @param mixed $value The value to validate.
1283
     * @param ValidationField $field The validation results to add.
1284
     * @return string|Invalid Returns the valid string or **null** if validation fails.
1285
     */
1286 89
    protected function validateString($value, ValidationField $field) {
1287 89
        if ($field->val('format') === 'date-time') {
1288 12
            $result = $this->validateDatetime($value, $field);
1289 12
            return $result;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $result also could return the type DateTimeInterface which is incompatible with the documented return type Garden\Schema\Invalid|string.
Loading history...
1290
        }
1291
1292 78
        if (is_string($value) || is_numeric($value)) {
1293 76
            $value = $result = (string)$value;
1294
        } else {
1295 5
            $field->addTypeError($value, 'string');
1296 5
            return Invalid::value();
1297
        }
1298
1299 76
        if (($minLength = $field->val('minLength', 0)) > 0 && mb_strlen($value) < $minLength) {
1300 4
            $field->addError(
1301 4
                'minLength',
1302
                [
1303 4
                    'messageCode' => 'The value should be at least {minLength} {minLength,plural,character,characters} long.',
1304 4
                    'minLength' => $minLength,
1305
                ]
1306
            );
1307
        }
1308 76
        if (($maxLength = $field->val('maxLength', 0)) > 0 && mb_strlen($value) > $maxLength) {
1309 1
            $field->addError(
1310 1
                'maxLength',
1311
                [
1312 1
                    'messageCode' => 'The value is {overflow} {overflow,plural,character,characters} too long.',
1313 1
                    'maxLength' => $maxLength,
1314 1
                    'overflow' => mb_strlen($value) - $maxLength,
1315
                ]
1316
            );
1317
        }
1318 76
        if ($pattern = $field->val('pattern')) {
1319 4
            $regex = '`'.str_replace('`', preg_quote('`', '`'), $pattern).'`';
1320
1321 4
            if (!preg_match($regex, $value)) {
1322 2
                $field->addError(
1323 2
                    'pattern',
1324
                    [
1325 2
                        'messageCode' => $field->val('x-patternMessageCode', 'The value doesn\'t match the required pattern.'),
1326
                    ]
1327
                );
1328
            }
1329
        }
1330 76
        if ($format = $field->val('format')) {
1331 11
            $type = $format;
1332
            switch ($format) {
1333 11
                case 'date':
1334
                    $result = $this->validateDatetime($result, $field);
1335
                    if ($result instanceof \DateTimeInterface) {
1336
                        $result = $result->format("Y-m-d\T00:00:00P");
1337
                    }
1338
                    break;
1339 11
                case 'email':
1340 1
                    $result = filter_var($result, FILTER_VALIDATE_EMAIL);
1341 1
                    break;
1342 10
                case 'ipv4':
1343 1
                    $type = 'IPv4 address';
1344 1
                    $result = filter_var($result, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
1345 1
                    break;
1346 9
                case 'ipv6':
1347 1
                    $type = 'IPv6 address';
1348 1
                    $result = filter_var($result, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
1349 1
                    break;
1350 8
                case 'ip':
1351 1
                    $type = 'IP address';
1352 1
                    $result = filter_var($result, FILTER_VALIDATE_IP);
1353 1
                    break;
1354 7
                case 'uri':
1355 7
                    $type = 'URL';
1356 7
                    $result = filter_var($result, FILTER_VALIDATE_URL);
1357 7
                    break;
1358
                default:
1359
                    trigger_error("Unrecognized format '$format'.", E_USER_NOTICE);
1360
            }
1361 11
            if ($result === false) {
1362 5
                $field->addError('format', [
1363 5
                    'format' => $format,
1364 5
                    'formatCode' => $type,
1365 5
                    'value' => $value,
1366 5
                    'messageCode' => '{value} is not a valid {formatCode}.'
1367
                ]);
1368
            }
1369
        }
1370
1371 76
        if ($field->isValid()) {
1372 68
            return $result;
1373
        } else {
1374 12
            return Invalid::value();
1375
        }
1376
    }
1377
1378
    /**
1379
     * Validate a date time.
1380
     *
1381
     * @param mixed $value The value to validate.
1382
     * @param ValidationField $field The validation results to add.
1383
     * @return \DateTimeInterface|Invalid Returns the cleaned value or **null** if it isn't valid.
1384
     */
1385 14
    protected function validateDatetime($value, ValidationField $field) {
1386 14
        if ($value instanceof \DateTimeInterface) {
1387
            // do nothing, we're good
1388 11
        } elseif (is_string($value) && $value !== '' && !is_numeric($value)) {
1389
            try {
1390 7
                $dt = new \DateTimeImmutable($value);
1391 6
                if ($dt) {
0 ignored issues
show
introduced by
$dt is of type DateTimeImmutable, thus it always evaluated to true.
Loading history...
1392 6
                    $value = $dt;
1393
                } else {
1394 6
                    $value = null;
1395
                }
1396 1
            } catch (\Throwable $ex) {
1397 7
                $value = Invalid::value();
1398
            }
1399 4
        } elseif (is_int($value) && $value > 0) {
1400
            try {
1401 1
                $value = new \DateTimeImmutable('@'.(string)round($value));
1402
            } catch (\Throwable $ex) {
1403 1
                $value = Invalid::value();
1404
            }
1405
        } else {
1406 3
            $value = Invalid::value();
1407
        }
1408
1409 14
        if (Invalid::isInvalid($value)) {
1410 4
            $field->addTypeError($value, 'date/time');
1411
        }
1412 14
        return $value;
1413
    }
1414
1415
    /**
1416
     * Validate an array.
1417
     *
1418
     * @param mixed $value The value to validate.
1419
     * @param ValidationField $field The validation results to add.
1420
     * @return array|Invalid Returns an array or invalid if validation fails.
1421
     * @throws RefNotFoundException Throws an exception if the array has an items `$ref` that cannot be found.
1422
     */
1423 38
    protected function validateArray($value, ValidationField $field) {
1424 38
        if ((!is_array($value) || (count($value) > 0 && !array_key_exists(0, $value))) && !$value instanceof \Traversable) {
0 ignored issues
show
introduced by
Consider adding parentheses for clarity. Current Interpretation: (! is_array($value) || c... instanceof Traversable, Probably Intended Meaning: ! is_array($value) || (c...instanceof Traversable)
Loading history...
1425 6
            $field->addTypeError($value, 'array');
1426 6
            return Invalid::value();
1427
        } else {
1428 33
            if ((null !== $minItems = $field->val('minItems')) && count($value) < $minItems) {
0 ignored issues
show
Bug introduced by
It seems like $value can also be of type Traversable; however, parameter $var of count() does only seem to accept Countable|array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1428
            if ((null !== $minItems = $field->val('minItems')) && count(/** @scrutinizer ignore-type */ $value) < $minItems) {
Loading history...
1429 1
                $field->addError(
1430 1
                    'minItems',
1431
                    [
1432 1
                        'messageCode' => 'This must contain at least {minItems} {minItems,plural,item,items}.',
1433 1
                        'minItems' => $minItems,
1434
                    ]
1435
                );
1436
            }
1437 33
            if ((null !== $maxItems = $field->val('maxItems')) && count($value) > $maxItems) {
1438 1
                $field->addError(
1439 1
                    'maxItems',
1440
                    [
1441 1
                        'messageCode' => 'This must contain no more than {maxItems} {maxItems,plural,item,items}.',
1442 1
                        'maxItems' => $maxItems,
1443
                    ]
1444
                );
1445
            }
1446
1447 33
            if ($field->val('uniqueItems') && count($value) > count(array_unique($value))) {
0 ignored issues
show
Bug introduced by
It seems like $value can also be of type Traversable; however, parameter $array of array_unique() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1447
            if ($field->val('uniqueItems') && count($value) > count(array_unique(/** @scrutinizer ignore-type */ $value))) {
Loading history...
1448 1
                $field->addError(
1449 1
                    'uniqueItems',
1450
                    [
1451 1
                        'messageCode' => 'The array must contain unique items.',
1452
                    ]
1453
                );
1454
            }
1455
1456 33
            if ($field->val('items') !== null) {
1457 25
                list ($items, $schemaPath) = $this->lookupSchema($field->val('items'), $field->getSchemaPath().'/items');
1458
1459
                // Validate each of the types.
1460 25
                $itemValidation = new ValidationField(
1461 25
                    $field->getValidation(),
1462 25
                    $items,
1463 25
                    '',
1464 25
                    $schemaPath,
1465 25
                    $field->getOptions()
1466
                );
1467
1468 25
                $result = [];
1469 25
                $count = 0;
1470 25
                foreach ($value as $i => $item) {
1471 25
                    $itemValidation->setName($field->getName()."/$i");
1472 25
                    $validItem = $this->validateField($item, $itemValidation);
1473 25
                    if (Invalid::isValid($validItem)) {
1474 25
                        $result[] = $validItem;
1475
                    }
1476 25
                    $count++;
1477
                }
1478
1479 25
                return empty($result) && $count > 0 ? Invalid::value() : $result;
1480
            } else {
1481
                // Cast the items into a proper numeric array.
1482 8
                $result = is_array($value) ? array_values($value) : iterator_to_array($value);
1483 8
                return $result;
1484
            }
1485
        }
1486
    }
1487
1488
    /**
1489
     * Validate an object.
1490
     *
1491
     * @param mixed $value The value to validate.
1492
     * @param ValidationField $field The validation results to add.
1493
     * @return object|Invalid Returns a clean object or **null** if validation fails.
1494
     * @throws RefNotFoundException Throws an exception when a schema `$ref` is not found.
1495
     */
1496 121
    protected function validateObject($value, ValidationField $field) {
1497 121
        if (!$this->isArray($value) || isset($value[0])) {
1498 6
            $field->addTypeError($value, 'object');
1499 6
            return Invalid::value();
1500 121
        } elseif (is_array($field->val('properties')) || null !== $field->val('additionalProperties')) {
1501
            // Validate the data against the internal schema.
1502 114
            $value = $this->validateProperties($value, $field);
1503 7
        } elseif (!is_array($value)) {
1504 3
            $value = $this->toObjectArray($value);
1505
        }
1506
1507 119
        if (($maxProperties = $field->val('maxProperties')) && count($value) > $maxProperties) {
1508 1
            $field->addError(
1509 1
                'maxProperties',
1510
                [
1511 1
                    'messageCode' => 'This must contain no more than {maxProperties} {maxProperties,plural,item,items}.',
1512 1
                    'maxItems' => $maxProperties,
1513
                ]
1514
            );
1515
        }
1516
1517 119
        if (($minProperties = $field->val('minProperties')) && count($value) < $minProperties) {
1518 1
            $field->addError(
1519 1
                'minProperties',
1520
                [
1521 1
                    'messageCode' => 'This must contain at least {minProperties} {minProperties,plural,item,items}.',
1522 1
                    'minItems' => $minProperties,
1523
                ]
1524
            );
1525
        }
1526
1527 119
        return $value;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $value also could return the type array which is incompatible with the documented return type object|Garden\Schema\Invalid.
Loading history...
1528
    }
1529
1530
    /**
1531
     * Check whether or not a value is an array or accessible like an array.
1532
     *
1533
     * @param mixed $value The value to check.
1534
     * @return bool Returns **true** if the value can be used like an array or **false** otherwise.
1535
     */
1536 121
    private function isArray($value) {
1537 121
        return is_array($value) || ($value instanceof \ArrayAccess && $value instanceof \Traversable);
1538
    }
1539
1540
    /**
1541
     * Validate data against the schema and return the result.
1542
     *
1543
     * @param array|\Traversable|\ArrayAccess $data The data to validate.
1544
     * @param ValidationField $field This argument will be filled with the validation result.
1545
     * @return array|\ArrayObject|Invalid Returns a clean array with only the appropriate properties and the data coerced to proper types.
1546
     * or invalid if there are no valid properties.
1547
     * @throws RefNotFoundException Throws an exception of a property or additional property has a `$ref` that cannot be found.
1548
     */
1549 114
    protected function validateProperties($data, ValidationField $field) {
1550 114
        $properties = $field->val('properties', []);
1551 114
        $additionalProperties = $field->val('additionalProperties');
1552 114
        $required = array_flip($field->val('required', []));
1553 114
        $isRequest = $field->isRequest();
1554 114
        $isResponse = $field->isResponse();
1555
1556 114
        if (is_array($data)) {
1557 110
            $keys = array_keys($data);
1558 110
            $clean = [];
1559
        } else {
1560 4
            $keys = array_keys(iterator_to_array($data));
0 ignored issues
show
Bug introduced by
It seems like $data can also be of type ArrayAccess; however, parameter $iterator of iterator_to_array() does only seem to accept Traversable, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1560
            $keys = array_keys(iterator_to_array(/** @scrutinizer ignore-type */ $data));
Loading history...
1561 4
            $class = get_class($data);
1562 4
            $clean = new $class;
1563
1564 4
            if ($clean instanceof \ArrayObject && $data instanceof \ArrayObject) {
1565 3
                $clean->setFlags($data->getFlags());
1566 3
                $clean->setIteratorClass($data->getIteratorClass());
1567
            }
1568
        }
1569 114
        $keys = array_combine(array_map('strtolower', $keys), $keys);
1570
1571 114
        $propertyField = new ValidationField($field->getValidation(), [], '', '', $field->getOptions());
1572
1573
        // Loop through the schema fields and validate each one.
1574 114
        foreach ($properties as $propertyName => $property) {
1575 112
            list($property, $schemaPath) = $this->lookupSchema($property, $field->getSchemaPath().'/properties/'.self::escapeRef($propertyName));
1576
1577
            $propertyField
1578 112
                ->setField($property)
1579 112
                ->setName(ltrim($field->getName().'/'.self::escapeRef($propertyName), '/'))
1580 112
                ->setSchemaPath($schemaPath);
1581
1582 112
            $lName = strtolower($propertyName);
1583 112
            $isRequired = isset($required[$propertyName]);
1584
1585
            // Check to strip this field if it is readOnly or writeOnly.
1586 112
            if (($isRequest && $propertyField->val('readOnly')) || ($isResponse && $propertyField->val('writeOnly'))) {
1587 6
                unset($keys[$lName]);
1588 6
                continue;
1589
            }
1590
1591
            // Check for required fields.
1592 112
            if (!array_key_exists($lName, $keys)) {
1593 34
                if ($field->isSparse()) {
1594
                    // Sparse validation can leave required fields out.
1595 33
                } elseif ($propertyField->hasVal('default')) {
1596 3
                    $clean[$propertyName] = $propertyField->val('default');
1597 30
                } elseif ($isRequired) {
1598 6
                    $propertyField->addError(
1599 6
                        'required',
1600 34
                        ['messageCode' => '{property} is required.', 'property' => $propertyName]
1601
                    );
1602
                }
1603
            } else {
1604 100
                $value = $data[$keys[$lName]];
1605
1606 100
                if (in_array($value, [null, ''], true) && !$isRequired && !($propertyField->val('nullable') || $propertyField->hasType('null'))) {
1607 5
                    if ($propertyField->getType() !== 'string' || $value === null) {
1608 2
                        continue;
1609
                    }
1610
                }
1611
1612 98
                $clean[$propertyName] = $this->validateField($value, $propertyField);
1613
            }
1614
1615 110
            unset($keys[$lName]);
1616
        }
1617
1618
        // Look for extraneous properties.
1619 114
        if (!empty($keys)) {
1620 21
            if ($additionalProperties) {
1621 10
                list($additionalProperties, $schemaPath) = $this->lookupSchema(
1622 10
                    $additionalProperties,
1623 10
                    $field->getSchemaPath().'/additionalProperties'
1624
                );
1625
1626 10
                $propertyField = new ValidationField(
1627 10
                    $field->getValidation(),
1628 10
                    $additionalProperties,
1629 10
                    '',
1630 10
                    $schemaPath,
1631 10
                    $field->getOptions()
1632
                );
1633
1634 10
                foreach ($keys as $key) {
1635
                    $propertyField
1636 10
                        ->setName(ltrim($field->getName()."/$key", '/'));
1637
1638 10
                    $valid = $this->validateField($data[$key], $propertyField);
1639 10
                    if (Invalid::isValid($valid)) {
1640 10
                        $clean[$key] = $valid;
1641
                    }
1642
                }
1643 11
            } elseif ($this->hasFlag(Schema::VALIDATE_EXTRA_PROPERTY_NOTICE)) {
1644 2
                $msg = sprintf("Unexpected properties: %s.", implode(', ', $keys));
1645 2
                trigger_error($msg, E_USER_NOTICE);
1646 9
            } elseif ($this->hasFlag(Schema::VALIDATE_EXTRA_PROPERTY_EXCEPTION)) {
1647 2
                $field->addError('unexpectedProperties', [
1648 2
                    'messageCode' => 'Unexpected {extra,plural,property,properties}: {extra}.',
1649 2
                    'extra' => array_values($keys),
1650
                ]);
1651
            }
1652
        }
1653
1654 112
        return $clean;
1655
    }
1656
1657
    /**
1658
     * Escape a JSON reference field.
1659
     *
1660
     * @param string $field The reference field to escape.
1661
     * @return string Returns an escaped reference.
1662
     */
1663 112
    public static function escapeRef(string $field): string {
1664 112
        return str_replace(['~', '/'], ['~0', '~1'], $field);
1665
    }
1666
1667
    /**
1668
     * Whether or not the schema has a flag (or combination of flags).
1669
     *
1670
     * @param int $flag One or more of the **Schema::VALIDATE_*** constants.
1671
     * @return bool Returns **true** if all of the flags are set or **false** otherwise.
1672
     */
1673 12
    public function hasFlag(int $flag): bool {
1674 12
        return ($this->flags & $flag) === $flag;
1675
    }
1676
1677
    /**
1678
     * Cast a value to an array.
1679
     *
1680
     * @param \Traversable $value The value to convert.
1681
     * @return array Returns an array.
1682
     */
1683 3
    private function toObjectArray(\Traversable $value) {
1684 3
        $class = get_class($value);
1685 3
        if ($value instanceof \ArrayObject) {
1686 2
            return new $class($value->getArrayCopy(), $value->getFlags(), $value->getIteratorClass());
0 ignored issues
show
Bug Best Practice introduced by
The expression return new $class($value...ue->getIteratorClass()) returns the type object which is incompatible with the documented return type array.
Loading history...
1687 1
        } elseif ($value instanceof \ArrayAccess) {
1688 1
            $r = new $class;
1689 1
            foreach ($value as $k => $v) {
1690 1
                $r[$k] = $v;
1691
            }
1692 1
            return $r;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $r returns the type object which is incompatible with the documented return type array.
Loading history...
1693
        }
1694
        return iterator_to_array($value);
1695
    }
1696
1697
    /**
1698
     * Validate a null value.
1699
     *
1700
     * @param mixed $value The value to validate.
1701
     * @param ValidationField $field The error collector for the field.
1702
     * @return null|Invalid Returns **null** or invalid.
1703
     */
1704 1
    protected function validateNull($value, ValidationField $field) {
1705 1
        if ($value === null) {
1706
            return null;
1707
        }
1708 1
        $field->addError('type', ['messageCode' => 'The value should be null.', 'type' => 'null']);
1709 1
        return Invalid::value();
1710
    }
1711
1712
    /**
1713
     * Validate a value against an enum.
1714
     *
1715
     * @param mixed $value The value to test.
1716
     * @param ValidationField $field The validation object for adding errors.
1717
     * @return mixed|Invalid Returns the value if it is one of the enumerated values or invalid otherwise.
1718
     */
1719 206
    protected function validateEnum($value, ValidationField $field) {
1720 206
        $enum = $field->val('enum');
1721 206
        if (empty($enum)) {
1722 205
            return $value;
1723
        }
1724
1725 1
        if (!in_array($value, $enum, true)) {
1726 1
            $field->addError(
1727 1
                'enum',
1728
                [
1729 1
                    'messageCode' => 'The value must be one of: {enum}.',
1730 1
                    'enum' => $enum,
1731
                ]
1732
            );
1733 1
            return Invalid::value();
1734
        }
1735 1
        return $value;
1736
    }
1737
1738
    /**
1739
     * Call all of the validators attached to a field.
1740
     *
1741
     * @param mixed $value The field value being validated.
1742
     * @param ValidationField $field The validation object to add errors.
1743
     */
1744 207
    private function callValidators($value, ValidationField $field) {
1745 207
        $valid = true;
1746
1747
        // Strip array references in the name except for the last one.
1748 207
        $key = $field->getSchemaPath();
1749 207
        if (!empty($this->validators[$key])) {
1750 5
            foreach ($this->validators[$key] as $validator) {
1751 5
                $r = call_user_func($validator, $value, $field);
1752
1753 5
                if ($r === false || Invalid::isInvalid($r)) {
1754 5
                    $valid = false;
1755
                }
1756
            }
1757
        }
1758
1759
        // Add an error on the field if the validator hasn't done so.
1760 207
        if (!$valid && $field->isValid()) {
1761 1
            $field->addError('invalid', ['messageCode' => 'The value is invalid.']);
1762
        }
1763 207
    }
1764
1765
    /**
1766
     * Specify data which should be serialized to JSON.
1767
     *
1768
     * This method specifically returns data compatible with the JSON schema format.
1769
     *
1770
     * @return mixed Returns data which can be serialized by **json_encode()**, which is a value of any type other than a resource.
1771
     * @link http://php.net/manual/en/jsonserializable.jsonserialize.php
1772
     * @link http://json-schema.org/
1773
     */
1774
    public function jsonSerialize() {
1775 16
        $fix = function ($schema) use (&$fix) {
1776 16
            if ($schema instanceof Schema) {
1777 1
                return $schema->jsonSerialize();
1778
            }
1779
1780 16
            if (!empty($schema['type'])) {
1781 15
                $types = (array)$schema['type'];
1782
1783 15
                foreach ($types as $i => &$type) {
1784
                    // Swap datetime and timestamp to other types with formats.
1785 15
                    if ($type === 'datetime') {
1786 4
                        $type = 'string';
1787 4
                        $schema['format'] = 'date-time';
1788 14
                    } elseif ($schema['type'] === 'timestamp') {
1789 2
                        $type = 'integer';
1790 15
                        $schema['format'] = 'timestamp';
1791
                    }
1792
                }
1793 15
                $types = array_unique($types);
1794 15
                $schema['type'] = count($types) === 1 ? reset($types) : $types;
1795
            }
1796
1797 16
            if (!empty($schema['items'])) {
1798 4
                $schema['items'] = $fix($schema['items']);
1799
            }
1800 16
            if (!empty($schema['properties'])) {
1801 11
                $properties = [];
1802 11
                foreach ($schema['properties'] as $key => $property) {
1803 11
                    $properties[$key] = $fix($property);
1804
                }
1805 11
                $schema['properties'] = $properties;
1806
            }
1807
1808 16
            return $schema;
1809 16
        };
1810
1811 16
        $result = $fix($this->schema);
1812
1813 16
        return $result;
1814
    }
1815
1816
    /**
1817
     * Get the class that's used to contain validation information.
1818
     *
1819
     * @return Validation|string Returns the validation class.
1820
     * @deprecated
1821
     */
1822 1
    public function getValidationClass() {
1823 1
        trigger_error('Schema::getValidationClass() is deprecated. Use Schema::getValidationFactory() instead.', E_USER_DEPRECATED);
1824 1
        return $this->validationClass;
0 ignored issues
show
Deprecated Code introduced by
The property Garden\Schema\Schema::$validationClass has been deprecated. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

1824
        return /** @scrutinizer ignore-deprecated */ $this->validationClass;
Loading history...
1825
    }
1826
1827
    /**
1828
     * Set the class that's used to contain validation information.
1829
     *
1830
     * @param Validation|string $class Either the name of a class or a class that will be cloned.
1831
     * @return $this
1832
     * @deprecated
1833
     */
1834 1
    public function setValidationClass($class) {
1835 1
        trigger_error('Schema::setValidationClass() is deprecated. Use Schema::setValidationFactory() instead.', E_USER_DEPRECATED);
1836
1837 1
        if (!is_a($class, Validation::class, true)) {
1838
            throw new \InvalidArgumentException("$class must be a subclass of ".Validation::class, 500);
1839
        }
1840
1841 1
        $this->setValidationFactory(function () use ($class) {
1842 1
            if ($class instanceof Validation) {
1843 1
                $result = clone $class;
1844
            } else {
1845 1
                $result = new $class;
1846
            }
1847 1
            return $result;
1848 1
        });
1849 1
        $this->validationClass = $class;
0 ignored issues
show
Deprecated Code introduced by
The property Garden\Schema\Schema::$validationClass has been deprecated. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

1849
        /** @scrutinizer ignore-deprecated */ $this->validationClass = $class;
Loading history...
1850 1
        return $this;
1851
    }
1852
1853
    /**
1854
     * Return a sparse version of this schema.
1855
     *
1856
     * A sparse schema has no required properties.
1857
     *
1858
     * @return Schema Returns a new sparse schema.
1859
     */
1860 2
    public function withSparse() {
1861 2
        $sparseSchema = $this->withSparseInternal($this, new \SplObjectStorage());
1862 2
        return $sparseSchema;
1863
    }
1864
1865
    /**
1866
     * The internal implementation of `Schema::withSparse()`.
1867
     *
1868
     * @param array|Schema $schema The schema to make sparse.
1869
     * @param \SplObjectStorage $schemas Collected sparse schemas that have already been made.
1870
     * @return mixed
1871
     */
1872 2
    private function withSparseInternal($schema, \SplObjectStorage $schemas) {
1873 2
        if ($schema instanceof Schema) {
1874 2
            if ($schemas->contains($schema)) {
1875 1
                return $schemas[$schema];
1876
            } else {
1877 2
                $schemas[$schema] = $sparseSchema = new Schema();
1878 2
                $sparseSchema->schema = $schema->withSparseInternal($schema->schema, $schemas);
1879 2
                if ($id = $sparseSchema->getID()) {
1880
                    $sparseSchema->setID($id.'Sparse');
1881
                }
1882
1883 2
                return $sparseSchema;
1884
            }
1885
        }
1886
1887 2
        unset($schema['required']);
1888
1889 2
        if (isset($schema['items'])) {
1890 1
            $schema['items'] = $this->withSparseInternal($schema['items'], $schemas);
1891
        }
1892 2
        if (isset($schema['properties'])) {
1893 2
            foreach ($schema['properties'] as $name => &$property) {
1894 2
                $property = $this->withSparseInternal($property, $schemas);
1895
            }
1896
        }
1897
1898 2
        return $schema;
1899
    }
1900
1901
    /**
1902
     * Get the ID for the schema.
1903
     *
1904
     * @return string
1905
     */
1906 3
    public function getID(): string {
1907 3
        return $this->schema['id'] ?? '';
1908
    }
1909
1910
    /**
1911
     * Set the ID for the schema.
1912
     *
1913
     * @param string $id The new ID.
1914
     * @return $this
1915
     */
1916 1
    public function setID(string $id) {
1917 1
        $this->schema['id'] = $id;
1918
1919 1
        return $this;
1920
    }
1921
1922
    /**
1923
     * Whether a offset exists.
1924
     *
1925
     * @param mixed $offset An offset to check for.
1926
     * @return boolean true on success or false on failure.
1927
     * @link http://php.net/manual/en/arrayaccess.offsetexists.php
1928
     */
1929 7
    public function offsetExists($offset) {
1930 7
        return isset($this->schema[$offset]);
1931
    }
1932
1933
    /**
1934
     * Offset to retrieve.
1935
     *
1936
     * @param mixed $offset The offset to retrieve.
1937
     * @return mixed Can return all value types.
1938
     * @link http://php.net/manual/en/arrayaccess.offsetget.php
1939
     */
1940 7
    public function offsetGet($offset) {
1941 7
        return isset($this->schema[$offset]) ? $this->schema[$offset] : null;
1942
    }
1943
1944
    /**
1945
     * Offset to set.
1946
     *
1947
     * @param mixed $offset The offset to assign the value to.
1948
     * @param mixed $value The value to set.
1949
     * @link http://php.net/manual/en/arrayaccess.offsetset.php
1950
     */
1951 1
    public function offsetSet($offset, $value) {
1952 1
        $this->schema[$offset] = $value;
1953 1
    }
1954
1955
    /**
1956
     * Offset to unset.
1957
     *
1958
     * @param mixed $offset The offset to unset.
1959
     * @link http://php.net/manual/en/arrayaccess.offsetunset.php
1960
     */
1961 1
    public function offsetUnset($offset) {
1962 1
        unset($this->schema[$offset]);
1963 1
    }
1964
}
1965