Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
95.10% |
97 / 102 |
|
42.86% |
3 / 7 |
CRAP | |
0.00% |
0 / 1 |
| Writer | |
95.10% |
97 / 102 |
|
42.86% |
3 / 7 |
68 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| write | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
5 | |||
| nodeObject | |
94.59% |
35 / 37 |
|
0.00% |
0 / 1 |
19.06 | |||
| valuesFit | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
7.10 | |||
| value | |
94.12% |
16 / 17 |
|
0.00% |
0 / 1 |
14.04 | |||
| literal | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
9 | |||
| native | |
92.86% |
13 / 14 |
|
0.00% |
0 / 1 |
13.06 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace LambdaTwelve\OneRecord\JsonLd; |
| 6 | |
| 7 | use LambdaTwelve\OneRecord\Rdf\BlankNode; |
| 8 | use LambdaTwelve\OneRecord\Rdf\Graph; |
| 9 | use LambdaTwelve\OneRecord\Rdf\Iri; |
| 10 | use LambdaTwelve\OneRecord\Rdf\Literal; |
| 11 | use LambdaTwelve\OneRecord\Rdf\Term; |
| 12 | use LambdaTwelve\OneRecord\Rdf\Triple; |
| 13 | use 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 | */ |
| 25 | final 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 | } |