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

Schema::setValidationClass()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 17
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 3.0052

Importance

Changes 0
Metric Value
cc 3
eloc 11
nc 2
nop 1
dl 0
loc 17
ccs 11
cts 12
cp 0.9167
crap 3.0052
rs 9.9
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 279
    public function __construct(array $schema = [], callable $refLookup = null) {
90 279
        $this->schema = $schema;
91
92 273
        $this->refLookup = $refLookup ?? function (/** @scrutinizer ignore-unused */
93
                string $_) {
94 1
                return null;
95 273
            };
96 279
    }
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
     * Add a custom filter for a schema format.
691
     *
692
     * Schemas can use the `format` property to specify a specific format on a field. Adding a filter for a format
693
     * allows you to customize the behavior of that format.
694
     *
695
     * @param string $format The format to filter.
696
     * @param callable $callback The callback used to filter values.
697
     * @param bool $validate Whether or not the filter should also validate. If true default validation is skipped.
698
     * @return $this
699
     */
700 2
    public function addFormatFilter(string $format, callable $callback, bool $validate = false) {
701 2
        if (empty($format)) {
702
            throw new \InvalidArgumentException('The filter format cannot be empty.', 500);
703
        }
704
705 2
        $filter = "/format/$format";
706 2
        $this->filters[$filter][] = [$callback, $validate];
707
708 2
        return $this;
709
    }
710
711
    /**
712
     * Require one of a given set of fields in the schema.
713
     *
714
     * @param array $required The field names to require.
715
     * @param string $fieldname The name of the field to attach to.
716
     * @param int $count The count of required items.
717
     * @return Schema Returns `$this` for fluent calls.
718
     */
719 3
    public function requireOneOf(array $required, string $fieldname = '', int $count = 1) {
720 3
        $result = $this->addValidator(
721 3
            $fieldname,
722 3
            function ($data, ValidationField $field) use ($required, $count) {
723
                // This validator does not apply to sparse validation.
724 3
                if ($field->isSparse()) {
725 1
                    return true;
726
                }
727
728 2
                $hasCount = 0;
729 2
                $flattened = [];
730
731 2
                foreach ($required as $name) {
732 2
                    $flattened = array_merge($flattened, (array)$name);
733
734 2
                    if (is_array($name)) {
735
                        // This is an array of required names. They all must match.
736 1
                        $hasCountInner = 0;
737 1
                        foreach ($name as $nameInner) {
738 1
                            if (array_key_exists($nameInner, $data)) {
739 1
                                $hasCountInner++;
740
                            } else {
741 1
                                break;
742
                            }
743
                        }
744 1
                        if ($hasCountInner >= count($name)) {
745 1
                            $hasCount++;
746
                        }
747 2
                    } elseif (array_key_exists($name, $data)) {
748 1
                        $hasCount++;
749
                    }
750
751 2
                    if ($hasCount >= $count) {
752 2
                        return true;
753
                    }
754
                }
755
756 2
                if ($count === 1) {
757 1
                    $message = 'One of {properties} are required.';
758
                } else {
759 1
                    $message = '{count} of {properties} are required.';
760
                }
761
762 2
                $field->addError('oneOfRequired', [
763 2
                    'messageCode' => $message,
764 2
                    'properties' => $required,
765 2
                    'count' => $count
766
                ]);
767 2
                return false;
768 3
            }
769
        );
770
771 3
        return $result;
772
    }
773
774
    /**
775
     * Add a custom validator to to validate the schema.
776
     *
777
     * @param string $fieldname The name of the field to validate, if any.
778
     *
779
     * If you are adding a validator to a deeply nested field then separate the path with dots.
780
     * @param callable $callback The callback to validate with.
781
     * @return Schema Returns `$this` for fluent calls.
782
     */
783 5
    public function addValidator(string $fieldname, callable $callback) {
784 5
        $fieldname = $this->parseFieldSelector($fieldname);
785 5
        $this->validators[$fieldname][] = $callback;
786 5
        return $this;
787
    }
788
789
    /**
790
     * Validate data against the schema and return the result.
791
     *
792
     * @param mixed $data The data to validate.
793
     * @param array $options Validation options. See `Schema::validate()`.
794
     * @return bool Returns true if the data is valid. False otherwise.
795
     * @throws RefNotFoundException Throws an exception when there is an unknown `$ref` in the schema.
796
     */
