Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
52.54% |
31 / 59 |
|
42.86% |
3 / 7 |
CRAP | |
0.00% |
0 / 1 |
| Xsd | |
52.54% |
31 / 59 |
|
42.86% |
3 / 7 |
272.17 | |
0.00% |
0 / 1 |
| satisfies | |
55.56% |
5 / 9 |
|
0.00% |
0 / 1 |
11.30 | |||
| ancestry | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| isIntegerType | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| lexicallyValid | |
62.50% |
10 / 16 |
|
0.00% |
0 / 1 |
26.87 | |||
| dateTimeValid | |
62.50% |
5 / 8 |
|
0.00% |
0 / 1 |
15.27 | |||
| offsetValid | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
5 | |||
| compareIntegers | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
42 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace LambdaTwelve\OneRecord\Rdf; |
| 6 | |
| 7 | use 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 | */ |
| 19 | final 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 | } |