Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.10% covered (success)
95.10%
97 / 102
42.86% covered (danger)
42.86%
3 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Writer
95.10% covered (success)
95.10%
97 / 102
42.86% covered (danger)
42.86%
3 / 7
68
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 write
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 nodeObject
94.59% covered (success)
94.59%
35 / 37
0.00% covered (danger)
0.00%
0 / 1
19.06
 valuesFit
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
7.10
 value
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
14.04
 literal
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
9
 native
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
13.06
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\JsonLd;
6
7use LambdaTwelve\OneRecord\Rdf\BlankNode;
8use LambdaTwelve\OneRecord\Rdf\Graph;
9use LambdaTwelve\OneRecord\Rdf\Iri;
10use LambdaTwelve\OneRecord\Rdf\Literal;
11use LambdaTwelve\OneRecord\Rdf\Term;
12use LambdaTwelve\OneRecord\Rdf\Triple;
13use LogicException;
14
15/**
16 * Writes a graph back as compacted JSON-LD, rooted at one node.
17 *
18 * Every node that has triples of its own is embedded where it is first
19 * referenced (identified nodes keep their @id, blank nodes get none unless
20 * they are referenced twice), later references become {"@id": ...}, and a
21 * node that only has rdf:type triples is written as a typed reference. Keys
22 * and values are sorted, so the same graph always yields the same JSON. The
23 * output expands back to the same graph.
24 */
25final class Writer
26{
27    /** @var array<string, true> */
28    private array $visited = [];
29
30    /** @var array<string, int> */
31    private array $references = [];
32
33    public function __construct(
34        private readonly bool $nativeLiterals = true,
35    ) {}
36
37    /**
38     * @return array<string, mixed>
39     */
40    public function write(Graph $graph, Iri|BlankNode $root, Context $context, bool $includeContext = true): array
41    {
42        $this->visited = [];
43        $this->references = [];
44        foreach ($graph as $triple) {
45            if ($triple->object instanceof BlankNode) {
46                $key = $triple->object->toNTriples();
47                $this->references[$key] = ($this->references[$key] ?? 0) + 1;
48            }
49        }
50
51        $object = $this->nodeObject($graph, $root, $context, isRoot: true);
52        if ($includeContext) {
53            $raw = $context->toRaw();
54            if ($raw !== []) {
55                $object = ['@context' => $raw, ...$object];
56            }
57        }
58
59        return $object;
60    }
61
62    /**
63     * @return array<string, mixed>
64     */
65    private function nodeObject(Graph $graph, Iri|BlankNode $node, Context $context, bool $isRoot): array
66    {
67        $this->visited[$node->toNTriples()] = true;
68        $out = [];
69        if ($node instanceof Iri) {
70            $out['@id'] = $context->compactIri($node->value, vocabRelative: false);
71        } elseif (($this->references[$node->toNTriples()] ?? 0) > 1 || ($isRoot && ($this->references[$node->toNTriples()] ?? 0) >= 1)) {
72            // A blank root that anything references (itself included) needs a label, or the reference
73            // becomes a different anonymous node on reading (AR-009).
74            $out['@id'] = $node->toNTriples();
75        }
76
77        $types = [];
78        /** @var array<string, list<Term>> $properties */
79        $properties = [];
80        foreach ($graph->about($node) as $triple) {
81            if ($triple->predicate->value === Graph::RDF_TYPE && $triple->object instanceof Iri) {
82                $types[] = $context->compactIri($triple->object->value);
83                continue;
84            }
85            $properties[$triple->predicate->value][] = $triple->object;
86        }
87        if ($types !== []) {
88            sort($types, SORT_STRING);
89            $out['@type'] = \count($types) === 1 ? $types[0] : $types;
90        }
91
92        $compactKeys = [];
93        foreach ($properties as $predicate => $values) {
94            // Of the keys that read back as this predicate, the shortest whose coercion fits every value;
95            // a key coerced to @id would turn a string into an IRI (R2-002, R3-002). Two predicates can
96            // never share a key, since every candidate expands to its own predicate.
97            $chosen = null;
98            foreach ($context->keyCandidates($predicate) as $candidate) {
99                $coercion = $context->coercionOfTerm($candidate);
100                if ($coercion === null || self::valuesFit($values, $coercion)) {
101                    $chosen = [$predicate, $coercion];
102                    $key = $candidate;
103                    break;
104                }
105            }
106            if ($chosen === null || !isset($key)) {
107                throw JsonLdException::at('', \sprintf('"%s" cannot be written in this context without changing its values.', $predicate));
108            }
109            if (isset($compactKeys[$key])) {
110                throw new LogicException(\sprintf('Key "%s" chosen for two predicates.', $key));
111            }
112            $compactKeys[$key] = $chosen;
113        }
114        ksort($compactKeys, SORT_STRING);
115        foreach ($compactKeys as $key => [$predicate, $coercion]) {
116            $values = $properties[$predicate];
117            usort($values, static fn(Term $a, Term $b): int => strcmp($a->toNTriples(), $b->toNTriples()));
118            $written = array_map(fn(Term $term): mixed => $this->value($graph, $term, $context, $coercion), $values);
119            $out[$key] = \count($written) === 1 ? $written[0] : $written;
120        }
121
122        return $out;
123    }
124
125    /**
126     * @param list<Term> $values
127     */
128    private static function valuesFit(array $values, string $coercion): bool
129    {
130        foreach ($values as $value) {
131            if ($coercion === Context::JSON_LD_ID) {
132                if ($value instanceof Literal) {
133                    return false;
134                }
135                continue;
136            }
137            if (!$value instanceof Literal || $value->language !== null || $value->datatype !== $coercion) {
138                return false;
139            }
140        }
141
142        return true;
143    }
144
145    private function value(Graph $graph, Term $term, Context $context, ?string $coercion): mixed
146    {
147        if ($term instanceof Literal) {
148            return $this->literal($term, $context, $coercion);
149        }
150        if (!$term instanceof Iri && !$term instanceof BlankNode) {
151            throw new LogicException('Unknown term type ' . $term::class);
152        }
153        $about = $graph->about($term);
154        $alreadyWritten = isset($this->visited[$term->toNTriples()]);
155        $onlyTypes = $about !== [] && array_filter($about, static fn(Triple $t): bool => $t->predicate->value !== Graph::RDF_TYPE) === [];
156
157        if ($about === [] || $alreadyWritten) {
158            if ($term instanceof Iri && $coercion === Context::JSON_LD_ID) {
159                return $context->compactIri($term->value, vocabRelative: false);
160            }
161
162            return ['@id' => $term instanceof Iri ? $context->compactIri($term->value, vocabRelative: false) : $term->toNTriples()];
163        }
164        if ($onlyTypes && $term instanceof Iri) {
165            $this->visited[$term->toNTriples()] = true;
166            $types = array_map(static fn(Triple $t): string => $context->compactIri($t->object instanceof Iri ? $t->object->value : ''), $about);
167            sort($types, SORT_STRING);
168
169            return ['@id' => $context->compactIri($term->value, vocabRelative: false), '@type' => \count($types) === 1 ? $types[0] : $types];
170        }
171
172        return $this->nodeObject($graph, $term, $context, isRoot: false);
173    }
174
175    private function literal(Literal $literal, Context $context, ?string $coercion): mixed
176    {
177        if ($literal->language !== null) {
178            return $literal->language === $context->language
179                ? $literal->lexical
180                : ['@language' => $literal->language, '@value' => $literal->lexical];
181        }
182        if ($coercion !== null && $coercion === $literal->datatype) {
183            return $literal->lexical;
184        }
185        if ($literal->isString()) {
186            // A plain string in a document with a default language would read back language-tagged.
187            return $context->language === null ? $literal->lexical : ['@type' => $context->compactIri(Literal::XSD_STRING), '@value' => $literal->lexical];
188        }
189        if ($this->nativeLiterals) {
190            $native = self::native($literal);
191            if ($native !== null) {
192                return $native;
193            }
194        }
195
196        return ['@type' => $context->compactIri($literal->datatype), '@value' => $literal->lexical];
197    }
198
199    /**
200     * Only lexical forms that survive a round trip through JSON become native values.
201     */
202    private static function native(Literal $literal): bool|int|float|null
203    {
204        return match ($literal->datatype) {
205            Literal::XSD_BOOLEAN => match ($literal->lexical) {
206                'true' => true,
207                'false' => false,
208                default => null,
209            },
210            Literal::XSD_INTEGER => preg_match('/^(0|-?[1-9][0-9]*)$/', $literal->lexical) === 1 && (string) (int) $literal->lexical === $literal->lexical
211                ? (int) $literal->lexical
212                : null,
213            // A JSON number without a fractional part reads back as xsd:integer in any JSON-LD processor
214            // (object to RDF conversion), whatever PHP's zero-fraction flag does; an integral double is
215            // written as an explicit value object instead (R7-002).
216            Literal::XSD_DOUBLE => is_numeric($literal->lexical) && Literal::formatDouble((float) $literal->lexical) === $literal->lexical && floor((float) $literal->lexical) !== (float) $literal->lexical
217                ? (float) $literal->lexical
218                : null,
219            default => null,
220        };
221    }
222}