797 45
    public function isValid($data, $options = []) {
798
        try {
799 45
            $this->validate($data, $options);
800 31
            return true;
801 24
        } catch (ValidationException $ex) {
802 24
            return false;
803
        }
804
    }
805
806
    /**
807
     * Validate data against the schema.
808
     *
809
     * @param mixed $data The data to validate.
810
     * @param array $options Validation options.
811
     *
812
     * - **sparse**: Whether or not this is a sparse validation.
813
     * @return mixed Returns a cleaned version of the data.
814
     * @throws ValidationException Throws an exception when the data does not validate against the schema.
815
     * @throws RefNotFoundException Throws an exception when a schema `$ref` is not found.
816
     */
817 224
    public function validate($data, $options = []) {
818 224
        if (is_bool($options)) {
0 ignored issues
show
introduced by
The condition is_bool($options) is always false.
Loading history...
819 1
            trigger_error('The $sparse parameter is deprecated. Use [\'sparse\' => true] instead.', E_USER_DEPRECATED);
820 1
            $options = ['sparse' => true];
821
        }
822 224
        $options += ['sparse' => false];
823
824
825 224
        list($schema, $schemaPath) = $this->lookupSchema($this->schema, '');
826 220
        $field = new ValidationField($this->createValidation(), $schema, '', $schemaPath, $options);
827
828 220
        $clean = $this->validateField($data, $field);
829
830 218
        if (Invalid::isInvalid($clean) && $field->isValid()) {
831
            // This really shouldn't happen, but we want to protect against seeing the invalid object.
832 1
            $field->addError('invalid', ['messageCode' => 'The value is invalid.']);
833
        }
834
835 218
        if (!$field->getValidation()->isValid()) {
836 74
            throw new ValidationException($field->getValidation());
837
        }
838
839 160
        return $clean;
840
    }
841
842
    /**
843
     * Lookup a schema based on a schema node.
844
     *
845
     * The node could be a schema array, `Schema` object, or a schema reference.
846
     *
847
     * @param mixed $schema The schema node to lookup with.
848
     * @param string $schemaPath The current path of the schema.
849
     * @return array Returns an array with two elements:
850
     * - Schema|array|\ArrayAccess The schema that was found.
851
     * - string The path of the schema. This is either the reference or the `$path` parameter for inline schemas.
852
     * @throws RefNotFoundException Throws an exception when a reference could not be found.
853
     */
854 224
    private function lookupSchema($schema, string $schemaPath) {
855 224
        if ($schema instanceof Schema) {
856 6
            return [$schema, $schemaPath];
857
        } else {
858 224
            $lookup = $this->getRefLookup();
859 224
            $visited = [];
860
861 224
            while (!empty($schema['$ref'])) {
862 17
                $schemaPath = $schema['$ref'];
863
864 17
                if (isset($visited[$schemaPath])) {
865 1
                    throw new RefNotFoundException("Cyclical reference cannot be resolved. ($schemaPath)", 508);
866
                }
867 17
                $visited[$schemaPath] = true;
868
869
                try {
870 17
                    $schema = call_user_func($lookup, $schemaPath);
871 1
                } catch (\Exception $ex) {
872 1
                    throw new RefNotFoundException($ex->getMessage(), $ex->getCode(), $ex);
873
                }
874 16
                if ($schema === null) {
875 2
                    throw new RefNotFoundException("Schema reference could not be found. ($schemaPath)");
876
                }
877
            }
878 220
            return [$schema, $schemaPath];
879
        }
880
    }
881
882
    /**
883
     * Get the function used to resolve `$ref` lookups.
884
     *
885
     * @return callable Returns the current `$ref` lookup.
886
     */
887 224
    public function getRefLookup(): callable {
888 224
        return $this->refLookup;
889
    }
890
891
    /**
892
     * Set the function used to resolve `$ref` lookups.
893
     *
894
     * The function should have the following signature:
895
     *
896
     * ```php
897
     * function(string $ref): array|Schema|null {
898
     *     ...
899
     * }
900
     * ```
901
     * The function should take a string reference and return a schema array, `Schema` or **null**.
902
     *
903
     * @param callable $refLookup The new lookup function.
904
     * @return $this
905
     */
906 10
    public function setRefLookup(callable $refLookup) {
907 10
        $this->refLookup = $refLookup;
908 10
        return $this;
909
    }
