Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
61 / 61
100.00% covered (success)
100.00%
18 / 18
CRAP
100.00% covered (success)
100.00%
1 / 1
LogisticsObject
100.00% covered (success)
100.00%
61 / 61
100.00% covered (success)
100.00%
18 / 18
47
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 fromJsonLd
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 types
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 mostSpecificType
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 values
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 value
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 literal
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 embeddedNodes
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 isEmbeddedId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isEmbeddedIn
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 isDescribedIn
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 withIri
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 withGraph
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 withEmbeddedIds
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
12
 toJsonLd
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 toJson
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isSameAs
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 rename
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\Model;
6
7use LambdaTwelve\OneRecord\JsonLd\Comparer;
8use LambdaTwelve\OneRecord\JsonLd\Context;
9use LambdaTwelve\OneRecord\JsonLd\JsonLd;
10use LambdaTwelve\OneRecord\JsonLd\Writer;
11use LambdaTwelve\OneRecord\Rdf\BlankNode;
12use LambdaTwelve\OneRecord\Rdf\Graph;
13use LambdaTwelve\OneRecord\Rdf\Iri;
14use LambdaTwelve\OneRecord\Rdf\Literal;
15use LambdaTwelve\OneRecord\Rdf\Term;
16use LambdaTwelve\OneRecord\Rdf\Triple;
17use LambdaTwelve\OneRecord\Spec\Namespaces;
18use LambdaTwelve\OneRecord\Vocabulary\Vocabulary;
19
20/**
21 * A logistics object: everything said about one URI, including its embedded
22 * objects (blank nodes, or nodes under the `internal:` prefix once stored).
23 *
24 * Immutable. Revision, last-modified and the other things a server knows
25 * about a stored object are not part of the object itself; they live on the
26 * stored revision record and in HTTP headers.
27 */
28final readonly class LogisticsObject
29{
30    public function __construct(
31        public Iri $iri,
32        public Graph $graph,
33    ) {
34        if (LocalRef::isLocal($iri)) {
35            throw new ModelException(\sprintf('"%s" is a local key, not a published URI; resolve the graph first.', $iri->value));
36        }
37    }
38
39    /**
40     * @param string|array<string, mixed> $json
41     */
42    public static function fromJsonLd(string|array $json, ?Iri $expectedIri = null): self
43    {
44        $document = JsonLd::expand($json, $expectedIri);
45        $root = $document->root;
46        if ($root instanceof BlankNode) {
47            if ($expectedIri === null) {
48                throw new ModelException('The document has no @id; give the IRI the object is published under.');
49            }
50            $root = $expectedIri;
51            $graph = self::rename($document->graph, $document->root, $root);
52        } else {
53            if ($expectedIri !== null && !$root->equals($expectedIri)) {
54                throw new ModelException(\sprintf('The document is about "%s", not "%s".', $root->value, $expectedIri->value));
55            }
56            $graph = $document->graph;
57        }
58
59        return new self($root, $graph);
60    }
61
62    /**
63     * @return list<string> class IRIs, sorted
64     */
65    public function types(): array
66    {
67        $types = array_map(static fn(Iri $t): string => $t->value, $this->graph->typesOf($this->iri));
68        sort($types, SORT_STRING);
69
70        return $types;
71    }
72
73    /**
74     * The one class to put in the `Type` header: the most specific of the
75     * declared types, or the first declared type when they are unrelated.
76     */
77    public function mostSpecificType(?Vocabulary $vocabulary = null): ?string
78    {
79        $vocabulary ??= Vocabulary::default();
80        $specific = $vocabulary->mostSpecific($this->types());
81        $known = array_values(array_filter($specific, static fn(string $t): bool => $vocabulary->isClass($t)));
82
83        return $known[0] ?? $specific[0] ?? null;
84    }
85
86    /**
87     * @return list<Term>
88     */
89    public function values(string $propertyIri): array
90    {
91        return $this->graph->objects($this->iri, $propertyIri);
92    }
93
94    public function value(string $propertyIri): ?Term
95    {
96        return $this->graph->firstObject($this->iri, $propertyIri);
97    }
98
99    public function literal(string $propertyIri): ?string
100    {
101        $value = $this->value($propertyIri);
102
103        return $value instanceof Literal ? $value->lexical : null;
104    }
105
106    /**
107     * @return list<Iri|BlankNode> the nodes embedded in this object, in a stable order
108     */
109    public function embeddedNodes(): array
110    {
111        $nodes = [];
112        foreach ($this->graph->subjects() as $subject) {
113            if (self::isEmbeddedIn($this->graph, $subject, $this->iri)) {
114                $nodes[$subject->toNTriples()] = $subject;
115            }
116        }
117        ksort($nodes, SORT_STRING);
118
119        return array_values($nodes);
120    }
121
122    public static function isEmbeddedId(Iri|BlankNode $node): bool
123    {
124        return $node instanceof BlankNode || $node->startsWith(Namespaces::EMBEDDED);
125    }
126
127    /**
128     * Whether a node is embedded in a graph: a blank node, a node under the
129     * embedded-id scheme, or any other node that is described in this graph,
130     * that is, carries a triple other than its class, without being the root.
131     * The id a client chose for an embedded object (an https: or urn:
132     * identifier, NE:ONE's neone: scheme) makes it no less embedded (R11-001);
133     * withEmbeddedIds() replaces such ids when the object is stored. A typed
134     * link, a reference that states the class of the thing it points to and
135     * nothing else, remains a reference.
136     */
137    public static function isEmbeddedIn(Graph $graph, Iri|BlankNode $node, ?Iri $root = null): bool
138    {
139        if ($root !== null && $node->equals($root)) {
140            return false;
141        }
142
143        return self::isEmbeddedId($node) || self::isDescribedIn($graph, $node);
144    }
145
146    /**
147     * Whether the graph says more about a node than its class.
148     */
149    public static function isDescribedIn(Graph $graph, Iri|BlankNode $node): bool
150    {
151        foreach ($graph->about($node) as $triple) {
152            if ($triple->predicate->value !== Graph::RDF_TYPE) {
153                return true;
154            }
155        }
156
157        return false;
158    }
159
160    public function withIri(Iri $iri): self
161    {
162        return new self($iri, self::rename($this->graph, $this->iri, $iri));
163    }
164
165    public function withGraph(Graph $graph): self
166    {
167        return new self($this->iri, $graph);
168    }
169
170    /**
171     * Give every embedded node a stable embedded id (the spec's `internal:`
172     * scheme): blank nodes, and nodes a client identified itself (an https: or
173     * urn: id, NE:ONE's neone: scheme), which are embedded all the same since
174     * they carry triples inside this object (R11-001). A server does this once,
175     * when it first stores an object: from then on a change can name an
176     * embedded node by the id the server minted, and nothing else in the
177     * stored graph is a subject but the object and its embedded nodes.
178     * References without triples of their own (other logistics objects,
179     * code-list members) are left as they are.
180     */
181    public function withEmbeddedIds(EmbeddedIdMinter $minter): self
182    {
183        $graph = $this->graph;
184        /** @var array<string, array{Iri|BlankNode, Iri}> $minted */
185        $minted = [];
186        foreach ($graph as $triple) {
187            foreach ([$triple->subject, $triple->object] as $term) {
188                if ($term instanceof BlankNode && !isset($minted[$term->toNTriples()])) {
189                    $minted[$term->toNTriples()] = [$term, $minter->mint($this->iri, 'r1:' . $term->label)];
190                }
191            }
192        }
193        foreach ($graph->subjects() as $subject) {
194            if ($subject instanceof Iri && !self::isEmbeddedId($subject) && self::isEmbeddedIn($graph, $subject, $this->iri) && !isset($minted[$subject->toNTriples()])) {
195                $minted[$subject->toNTriples()] = [$subject, $minter->mint($this->iri, 'r1:' . $subject->value)];
196            }
197        }
198        foreach ($minted as [$from, $iri]) {
199            $graph = self::rename($graph, $from, $iri);
200        }
201
202        return $minted === [] ? $this : new self($this->iri, $graph);
203    }
204
205    /**
206     * @return array<string, mixed>
207     */
208    public function toJsonLd(?Context $context = null): array
209    {
210        return (new Writer())->write($this->graph, $this->iri, $context ?? Context::oneRecord());
211    }
212
213    public function toJson(?Context $context = null, bool $pretty = true): string
214    {
215        return JsonLd::compactToJson($this->graph, $this->iri, $context ?? Context::oneRecord(), $pretty);
216    }
217
218    /**
219     * Same URI and isomorphic graphs (embedded ids compared as blank nodes).
220     */
221    public function isSameAs(self $other, ?Comparer $comparer = null): bool
222    {
223        return $this->iri->equals($other->iri) && ($comparer ?? new Comparer())->isomorphic($this->graph, $other->graph);
224    }
225
226    private static function rename(Graph $graph, Iri|BlankNode $from, Iri $to): Graph
227    {
228        $renamed = new Graph();
229        foreach ($graph as $triple) {
230            $subject = $triple->subject->equals($from) ? $to : $triple->subject;
231            $object = $triple->object->equals($from) ? $to : $triple->object;
232            $renamed->add(new Triple($subject, $triple->predicate, $object));
233        }
234
235        return $renamed;
236    }
237}