Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
99.27% |
136 / 137 |
|
91.67% |
11 / 12 |
CRAP | |
0.00% |
0 / 1 |
| ChangeApplier | |
99.27% |
136 / 137 |
|
91.67% |
11 / 12 |
73 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| apply | |
97.56% |
40 / 41 |
|
0.00% |
0 / 1 |
18 | |||
| changedProperties | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
10 | |||
| assignRootProperty | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
6 | |||
| resolveSubject | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
5 | |||
| delete | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| add | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
10 | |||
| validateGraph | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| objectTerm | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| sameValue | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| lexicallyValid | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| removeOrphans | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
11 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace LambdaTwelve\OneRecord\Change; |
| 6 | |
| 7 | use LambdaTwelve\OneRecord\Api\Error; |
| 8 | use LambdaTwelve\OneRecord\JsonLd\Comparer; |
| 9 | use LambdaTwelve\OneRecord\Model\EmbeddedIdMinter; |
| 10 | use LambdaTwelve\OneRecord\Model\GraphValidator; |
| 11 | use LambdaTwelve\OneRecord\Model\LogisticsObject; |
| 12 | use LambdaTwelve\OneRecord\Model\Uuid5EmbeddedIdMinter; |
| 13 | use LambdaTwelve\OneRecord\Rdf\BlankNode; |
| 14 | use LambdaTwelve\OneRecord\Rdf\Graph; |
| 15 | use LambdaTwelve\OneRecord\Rdf\Iri; |
| 16 | use LambdaTwelve\OneRecord\Rdf\Literal; |
| 17 | use LambdaTwelve\OneRecord\Rdf\Term; |
| 18 | use LambdaTwelve\OneRecord\Rdf\Triple; |
| 19 | use LambdaTwelve\OneRecord\Rdf\Xsd; |
| 20 | use LambdaTwelve\OneRecord\Vocabulary\Generated\Api; |
| 21 | use LambdaTwelve\OneRecord\Vocabulary\Generated\Cargo; |
| 22 | use 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 | */ |
| 35 | final 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 | } |