Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.04% covered (success)
95.04%
115 / 121
37.50% covered (danger)
37.50%
3 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Change
95.04% covered (success)
95.04%
115 / 121
37.50% covered (danger)
37.50%
3 / 8
55
0.00% covered (danger)
0.00%
0 / 1
 __construct
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
2.50
 fromJsonLd
97.14% covered (success)
97.14%
34 / 35
0.00% covered (danger)
0.00%
0 / 1
17
 toJsonLd
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
6
 toJson
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 context
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 changedProperties
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 operationsFrom
96.88% covered (success)
96.88%
31 / 32
0.00% covered (danger)
0.00%
0 / 1
18
 nodeFrom
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
7.39
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\Change;
6
7use LambdaTwelve\OneRecord\JsonLd\Context;
8use LambdaTwelve\OneRecord\JsonLd\ExpandedDocument;
9use LambdaTwelve\OneRecord\JsonLd\Json;
10use LambdaTwelve\OneRecord\JsonLd\JsonLd;
11use LambdaTwelve\OneRecord\JsonLd\JsonLdException;
12use LambdaTwelve\OneRecord\Rdf\BlankNode;
13use LambdaTwelve\OneRecord\Rdf\Graph;
14use LambdaTwelve\OneRecord\Rdf\Iri;
15use LambdaTwelve\OneRecord\Rdf\Literal;
16use LambdaTwelve\OneRecord\Rdf\Term;
17use LambdaTwelve\OneRecord\Spec\Namespaces;
18use LambdaTwelve\OneRecord\Vocabulary\Generated\Api;
19
20/**
21 * api:Change: the body of PATCH /logistics-objects/{id}.
22 *
23 * `revision` is the revision of the logistics object the change was written
24 * against (what the requester last saw); the holder applies it only while
25 * that is still the current revision, and the result becomes revision + 1.
26 * This is what NE:ONE enforces and what the "outdated revision" example in
27 * the spec implies (spec question 14).
28 */
29final readonly class Change
30{
31    /**
32     * @param non-empty-list<Operation> $operations
33     * @param list<Iri> $verificationRequests
34     */
35    public function __construct(
36        public Iri $logisticsObject,
37        public int $revision,
38        public array $operations,
39        public ?string $description = null,
40        public bool $notifyRequestStatusChange = false,
41        public array $verificationRequests = [],
42    ) {
43        if ($revision < 1) {
44            throw new ChangeException('A change must name the revision it applies to (1 or higher).');
45        }
46    }
47
48    /**
49     * @param string|array<string, mixed>|ExpandedDocument $document
50     */
51    public static function fromJsonLd(string|array|ExpandedDocument $document): self
52    {
53        try {
54            $expanded = $document instanceof ExpandedDocument ? $document : JsonLd::expand($document);
55        } catch (JsonLdException $e) {
56            throw ChangeException::because('Invalid body request', $e->getMessage());
57        }
58        $graph = $expanded->graph;
59        $root = $expanded->root;
60        if (!\in_array(Api::Change, $expanded->rootTypes(), true)) {
61            throw ChangeException::because('Invalid resource', 'The body is not an api:Change.');
62        }
63
64        $target = $graph->firstObject($root, Api::hasLogisticsObject);
65        if (!$target instanceof Iri) {
66            throw ChangeException::because('Invalid resource', 'api:hasLogisticsObject must reference the logistics object by IRI.', Api::hasLogisticsObject);
67        }
68        $revisionTerm = $graph->firstObject($root, Api::hasRevision);
69        if (!$revisionTerm instanceof Literal || preg_match('/^\+?\d+$/', $revisionTerm->lexical) !== 1 || (int) $revisionTerm->lexical < 1) {
70            throw ChangeException::because('Invalid resource', 'api:hasRevision must be a positive integer.', Api::hasRevision);
71        }
72        $description = $graph->firstObject($root, Api::hasDescription);
73        $notify = $graph->firstObject($root, Api::notifyRequestStatusChange);
74        $verifications = [];
75        foreach ($graph->objects($root, Api::hasVerificationRequest) as $term) {
76            if ($term instanceof Iri) {
77                $verifications[] = $term;
78            }
79        }
80
81        $operations = [];
82        foreach ($graph->objects($root, Api::hasOperation) as $node) {
83            if (!$node instanceof Iri && !$node instanceof BlankNode) {
84                throw ChangeException::because('Invalid resource', 'api:hasOperation must contain api:Operation objects.', Api::hasOperation);
85            }
86            foreach (self::operationsFrom($graph, $node) as $operation) {
87                $operations[] = $operation;
88            }
89        }
90        if ($operations === []) {
91            throw ChangeException::because('Invalid resource', 'A change must contain at least one operation.', Api::hasOperation);
92        }
93
94        return new self(
95            $target,
96            (int) $revisionTerm->lexical,
97            $operations,
98            $description instanceof Literal ? $description->lexical : null,
99            $notify instanceof Literal && $notify->lexical === 'true',
100            $verifications,
101        );
102    }
103
104    /**
105     * @return array<string, mixed>
106     */
107    public function toJsonLd(): array
108    {
109        $operations = [];
110        foreach ($this->operations as $operation) {
111            $operations[] = [
112                '@type' => 'api:Operation',
113                'api:op' => ['@id' => 'api:' . ($operation->kind === OperationKind::Add ? 'ADD' : 'DELETE')],
114                'api:s' => $operation->subjectString(),
115                'api:p' => $operation->predicate->value,
116                'api:o' => [[
117                    '@type' => 'api:OperationObject',
118                    'api:hasDatatype' => $operation->object->datatype,
119                    'api:hasValue' => $operation->object->value,
120                ]],
121            ];
122        }
123        $document = [
124            '@context' => [
125                'cargo' => Namespaces::CARGO,
126                'api' => Namespaces::API,
127                'xsd' => Namespaces::XSD,
128                'api:hasDatatype' => ['@type' => 'xsd:anyURI'],
129                'api:p' => ['@type' => 'xsd:anyURI'],
130            ],
131            '@type' => 'api:Change',
132            'api:hasLogisticsObject' => ['@id' => $this->logisticsObject->value],
133        ];
134        if ($this->description !== null) {
135            $document['api:hasDescription'] = $this->description;
136        }
137        $document['api:hasOperation'] = $operations;
138        $document['api:hasRevision'] = ['@type' => 'xsd:positiveInteger', '@value' => (string) $this->revision];
139        if ($this->notifyRequestStatusChange) {
140            $document['api:notifyRequestStatusChange'] = true;
141        }
142        if ($this->verificationRequests !== []) {
143            $document['api:hasVerificationRequest'] = array_map(static fn(Iri $iri): array => ['@id' => $iri->value], $this->verificationRequests);
144        }
145
146        return $document;
147    }
148
149    public function toJson(bool $pretty = true): string
150    {
151        return Json::encode($this->toJsonLd(), $pretty);
152    }
153
154    public static function context(): Context
155    {
156        return Context::fromRaw((new self(new Iri('https://example.invalid/x'), 1, [Operation::add(new Iri('https://example.invalid/x'), new Iri(Namespaces::CARGO . 'x'), new OperationObject(Literal::XSD_STRING, ''))]))->toJsonLd()['@context']);
157    }
158
159    /**
160     * @return list<string> the property IRIs this change touches on the logistics object itself
161     */
162    public function changedProperties(): array
163    {
164        $properties = [];
165        foreach ($this->operations as $operation) {
166            if ($operation->subject->equals($this->logisticsObject)) {
167                $properties[$operation->predicate->value] = true;
168            }
169        }
170        $list = array_keys($properties);
171        sort($list, SORT_STRING);
172
173        return $list;
174    }
175
176    /**
177     * @return list<Operation>
178     */
179    private static function operationsFrom(Graph $graph, Iri|BlankNode $node): array
180    {
181        $kindTerm = $graph->firstObject($node, Api::op);
182        $kind = $kindTerm instanceof Iri ? OperationKind::tryFrom($kindTerm->value) : null;
183        if ($kind === null) {
184            throw ChangeException::because('Invalid resource', 'api:op must be api:ADD or api:DELETE.', Api::op);
185        }
186        $subject = self::nodeFrom($graph->firstObject($node, Api::s), Api::s);
187        $predicateTerm = $graph->firstObject($node, Api::p);
188        $predicate = match (true) {
189            $predicateTerm instanceof Iri => $predicateTerm,
190            $predicateTerm instanceof Literal && Context::isAbsoluteIri($predicateTerm->lexical) => new Iri($predicateTerm->lexical),
191            default => throw ChangeException::because('Invalid resource', 'api:p must be the IRI of a property.', Api::p),
192        };
193
194        $objects = $graph->objects($node, Api::o);
195        if ($objects === []) {
196            throw ChangeException::because('Invalid resource', 'An operation needs an api:o object.', Api::o);
197        }
198        $operations = [];
199        foreach ($objects as $objectNode) {
200            if (!$objectNode instanceof Iri && !$objectNode instanceof BlankNode) {
201                throw ChangeException::because('Invalid resource', 'api:o must be an api:OperationObject.', Api::o);
202            }
203            $datatype = $graph->firstObject($objectNode, Api::hasDatatype);
204            $value = $graph->firstObject($objectNode, Api::hasValue);
205            $datatypeIri = match (true) {
206                $datatype instanceof Iri => $datatype->value,
207                $datatype instanceof Literal && Context::isAbsoluteIri($datatype->lexical) => $datatype->lexical,
208                default => throw ChangeException::because('Invalid resource', 'api:hasDatatype must be an IRI.', Api::hasDatatype),
209            };
210            $valueString = match (true) {
211                $value instanceof Literal => $value->lexical,
212                $value instanceof Iri => $value->value,
213                default => throw ChangeException::because('Invalid resource', 'api:hasValue is required.', Api::hasValue),
214            };
215            $operations[] = new Operation($kind, $subject, $predicate, new OperationObject($datatypeIri, $valueString));
216        }
217
218        return $operations;
219    }
220
221    private static function nodeFrom(?Term $term, string $property): Iri|BlankNode
222    {
223        if ($term instanceof Iri) {
224            return $term;
225        }
226        if ($term instanceof BlankNode) {
227            return $term;
228        }
229        if ($term instanceof Literal) {
230            if (str_starts_with($term->lexical, '_:') && \strlen($term->lexical) > 2) {
231                return new BlankNode(substr($term->lexical, 2));
232            }
233            if (Context::isAbsoluteIri($term->lexical)) {
234                return new Iri($term->lexical);
235            }
236        }
237
238        throw ChangeException::because('Invalid resource', 'api:s must be the IRI of the logistics object, an embedded object id, or a blank node label.', $property);
239    }
240}