silverstripe-framework/src/Dev/Validation/RelationValidationService.php

636 lines
18 KiB
PHP

<?php
namespace SilverStripe\Dev\Validation;
use ReflectionException;
use SilverStripe\Core\ClassInfo;
use SilverStripe\Core\Config\Configurable;
use SilverStripe\Core\Injector\Injectable;
use SilverStripe\Core\Resettable;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DB;
/**
* Basic validation of relationship setup, this tool makes sure your relationships are set up correctly in both directions
* The validation is configurable and inspection can be narrowed down by namespace, class and relation name
*
* This tool is opt-in and runs via flush and outputs notices
* For strict validation it is recommended to hook this up to your unit test suite
*/
class RelationValidationService implements Resettable
{
use Configurable;
use Injectable;
/**
* Enable / disable validation output during flush
* This is disabled by default (opt-in)
*
* @var bool
*/
private static $output_enabled = false;
/**
* Only inspect classes with the following namespaces/class prefixes
* Empty string is a special value which represents classes without namespaces
* Set the value to null to disable the rule (useful when overriding configuration)
*
* @var array
*/
private static $allow_rules = [
'empty' => '',
'app' => 'App',
];
/**
* Any classes with the following namespaces/class prefixes will not be inspected
* This config is intended to be used together with @see $allow_rules to narrow down the inspected classes
* Empty string is a special value which represents classes without namespaces
* Set the value to null to disable the rule (useful when overriding configuration)
*
* @var array
*/
private static $deny_rules = [];
/**
* Relations listed here will not be inspected
* Format is <class>.<relation>
* for example: Page::class.'.LinkTracking'
*
* @var array
*/
private static $deny_relations = [];
/**
* Ignore any configuration, useful for debugging specific classes
*
* @var bool
*/
protected $ignoreConfig = false;
/**
* @var array
*/
protected $errors = [];
public function clearErrors(): void
{
$this->errors = [];
}
public static function reset(): void
{
$service = self::singleton();
$service->clearErrors();
$service->ignoreConfig = false;
}
/**
* Hook this into your unit tests and assert for empty array like this
*
* $messages = RelationValidationService::singleton()->validateRelations();
* $this->assertEmpty($messages, print_r($messages, true));
*
* @return array
* @throws ReflectionException
*/
public function validateRelations(): array
{
self::reset();
$classes = ClassInfo::subclassesFor(DataObject::class);
return $this->validateClasses($classes);
}
/**
* @throws ReflectionException
*/
public function executeValidation(): void
{
$errors = $this->validateRelations();
$count = count($errors);
if ($count === 0) {
return;
}
DB::alteration_message(
sprintf(
'%s : %d issues found (listed below)',
ClassInfo::shortName(static::class),
$count
),
'error'
);
foreach ($errors as $message) {
DB::alteration_message($message, 'error');
}
}
/**
* Inspect specified classes - this ignores any configuration
* Useful for checking specific classes when trying to fix relation configuration
*
* @param array $classes
* @return array
*/
public function inspectClasses(array $classes): array
{
self::reset();
$this->ignoreConfig = true;
return $this->validateClasses($classes);
}
/**
* Check if class is ignored during inspection or not
* Useful checking if your configuration works as expected
* Check goes through rules in this order (from generic to more specific):
* 1 - Allow rules
* 2 - Deny rules
* 3 - Deny relations
*
* @param string $class
* @param string|null $relation
* @return bool
*/
public function isIgnored(string $class, ?string $relation = null): bool
{
// Top level override - bail out if configuration should be ignored
if ($this->ignoreConfig) {
return false;
}
// Allow rules - if class doesn't match any allow rule we bail out
if (!$this->matchRules($class, 'allow_rules')) {
return true;
}
// Deny rules - if class matches any deny rule we bail out
if ($this->matchRules($class, 'deny_rules')) {
return true;
}
if ($relation === null) {
// Check is for the class as a whole so we don't need to check specific relation
// Class is considered NOT ignored
return false;
}
// Deny relations
$rules = (array) $this->config()->get('deny_relations');
foreach ($rules as $relationData) {
if ($relationData === null) {
// Disabled rule - bail out
continue;
}
$parsedRelation = $this->parsePlainRelation($relationData);
if ($parsedRelation === null) {
// Invalid rule - bail out
continue;
}
if ($class === $parsedRelation['class'] && $relation === $parsedRelation['relation']) {
// This class and relation combination is supposed to be ignored
return true;
}
}
// Default - Class is considered NOT ignored
return false;
}
/**
* Match class against specified rules
*
* @param string $class
* @param string $rule
* @return bool
*/
protected function matchRules(string $class, string $rule): bool
{
$rules = (array) $this->config()->get($rule);
foreach ($rules as $key => $pattern) {
if ($pattern === null) {
// Disabled rule - bail out
continue;
}
// Special case for classes without a namespace
if ($pattern === '') {
if ($class === ClassInfo::shortName($class)) {
// This is a class without namespace so we match this rule
return true;
}
continue;
}
if (mb_strpos($class, $pattern) === 0) {
// Classname prefix matches the pattern
return true;
}
}
return false;
}
/**
* Execute validation for specified classes
*
* @param array $classes
* @return array
*/
protected function validateClasses(array $classes): array
{
foreach ($classes as $class) {
if ($class === DataObject::class) {
// This is a generic class and doesn't need to be validated
continue;
}
if ($this->isIgnored($class)) {
continue;
}
$this->validateClass($class);
}
return $this->errors;
}
/**
* @param string $class
*/
protected function validateClass(string $class): void
{
if (!is_subclass_of($class, DataObject::class)) {
$this->logError($class, '', 'Inspected class is not a DataObject.');
return;
}
$this->validateHasOne($class);
$this->validateBelongsTo($class);
$this->validateHasMany($class);
$this->validateManyMany($class);
$this->validateBelongsManyMany($class);
}
/**
* @param string $class
*/
protected function validateHasOne(string $class): void
{
$singleton = DataObject::singleton($class);
$relations = (array) $singleton->config()->uninherited('has_one');
foreach ($relations as $relationName => $relationData) {
if ($this->isIgnored($class, $relationName)) {
continue;
}
if (mb_strpos($relationData, '.') !== false) {
$this->logError(
$class,
$relationName,
sprintf('Relation %s is not in the expected format (needs class only format).', $relationData)
);
return;
}
if (!is_subclass_of($relationData, DataObject::class)) {
$this->logError(
$class,
$relationName,
sprintf('Related class %s is not a DataObject.', $relationData)
);
return;
}
$relatedObject = DataObject::singleton($relationData);
// Try to find the back relation - it can be either in belongs_to or has_many
$belongsTo = (array) $relatedObject->config()->uninherited('belongs_to');
$hasMany = (array) $relatedObject->config()->uninherited('has_many');
$found = 0;
foreach ([$hasMany, $belongsTo] as $relationItem) {
foreach ($relationItem as $key => $value) {
$parsedRelation = $this->parsePlainRelation($value);
if ($parsedRelation === null) {
continue;
}
if ($class !== $parsedRelation['class']) {
continue;
}
if ($relationName !== $parsedRelation['relation']) {
continue;
}
$found += 1;
}
}
if ($found === 0) {
$this->logError(
$class,
$relationName,
'Back relation not found or ambiguous (needs class.relation format)'
);
} elseif ($found > 1) {
$this->logError($class, $relationName, 'Back relation is ambiguous');
}
}
}
/**
* @param string $class
*/
protected function validateBelongsTo(string $class): void
{
$singleton = DataObject::singleton($class);
$relations = (array) $singleton->config()->uninherited('belongs_to');
foreach ($relations as $relationName => $relationData) {
if ($this->isIgnored($class, $relationName)) {
continue;
}
$parsedRelation = $this->parsePlainRelation($relationData);
if ($parsedRelation === null) {
$this->logError(
$class,
$relationName,
'Relation is not in the expected format (needs class.relation format)'
);
continue;
}
$relatedClass = $parsedRelation['class'];
$relatedRelation = $parsedRelation['relation'];
if (!is_subclass_of($relatedClass, DataObject::class)) {
$this->logError(
$class,
$relationName,
sprintf('Related class %s is not a DataObject.', $relatedClass)
);
continue;
}
$relatedObject = DataObject::singleton($relatedClass);
$relatedRelations = (array) $relatedObject->config()->uninherited('has_one');
if (array_key_exists($relatedRelation, $relatedRelations)) {
continue;
}
$this->logError($class, $relationName, 'Back relation not found');
}
}
/**
* @param string $class
*/
protected function validateHasMany(string $class): void
{
$singleton = DataObject::singleton($class);
$relations = (array) $singleton->config()->uninherited('has_many');
foreach ($relations as $relationName => $relationData) {
if ($this->isIgnored($class, $relationName)) {
continue;
}
$parsedRelation = $this->parsePlainRelation($relationData);
if ($parsedRelation === null) {
$this->logError(
$class,
$relationName,
'Relation is not in the expected format (needs class.relation format)'
);
continue;
}
$relatedClass = $parsedRelation['class'];
$relatedRelation = $parsedRelation['relation'];
if (!is_subclass_of($relatedClass, DataObject::class)) {
$this->logError(
$class,
$relationName,
sprintf('Related class %s is not a DataObject.', $relatedClass)
);
continue;
}
$relatedObject = DataObject::singleton($relatedClass);
$relatedRelations = (array) $relatedObject->config()->uninherited('has_one');
if (array_key_exists($relatedRelation, $relatedRelations)) {
continue;
}
$this->logError(
$class,
$relationName,
'Back relation not found or ambiguous (needs class.relation format)'
);
}
}
/**
* @param string $class
*/
protected function validateManyMany(string $class): void
{
$singleton = DataObject::singleton($class);
$relations = (array) $singleton->config()->uninherited('many_many');
foreach ($relations as $relationName => $relationData) {
if ($this->isIgnored($class, $relationName)) {
continue;
}
$relatedClass = $this->parseManyManyRelation($relationData);
if (!is_subclass_of($relatedClass, DataObject::class)) {
$this->logError(
$class,
$relationName,
sprintf('Related class %s is not a DataObject.', $relatedClass)
);
continue;
}
$relatedObject = DataObject::singleton($relatedClass);
$relatedRelations = (array) $relatedObject->config()->uninherited('belongs_many_many');
$found = 0;
foreach ($relatedRelations as $key => $value) {
$parsedRelation = $this->parsePlainRelation($value);
if ($parsedRelation === null) {
continue;
}
if ($class !== $parsedRelation['class']) {
continue;
}
if ($relationName !== $parsedRelation['relation']) {
continue;
}
$found += 1;
}
if ($found === 0) {
$this->logError(
$class,
$relationName,
'Back relation not found or ambiguous (needs class.relation format)'
);
} elseif ($found > 1) {
$this->logError($class, $relationName, 'Back relation is ambiguous');
}
}
}
/**
* @param string $class
*/
protected function validateBelongsManyMany(string $class): void
{
$singleton = DataObject::singleton($class);
$relations = (array) $singleton->config()->uninherited('belongs_many_many');
foreach ($relations as $relationName => $relationData) {
if ($this->isIgnored($class, $relationName)) {
continue;
}
$parsedRelation = $this->parsePlainRelation($relationData);
if ($parsedRelation === null) {
$this->logError(
$class,
$relationName,
'Relation is not in the expected format (needs class.relation format)'
);
continue;
}
$relatedClass = $parsedRelation['class'];
$relatedRelation = $parsedRelation['relation'];
if (!is_subclass_of($relatedClass, DataObject::class)) {
$this->logError(
$class,
$relationName,
sprintf('Related class %s is not a DataObject.', $relatedClass)
);
continue;
}
$relatedObject = DataObject::singleton($relatedClass);
$relatedRelations = (array) $relatedObject->config()->uninherited('many_many');
if (array_key_exists($relatedRelation, $relatedRelations)) {
continue;
}
$this->logError($class, $relationName, 'Back relation not found');
}
}
/**
* @param string $relationData
* @return array|null
*/
protected function parsePlainRelation(string $relationData): ?array
{
if (mb_strpos($relationData, '.') === false) {
return null;
}
$segments = explode('.', $relationData);
if (count($segments) !== 2) {
return null;
}
$class = array_shift($segments);
$relation = array_shift($segments);
return [
'class' => $class,
'relation' => $relation,
];
}
/**
* @param array|string $relationData
* @return string|null
*/
protected function parseManyManyRelation($relationData): ?string
{
if (is_array($relationData)) {
foreach (['through', 'to'] as $key) {
if (!array_key_exists($key, $relationData)) {
return null;
}
}
$to = $relationData['to'];
$through = $relationData['through'];
if (!is_subclass_of($through, DataObject::class)) {
return null;
}
$throughObject = DataObject::singleton($through);
$throughRelations = (array) $throughObject->config()->uninherited('has_one');
if (!array_key_exists($to, $throughRelations)) {
return null;
}
return $throughRelations[$to];
}
return $relationData;
}
/**
* @param string $class
* @param string $relation
* @param string $message
*/
protected function logError(string $class, string $relation, string $message)
{
$classPrefix = $relation ? sprintf('%s / %s', $class, $relation) : $class;
$this->errors[] = sprintf('%s : %s', $classPrefix, $message);
}
}