Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.86% covered (success)
92.86%
130 / 140
54.55% covered (warning)
54.55%
6 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Expander
92.86% covered (success)
92.86%
130 / 140
54.55% covered (warning)
54.55%
6 / 11
109.02
0.00% covered (danger)
0.00%
0 / 1
 expand
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 flatGraph
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
18
 node
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
16
 subjectOf
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 identifier
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
5.27
 valueTerm
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
24
 valueObject
82.76% covered (warning)
82.76%
24 / 29
0.00% covered (danger)
0.00%
0 / 1
23.26
 lexical
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
5
 strings
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 freshBlankNode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 join
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\JsonLd;
6
7use InvalidArgumentException;
8use LambdaTwelve\OneRecord\Rdf\BlankNode;
9use LambdaTwelve\OneRecord\Rdf\Graph;
10use LambdaTwelve\OneRecord\Rdf\Iri;
11use LambdaTwelve\OneRecord\Rdf\Literal;
12use LambdaTwelve\OneRecord\Rdf\Term;
13use LambdaTwelve\OneRecord\Rdf\Triple;
14
15/**
16 * Turns a compacted JSON-LD document into triples.
17 *
18 * Supported: one inline object @context at the root, @id, @type (string or
19 * list), properties as terms, compact IRIs or absolute IRIs, native scalars,
20 * value objects (@value with @type or @language), arrays as multi-valued
21 * properties, embedded objects (blank nodes or identified nodes) and
22 * references ({"@id": ...}). Everything else raises JsonLdException, because
23 * a construct this reader does not model (lists, reverse properties, named
24 * graphs, nested contexts, remote contexts) cannot be processed faithfully.
25 *
26 * Blank nodes are labelled in document order, so the same document always
27 * expands to the same graph.
28 */
29final class Expander
30{
31    private const array REJECTED_KEYWORDS = [
32        '@graph' => 'A @graph inside a node (named graphs)',
33        '@list' => 'Ordered lists (@list)',
34        '@set' => '@set',
35        '@reverse' => 'Reverse properties (@reverse)',
36        '@nest' => '@nest',
37        '@index' => '@index',
38        '@included' => '@included',
39        '@json' => 'JSON literals (@json)',
40        '@direction' => '@direction',
41        '@container' => '@container',
42    ];
43
44    private int $counter = 0;
45
46    /** @var array<string, BlankNode> document blank node label => node */
47    private array $documentBlankNodes = [];
48
49    /**
50     * @param array<string, mixed> $document a decoded JSON object
51     * @param Iri|list<Iri>|null $preferredRoot the node(s) to treat as the document's subject when it is a flat @graph, in order of preference
52     */
53    public function expand(array $document, Iri|array|null $preferredRoot = null): ExpandedDocument
54    {
55        $this->counter = 0;
56        $this->documentBlankNodes = [];
57        $graph = new Graph();
58        $context = Context::fromRaw($document['@context'] ?? null);
59        unset($document['@context']);
60        if (\array_key_exists('@graph', $document)) {
61            return new ExpandedDocument($graph, $this->flatGraph($document, $context, $graph, $preferredRoot instanceof Iri ? [$preferredRoot] : ($preferredRoot ?? [])), $context);
62        }
63        $root = $this->node($document, '', $context, $graph);
64
65        return new ExpandedDocument($graph, $root, $context);
66    }
67
68    /**
69     * A top-level @graph is the flattened form of one document: a list of
70     * nodes referencing each other by @id (NE:ONE answers this way for any
71     * object with embedded nodes). Every node joins the same graph; the root
72     * is the first preferred node present, else the top-level @id, else the one
73     * node nothing else references, else the first. A caller that knows which
74     * identities it would accept names them all up front, so a cyclic graph
75     * cannot make the choice depend on node order (R3-004).
76     *
77     * @param array<string, mixed> $document
78     * @param list<Iri> $preferredRoots
79     */
80    private function flatGraph(array $document, Context $context, Graph $graph, array $preferredRoots): Iri|BlankNode
81    {
82        foreach (array_keys($document) as $key) {
83            if (!\in_array($key, ['@graph', '@id'], true)) {
84                throw JsonLdException::unsupported((string) $key, 'A property next to a top-level @graph');
85            }
86        }
87        $nodes = $document['@graph'];
88        if (!\is_array($nodes) || !array_is_list($nodes) || $nodes === []) {
89            throw JsonLdException::at('@graph', '@graph must be a non-empty array of node objects');
90        }
91        $subjects = [];
92        foreach ($nodes as $index => $node) {
93            if (!\is_array($node) || array_is_list($node)) {
94                throw JsonLdException::at('@graph[' . $index . ']', 'Every @graph entry must be a node object');
95            }
96            /** @var array<string, mixed> $node */
97            $subjects[] = $this->node($node, '@graph[' . $index . ']', $context, $graph);
98        }
99        foreach ($preferredRoots as $preferred) {
100            if ($graph->about($preferred) !== []) {
101                return $preferred;
102            }
103        }
104        if (\is_string($document['@id'] ?? null) && $document['@id'] !== '') {
105            $named = $this->identifier($document['@id'], '@id', $context);
106            if ($graph->about($named) !== []) {
107                return $named;
108            }
109        }
110        $referenced = [];
111        foreach ($graph as $triple) {
112            if ($triple->object instanceof Iri || $triple->object instanceof BlankNode) {
113                $referenced[$triple->object->toNTriples()] = true;
114            }
115        }
116        $unreferenced = array_values(array_filter($subjects, static fn(Iri|BlankNode $s): bool => !isset($referenced[$s->toNTriples()])));
117
118        return \count($unreferenced) === 1 ? $unreferenced[0] : $subjects[0];
119    }
120
121    /**
122     * @param array<string, mixed> $object
123     */
124    private function node(array $object, string $path, Context $context, Graph $graph): Iri|BlankNode
125    {
126        if (\array_key_exists('@context', $object)) {
127            throw JsonLdException::unsupported(self::join($path, '@context'), 'A @context inside an embedded object');
128        }
129        if (\array_key_exists('@value', $object)) {
130            throw JsonLdException::at($path, 'A value object (@value) cannot carry other properties');
131        }
132
133        $subject = $this->subjectOf($object, $path, $context);
134
135        foreach ($object as $key => $value) {
136            $here = self::join($path, $key);
137            if ($key === '@id') {
138                continue;
139            }
140            if ($key === '@type') {
141                foreach ($this->strings($value, $here) as $type) {
142                    $graph->add(new Triple($subject, new Iri(Graph::RDF_TYPE), new Iri($context->expandIri($type, $here, vocabRelative: true))));
143                }
144                continue;
145            }
146            if (str_starts_with($key, '@')) {
147                throw JsonLdException::unsupported($here, self::REJECTED_KEYWORDS[$key] ?? "The keyword {$key}");
148            }
149
150            $predicate = new Iri($context->expandIri($key, $here, vocabRelative: true));
151            $coercion = $context->coercionOfTerm($key);
152            $values = \is_array($value) && array_is_list($value) ? $value : [$value];
153            foreach ($values as $index => $item) {
154                if ($item === null) {
155                    continue;
156                }
157                $itemPath = \is_array($value) && array_is_list($value) ? $here . '[' . $index . ']' : $here;
158                if (\is_array($item) && array_is_list($item)) {
159                    throw JsonLdException::unsupported($itemPath, 'A nested array (list of lists)');
160                }
161                $graph->add(new Triple($subject, $predicate, $this->valueTerm($item, $itemPath, $coercion, $context, $graph)));
162            }
163        }
164
165        return $subject;
166    }
167
168    /**
169     * @param array<string, mixed> $object
170     */
171    private function subjectOf(array $object, string $path, Context $context): Iri|BlankNode
172    {
173        if (!\array_key_exists('@id', $object)) {
174            return $this->freshBlankNode();
175        }
176        $id = $object['@id'];
177        if (!\is_string($id) || $id === '') {
178            throw JsonLdException::at(self::join($path, '@id'), '@id must be a non-empty string');
179        }
180
181        return $this->identifier($id, self::join($path, '@id'), $context);
182    }
183
184    private function identifier(string $id, string $path, Context $context): Iri|BlankNode
185    {
186        if (str_starts_with($id, '_:')) {
187            $label = substr($id, 2);
188            if ($label === '' || preg_match('/^[A-Za-z0-9_][A-Za-z0-9_.-]*$/', $label) !== 1) {
189                throw JsonLdException::at($path, \sprintf('"%s" is not a valid blank node identifier', $id));
190            }
191
192            // Document labels are kept apart from generated ones so neither can collide.
193            return $this->documentBlankNodes[$label] ??= new BlankNode('n_' . $label);
194        }
195        $iri = $context->expandIri($id, $path, vocabRelative: false);
196        try {
197            return new Iri($iri);
198        } catch (InvalidArgumentException $e) {
199            throw JsonLdException::at($path, \sprintf('"%s" is not a valid IRI', $iri));
200        }
201    }
202
203    /**
204     * @param ?string $coercion "@id", a datatype IRI, or null, from the context's term definition
205     */
206    private function valueTerm(mixed $value, string $path, ?string $coercion, Context $context, Graph $graph): Term
207    {
208        if (\is_string($value)) {
209            if ($coercion === Context::JSON_LD_ID) {
210                return $this->identifier($value, $path, $context);
211            }
212            if ($coercion !== null) {
213                return new Literal($value, $coercion);
214            }
215
216            return $context->language !== null ? new Literal($value, null, $context->language) : Literal::string($value);
217        }
218        if (\is_float($value) && !is_finite($value)) {
219            // json_decode turns 1e400 into INF; RDF has no lexical form for it (AR-023).
220            throw JsonLdException::at($path, 'A number must be finite');
221        }
222        if (\is_bool($value) || \is_int($value) || \is_float($value)) {
223            // @id coercion applies to strings; a native number or boolean under such a term is the
224            // value it is (value expansion, R8-003). A datatype coercion applies to natives too (R7-003).
225            if ($coercion !== null && $coercion !== Context::JSON_LD_ID) {
226                // A term's datatype coerces native values too (value expansion), not only strings (R7-003).
227                return $coercion === Literal::XSD_DOUBLE && !\is_bool($value) ? Literal::double((float) $value) : new Literal(self::lexical($value), $coercion);
228            }
229            if (\is_bool($value)) {
230                return Literal::boolean($value);
231            }
232
233            return \is_int($value) ? Literal::integer($value) : Literal::double($value);
234        }
235        if (!\is_array($value)) {
236            throw JsonLdException::at($path, 'Unsupported JSON value of type ' . get_debug_type($value));
237        }
238        /** @var array<string, mixed> $value */
239        if (\array_key_exists('@value', $value)) {
240            return $this->valueObject($value, $path, $context);
241        }
242        if (\array_key_exists('@id', $value) && \count($value) <= 2 && (\count($value) === 1 || \array_key_exists('@type', $value))) {
243            // A reference, possibly typed: {"@id": X, "@type": T}. Its type is a fact about X.
244            $node = $this->subjectOf($value, $path, $context);
245            if (\array_key_exists('@type', $value)) {
246                foreach ($this->strings($value['@type'], self::join($path, '@type')) as $type) {
247                    $graph->add(new Triple($node, new Iri(Graph::RDF_TYPE), new Iri($context->expandIri($type, self::join($path, '@type'), vocabRelative: true))));
248                }
249            }
250
251            return $node;
252        }
253
254        return $this->node($value, $path, $context, $graph);
255    }
256
257    /**
258     * @param array<string, mixed> $object
259     */
260    private function valueObject(array $object, string $path, Context $context): Literal
261    {
262        foreach (array_keys($object) as $key) {
263            if (!\in_array($key, ['@value', '@type', '@language'], true)) {
264                throw JsonLdException::at(self::join($path, $key), 'A value object may only contain @value, @type and @language');
265            }
266        }
267        $raw = $object['@value'];
268        if (\is_float($raw) && !is_finite($raw)) {
269            // json_decode turns 1e400 into INF; RDF has no lexical form for it (AR-023).
270            throw JsonLdException::at($path, 'A number must be finite');
271        }
272        if (!\is_string($raw) && !\is_bool($raw) && !\is_int($raw) && !\is_float($raw)) {
273            throw JsonLdException::at(self::join($path, '@value'), '@value must be a string, number or boolean');
274        }
275        $hasType = \array_key_exists('@type', $object);
276        $hasLanguage = \array_key_exists('@language', $object);
277        if ($hasType && $hasLanguage) {
278            throw JsonLdException::at($path, 'A value object cannot have both @type and @language');
279        }
280        if ($hasLanguage) {
281            $language = $object['@language'];
282            if (!\is_string($language) || $language === '' || !\is_string($raw)) {
283                throw JsonLdException::at(self::join($path, '@language'), 'A language-tagged value must be a string with a non-empty language tag');
284            }
285
286            return new Literal($raw, null, $language);
287        }
288        if ($hasType) {
289            $type = $object['@type'];
290            if (!\is_string($type) || $type === '') {
291                throw JsonLdException::at(self::join($path, '@type'), '@type of a value object must be a non-empty string');
292            }
293
294            return new Literal(self::lexical($raw), $context->expandIri($type, self::join($path, '@type'), vocabRelative: true));
295        }
296        if (\is_string($raw)) {
297            // An explicit value object without @language is a plain string; the context's default
298            // language applies to bare strings only (value expansion, R7-003).
299            return Literal::string($raw);
300        }
301        if (\is_bool($raw)) {
302            return Literal::boolean($raw);
303        }
304        if (\is_int($raw)) {
305            return Literal::integer($raw);
306        }
307
308        return Literal::double($raw);
309    }
310
311    private static function lexical(string|int|float|bool $raw): string
312    {
313        return match (true) {
314            \is_bool($raw) => $raw ? 'true' : 'false',
315            \is_float($raw) => Literal::formatDouble($raw),
316            default => (string) $raw,
317        };
318    }
319
320    /**
321     * @return list<string>
322     */
323    private function strings(mixed $value, string $path): array
324    {
325        $values = \is_array($value) && array_is_list($value) ? $value : [$value];
326        $out = [];
327        foreach ($values as $item) {
328            if (!\is_string($item) || $item === '') {
329                throw JsonLdException::at($path, '@type values must be non-empty strings');
330            }
331            $out[] = $item;
332        }
333
334        return $out;
335    }
336
337    private function freshBlankNode(): BlankNode
338    {
339        return new BlankNode('b' . $this->counter++);
340    }
341
342    private static function join(string $path, string $key): string
343    {
344        return $path === '' ? $key : $path . '.' . $key;
345    }
346}