Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
52.54% covered (warning)
52.54%
31 / 59
42.86% covered (danger)
42.86%
3 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Xsd
52.54% covered (warning)
52.54%
31 / 59
42.86% covered (danger)
42.86%
3 / 7
272.17
0.00% covered (danger)
0.00%
0 / 1
 satisfies
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
11.30
 ancestry
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 isIntegerType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 lexicallyValid
62.50% covered (warning)
62.50%
10 / 16
0.00% covered (danger)
0.00%
0 / 1
26.87
 dateTimeValid
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
15.27
 offsetValid
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 compareIntegers
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
42
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\Rdf;
6
7use LambdaTwelve\OneRecord\Spec\Namespaces;
8
9/**
10 * What the SDK knows about XML Schema datatypes: which derive from which
11 * (an xsd:int is an xsd:integer, which is an xsd:decimal), and each one's
12 * lexical grammar with its bounds. The builder and the change applier share
13 * satisfies(), the range rule (R8-002). lexicallyValid() is applied by the
14 * change applier to every literal a change adds (R7-007); the builder trusts
15 * the lexical form of a Literal a caller supplies, so a successful build
16 * says the property accepts a value of that datatype, not that the supplied
17 * text is within the datatype's grammar or bounds (D9-001).
18 */
19final class Xsd
20{
21    private const string NS = Namespaces::XSD;
22
23    /** Derived type => the type it restricts (XSD 1.1 part 2, built-in derived types). */
24    private const array PARENT = [
25        self::NS . 'long' => self::NS . 'integer',
26        self::NS . 'int' => self::NS . 'long',
27        self::NS . 'short' => self::NS . 'int',
28        self::NS . 'byte' => self::NS . 'short',
29        self::NS . 'nonNegativeInteger' => self::NS . 'integer',
30        self::NS . 'positiveInteger' => self::NS . 'nonNegativeInteger',
31        self::NS . 'unsignedLong' => self::NS . 'nonNegativeInteger',
32        self::NS . 'unsignedInt' => self::NS . 'unsignedLong',
33        self::NS . 'unsignedShort' => self::NS . 'unsignedInt',
34        self::NS . 'unsignedByte' => self::NS . 'unsignedShort',
35        self::NS . 'nonPositiveInteger' => self::NS . 'integer',
36        self::NS . 'negativeInteger' => self::NS . 'nonPositiveInteger',
37        self::NS . 'integer' => self::NS . 'decimal',
38    ];
39
40    /** Bounded integer types: [minimum, maximum] as digit strings. */
41    private const array BOUNDS = [
42        self::NS . 'long' => ['-9223372036854775808', '9223372036854775807'],
43        self::NS . 'int' => ['-2147483648', '2147483647'],
44        self::NS . 'short' => ['-32768', '32767'],
45        self::NS . 'byte' => ['-128', '127'],
46        self::NS . 'unsignedLong' => ['0', '18446744073709551615'],
47        self::NS . 'unsignedInt' => ['0', '4294967295'],
48        self::NS . 'unsignedShort' => ['0', '65535'],
49        self::NS . 'unsignedByte' => ['0', '255'],
50        self::NS . 'nonNegativeInteger' => ['0', null],
51        self::NS . 'positiveInteger' => ['1', null],
52        self::NS . 'nonPositiveInteger' => [null, '0'],
53        self::NS . 'negativeInteger' => [null, '-1'],
54    ];
55
56    /**
57     * Whether a value of $datatype satisfies a property whose range is one of
58     * $ranges: the range itself, a type it is derived from, or, for the
59     * numeric ranges, a type whose values are numbers of that kind (any
60     * integer or decimal type where a double or float is expected).
61     *
62     * @param list<string> $ranges
63     */
64    public static function satisfies(string $datatype, array $ranges): bool
65    {
66        if ($ranges === []) {
67            return true;
68        }
69        $ancestry = self::ancestry($datatype);
70        foreach ($ranges as $range) {
71            if (\in_array($range, $ancestry, true)) {
72                return true;
73            }
74            if (($range === self::NS . 'double' || $range === self::NS . 'float') && \in_array(self::NS . 'decimal', $ancestry, true)) {
75                return true;
76            }
77        }
78
79        return false;
80    }
81
82    /**
83     * The datatype and every type it restricts, nearest first.
84     *
85     * @return non-empty-list<string>
86     */
87    public static function ancestry(string $datatype): array
88    {
89        $chain = [$datatype];
90        while (isset(self::PARENT[$datatype])) {
91            $datatype = self::PARENT[$datatype];
92            $chain[] = $datatype;
93        }
94
95        return $chain;
96    }
97
98    public static function isIntegerType(string $datatype): bool
99    {
100        return \in_array(self::NS . 'integer', self::ancestry($datatype), true);
101    }
102
103    /**
104     * The lexical grammar of each datatype the SDK knows, bounds included;
105     * unknown datatypes are not checked.
106     */
107    public static function lexicallyValid(Literal $literal): bool
108    {
109        $lexical = $literal->lexical;
110        $datatype = $literal->datatype;
111        if (self::isIntegerType($datatype)) {
112            if (preg_match('/^[+-]?\d+$/', $lexical) !== 1) {
113                return false;
114            }
115            [$min, $max] = self::BOUNDS[$datatype] ?? [null, null];
116
117            return ($min === null || self::compareIntegers($lexical, $min) >= 0) && ($max === null || self::compareIntegers($lexical, $max) <= 0);
118        }
119
120        return match ($datatype) {
121            self::NS . 'boolean' => \in_array($lexical, ['true', 'false', '1', '0'], true),
122            self::NS . 'decimal' => preg_match('/^[+-]?(\d+(\.\d*)?|\.\d+)$/', $lexical) === 1,
123            self::NS . 'double', self::NS . 'float' => preg_match('/^([+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?|[+-]?INF|NaN)$/', $lexical) === 1,
124            self::NS . 'dateTime' => self::dateTimeValid($lexical),
125            self::NS . 'date' => preg_match('/^(-?\d{4,})-(\d{2})-(\d{2})(Z|[+-](\d{2}):(\d{2}))?$/', $lexical, $m) === 1 && checkdate((int) $m[2], (int) $m[3], abs((int) $m[1])) && self::offsetValid($m[5] ?? '', $m[6] ?? ''),
126            self::NS . 'anyURI' => preg_match('/[\s<>"{}|\\^`]/', $lexical) !== 1,
127            default => true,
128        };
129    }
130
131    /**
132     * XSD dateTime: calendar date, 24:00:00 allowed as the end of the day, offsets to 14:00.
133     */
134    private static function dateTimeValid(string $lexical): bool
135    {
136        if (preg_match('/^(-?\d{4,})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d+)?(Z|([+-])(\d{2}):(\d{2}))?$/', $lexical, $m) !== 1) {
137            return false;
138        }
139        if (!checkdate((int) $m[2], (int) $m[3], abs((int) $m[1])) || (int) $m[5] > 59 || (int) $m[6] > 59) {
140            return false;
141        }
142        $hour = (int) $m[4];
143        if ($hour > 24 || ($hour === 24 && ((int) $m[5] !== 0 || (int) $m[6] !== 0 || ltrim($m[7] ?? '', '.0') !== ''))) {
144            return false;
145        }
146        return self::offsetValid($m[10] ?? '', $m[11] ?? '');
147    }
148
149    /**
150     * A timezone offset of at most 14:00 (XSD part 2, timezoneOffset); empty means none given.
151     */
152    private static function offsetValid(string $hours, string $minutes): bool
153    {
154        if ($hours === '') {
155            return true;
156        }
157        $h = (int) $hours;
158        $m = (int) $minutes;
159
160        return $h <= 14 && $m <= 59 && !($h === 14 && $m !== 0);
161    }
162
163    /**
164     * Compares two integer lexicals of any length: -1, 0 or 1.
165     */
166    private static function compareIntegers(string $a, string $b): int
167    {
168        $normalise = static function (string $n): array {
169            $negative = str_starts_with($n, '-');
170            $digits = ltrim(ltrim($n, '+-'), '0');
171            if ($digits === '') {
172                return [false, '0'];
173            }
174
175            return [$negative, $digits];
176        };
177        [$negA, $digitsA] = $normalise($a);
178        [$negB, $digitsB] = $normalise($b);
179        if ($negA !== $negB) {
180            return $negA ? -1 : 1;
181        }
182        $magnitude = \strlen($digitsA) <=> \strlen($digitsB);
183        if ($magnitude === 0) {
184            $magnitude = strcmp($digitsA, $digitsB) <=> 0;
185        }
186
187        return $negA ? -$magnitude : $magnitude;
188    }
189}