'', '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 . * 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); } }