910
911
    /**
912
     * Create a new validation instance.
913
     *
914
     * @return Validation Returns a validation object.
915
     */
916 220
    protected function createValidation(): Validation {
917 220
        return call_user_func($this->getValidationFactory());
918
    }
919
920
    /**
921
     * Get factory used to create validation objects.
922
     *
923
     * @return callable Returns the current factory.
924
     */
925 220
    public function getValidationFactory(): callable {
926 220
        return $this->validationFactory;
927
    }
928
929
    /**
930
     * Set the factory used to create validation objects.
931
     *
932
     * @param callable $validationFactory The new factory.
933
     * @return $this
934
     */
935 2
    public function setValidationFactory(callable $validationFactory) {
936 2
        $this->validationFactory = $validationFactory;
937 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

937
        /** @scrutinizer ignore-deprecated */ $this->validationClass = null;
Loading history...
938 2
        return $this;
939
    }
940
941
    /**
942
     * Validate a field.
943
     *
944
     * @param mixed $value The value to validate.
945
     * @param ValidationField $field A validation object to add errors to.
946
     * @return mixed|Invalid Returns a clean version of the value with all extra fields stripped out or invalid if the value
947
     * is completely invalid.
948
     * @throws RefNotFoundException Throws an exception when a schema `$ref` is not found.
949
     */
950 220
    protected function validateField($value, ValidationField $field) {
951 220
        $validated = false;
952 220
        $result = $value = $this->filterField($value, $field, $validated);
953
954 220
        if ($validated) {
955 3
            return $result;
956 217
        } elseif ($field->getField() instanceof Schema) {
957
            try {
958 5
                $result = $field->getField()->validate($value, $field->getOptions());
959 2
            } catch (ValidationException $ex) {
960
                // The validation failed, so merge the validations together.
961 5
                $field->getValidation()->merge($ex->getValidation(), $field->getName());
962
            }
963 217
        } 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...
964 14
            $result = null;
965
        } else {
966
            // Validate the field's type.
967 217
            $type = $field->getType();
968 217
            if (is_array($type)) {
969 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

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

1462
            if ((null !== $minItems = $field->val('minItems')) && count(/** @scrutinizer ignore-type */ $value) < $minItems) {
Loading history...
1463 1
                $field->addError(
1464 1
                    'minItems',
1465
                    [
1466 1
                        'messageCode' => 'This must contain at least {minItems} {minItems,plural,item,items}.',
1467 1
                        'minItems' => $minItems,
1468
                    ]
1469
                );
1470
            }
1471 33
            if ((null !== $maxItems = $field->val('maxItems')) && count($value) > $maxItems) {
1472 1
                $field->addError(
1473 1
                    'maxItems',
1474
                    [
1475 1
                        'messageCode' => 'This must contain no more than {maxItems} {maxItems,plural,item,items}.',
1476 1
                        'maxItems' => $maxItems,
1477
                    ]
1478
                );
1479
            }
1480
1481 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

1481
            if ($field->val('uniqueItems') && count($value) > count(array_unique(/** @scrutinizer ignore-type */ $value))) {
Loading history...
1482 1
                $field->addError(
1483 1
                    'uniqueItems',
1484
                    [
1485 1
                        'messageCode' => 'The array must contain unique items.',
1486
                    ]
1487
                );
1488
            }
1489
1490 33
            if ($field->val('items') !== null) {
1491 25
                list ($items, $schemaPath) = $this->lookupSchema($field->val('items'), $field->getSchemaPath().'/items');
1492
1493
                // Validate each of the types.
1494 25
                $itemValidation = new ValidationField(
1495 25
                    $field->getValidation(),
1496 25
                    $items,
1497 25
                    '',
1498 25
                    $schemaPath,
1499 25
                    $field->getOptions()
1500
                );
1501
1502 25
                $result = [];
1503 25
                $count = 0;
1504 25
                foreach ($value as $i => $item) {
1505 25
                    $itemValidation->setName($field->getName()."/$i");
1506 25
                    $validItem = $this->validateField($item, $itemValidation);
1507 25
                    if (Invalid::isValid($validItem)) {
1508 25
                        $result[] = $validItem;
1509
                    }
1510 25
                    $count++;
1511
                }
1512
1513 25
                return empty($result) && $count > 0 ? Invalid::value() : $result;
1514
            } else {
1515
                // Cast the items into a proper numeric array.
1516 8
                $result = is_array($value) ? array_values($value) : iterator_to_array($value);
1517 8
                return $result;
1518
            }
1519
        }
1520
    }
