Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.27% covered (success)
99.27%
136 / 137
91.67% covered (success)
91.67%
11 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChangeApplier
99.27% covered (success)
99.27%
136 / 137
91.67% covered (success)
91.67%
11 / 12
73
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 apply
97.56% covered (success)
97.56%
40 / 41
0.00% covered (danger)
0.00%
0 / 1
18
 changedProperties
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
10
 assignRootProperty
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 resolveSubject
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 delete
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 add
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
10
 validateGraph
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 objectTerm
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 sameValue
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 lexicallyValid
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeOrphans
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
11
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\Change;
6
7use LambdaTwelve\OneRecord\Api\Error;
8use LambdaTwelve\OneRecord\JsonLd\Comparer;
9use LambdaTwelve\OneRecord\Model\EmbeddedIdMinter;
10use LambdaTwelve\OneRecord\Model\GraphValidator;
11use LambdaTwelve\OneRecord\Model\LogisticsObject;
12use LambdaTwelve\OneRecord\Model\Uuid5EmbeddedIdMinter;
13use LambdaTwelve\OneRecord\Rdf\BlankNode;
14use LambdaTwelve\OneRecord\Rdf\Graph;
15use LambdaTwelve\OneRecord\Rdf\Iri;
16use LambdaTwelve\OneRecord\Rdf\Literal;
17use LambdaTwelve\OneRecord\Rdf\Term;
18use LambdaTwelve\OneRecord\Rdf\Triple;
19use LambdaTwelve\OneRecord\Rdf\Xsd;
20use LambdaTwelve\OneRecord\Vocabulary\Generated\Api;
21use LambdaTwelve\OneRecord\Vocabulary\Generated\Cargo;
22use LambdaTwelve\OneRecord\Vocabulary\Vocabulary;
23
24/**
25 * Applies an accepted api:Change to a logistics object, as the spec's
26 * "Update a Logistics Object" rules require: all or nothing, deletes before
27 * adds, revision checked, hasLogisticsObject checked, no edits to events,
28 * subjects limited to the object and its embedded objects, new embedded
29 * objects given stable ids, orphaned embedded objects removed, and every
30 * added value checked against the ontology.
31 *
32 * Failures raise ChangeRejected with api:Error objects; the server records
33 * those on the ChangeRequest and marks it REQUEST_FAILED.
34 */
35final class ChangeApplier
36{
37    private readonly Vocabulary $vocabulary;
38    private readonly EmbeddedIdMinter $minter;
39    private readonly Comparer $comparer;
40
41    public function __construct(?Vocabulary $vocabulary = null, ?EmbeddedIdMinter $minter = null, ?Comparer $comparer = null)
42    {
43        $this->vocabulary = $vocabulary ?? Vocabulary::default();
44        $this->minter = $minter ?? new Uuid5EmbeddedIdMinter();
45        $this->comparer = $comparer ?? new Comparer();
46    }
47
48    /**
49     * @param int $currentRevision the stored revision of $current
50     */
51    public function apply(LogisticsObject $current, int $currentRevision, Change $change): ChangeResult
52    {
53        if (!$change->logisticsObject->equals($current->iri)) {
54            throw new ChangeRejected([Error::of('Invalid resource', '400', 'api:hasLogisticsObject does not match the logistics object being changed.', null, $change->logisticsObject->value)]);
55        }
56        if ($change->revision !== $currentRevision) {
57            throw new ChangeRejected([Error::of('Conflict with Logistics Object revision number', '409', \sprintf('The change applies to revision %d but the logistics object is at revision %d.', $change->revision, $currentRevision), null, $current->iri->value)]);
58        }
59
60        $errors = [];
61        foreach ($change->operations as $operation) {
62            if ($operation->predicate->value === Cargo::events) {
63                $errors[] = Error::of('Invalid resource', '400', 'Logistics events cannot be changed through a Change; post them to /logistics-events.', Cargo::events, $current->iri->value);
64            }
65            if ($operation->predicate->value === Graph::RDF_TYPE) {
66                // The root's class is its identity; an embedded node's class arrives with the operation that
67                // creates it (example C2). Changing either afterwards is the one way a change could turn an
68                // embedded node into a logistics object, so neither is allowed.
69                $errors[] = Error::of('Invalid resource', '400', 'The type of an object is set when it is created and cannot be changed.', Graph::RDF_TYPE, $operation->subject instanceof Iri ? $operation->subject->value : $operation->subject->toNTriples());
70            }
71            if (str_starts_with($operation->predicate->value, \LambdaTwelve\OneRecord\Spec\Namespaces::API)) {
72                // Revision counters and anything else in the API namespace are the server's to write (R2-006).
73                $errors[] = Error::of('Invalid resource', '400', \sprintf('%s is set by the server, not through a change.', $operation->predicate->value), $operation->predicate->value, $current->iri->value);
74            }
75        }
76        if ($errors !== []) {
77            throw new ChangeRejected($errors);
78        }
79
80        $graph = new Graph($current->graph);
81        /** @var array<string, Iri> $minted blank label => embedded id */
82        $minted = [];
83        // Blank nodes are introduced by ADD operations whose value is the label; mint their ids first
84        // so operations on the new node (as subject) resolve whatever their order in the document.
85        $sequence = 0;
86        foreach ($change->operations as $operation) {
87            if ($operation->kind === OperationKind::Add && $operation->object->isBlankNode()) {
88                $label = substr($operation->object->value, 2);
89                $minted[$label] ??= $this->minter->mint($current->iri, \sprintf('r%d:%s:%d', $currentRevision + 1, $label, $sequence++));
90            }
91        }
92
93        $deletes = array_values(array_filter($change->operations, static fn(Operation $o): bool => $o->kind === OperationKind::Delete));
94        $adds = array_values(array_filter($change->operations, static fn(Operation $o): bool => $o->kind === OperationKind::Add));
95
96        foreach ($deletes as $operation) {
97            $subject = $this->resolveSubject($graph, $current->iri, $operation, $minted, $errors);
98            if ($subject === null) {
99                continue;
100            }
101            $this->delete($graph, $subject, $operation, $minted, $errors);
102        }
103        foreach ($adds as $operation) {
104            $subject = $this->resolveSubject($graph, $current->iri, $operation, $minted, $errors);
105            if ($subject === null) {
106                continue;
107            }
108            $this->add($graph, $subject, $operation, $minted, $errors);
109        }
110        if ($errors !== []) {
111            throw new ChangeRejected($errors);
112        }
113
114        $this->removeOrphans($graph, $current->iri);
115        $this->validateGraph($graph, $current->iri, $minted, $errors);
116        if ($errors !== []) {
117            throw new ChangeRejected($errors);
118        }
119        $changed = $this->changedProperties($change, $current->iri, $current->graph, $graph, $minted);
120
121        return new ChangeResult(new LogisticsObject($current->iri, $graph), $changed);
122    }
123
124    /**
125     * The root properties a change touched, as a notification's
126     * api:hasChangedProperty wants them: an operation on an embedded node
127     * counts for the property through which the node hangs off the object
128     * (editing the numericalValue of a grossWeight changes grossWeight).
129     *
130     * @param array<string, Iri> $minted
131     * @return list<string>
132     */
133    private function changedProperties(Change $change, Iri $root, Graph $before, Graph $after, array $minted): array
134    {
135        $rootPropertiesOf = [];
136        foreach ([$before, $after] as $graph) {
137            foreach ($graph->about($root) as $triple) {
138                $object = $triple->object;
139                if (($object instanceof Iri || $object instanceof BlankNode) && LogisticsObject::isEmbeddedIn($graph, $object, $root)) {
140                    $this->assignRootProperty($graph, $object, $triple->predicate->value, $rootPropertiesOf);
141                }
142            }
143        }
144        $properties = [];
145        foreach ($change->operations as $operation) {
146            $subject = $operation->subject;
147            if ($subject instanceof BlankNode) {
148                $subject = $minted[$subject->label] ?? $subject;
149            }
150            if ($subject->equals($root)) {
151                $properties[$operation->predicate->value] = true;
152            } else {
153                // A node shared under several root properties changes all of them (R3-005).
154                foreach (array_keys($rootPropertiesOf[$subject->toNTriples()] ?? []) as $property) {
155                    $properties[$property] = true;
156                }
157            }
158        }
159        $list = array_keys($properties);
160        sort($list, SORT_STRING);
161
162        return $list;
163    }
164
165    /**
166     * @param array<string, array<string, true>> $rootPropertiesOf node => the root properties it hangs from
167     */
168    private function assignRootProperty(Graph $graph, Iri|BlankNode $node, string $property, array &$rootPropertiesOf): void
169    {
170        $key = $node->toNTriples();
171        if (isset($rootPropertiesOf[$key][$property])) {
172            // Visited under this property already: a cycle, or a second path under the same property.
173            return;
174        }
175        $rootPropertiesOf[$key][$property] = true;
176        foreach ($graph->about($node) as $triple) {
177            $object = $triple->object;
178            if (($object instanceof Iri || $object instanceof BlankNode) && LogisticsObject::isEmbeddedIn($graph, $object)) {
179                $this->assignRootProperty($graph, $object, $property, $rootPropertiesOf);
180            }
181        }
182    }
183
184    /**
185     * @param array<string, Iri> $minted
186     * @param list<Error> $errors
187     */
188    private function resolveSubject(Graph $graph, Iri $root, Operation $operation, array $minted, array &$errors): ?Iri
189    {
190        $subject = $operation->subject;
191        if ($subject instanceof BlankNode) {
192            if (isset($minted[$subject->label])) {
193                return $minted[$subject->label];
194            }
195            $errors[] = Error::of('Invalid resource', '400', \sprintf('Blank node %s is used as a subject but no ADD operation introduces it.', $subject->toNTriples()), $operation->predicate->value);
196
197            return null;
198        }
199        if ($subject->equals($root)) {
200            return $subject;
201        }
202        // Any node the stored graph describes is one of the object's embedded nodes, whatever id it
203        // carries (R11-001).
204        if (LogisticsObject::isEmbeddedIn($graph, $subject, $root)) {
205            return $subject;
206        }
207        $errors[] = Error::of('Invalid resource', '400', \sprintf('"%s" is neither the logistics object nor one of its embedded objects.', $subject->value), $operation->predicate->value, $subject->value);
208
209        return null;
210    }
211
212    /**
213     * @param array<string, Iri> $minted
214     * @param list<Error> $errors
215     */
216    private function delete(Graph $graph, Iri $subject, Operation $operation, array $minted, array &$errors): void
217    {
218        $wanted = $this->objectTerm($operation, $minted);
219        $existing = $graph->objects($subject, $operation->predicate);
220        foreach ($existing as $candidate) {
221            if ($this->sameValue($candidate, $wanted)) {
222                $graph->remove(new Triple($subject, $operation->predicate, $candidate));
223
224                return;
225            }
226        }
227        $errors[] = Error::of('Unprocessable content', '422', \sprintf('Cannot delete %s: the value is not present on %s.', $operation->object->value, $subject->value), $operation->predicate->value);
228    }
229
230    /**
231     * @param array<string, Iri> $minted
232     * @param list<Error> $errors
233     */
234    private function add(Graph $graph, Iri $subject, Operation $operation, array $minted, array &$errors): void
235    {
236        $term = $this->objectTerm($operation, $minted);
237        $predicate = $operation->predicate->value;
238        // Property validity is judged on the finished graph (validateGraph): judging it here would
239        // depend on whether the node's type arrived before or after this operation (AR-013).
240        if ($term instanceof Literal && !self::lexicallyValid($term)) {
241            $errors[] = Error::of('Invalid resource', '400', \sprintf('"%s" is not a valid %s.', $term->lexical, $term->datatype), $predicate);
242
243            return;
244        }
245        foreach ($graph->objects($subject, $operation->predicate) as $candidate) {
246            if ($this->sameValue($candidate, $term)) {
247                $errors[] = Error::of('Unprocessable content', '422', \sprintf('Cannot add %s: the value is already present.', $operation->object->value), $predicate);
248
249                return;
250            }
251        }
252
253        // A new embedded object carries its class in the operation's datatype (spec example C2); an
254        // unknown class, or a logistics object class, is refused here rather than left untyped (R10-001).
255        if ($operation->object->isBlankNode() && $term instanceof Iri && $graph->typesOf($term) === []) {
256            $class = $operation->object->datatype;
257            if (!$this->vocabulary->isClass($class)) {
258                $errors[] = Error::of('Invalid resource', '400', \sprintf('"%s" is not a class of the ontology.', $class), $predicate);
259
260                return;
261            }
262            if ($this->vocabulary->isLogisticsObjectClass($class)) {
263                $errors[] = Error::of('Invalid resource', '400', \sprintf('%s is a logistics object class; a logistics object has its own URI and is referred to, not embedded.', $class), $predicate);
264
265                return;
266            }
267            $graph->add(new Triple($subject, $operation->predicate, $term));
268            $graph->add(new Triple($term, new Iri(Graph::RDF_TYPE), new Iri($class)));
269
270            return;
271        }
272        $graph->add(new Triple($subject, $operation->predicate, $term));
273    }
274
275    /**
276     * The whole graph after the change, root and every embedded node, must
277     * satisfy the ontology: the same GraphValidator that judges a created
278     * object or a posted event, so a change cannot smuggle in what creation
279     * refuses (R10-001, R10-002, R10-004). Checked once the whole change is
280     * applied so the answer does not depend on operation order.
281     *
282     * @param array<string, Iri> $minted
283     * @param list<Error> $errors
284     */
285    private function validateGraph(Graph $graph, Iri $root, array $minted, array &$errors): void
286    {
287        // The two revision properties are metadata a stored object may legitimately carry; no other API term is.
288        // A nested logistics object that creation accepted (spec question 33) must survive unrelated changes
289        // (R14-001); a change cannot introduce one, since add() refuses the class on a new node and rdf:type
290        // cannot be changed, so the finished graph is judged with such nodes allowed.
291        foreach ((new GraphValidator($this->vocabulary))->validate($graph, $root, [Api::hasRevision, Api::hasLatestRevision], nestedLogisticsObjects: true) as $violation) {
292            $errors[] = Error::of('Invalid resource', '400', $violation->message, $violation->property, $violation->subject);
293        }
294    }
295
296    /**
297     * @param array<string, Iri> $minted
298     */
299    private function objectTerm(Operation $operation, array $minted): Term
300    {
301        $object = $operation->object;
302        if ($object->isLiteral()) {
303            return $object->toLiteral();
304        }
305        if ($object->isBlankNode()) {
306            return $minted[substr($object->value, 2)] ?? throw new ChangeRejected([Error::of('Invalid resource', '400', \sprintf('Blank node %s is deleted but was never added.', $object->value), $operation->predicate->value)]);
307        }
308
309        return new Iri($object->value);
310    }
311
312    private function sameValue(Term $a, Term $b): bool
313    {
314        if ($a instanceof Literal && $b instanceof Literal) {
315            return $this->comparer->normaliseLiteral($a)->equals($this->comparer->normaliseLiteral($b));
316        }
317
318        return $a->equals($b);
319    }
320
321    /**
322     * The XSD lexical grammar of the literal's datatype, bounds included (R7-007); see Xsd.
323     */
324    public static function lexicallyValid(Literal $literal): bool
325    {
326        return Xsd::lexicallyValid($literal);
327    }
328
329    /**
330     * Embedded objects no longer reachable from the logistics object take
331     * their triples with them (the spec's "cleansing of the triples").
332     */
333    private function removeOrphans(Graph $graph, Iri $root): void
334    {
335        $reachable = [$root->toNTriples() => true];
336        $queue = [$root];
337        while ($queue !== []) {
338            $node = array_shift($queue);
339            foreach ($graph->about($node) as $triple) {
340                $object = $triple->object;
341                if (!$object instanceof BlankNode && !$object instanceof Iri || isset($reachable[$object->toNTriples()])) {
342                    continue;
343                }
344                // Whatever is linked from a reachable node stays, a typed link's class included (R12-002);
345                // only an embedded node is followed further.
346                $reachable[$object->toNTriples()] = true;
347                if (LogisticsObject::isEmbeddedIn($graph, $object, $root)) {
348                    $queue[] = $object;
349                }
350            }
351        }
352        foreach ($graph->subjects() as $subject) {
353            if (!isset($reachable[$subject->toNTriples()]) && !$subject->equals($root)) {
354                foreach ($graph->about($subject) as $triple) {
355                    $graph->remove($triple);
356                }
357            }
358        }
359    }
360}