|
4 | 4 |
|
5 | 5 | namespace ProfessionalWiki\NeoWiki\Application\Validation; |
6 | 6 |
|
| 7 | +use ProfessionalWiki\NeoWiki\Application\SubjectLookup; |
7 | 8 | use ProfessionalWiki\NeoWiki\Domain\PropertyType\PropertyTypeLookup; |
| 9 | +use ProfessionalWiki\NeoWiki\Domain\Relation\Relation; |
| 10 | +use ProfessionalWiki\NeoWiki\Domain\Schema\Property\RelationProperty; |
8 | 11 | use ProfessionalWiki\NeoWiki\Domain\Schema\PropertyDefinition; |
9 | 12 | use ProfessionalWiki\NeoWiki\Domain\Schema\PropertyName; |
10 | 13 | use ProfessionalWiki\NeoWiki\Domain\Schema\Schema; |
| 14 | +use ProfessionalWiki\NeoWiki\Domain\Schema\SchemaName; |
11 | 15 | use ProfessionalWiki\NeoWiki\Domain\Statement; |
12 | 16 | use ProfessionalWiki\NeoWiki\Domain\Subject\StatementList; |
13 | 17 | use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectLabel; |
14 | 18 | use ProfessionalWiki\NeoWiki\Domain\Validation\Violation; |
| 19 | +use ProfessionalWiki\NeoWiki\Domain\Value\RelationValue; |
15 | 20 |
|
16 | 21 | readonly class SubjectValidator { |
17 | 22 |
|
18 | 23 | public function __construct( |
19 | 24 | private PropertyTypeLookup $propertyTypeLookup, |
| 25 | + private SubjectLookup $subjectLookup, |
20 | 26 | ) { |
21 | 27 | } |
22 | 28 |
|
@@ -75,9 +81,88 @@ private function validateStatement( Statement $statement, PropertyDefinition $de |
75 | 81 | $violations[] = $rawViolation->withPropertyName( $propertyName ); |
76 | 82 | } |
77 | 83 |
|
| 84 | + return array_merge( $violations, $this->validateRelationTargets( $statement, $definition ) ); |
| 85 | + } |
| 86 | + |
| 87 | + /** |
| 88 | + * Server-side relation-target checks. Schema-scoped by necessity: both need the writer's-schema |
| 89 | + * RelationProperty (for its declared targetSchema) and a Subject lookup, neither of which |
| 90 | + * PropertyType::validate() has access to. This runs after the per-type dispatch, so the |
| 91 | + * Statement's type still matches the Schema's relation property here; a type-mismatched |
| 92 | + * Statement returned earlier and is not treated as a relation. |
| 93 | + * |
| 94 | + * A missing target is a non-blocking `relation-target-not-found` warning (red-link philosophy: |
| 95 | + * the target may be minted later, e.g. during import); a resolvable target whose own Schema is |
| 96 | + * not the declared targetSchema is a blocking `relation-target-schema-mismatch` error. |
| 97 | + * |
| 98 | + * The Schema compared is the target's own writer's-schema, read from its revision slot rather |
| 99 | + * than from a graph node property. Reaching that slot still resolves the target id through the |
| 100 | + * subject -> page index, which lives only in the graph projection (see |
| 101 | + * {@see \ProfessionalWiki\NeoWiki\NeoWikiExtension::getPageIdentifiersLookup()}), so an |
| 102 | + * unrebuilt or stale graph reports an existing target as not found. That is the same |
| 103 | + * degradation the read path has, and the reason not-found is non-blocking. |
| 104 | + * |
| 105 | + * @return Violation[] |
| 106 | + */ |
| 107 | + private function validateRelationTargets( Statement $statement, PropertyDefinition $definition ): array { |
| 108 | + $value = $statement->getValue(); |
| 109 | + |
| 110 | + if ( !$definition instanceof RelationProperty || !$value instanceof RelationValue ) { |
| 111 | + return []; |
| 112 | + } |
| 113 | + |
| 114 | + $violations = []; |
| 115 | + |
| 116 | + foreach ( $value->relations as $index => $relation ) { |
| 117 | + $violation = $this->validateRelationTarget( |
| 118 | + $relation, |
| 119 | + $statement->getPropertyName(), |
| 120 | + $definition->getTargetSchema(), |
| 121 | + (int)$index |
| 122 | + ); |
| 123 | + |
| 124 | + if ( $violation !== null ) { |
| 125 | + $violations[] = $violation; |
| 126 | + } |
| 127 | + } |
| 128 | + |
78 | 129 | return $violations; |
79 | 130 | } |
80 | 131 |
|
| 132 | + /** |
| 133 | + * The target's position in the multi-value RelationValue is carried as valuePartIndex so |
| 134 | + * ViolationDiff can tell a newly-added bad target apart from a pre-existing same-code |
| 135 | + * violation on the same property, exactly as SelectType/UrlType do for their parts. |
| 136 | + */ |
| 137 | + private function validateRelationTarget( |
| 138 | + Relation $relation, |
| 139 | + PropertyName $propertyName, |
| 140 | + SchemaName $targetSchema, |
| 141 | + int $valuePartIndex |
| 142 | + ): ?Violation { |
| 143 | + $target = $this->subjectLookup->getSubject( $relation->targetId ); |
| 144 | + |
| 145 | + if ( $target === null ) { |
| 146 | + return new Violation( |
| 147 | + propertyName: $propertyName, |
| 148 | + code: 'relation-target-not-found', |
| 149 | + args: [ $relation->targetId->text ], |
| 150 | + valuePartIndex: $valuePartIndex, |
| 151 | + ); |
| 152 | + } |
| 153 | + |
| 154 | + if ( $target->getSchemaName()->getText() !== $targetSchema->getText() ) { |
| 155 | + return new Violation( |
| 156 | + propertyName: $propertyName, |
| 157 | + code: 'relation-target-schema-mismatch', |
| 158 | + args: [ $targetSchema->getText(), $target->getSchemaName()->getText() ], |
| 159 | + valuePartIndex: $valuePartIndex, |
| 160 | + ); |
| 161 | + } |
| 162 | + |
| 163 | + return null; |
| 164 | + } |
| 165 | + |
81 | 166 | /** |
82 | 167 | * Catch required-but-missing: the Schema declares the property as required, but no |
83 | 168 | * Statement for it is present on the Subject. This also covers the "empty Value |
|
0 commit comments