1521
1522
    /**
1523
     * Validate an object.
1524
     *
1525
     * @param mixed $value The value to validate.
1526
     * @param ValidationField $field The validation results to add.
1527
     * @return object|Invalid Returns a clean object or **null** if validation fails.
1528
     * @throws RefNotFoundException Throws an exception when a schema `$ref` is not found.
1529
     */
1530 121
    protected function validateObject($value, ValidationField $field) {
1531 121
        if (!$this->isArray($value) || isset($value[0])) {
1532 6
            $field->addTypeError($value, 'object');
1533 6
            return Invalid::value();
1534 121
        } elseif (is_array($field->val('properties')) || null !== $field->val('additionalProperties')) {
1535
            // Validate the data against the internal schema.
1536 114
            $value = $this->validateProperties($value, $field);
1537 7
        } elseif (!is_array($value)) {
1538 3
            $value = $this->toObjectArray($value);
1539
        }
1540
1541 119
        if (($maxProperties = $field->val('maxProperties')) && count($value) > $maxProperties) {
1542 1
            $field->addError(
1543 1
                'maxProperties',
1544
                [
1545 1
                    'messageCode' => 'This must contain no more than {maxProperties} {maxProperties,plural,item,items}.',
1546 1
                    'maxItems' => $maxProperties,
1547
                ]
1548
            );
1549
        }
1550
1551 119
        if (($minProperties = $field->val('minProperties')) && count($value) < $minProperties) {
1552 1
            $field->addError(
1553 1
                'minProperties',
1554
                [
1555 1
                    'messageCode' => 'This must contain at least {minProperties} {minProperties,plural,item,items}.',
1556 1
                    'minItems' => $minProperties,
1557
                ]
1558
            );
1559
        }
1560
1561 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...
1562
    }
1563
1564
    /**
1565
     * Check whether or not a value is an array or accessible like an array.
1566
     *
1567
     * @param mixed $value The value to check.
1568
     * @return bool Returns **true** if the value can be used like an array or **false** otherwise.
1569
     */
1570 121
    private function isArray($value) {
1571 121
        return is_array($value) || ($value instanceof \ArrayAccess && $value instanceof \Traversable);
1572
    }
1573
1574
    /**
1575
     * Validate data against the schema and return the result.
1576
     *
1577
     * @param array|\Traversable|\ArrayAccess $data The data to validate.
1578
     * @param ValidationField $field This argument will be filled with the validation result.
1579
     * @return array|\ArrayObject|Invalid Returns a clean array with only the appropriate properties and the data coerced to proper types.
1580
     * or invalid if there are no valid properties.
1581
     * @throws RefNotFoundException Throws an exception of a property or additional property has a `$ref` that cannot be found.
1582
     */
