Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.59% covered (success)
93.59%
73 / 78
72.22% covered (warning)
72.22%
13 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
ObjectBuilder
93.59% covered (success)
93.59%
73 / 78
72.22% covered (warning)
72.22%
13 / 18
61.98
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
11
 of
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 ofTypes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 ofEvent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 unchecked
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 set
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 add
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setIf
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 addAll
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 build
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 buildEvent
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 buildGraph
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 write
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 term
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 check
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 checkOn
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
12.03
 checkKind
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
9
 short
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\Model\Builder;
6
7use DateTimeImmutable;
8use LambdaTwelve\OneRecord\Model\LocalRef;
9use LambdaTwelve\OneRecord\Model\LogisticsEvent;
10use LambdaTwelve\OneRecord\Model\LogisticsObject;
11use LambdaTwelve\OneRecord\Model\ModelException;
12use LambdaTwelve\OneRecord\Rdf\BlankNode;
13use LambdaTwelve\OneRecord\Rdf\Graph;
14use LambdaTwelve\OneRecord\Rdf\Iri;
15use LambdaTwelve\OneRecord\Rdf\Literal;
16use LambdaTwelve\OneRecord\Rdf\Triple;
17use LambdaTwelve\OneRecord\Rdf\Xsd;
18use LambdaTwelve\OneRecord\Spec\Namespaces;
19use LambdaTwelve\OneRecord\Vocabulary\Generated\Cargo;
20use LambdaTwelve\OneRecord\Vocabulary\PropertyInfo;
21use LambdaTwelve\OneRecord\Vocabulary\PropertyKind;
22use LambdaTwelve\OneRecord\Vocabulary\Vocabulary;
23
24/**
25 * Builds a logistics object property by property, checking each one against
26 * the ontology: the class must exist and be a logistics object, the property
27 * must be accepted by the class, and the value must match the property's
28 * kind (a literal for datatype properties, a node for object properties).
29 * Give it a version-limited Vocabulary to refuse terms a partner on an older
30 * data model would not understand.
31 */
32final class ObjectBuilder
33{
34    private readonly Vocabulary $vocabulary;
35
36    /** @var array<string, list<Literal|Iri|LocalRef|Embedded>> */
37    private array $properties = [];
38
39    private int $blankCounter = 0;
40
41    /**
42     * @param list<string> $types
43     */
44    private function __construct(
45        private readonly array $types,
46        ?Vocabulary $vocabulary,
47        private readonly bool $strict,
48        private readonly bool $event = false,
49    ) {
50        $this->vocabulary = $vocabulary ?? Vocabulary::default();
51        foreach ($types as $type) {
52            if ($strict && !$this->vocabulary->isClass($type)) {
53                throw new ModelException(\sprintf('"%s" is not a class of the ontology.', $type));
54            }
55        }
56        if ($strict && $event && array_filter($types, fn(string $t): bool => $t === Cargo::LogisticsEvent || $this->vocabulary->isSubclassOf($t, Cargo::LogisticsEvent)) === []) {
57            throw new ModelException(\sprintf('%s is not a logistics event class.', implode(', ', $types)));
58        }
59        if ($strict && !$event && array_filter($types, fn(string $t): bool => $this->vocabulary->isLogisticsObjectClass($t)) === []) {
60            throw new ModelException(\sprintf('%s is not a logistics object class; embedded objects are built with Embedded::of(), events with ObjectBuilder::ofEvent().', implode(', ', $types)));
61        }
62    }
63
64    /**
65     * @param ?Vocabulary $vocabulary the terms allowed, e.g. Vocabulary::for(DataModelVersion::V3_2)
66     */
67    public static function of(string $type, ?Vocabulary $vocabulary = null): self
68    {
69        return new self([$type], $vocabulary, strict: true);
70    }
71
72    /**
73     * Several types, e.g. [Company, Organization, LogisticsAgent, LogisticsObject] as IATA's examples do.
74     *
75     * @param non-empty-list<string> $types
76     */
77    public static function ofTypes(array $types, ?Vocabulary $vocabulary = null): self
78    {
79        return new self($types, $vocabulary, strict: true);
80    }
81
82    /**
83     * A logistics event (cargo:LogisticsEvent or a subclass such as 3.3's
84     * StatusUpdateEvent), validated against the event class's properties.
85     * Finish with buildEvent().
86     */
87    public static function ofEvent(string $type = Cargo::LogisticsEvent, ?Vocabulary $vocabulary = null): self
88    {
89        return new self([$type], $vocabulary, strict: true, event: true);
90    }
91
92    /**
93     * No ontology checks: for terms outside the vocabulary (a host extension)
94     * or when reproducing a partner's document verbatim.
95     *
96     * @param non-empty-list<string> $types
97     */
98    public static function unchecked(array $types): self
99    {
100        return new self($types, null, strict: false);
101    }
102
103    /**
104     * @return $this
105     */
106    public function set(string $property, mixed $value): self
107    {
108        $this->properties[$property] = [$this->check($property, Values::term($value))];
109
110        return $this;
111    }
112
113    /**
114     * @return $this
115     */
116    public function add(string $property, mixed $value): self
117    {
118        $this->properties[$property][] = $this->check($property, Values::term($value));
119
120        return $this;
121    }
122
123    /**
124     * @return $this
125     */
126    public function setIf(string $property, mixed $value): self
127    {
128        return $value === null ? $this : $this->set($property, $value);
129    }
130
131    /**
132     * @param iterable<mixed> $values
133     * @return $this
134     */
135    public function addAll(string $property, iterable $values): self
136    {
137        foreach ($values as $value) {
138            $this->add($property, $value);
139        }
140
141        return $this;
142    }
143
144    public function build(Iri $iri): LogisticsObject
145    {
146        if ($this->event) {
147            throw new ModelException('An event builder finishes with buildEvent().');
148        }
149
150        return new LogisticsObject($iri, $this->buildGraph($iri));
151    }
152
153    /**
154     * The event with its URI under the object; cargo:eventFor is set to the
155     * object unless given, and cargo:eventDate is required as the spec says.
156     */
157    public function buildEvent(Iri $iri, Iri $logisticsObject, DateTimeImmutable $created): LogisticsEvent
158    {
159        if (!$this->event) {
160            throw new ModelException('buildEvent() is for builders made with ofEvent().');
161        }
162        if (!isset($this->properties[Cargo::eventDate])) {
163            throw new ModelException('Every logistics event must have a cargo:eventDate.');
164        }
165        $graph = $this->buildGraph($iri);
166        if ($graph->objects($iri, Cargo::eventFor) === []) {
167            $graph->add(new Triple($iri, new Iri(Cargo::eventFor), $logisticsObject));
168        }
169
170        return new LogisticsEvent($iri, $logisticsObject, $graph, $created);
171    }
172
173    /**
174     * The triples with the given subject (a real URI or a local:<key> placeholder).
175     */
176    public function buildGraph(Iri $subject): Graph
177    {
178        $this->blankCounter = 0;
179        $graph = new Graph();
180        foreach ($this->types as $type) {
181            $graph->add(new Triple($subject, new Iri(Graph::RDF_TYPE), new Iri($type)));
182        }
183        $this->write($graph, $subject, $this->properties);
184
185        return $graph;
186    }
187
188    /**
189     * @param array<string, list<Literal|Iri|LocalRef|Embedded>> $properties
190     */
191    private function write(Graph $graph, Iri|BlankNode $subject, array $properties): void
192    {
193        foreach ($properties as $property => $values) {
194            foreach ($values as $value) {
195                $graph->add(new Triple($subject, new Iri($property), $this->term($graph, $value)));
196            }
197        }
198    }
199
200    private function term(Graph $graph, Literal|Iri|LocalRef|Embedded $value): Literal|Iri|BlankNode
201    {
202        if ($value instanceof LocalRef) {
203            return $value->iri();
204        }
205        if ($value instanceof Embedded) {
206            $node = new BlankNode('e' . $this->blankCounter++);
207            foreach ($value->types() as $type) {
208                $graph->add(new Triple($node, new Iri(Graph::RDF_TYPE), new Iri($type)));
209            }
210            $this->write($graph, $node, $value->properties());
211
212            return $node;
213        }
214
215        return $value;
216    }
217
218    private function check(string $property, Literal|Iri|LocalRef|Embedded $value): Literal|Iri|LocalRef|Embedded
219    {
220        $this->checkOn($this->types, $property, $value);
221
222        return $value;
223    }
224
225    /**
226     * @param list<string> $types
227     */
228    private function checkOn(array $types, string $property, Literal|Iri|LocalRef|Embedded $value): void
229    {
230        if (!$this->strict) {
231            return;
232        }
233        $info = $this->vocabulary->property($property);
234        if ($info === null) {
235            throw new ModelException(\sprintf('"%s" is not a property of the ontology%s.', $property, $this->vocabulary->ceiling() !== null ? ' (data model ' . $this->vocabulary->ceiling()->value . ')' : ''));
236        }
237        if (!$this->vocabulary->accepts($types, $property)) {
238            throw new ModelException(\sprintf('%s does not accept %s%s.', implode(', ', array_map(self::short(...), $types)), self::short($property), $this->vocabulary->ceiling() !== null ? ' in data model ' . $this->vocabulary->ceiling()->value : ''));
239        }
240        $this->checkKind($info, $value);
241        if ($value instanceof Embedded) {
242            foreach ($value->types() as $type) {
243                if (!$this->vocabulary->isClass($type)) {
244                    throw new ModelException(\sprintf('"%s" is not a class of the ontology.', $type));
245                }
246                if ($this->vocabulary->isLogisticsObjectClass($type)) {
247                    throw new ModelException(\sprintf('%s is a logistics object class; publish it as its own object and refer to it with Values::ref().', self::short($type)));
248                }
249            }
250            foreach ($value->properties() as $nested => $items) {
251                foreach ($items as $item) {
252                    $this->checkOn($value->types(), $nested, $item);
253                }
254            }
255        }
256    }
257
258    private function checkKind(PropertyInfo $info, Literal|Iri|LocalRef|Embedded $value): void
259    {
260        if ($info->kind === PropertyKind::Datatype && !$value instanceof Literal) {
261            throw new ModelException(\sprintf('%s takes a literal value, not an object or reference.', self::short($info->iri)));
262        }
263        if ($info->kind === PropertyKind::Object && $value instanceof Literal) {
264            throw new ModelException(\sprintf('%s takes an object, reference or code-list IRI, not a literal.', self::short($info->iri)));
265        }
266        if ($info->kind === PropertyKind::Datatype && $value instanceof Literal && $value->language === null && !Xsd::satisfies($value->datatype, $info->ranges)) {
267            throw new ModelException(\sprintf('%s expects %s, got %s.', self::short($info->iri), implode(' or ', array_map(self::short(...), $info->ranges)), self::short($value->datatype)));
268        }
269    }
270
271    private static function short(string $iri): string
272    {
273        foreach ([Namespaces::CARGO => 'cargo:', Namespaces::API => 'api:', Namespaces::XSD => 'xsd:', Namespaces::CODE_LISTS => 'codes:'] as $ns => $prefix) {
274            if (str_starts_with($iri, $ns)) {
275                return $prefix . substr($iri, \strlen($ns));
276            }
277        }
278
279        return $iri;
280    }
281}