Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
93.59% |
73 / 78 |
|
72.22% |
13 / 18 |
CRAP | |
0.00% |
0 / 1 |
| ObjectBuilder | |
93.59% |
73 / 78 |
|
72.22% |
13 / 18 |
61.98 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
11 | |||
| of | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| ofTypes | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| ofEvent | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| unchecked | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| set | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| add | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| setIf | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
| addAll | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| build | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
| buildEvent | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
4.03 | |||
| buildGraph | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| write | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| term | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
4 | |||
| check | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| checkOn | |
94.12% |
16 / 17 |
|
0.00% |
0 / 1 |
12.03 | |||
| checkKind | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
9 | |||
| short | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
3.14 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace LambdaTwelve\OneRecord\Model\Builder; |
| 6 | |
| 7 | use DateTimeImmutable; |
| 8 | use LambdaTwelve\OneRecord\Model\LocalRef; |
| 9 | use LambdaTwelve\OneRecord\Model\LogisticsEvent; |
| 10 | use LambdaTwelve\OneRecord\Model\LogisticsObject; |
| 11 | use LambdaTwelve\OneRecord\Model\ModelException; |
| 12 | use LambdaTwelve\OneRecord\Rdf\BlankNode; |
| 13 | use LambdaTwelve\OneRecord\Rdf\Graph; |
| 14 | use LambdaTwelve\OneRecord\Rdf\Iri; |
| 15 | use LambdaTwelve\OneRecord\Rdf\Literal; |
| 16 | use LambdaTwelve\OneRecord\Rdf\Triple; |
| 17 | use LambdaTwelve\OneRecord\Rdf\Xsd; |
| 18 | use LambdaTwelve\OneRecord\Spec\Namespaces; |
| 19 | use LambdaTwelve\OneRecord\Vocabulary\Generated\Cargo; |
| 20 | use LambdaTwelve\OneRecord\Vocabulary\PropertyInfo; |
| 21 | use LambdaTwelve\OneRecord\Vocabulary\PropertyKind; |
| 22 | use LambdaTwelve\OneRecord\Vocabulary\Vocabulary; |
| 23 | |
| 24 | /** |
| 25 | * Builds a logistics object property by property, checking each one against |
| 26 | * the ontology: the class must exist and be a logistics object, the property |
| 27 | * must be accepted by the class, and the value must match the property's |
| 28 | * kind (a literal for datatype properties, a node for object properties). |
| 29 | * Give it a version-limited Vocabulary to refuse terms a partner on an older |
| 30 | * data model would not understand. |
| 31 | */ |
| 32 | final class ObjectBuilder |
| 33 | { |
| 34 | private readonly Vocabulary $vocabulary; |
| 35 | |
| 36 | /** @var array<string, list<Literal|Iri|LocalRef|Embedded>> */ |
| 37 | private array $properties = []; |
| 38 | |
| 39 | private int $blankCounter = 0; |
| 40 | |
| 41 | /** |
| 42 | * @param list<string> $types |
| 43 | */ |
| 44 | private function __construct( |
| 45 | private readonly array $types, |
| 46 | ?Vocabulary $vocabulary, |
| 47 | private readonly bool $strict, |
| 48 | private readonly bool $event = false, |
| 49 | ) { |
| 50 | $this->vocabulary = $vocabulary ?? Vocabulary::default(); |
| 51 | foreach ($types as $type) { |
| 52 | if ($strict && !$this->vocabulary->isClass($type)) { |
| 53 | throw new ModelException(\sprintf('"%s" is not a class of the ontology.', $type)); |
| 54 | } |
| 55 | } |
| 56 | if ($strict && $event && array_filter($types, fn(string $t): bool => $t === Cargo::LogisticsEvent || $this->vocabulary->isSubclassOf($t, Cargo::LogisticsEvent)) === []) { |
| 57 | throw new ModelException(\sprintf('%s is not a logistics event class.', implode(', ', $types))); |
| 58 | } |
| 59 | if ($strict && !$event && array_filter($types, fn(string $t): bool => $this->vocabulary->isLogisticsObjectClass($t)) === []) { |
| 60 | throw new ModelException(\sprintf('%s is not a logistics object class; embedded objects are built with Embedded::of(), events with ObjectBuilder::ofEvent().', implode(', ', $types))); |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | /** |
| 65 | * @param ?Vocabulary $vocabulary the terms allowed, e.g. Vocabulary::for(DataModelVersion::V3_2) |
| 66 | */ |
| 67 | public static function of(string $type, ?Vocabulary $vocabulary = null): self |
| 68 | { |
| 69 | return new self([$type], $vocabulary, strict: true); |
| 70 | } |
| 71 | |
| 72 | /** |
| 73 | * Several types, e.g. [Company, Organization, LogisticsAgent, LogisticsObject] as IATA's examples do. |
| 74 | * |
| 75 | * @param non-empty-list<string> $types |
| 76 | */ |
| 77 | public static function ofTypes(array $types, ?Vocabulary $vocabulary = null): self |
| 78 | { |
| 79 | return new self($types, $vocabulary, strict: true); |
| 80 | } |
| 81 | |
| 82 | /** |
| 83 | * A logistics event (cargo:LogisticsEvent or a subclass such as 3.3's |
| 84 | * StatusUpdateEvent), validated against the event class's properties. |
| 85 | * Finish with buildEvent(). |
| 86 | */ |
| 87 | public static function ofEvent(string $type = Cargo::LogisticsEvent, ?Vocabulary $vocabulary = null): self |
| 88 | { |
| 89 | return new self([$type], $vocabulary, strict: true, event: true); |
| 90 | } |
| 91 | |
| 92 | /** |
| 93 | * No ontology checks: for terms outside the vocabulary (a host extension) |
| 94 | * or when reproducing a partner's document verbatim. |
| 95 | * |
| 96 | * @param non-empty-list<string> $types |
| 97 | */ |
| 98 | public static function unchecked(array $types): self |
| 99 | { |
| 100 | return new self($types, null, strict: false); |
| 101 | } |
| 102 | |
| 103 | /** |
| 104 | * @return $this |
| 105 | */ |
| 106 | public function set(string $property, mixed $value): self |
| 107 | { |
| 108 | $this->properties[$property] = [$this->check($property, Values::term($value))]; |
| 109 | |
| 110 | return $this; |
| 111 | } |
| 112 | |
| 113 | /** |
| 114 | * @return $this |
| 115 | */ |
| 116 | public function add(string $property, mixed $value): self |
| 117 | { |
| 118 | $this->properties[$property][] = $this->check($property, Values::term($value)); |
| 119 | |
| 120 | return $this; |
| 121 | } |
| 122 | |
| 123 | /** |
| 124 | * @return $this |
| 125 | */ |
| 126 | public function setIf(string $property, mixed $value): self |
| 127 | { |
| 128 | return $value === null ? $this : $this->set($property, $value); |
| 129 | } |
| 130 | |
| 131 | /** |
| 132 | * @param iterable<mixed> $values |
| 133 | * @return $this |
| 134 | */ |
| 135 | public function addAll(string $property, iterable $values): self |
| 136 | { |
| 137 | foreach ($values as $value) { |
| 138 | $this->add($property, $value); |
| 139 | } |
| 140 | |
| 141 | return $this; |
| 142 | } |
| 143 | |
| 144 | public function build(Iri $iri): LogisticsObject |
| 145 | { |
| 146 | if ($this->event) { |
| 147 | throw new ModelException('An event builder finishes with buildEvent().'); |
| 148 | } |
| 149 | |
| 150 | return new LogisticsObject($iri, $this->buildGraph($iri)); |
| 151 | } |
| 152 | |
| 153 | /** |
| 154 | * The event with its URI under the object; cargo:eventFor is set to the |
| 155 | * object unless given, and cargo:eventDate is required as the spec says. |
| 156 | */ |
| 157 | public function buildEvent(Iri $iri, Iri $logisticsObject, DateTimeImmutable $created): LogisticsEvent |
| 158 | { |
| 159 | if (!$this->event) { |
| 160 | throw new ModelException('buildEvent() is for builders made with ofEvent().'); |
| 161 | } |
| 162 | if (!isset($this->properties[Cargo::eventDate])) { |
| 163 | throw new ModelException('Every logistics event must have a cargo:eventDate.'); |
| 164 | } |
| 165 | $graph = $this->buildGraph($iri); |
| 166 | if ($graph->objects($iri, Cargo::eventFor) === []) { |
| 167 | $graph->add(new Triple($iri, new Iri(Cargo::eventFor), $logisticsObject)); |
| 168 | } |
| 169 | |
| 170 | return new LogisticsEvent($iri, $logisticsObject, $graph, $created); |
| 171 | } |
| 172 | |
| 173 | /** |
| 174 | * The triples with the given subject (a real URI or a local:<key> placeholder). |
| 175 | */ |
| 176 | public function buildGraph(Iri $subject): Graph |
| 177 | { |
| 178 | $this->blankCounter = 0; |
| 179 | $graph = new Graph(); |
| 180 | foreach ($this->types as $type) { |
| 181 | $graph->add(new Triple($subject, new Iri(Graph::RDF_TYPE), new Iri($type))); |
| 182 | } |
| 183 | $this->write($graph, $subject, $this->properties); |
| 184 | |
| 185 | return $graph; |
| 186 | } |
| 187 | |
| 188 | /** |
| 189 | * @param array<string, list<Literal|Iri|LocalRef|Embedded>> $properties |
| 190 | */ |
| 191 | private function write(Graph $graph, Iri|BlankNode $subject, array $properties): void |
| 192 | { |
| 193 | foreach ($properties as $property => $values) { |
| 194 | foreach ($values as $value) { |
| 195 | $graph->add(new Triple($subject, new Iri($property), $this->term($graph, $value))); |
| 196 | } |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | private function term(Graph $graph, Literal|Iri|LocalRef|Embedded $value): Literal|Iri|BlankNode |
| 201 | { |
| 202 | if ($value instanceof LocalRef) { |
| 203 | return $value->iri(); |
| 204 | } |
| 205 | if ($value instanceof Embedded) { |
| 206 | $node = new BlankNode('e' . $this->blankCounter++); |
| 207 | foreach ($value->types() as $type) { |
| 208 | $graph->add(new Triple($node, new Iri(Graph::RDF_TYPE), new Iri($type))); |
| 209 | } |
| 210 | $this->write($graph, $node, $value->properties()); |
| 211 | |
| 212 | return $node; |
| 213 | } |
| 214 | |
| 215 | return $value; |
| 216 | } |
| 217 | |
| 218 | private function check(string $property, Literal|Iri|LocalRef|Embedded $value): Literal|Iri|LocalRef|Embedded |
| 219 | { |
| 220 | $this->checkOn($this->types, $property, $value); |
| 221 | |
| 222 | return $value; |
| 223 | } |
| 224 | |
| 225 | /** |
| 226 | * @param list<string> $types |
| 227 | */ |
| 228 | private function checkOn(array $types, string $property, Literal|Iri|LocalRef|Embedded $value): void |
| 229 | { |
| 230 | if (!$this->strict) { |
| 231 | return; |
| 232 | } |
| 233 | $info = $this->vocabulary->property($property); |
| 234 | if ($info === null) { |
| 235 | throw new ModelException(\sprintf('"%s" is not a property of the ontology%s.', $property, $this->vocabulary->ceiling() !== null ? ' (data model ' . $this->vocabulary->ceiling()->value . ')' : '')); |
| 236 | } |
| 237 | if (!$this->vocabulary->accepts($types, $property)) { |
| 238 | throw new ModelException(\sprintf('%s does not accept %s%s.', implode(', ', array_map(self::short(...), $types)), self::short($property), $this->vocabulary->ceiling() !== null ? ' in data model ' . $this->vocabulary->ceiling()->value : '')); |
| 239 | } |
| 240 | $this->checkKind($info, $value); |
| 241 | if ($value instanceof Embedded) { |
| 242 | foreach ($value->types() as $type) { |
| 243 | if (!$this->vocabulary->isClass($type)) { |
| 244 | throw new ModelException(\sprintf('"%s" is not a class of the ontology.', $type)); |
| 245 | } |
| 246 | if ($this->vocabulary->isLogisticsObjectClass($type)) { |
| 247 | throw new ModelException(\sprintf('%s is a logistics object class; publish it as its own object and refer to it with Values::ref().', self::short($type))); |
| 248 | } |
| 249 | } |
| 250 | foreach ($value->properties() as $nested => $items) { |
| 251 | foreach ($items as $item) { |
| 252 | $this->checkOn($value->types(), $nested, $item); |
| 253 | } |
| 254 | } |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | private function checkKind(PropertyInfo $info, Literal|Iri|LocalRef|Embedded $value): void |
| 259 | { |
| 260 | if ($info->kind === PropertyKind::Datatype && !$value instanceof Literal) { |
| 261 | throw new ModelException(\sprintf('%s takes a literal value, not an object or reference.', self::short($info->iri))); |
| 262 | } |
| 263 | if ($info->kind === PropertyKind::Object && $value instanceof Literal) { |
| 264 | throw new ModelException(\sprintf('%s takes an object, reference or code-list IRI, not a literal.', self::short($info->iri))); |
| 265 | } |
| 266 | if ($info->kind === PropertyKind::Datatype && $value instanceof Literal && $value->language === null && !Xsd::satisfies($value->datatype, $info->ranges)) { |
| 267 | throw new ModelException(\sprintf('%s expects %s, got %s.', self::short($info->iri), implode(' or ', array_map(self::short(...), $info->ranges)), self::short($value->datatype))); |
| 268 | } |
| 269 | } |
| 270 | |
| 271 | private static function short(string $iri): string |
| 272 | { |
| 273 | foreach ([Namespaces::CARGO => 'cargo:', Namespaces::API => 'api:', Namespaces::XSD => 'xsd:', Namespaces::CODE_LISTS => 'codes:'] as $ns => $prefix) { |
| 274 | if (str_starts_with($iri, $ns)) { |
| 275 | return $prefix . substr($iri, \strlen($ns)); |
| 276 | } |
| 277 | } |
| 278 | |
| 279 | return $iri; |
| 280 | } |
| 281 | } |