1583 114
    protected function validateProperties($data, ValidationField $field) {
1584 114
        $properties = $field->val('properties', []);
1585 114
        $additionalProperties = $field->val('additionalProperties');
1586 114
        $required = array_flip($field->val('required', []));
1587 114
        $isRequest = $field->isRequest();
1588 114
        $isResponse = $field->isResponse();
1589
1590 114
        if (is_array($data)) {
1591 110
            $keys = array_keys($data);
1592 110
            $clean = [];
1593
        } else {
1594 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

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

1858
        return /** @scrutinizer ignore-deprecated */ $this->validationClass;
Loading history...
1859
    }
1860
1861
    /**
1862
     * Set the class that's used to contain validation information.
1863
     *
1864
     * @param Validation|string $class Either the name of a class or a class that will be cloned.
1865
     * @return $this
1866
     * @deprecated
1867
     */
1868 1
    public function setValidationClass($class) {
1869 1
        trigger_error('Schema::setValidationClass() is deprecated. Use Schema::setValidationFactory() instead.', E_USER_DEPRECATED);
1870
1871 1
        if (!is_a($class, Validation::class, true)) {
1872
            throw new \InvalidArgumentException("$class must be a subclass of ".Validation::class, 500);
1873
        }
1874
1875 1
        $this->setValidationFactory(function () use ($class) {
1876 1
            if ($class instanceof Validation) {
1877 1
                $result = clone $class;
1878
            } else {
1879 1
                $result = new $class;
1880
            }
1881 1
            return $result;
1882 1
        });
1883 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

1883
        /** @scrutinizer ignore-deprecated */ $this->validationClass = $class;
Loading history...
1884 1
        return $this;
1885
    }
1886
1887
    /**
1888
     * Return a sparse version of this schema.
1889
     *
1890
     * A sparse schema has no required properties.
1891
     *
1892
     * @return Schema Returns a new sparse schema.
1893
     */
1894 2
    public function withSparse() {
1895 2
        $sparseSchema = $this->withSparseInternal($this, new \SplObjectStorage());
1896 2
        return $sparseSchema;
1897
    }
1898
1899
    /**
1900
     * The internal implementation of `Schema::withSparse()`.
1901
     *
1902
     * @param array|Schema $schema The schema to make sparse.
1903
     * @param \SplObjectStorage $schemas Collected sparse schemas that have already been made.
1904
     * @return mixed
1905
     */
1906 2
    private function withSparseInternal($schema, \SplObjectStorage $schemas) {
1907 2
        if ($schema instanceof Schema) {
1908 2
            if ($schemas->contains($schema)) {
1909 1
                return $schemas[$schema];
1910
            } else {
1911 2
                $schemas[$schema] = $sparseSchema = new Schema();
1912 2
                $sparseSchema->schema = $schema->withSparseInternal($schema->schema, $schemas);
1913 2
                if ($id = $sparseSchema->getID()) {
1914
                    $sparseSchema->setID($id.'Sparse');
1915
                }
1916
1917 2
                return $sparseSchema;
1918
            }
1919
        }
1920
1921 2
        unset($schema['required']);
1922
1923 2
        if (isset($schema['items'])) {
1924 1
            $schema['items'] = $this->withSparseInternal($schema['items'], $schemas);
1925
        }
1926 2
        if (isset($schema['properties'])) {
1927 2
            foreach ($schema['properties'] as $name => &$property) {
1928 2
                $property = $this->withSparseInternal($property, $schemas);
1929
            }
1930
        }
1931
1932 2
        return $schema;
1933
    }
1934
1935
    /**
1936
     * Get the ID for the schema.
1937
     *
1938
     * @return string
1939
     */
1940 3
    public function getID(): string {
1941 3
        return $this->schema['id'] ?? '';
1942
    }
1943
1944
    /**
1945
     * Set the ID for the schema.
1946
     *
1947
     * @param string $id The new ID.
1948
     * @return $this
1949
     */
1950 1
    public function setID(string $id) {
1951 1
        $this->schema['id'] = $id;
1952
1953 1
        return $this;
1954
    }
1955
1956
    /**
1957
     * Whether a offset exists.
1958
     *
1959
     * @param mixed $offset An offset to check for.
1960
     * @return boolean true on success or false on failure.
1961
     * @link http://php.net/manual/en/arrayaccess.offsetexists.php
1962
     */
1963 7
    public function offsetExists($offset) {
1964 7
        return isset($this->schema[$offset]);
1965
    }
1966
1967
    /**
1968
     * Offset to retrieve.
1969
     *
1970
     * @param mixed $offset The offset to retrieve.
1971
     * @return mixed Can return all value types.
1972
     * @link http://php.net/manual/en/arrayaccess.offsetget.php
1973
     */
1974 7
    public function offsetGet($offset) {
1975 7
        return isset($this->schema[$offset]) ? $this->schema[$offset] : null;
1976
    }
1977
1978
    /**
1979
     * Offset to set.
1980
     *
1981
     * @param mixed $offset The offset to assign the value to.
1982
     * @param mixed $value The value to set.
1983
     * @link http://php.net/manual/en/arrayaccess.offsetset.php
1984
     */
1985 1
    public function offsetSet($offset, $value) {
1986 1
        $this->schema[$offset] = $value;
1987 1
    }
1988
1989
    /**
1990
     * Offset to unset.
1991
     *
1992
     * @param mixed $offset The offset to unset.
1993
     * @link http://php.net/manual/en/arrayaccess.offsetunset.php
1994
     */
1995 1
    public function offsetUnset($offset) {
1996 1
        unset($this->schema[$offset]);
1997 1
    }
1998
}
1999