Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.52% covered (success)
94.52%
69 / 73
66.67% covered (warning)
66.67%
4 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
GraphValidator
94.52% covered (success)
94.52%
69 / 73
66.67% covered (warning)
66.67%
4 / 6
54.48
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
 validate
94.64% covered (success)
94.64%
53 / 56
0.00% covered (danger)
0.00%
0 / 1
39.23
 acceptsText
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 isKnownClass
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 expectsACodeList
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 withinRanges
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\Model;
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\Xsd;
12use LambdaTwelve\OneRecord\Spec\Namespaces;
13use LambdaTwelve\OneRecord\Vocabulary\PropertyKind;
14use LambdaTwelve\OneRecord\Vocabulary\Vocabulary;
15
16/**
17 * Checks a whole logistics-object or event graph against the ontology: the
18 * root and every embedded node (blank, or under the embedded-id prefix),
19 * however deep. One set of rules for creation over HTTP, for changes and
20 * for posted events, so no path can accept what another refuses (R10-001
21 * to R10-004, D10-001).
22 *
23 * Rules, per subject: the root's classes must exist; an embedded node must
24 * declare at least one class, every class must exist and none may be a
25 * logistics object class (those have their own URIs). Per triple: the
26 * property must exist, be accepted by the subject's classes and be of the
27 * right kind; a literal must fit the property's range (XSD derivation
28 * included) and its datatype's grammar; a node reached through an object
29 * property must, when the graph knows its classes, be of the range or a
30 * subclass. API-namespace properties are the server's, except those a
31 * caller tolerates (the stored revision counters).
32 */
33final class GraphValidator
34{
35    public function __construct(private readonly Vocabulary $vocabulary) {}
36
37    /**
38     * @param list<string> $tolerated API-namespace predicates the graph may carry
39     * @param bool $nestedLogisticsObjects accept an embedded node of a logistics object class: the shape of
40     *                                     the spec's own POST examples, which a server is expected to split into
41     *                                     separate objects and this one keeps embedded (spec question 33)
42     * @return list<GraphViolation> empty when the graph is valid
43     */
44    public function validate(Graph $graph, Iri|BlankNode $root, array $tolerated = [], bool $nestedLogisticsObjects = false): array
45    {
46        $violations = [];
47        // Every node the graph describes is an embedded node, whatever id it carries (R11-001). A typed
48        // link, which only states the class of what it points to, is a reference: its class must still
49        // exist (R12-001), a logistics object class or a code list, and the range check below applies.
50        $subjects = [$root->toNTriples() => $root];
51        foreach ($graph->subjects() as $subject) {
52            if (!$subject->equals($root)) {
53                $subjects[$subject->toNTriples()] = $subject;
54            }
55        }
56        foreach ($subjects as $subject) {
57            $isRoot = $subject->equals($root);
58            $subjectName = $subject instanceof Iri ? $subject->value : $subject->toNTriples();
59            $types = array_map(static fn(Iri $t): string => $t->value, $graph->typesOf($subject));
60            if (!$isRoot && !LogisticsObject::isEmbeddedIn($graph, $subject, $root instanceof Iri ? $root : null)) {
61                foreach ($types as $type) {
62                    if (!$this->isKnownClass($type)) {
63                        $violations[] = new GraphViolation(\sprintf('"%s" is not a class of the ontology.', $type), Graph::RDF_TYPE, $subjectName);
64                    }
65                }
66                continue;
67            }
68            foreach ($types as $type) {
69                if (!$this->vocabulary->isClass($type)) {
70                    $violations[] = new GraphViolation(\sprintf('"%s" is not a class of the ontology.', $type), Graph::RDF_TYPE, $subjectName);
71                } elseif (!$isRoot && !$nestedLogisticsObjects && $this->vocabulary->isLogisticsObjectClass($type)) {
72                    $violations[] = new GraphViolation(\sprintf('%s is a logistics object class; a logistics object has its own URI and is referred to, not embedded.', $type), Graph::RDF_TYPE, $subjectName);
73                }
74            }
75            if ($types === []) {
76                $violations[] = new GraphViolation($isRoot ? 'The object declares no @type.' : 'An embedded object must declare its @type.', Graph::RDF_TYPE, $subjectName);
77                continue;
78            }
79            foreach ($graph->about($subject) as $triple) {
80                $predicate = $triple->predicate->value;
81                if ($predicate === Graph::RDF_TYPE || \in_array($predicate, $tolerated, true)) {
82                    continue;
83                }
84                if (str_starts_with($predicate, Namespaces::API)) {
85                    $violations[] = new GraphViolation(\sprintf('%s is set by the server, not through a document.', $predicate), $predicate, $subjectName);
86                    continue;
87                }
88                $info = $this->vocabulary->property($predicate);
89                if ($info === null) {
90                    $violations[] = new GraphViolation(\sprintf('"%s" is not a property of the ontology.', $predicate), $predicate, $subjectName);
91                    continue;
92                }
93                if (!$this->vocabulary->accepts($types, $predicate)) {
94                    $violations[] = new GraphViolation(\sprintf('%s does not accept %s.', implode(', ', $types), $predicate), $predicate, $subjectName);
95                    continue;
96                }
97                $object = $triple->object;
98                if ($info->kind === PropertyKind::Datatype) {
99                    if (!$object instanceof Literal) {
100                        $violations[] = new GraphViolation(\sprintf('%s takes a literal value.', $predicate), $predicate, $subjectName);
101                    } elseif ($object->language !== null ? !self::acceptsText($info->ranges) : !Xsd::satisfies($object->datatype, $info->ranges)) {
102                        // A language-tagged literal is rdf:langString, text in a language: it fits a string range
103                        // and nothing else, so a tag cannot smuggle "many" into an integer property (R14-002).
104                        $violations[] = new GraphViolation(\sprintf('%s expects %s, got %s.', $predicate, implode(' or ', $info->ranges), $object->datatype), $predicate, $subjectName);
105                    } elseif (!Xsd::lexicallyValid($object)) {
106                        $violations[] = new GraphViolation(\sprintf('"%s" is not a valid %s.', $object->lexical, $object->datatype), $predicate, $subjectName);
107                    }
108                    continue;
109                }
110                if ($object instanceof Literal) {
111                    $violations[] = new GraphViolation(\sprintf('%s takes an object or reference, not a literal.', $predicate), $predicate, $subjectName);
112                    continue;
113                }
114                // The target's classes, when the graph knows them, must be the range or below it; a code list
115                // is a class here as much as a data model class (R13-001). A class the ontology does not know
116                // is reported on the node itself. A reference to a node outside the graph carries no classes
117                // and is not judged, except that a code-list member must belong to a code list the property
118                // expects, when it expects one.
119                if (!$object instanceof Iri && !$object instanceof BlankNode) {
120                    continue;
121                }
122                $targetTypes = array_values(array_filter(array_map(static fn(Iri $t): string => $t->value, $graph->typesOf($object)), $this->isKnownClass(...)));
123                if ($targetTypes !== [] && $info->ranges !== [] && !$this->withinRanges($targetTypes, $info->ranges)) {
124                    $violations[] = new GraphViolation(\sprintf('%s expects %s, got %s.', $predicate, implode(' or ', $info->ranges), implode(', ', $targetTypes)), $predicate, $subjectName);
125                } elseif ($targetTypes === [] && $object instanceof Iri && ($list = $this->vocabulary->codeListOf($object->value)) !== null && $this->expectsACodeList($info->ranges) && !\in_array($list->iri, $info->ranges, true)) {
126                    $violations[] = new GraphViolation(\sprintf('%s expects %s, got a member of %s.', $predicate, implode(' or ', $info->ranges), $list->iri), $predicate, $subjectName);
127                }
128            }
129        }
130
131        return $violations;
132    }
133
134    /**
135     * @param list<string> $ranges
136     */
137    private static function acceptsText(array $ranges): bool
138    {
139        if ($ranges === []) {
140            return true;
141        }
142        foreach ([Literal::XSD_STRING, Literal::RDF_LANG_STRING, 'http://www.w3.org/2000/01/rdf-schema#Literal', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#PlainLiteral'] as $text) {
143            if (\in_array($text, $ranges, true)) {
144                return true;
145            }
146        }
147
148        return false;
149    }
150
151    private function isKnownClass(string $iri): bool
152    {
153        return $this->vocabulary->isClass($iri) || $this->vocabulary->codeList($iri) !== null;
154    }
155
156    /**
157     * @param list<string> $ranges
158     */
159    private function expectsACodeList(array $ranges): bool
160    {
161        foreach ($ranges as $range) {
162            if ($this->vocabulary->codeList($range) !== null) {
163                return true;
164            }
165        }
166
167        return false;
168    }
169
170    /**
171     * @param list<string> $types
172     * @param list<string> $ranges
173     */
174    private function withinRanges(array $types, array $ranges): bool
175    {
176        foreach ($types as $type) {
177            foreach ($ranges as $range) {
178                if ($type === $range || $this->vocabulary->isSubclassOf($type, $range)) {
179                    return true;
180                }
181            }
182        }
183
184        return false;
185    }
186}