Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.24% covered (success)
95.24%
100 / 105
82.14% covered (warning)
82.14%
23 / 28
CRAP
0.00% covered (danger)
0.00%
0 / 1
Vocabulary
95.24% covered (success)
95.24%
100 / 105
82.14% covered (warning)
82.14%
23 / 28
78
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 default
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 for
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 ceiling
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 class
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isClass
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 property
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isProperty
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 individual
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 codeList
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 codeListOf
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 isCodeListMember
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
4
 isOpenCodeListIri
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
4
 ancestors
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
5.01
 isSubclassOf
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isLogisticsObjectClass
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 mostSpecific
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
7
 propertiesOf
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
9
 accepts
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 classes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 properties
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 codeLists
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 load
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
7.01
 loadClasses
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 loadProperties
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 loadIndividuals
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
4.25
 visible
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 splitCodeIri
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\Vocabulary;
6
7use LambdaTwelve\OneRecord\Spec\DataModelVersion;
8use LambdaTwelve\OneRecord\Spec\Namespaces;
9use LambdaTwelve\OneRecord\Vocabulary\Generated\ApiSchema;
10use LambdaTwelve\OneRecord\Vocabulary\Generated\CargoSchema;
11use LambdaTwelve\OneRecord\Vocabulary\Generated\CodeListSchema;
12
13/**
14 * Runtime questions about the ONE Record ontologies: is this a class, which
15 * properties may a Piece carry, is this IRI a member of a code list, which of
16 * these types is the most specific one.
17 *
18 * The default instance knows every term of every merged edition. A view
19 * limited to one data model version (`for(DataModelVersion::V3_2)`) hides
20 * terms introduced later, so a document meant for a partner on an older
21 * ontology can be validated against what that partner understands. The API
22 * ontology is not filtered here: API versions are negotiated per request and
23 * handled by the server and client layers.
24 */
25final class Vocabulary
26{
27    public const string LOGISTICS_OBJECT = Namespaces::CARGO . 'LogisticsObject';
28
29    /** @var array<string, ClassInfo> */
30    private array $classes = [];
31
32    /** @var array<string, PropertyInfo> */
33    private array $properties = [];
34
35    /** @var array<string, IndividualInfo> */
36    private array $individuals = [];
37
38    /** @var array<string, CodeListInfo> */
39    private array $codeLists = [];
40
41    /** @var array<string, array<string, PropertyInfo>> class IRI => accepted properties, memoised */
42    private array $acceptedCache = [];
43
44    /** @var array<string, list<string>> class IRI => ancestors, memoised */
45    private array $ancestorCache = [];
46
47    /** @var list<PropertyInfo> */
48    private array $anyDomainProperties = [];
49
50    private static ?self $default = null;
51
52    private function __construct(private readonly ?DataModelVersion $ceiling)
53    {
54        $this->load();
55    }
56
57    /**
58     * Every term of every merged edition.
59     */
60    public static function default(): self
61    {
62        return self::$default ??= new self(null);
63    }
64
65    /**
66     * Only the terms a partner on this data model version understands.
67     */
68    public static function for(DataModelVersion $version): self
69    {
70        return new self($version);
71    }
72
73    public function ceiling(): ?DataModelVersion
74    {
75        return $this->ceiling;
76    }
77
78    public function class(string $iri): ?ClassInfo
79    {
80        return $this->classes[$iri] ?? null;
81    }
82
83    public function isClass(string $iri): bool
84    {
85        return isset($this->classes[$iri]);
86    }
87
88    public function property(string $iri): ?PropertyInfo
89    {
90        return $this->properties[$iri] ?? null;
91    }
92
93    public function isProperty(string $iri): bool
94    {
95        return isset($this->properties[$iri]);
96    }
97
98    public function individual(string $iri): ?IndividualInfo
99    {
100        return $this->individuals[$iri] ?? null;
101    }
102
103    public function codeList(string $iri): ?CodeListInfo
104    {
105        return $this->codeLists[$iri] ?? null;
106    }
107
108    /**
109     * The code list a member IRI (…/MeasurementUnitCode#KGM) belongs to, published
110     * code or not; null for an IRI outside the code-list namespace.
111     */
112    public function codeListOf(string $memberIri): ?CodeListInfo
113    {
114        [$list, $code] = $this->splitCodeIri($memberIri);
115
116        return $list === null || $code === null ? null : ($this->codeLists[$list] ?? null);
117    }
118
119    /**
120     * True for a published member of a code list. Members of open lists that
121     * are not published (an ISO currency, say) are not "known" but may still
122     * be acceptable; callers decide with isOpenCodeList().
123     */
124    public function isCodeListMember(string $iri): bool
125    {
126        [$list, $code] = $this->splitCodeIri($iri);
127
128        return $list !== null && $code !== null && isset($this->codeLists[$list]) && $this->codeLists[$list]->hasCode($code);
129    }
130
131    /**
132     * True when the IRI points into an open code list, whether or not the code is published.
133     */
134    public function isOpenCodeListIri(string $iri): bool
135    {
136        [$list, $code] = $this->splitCodeIri($iri);
137
138        return $list !== null && $code !== null && isset($this->codeLists[$list]) && $this->codeLists[$list]->open;
139    }
140
141    /**
142     * @return list<string> ancestor class IRIs, nearest first
143     */
144    public function ancestors(string $classIri): array
145    {
146        if (isset($this->ancestorCache[$classIri])) {
147            return $this->ancestorCache[$classIri];
148        }
149        $out = [];
150        $queue = $this->classes[$classIri]->parents ?? [];
151        while ($queue !== []) {
152            $parent = array_shift($queue);
153            if (\in_array($parent, $out, true)) {
154                continue;
155            }
156            $out[] = $parent;
157            foreach ($this->classes[$parent]->parents ?? [] as $grand) {
158                $queue[] = $grand;
159            }
160        }
161
162        return $this->ancestorCache[$classIri] = $out;
163    }
164
165    public function isSubclassOf(string $classIri, string $ancestorIri): bool
166    {
167        return $classIri === $ancestorIri || \in_array($ancestorIri, $this->ancestors($classIri), true);
168    }
169
170    /**
171     * Whether instances of this class are logistics objects (published under
172     * their own URI) rather than embedded objects.
173     */
174    public function isLogisticsObjectClass(string $classIri): bool
175    {
176        return $this->isSubclassOf($classIri, self::LOGISTICS_OBJECT);
177    }
178
179    /**
180     * Drops every type that is an ancestor of another type in the list, so a
181     * body typed [Company, Organization, LogisticsAgent, LogisticsObject]
182     * yields [Company]. Unknown types are kept: the server must not drop what
183     * it does not understand.
184     *
185     * @param list<string> $classIris
186     * @return list<string>
187     */
188    public function mostSpecific(array $classIris): array
189    {
190        $out = [];
191        foreach ($classIris as $candidate) {
192            $dominated = false;
193            foreach ($classIris as $other) {
194                if ($other !== $candidate && \in_array($candidate, $this->ancestors($other), true)) {
195                    $dominated = true;
196                    break;
197                }
198            }
199            if (!$dominated && !\in_array($candidate, $out, true)) {
200                $out[] = $candidate;
201            }
202        }
203
204        return $out;
205    }
206
207    /**
208     * Every property an instance of the class may carry: its own restrictions,
209     * those inherited from ancestors, and properties declared for any class.
210     *
211     * @return array<string, PropertyInfo> keyed by property IRI
212     */
213    public function propertiesOf(string $classIri): array
214    {
215        if (isset($this->acceptedCache[$classIri])) {
216            return $this->acceptedCache[$classIri];
217        }
218        $out = [];
219        foreach ([$classIri, ...$this->ancestors($classIri)] as $iri) {
220            foreach ($this->classes[$iri]->ownProperties ?? [] as $propertyIri => $_) {
221                if (isset($this->properties[$propertyIri])) {
222                    $out[$propertyIri] = $this->properties[$propertyIri];
223                }
224            }
225        }
226        foreach ($this->properties as $propertyIri => $property) {
227            if (!isset($out[$propertyIri]) && \in_array($classIri, $property->domains, true)) {
228                $out[$propertyIri] = $property;
229            }
230        }
231        foreach ($this->anyDomainProperties as $property) {
232            $out[$property->iri] ??= $property;
233        }
234        ksort($out, SORT_STRING);
235
236        return $this->acceptedCache[$classIri] = $out;
237    }
238
239    /**
240     * May an instance of any of these classes carry this property?
241     *
242     * @param list<string> $classIris
243     */
244    public function accepts(array $classIris, string $propertyIri): bool
245    {
246        foreach ($classIris as $classIri) {
247            if (isset($this->propertiesOf($classIri)[$propertyIri])) {
248                return true;
249            }
250        }
251
252        return false;
253    }
254
255    /**
256     * @return list<ClassInfo>
257     */
258    public function classes(): array
259    {
260        return array_values($this->classes);
261    }
262
263    /**
264     * @return list<PropertyInfo>
265     */
266    public function properties(): array
267    {
268        return array_values($this->properties);
269    }
270
271    /**
272     * @return list<CodeListInfo>
273     */
274    public function codeLists(): array
275    {
276        return array_values($this->codeLists);
277    }
278
279    private function load(): void
280    {
281        // The cargo ontology is filtered by the ceiling; the API ontology is negotiated per request instead.
282        $this->loadClasses(CargoSchema::CLASSES, true);
283        $this->loadProperties(CargoSchema::PROPERTIES, true);
284        $this->loadIndividuals(CargoSchema::INDIVIDUALS, true);
285        $this->loadClasses(ApiSchema::CLASSES, false);
286        $this->loadProperties(ApiSchema::PROPERTIES, false);
287        $this->loadIndividuals(ApiSchema::INDIVIDUALS, false);
288        foreach (CodeListSchema::LISTS as $iri => $row) {
289            if (!$this->visible($row['since'], null)) {
290                continue;
291            }
292            // Numeric codes ("0", "10") come back as integer keys from PHP; normalise to strings.
293            $codes = [];
294            foreach ($row['codes'] as $code => $since) {
295                if ($this->visible($since, null)) {
296                    $codes[(string) $code] = $since;
297                }
298            }
299            $this->codeLists[$iri] = new CodeListInfo($iri, $row['name'], $row['open'], $row['since'], $codes);
300        }
301        // Properties attached to a class in a later version are hidden with the version.
302        if ($this->ceiling !== null) {
303            foreach ($this->classes as $iri => $class) {
304                $own = array_filter($class->ownProperties, fn(string $since): bool => $this->visible($since, null));
305                $this->classes[$iri] = new ClassInfo($iri, $class->name, $class->parents, $own, $class->since, $class->deprecatedIn, $class->removedIn);
306            }
307        }
308    }
309
310    /**
311     * @param array<string, array{name: string, parents: list<string>, properties: array<string, string>, since: string, deprecatedIn: ?string, removedIn: ?string}> $rows
312     */
313    private function loadClasses(array $rows, bool $filtered): void
314    {
315        foreach ($rows as $iri => $row) {
316            if ($filtered && !$this->visible($row['since'], $row['removedIn'])) {
317                continue;
318            }
319            $this->classes[$iri] = new ClassInfo($iri, $row['name'], $row['parents'], $row['properties'], $row['since'], $row['deprecatedIn'], $row['removedIn']);
320        }
321    }
322
323    /**
324     * @param array<string, array{name: string, kind: 'object'|'datatype', ranges: list<string>, domains: list<string>, since: string, deprecatedIn: ?string, removedIn: ?string}> $rows
325     */
326    private function loadProperties(array $rows, bool $filtered): void
327    {
328        foreach ($rows as $iri => $row) {
329            if ($filtered && !$this->visible($row['since'], $row['removedIn'])) {
330                continue;
331            }
332            $info = new PropertyInfo($iri, $row['name'], PropertyKind::from($row['kind']), $row['ranges'], $row['domains'], $row['since'], $row['deprecatedIn'], $row['removedIn']);
333            $this->properties[$iri] = $info;
334            if ($info->acceptsAnyDomain()) {
335                $this->anyDomainProperties[] = $info;
336            }
337        }
338    }
339
340    /**
341     * @param array<string, array{name: string, types: list<string>, since: string, removedIn: ?string}> $rows
342     */
343    private function loadIndividuals(array $rows, bool $filtered): void
344    {
345        foreach ($rows as $iri => $row) {
346            if ($filtered && !$this->visible($row['since'], $row['removedIn'])) {
347                continue;
348            }
349            $this->individuals[$iri] = new IndividualInfo($iri, $row['name'], $row['types'], $row['since'], $row['removedIn']);
350        }
351    }
352
353    private function visible(string $since, ?string $removedIn): bool
354    {
355        if ($this->ceiling === null) {
356            return true;
357        }
358        if (version_compare($since, $this->ceiling->value, '>')) {
359            return false;
360        }
361
362        return $removedIn === null || version_compare($removedIn, $this->ceiling->value, '>');
363    }
364
365    /**
366     * @return array{?string, ?string}
367     */
368    private function splitCodeIri(string $iri): array
369    {
370        if (!str_starts_with($iri, Namespaces::CODE_LISTS)) {
371            return [null, null];
372        }
373        $hash = strpos($iri, '#');
374        if ($hash === false) {
375            return [null, null];
376        }
377
378        return [substr($iri, 0, $hash), substr($iri, $hash + 1)];
379    }
380}