From 89c87ddbf8e67c8195dd386b4eae11446489008a Mon Sep 17 00:00:00 2001 From: Mojmir Fendek Date: Fri, 4 Feb 2022 14:41:09 +1300 Subject: [PATCH] NEW: Static validation for relationships. (#9874) * NEW: Static validation for relationships. * Unit tests added. * PR fixes * PR feedback: Execute validation on flush. * PR fixes. * PR fixes. --- _config/extensions.yml | 5 +- .../00_Model/02_Relations.md | 16 +- src/Dev/Validation/DatabaseAdminExtension.php | 34 + .../Validation/RelationValidationService.php | 635 ++++++++++++++++++ src/ORM/DatabaseAdmin.php | 4 + tests/php/Dev/Validation/Freelancer.php | 36 + tests/php/Dev/Validation/Hat.php | 43 ++ tests/php/Dev/Validation/Member.php | 54 ++ .../Dev/Validation/RelationValidationTest.php | 291 ++++++++ tests/php/Dev/Validation/Team.php | 53 ++ 10 files changed, 1168 insertions(+), 3 deletions(-) create mode 100644 src/Dev/Validation/DatabaseAdminExtension.php create mode 100644 src/Dev/Validation/RelationValidationService.php create mode 100644 tests/php/Dev/Validation/Freelancer.php create mode 100644 tests/php/Dev/Validation/Hat.php create mode 100644 tests/php/Dev/Validation/Member.php create mode 100644 tests/php/Dev/Validation/RelationValidationTest.php create mode 100644 tests/php/Dev/Validation/Team.php diff --git a/_config/extensions.yml b/_config/extensions.yml index 26f8ed4f2..1d77a36dc 100644 --- a/_config/extensions.yml +++ b/_config/extensions.yml @@ -6,4 +6,7 @@ SilverStripe\Security\Member: - SilverStripe\Security\InheritedPermissionFlusher SilverStripe\Security\Group: extensions: - - SilverStripe\Security\InheritedPermissionFlusher \ No newline at end of file + - SilverStripe\Security\InheritedPermissionFlusher +SilverStripe\ORM\DatabaseAdmin: + extensions: + - SilverStripe\Dev\Validation\DatabaseAdminExtension diff --git a/docs/en/02_Developer_Guides/00_Model/02_Relations.md b/docs/en/02_Developer_Guides/00_Model/02_Relations.md index fc3ccd393..056fa66b8 100644 --- a/docs/en/02_Developer_Guides/00_Model/02_Relations.md +++ b/docs/en/02_Developer_Guides/00_Model/02_Relations.md @@ -201,7 +201,9 @@ class Company extends DataObject ``` Multiple `$has_one` relationships are okay if they aren't linking to the same object type. Otherwise, they have to be -named. +named. With that said, naming is recommended in all cases as it makes your code more resilient to change. Adding new relationships is easier when you don't need to review and update existing ones. + +You can use `RelationValidationService` for validation of relationships. This tool will point out the relationships which may need a review. If you're using the default scaffolded form fields with multiple `has_one` relationships, you will end up with a CMS field for each relation. If you don't want these you can remove them by their IDs: @@ -224,6 +226,8 @@ declaring the `$belongs_to`. Similarly with `$has_many`, dot notation can be used to explicitly specify the `$has_one` which refers to this relation. This is not mandatory unless the relationship would be otherwise ambiguous. +You can use `RelationValidationService` for validation of relationships. This tool will point out the relationships which may need a review. + ```php use SilverStripe\ORM\DataObject; @@ -251,7 +255,15 @@ how the developer wishes to manage this join table. [warning] Please specify a $belongs_many_many-relationship on the related class as well, in order -to have the necessary accessors available on both ends. +to have the necessary accessors available on both ends. You can use `RelationValidationService` for validation of relationships. This tool will point out the relationships which may need a review. + +Example configuration: + +```yaml +SilverStripe\Dev\Validation\RelationValidationService: + output_enabled: true +``` + [/warning] Much like the `has_one` relationship, `many_many` can be navigated through the `ORM` as well. diff --git a/src/Dev/Validation/DatabaseAdminExtension.php b/src/Dev/Validation/DatabaseAdminExtension.php new file mode 100644 index 000000000..e8abf24df --- /dev/null +++ b/src/Dev/Validation/DatabaseAdminExtension.php @@ -0,0 +1,34 @@ +config()->get('output_enabled')) { + return; + } + + $service->executeValidation(); + } +} diff --git a/src/Dev/Validation/RelationValidationService.php b/src/Dev/Validation/RelationValidationService.php new file mode 100644 index 000000000..2cff1926e --- /dev/null +++ b/src/Dev/Validation/RelationValidationService.php @@ -0,0 +1,635 @@ + '', + '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); + } +} diff --git a/src/ORM/DatabaseAdmin.php b/src/ORM/DatabaseAdmin.php index 7085f4bfe..0f4e60ae8 100644 --- a/src/ORM/DatabaseAdmin.php +++ b/src/ORM/DatabaseAdmin.php @@ -227,6 +227,8 @@ class DatabaseAdmin extends Controller */ public function doBuild($quiet = false, $populate = true, $testMode = false) { + $this->extend('onBeforeBuild', $quiet, $populate, $testMode); + if ($quiet) { DB::quiet(); } else { @@ -400,6 +402,8 @@ class DatabaseAdmin extends Controller } ClassInfo::reset_db_cache(); + + $this->extend('onAfterBuild', $quiet, $populate, $testMode); } /** diff --git a/tests/php/Dev/Validation/Freelancer.php b/tests/php/Dev/Validation/Freelancer.php new file mode 100644 index 000000000..7a00896ae --- /dev/null +++ b/tests/php/Dev/Validation/Freelancer.php @@ -0,0 +1,36 @@ + 'Varchar(255)', + ]; + + /** + * @var array + */ + private static $has_one = [ + 'TemporaryTeam' => Team::class, + 'TemporaryMember' => Member::class, + ]; +} diff --git a/tests/php/Dev/Validation/Hat.php b/tests/php/Dev/Validation/Hat.php new file mode 100644 index 000000000..b4cfc407d --- /dev/null +++ b/tests/php/Dev/Validation/Hat.php @@ -0,0 +1,43 @@ + 'Varchar(255)', + ]; + + /** + * @var array + */ + private static $belongs_to = [ + 'Hatter' => Member::class . '.Hat', + ]; + + /** + * @var array + */ + private static $belongs_many_many = [ + 'TeamHats' => Team::class . '.ReserveHats', + ]; +} diff --git a/tests/php/Dev/Validation/Member.php b/tests/php/Dev/Validation/Member.php new file mode 100644 index 000000000..1af4a81f7 --- /dev/null +++ b/tests/php/Dev/Validation/Member.php @@ -0,0 +1,54 @@ + 'Varchar(255)', + ]; + + /** + * @var array + */ + private static $has_one = [ + 'HomeTeam' => Team::class, + 'Hat' => Hat::class, + ]; + + /** + * @var array + */ + private static $has_many = [ + 'TemporaryMembers' => Freelancer::class . '.TemporaryMember', + ]; + + /** + * @var array + */ + private static $belongs_many_many = [ + 'FreelancerTeams' => Team::class . '.Freelancers', + ]; +} diff --git a/tests/php/Dev/Validation/RelationValidationTest.php b/tests/php/Dev/Validation/RelationValidationTest.php new file mode 100644 index 000000000..b609b68f0 --- /dev/null +++ b/tests/php/Dev/Validation/RelationValidationTest.php @@ -0,0 +1,291 @@ +set($class, $field, $value); + } + + $data = RelationValidationService::singleton()->inspectClasses([ + Team::class, + Member::class, + Hat::class, + Freelancer::class, + ]); + + $this->assertSame($expected, $data); + } + + /** + * @param string $class + * @param string|null $relation + * @param array $config + * @param bool $expected + * @dataProvider ignoredClassesProvider + */ + public function testIgnoredClass(string $class, ?string $relation, array $config, bool $expected): void + { + $service = RelationValidationService::singleton(); + + foreach ($config as $name => $value) { + $service->config()->set($name, $value); + } + + $result = $service->isIgnored($class, $relation); + + $this->assertEquals($expected, $result); + } + + public function validateCasesProvider(): array + { + return [ + 'correct setup' => [ + null, + null, + [], + [], + ], + 'ambiguous has_one - no relation name' => [ + Hat::class, + 'belongs_to', + [ + 'Hatter' => Member::class, + ], + [ + 'SilverStripe\Dev\Tests\Validation\Member / Hat : Back relation not found or ambiguous (needs class.relation format)', + 'SilverStripe\Dev\Tests\Validation\Hat / Hatter : Relation is not in the expected format (needs class.relation format)', + ], + ], + 'ambiguous has_one - incorrect relation name' => [ + Hat::class, + 'belongs_to', + [ + 'Hatter' => Member::class . '.ObviouslyWrong', + ], + [ + 'SilverStripe\Dev\Tests\Validation\Member / Hat : Back relation not found or ambiguous (needs class.relation format)', + 'SilverStripe\Dev\Tests\Validation\Hat / Hatter : Back relation not found', + ], + ], + 'ambiguous has_one - too many relations' => [ + Hat::class, + 'belongs_to', + [ + 'Hatter' => Member::class . '.Hat', + 'HatterCopy' => Member::class . '.Hat', + ], + [ + 'SilverStripe\Dev\Tests\Validation\Member / Hat : Back relation is ambiguous', + ], + ], + 'invalid has one' => [ + Member::class, + 'has_one', + [ + 'HomeTeam' => Team::class . '.UnnecessaryRelation', + 'Hat' => Hat::class, + ], + [ + 'SilverStripe\Dev\Tests\Validation\Member / HomeTeam : Relation SilverStripe\Dev\Tests\Validation\Team.UnnecessaryRelation is not in the expected format (needs class only format).' + ], + ], + 'ambiguous has_many - no relation name' => [ + Team::class, + 'has_many', + [ + 'Members' => Member::class, + 'FreelancerMembers' => Freelancer::class . '.TemporaryTeam', + ], + [ + 'SilverStripe\Dev\Tests\Validation\Team / Members : Relation is not in the expected format (needs class.relation format)', + 'SilverStripe\Dev\Tests\Validation\Member / HomeTeam : Back relation not found or ambiguous (needs class.relation format)', + ], + ], + 'ambiguous has_many - incorrect relation name' => [ + Team::class, + 'has_many', + [ + 'Members' => Member::class . '.ObviouslyWrong', + 'FreelancerMembers' => Freelancer::class . '.TemporaryTeam', + ], + [ + 'SilverStripe\Dev\Tests\Validation\Team / Members : Back relation not found or ambiguous (needs class.relation format)', + 'SilverStripe\Dev\Tests\Validation\Member / HomeTeam : Back relation not found or ambiguous (needs class.relation format)', + ], + ], + 'ambiguous has_many - too many relations' => [ + Team::class, + 'has_many', + [ + 'Members' => Member::class . '.HomeTeam', + 'MembersCopy' => Member::class . '.HomeTeam', + 'FreelancerMembers' => Freelancer::class . '.TemporaryTeam', + ], + [ + 'SilverStripe\Dev\Tests\Validation\Member / HomeTeam : Back relation is ambiguous', + ], + ], + 'ambiguous many_many - no relation name' => [ + Hat::class, + 'belongs_many_many', + [ + 'TeamHats' => Team::class, + ], + [ + 'SilverStripe\Dev\Tests\Validation\Team / ReserveHats : Back relation not found or ambiguous (needs class.relation format)', + 'SilverStripe\Dev\Tests\Validation\Hat / TeamHats : Relation is not in the expected format (needs class.relation format)', + ], + ], + 'ambiguous many_many - incorrect relation name' => [ + Hat::class, + 'belongs_many_many', + [ + 'TeamHats' => Team::class . '.ObviouslyWrong', + ], + [ + 'SilverStripe\Dev\Tests\Validation\Team / ReserveHats : Back relation not found or ambiguous (needs class.relation format)', + 'SilverStripe\Dev\Tests\Validation\Hat / TeamHats : Back relation not found', + ], + ], + 'ambiguous many_many - too many relations' => [ + Hat::class, + 'belongs_many_many', + [ + 'TeamHats' => Team::class . '.ReserveHats', + 'TeamHatsCopy' => Team::class . '.ReserveHats', + ], + [ + 'SilverStripe\Dev\Tests\Validation\Team / ReserveHats : Back relation is ambiguous', + ], + ], + 'ambiguous many_many through - no relation name' => [ + Member::class, + 'belongs_many_many', + [ + 'FreelancerTeams' => Team::class, + ], + [ + 'SilverStripe\Dev\Tests\Validation\Team / Freelancers : Back relation not found or ambiguous (needs class.relation format)', + 'SilverStripe\Dev\Tests\Validation\Member / FreelancerTeams : Relation is not in the expected format (needs class.relation format)', + ], + ], + 'ambiguous many_many through - incorrect relation name' => [ + Member::class, + 'belongs_many_many', + [ + 'FreelancerTeams' => Team::class . '.ObviouslyWrong', + ], + [ + 'SilverStripe\Dev\Tests\Validation\Team / Freelancers : Back relation not found or ambiguous (needs class.relation format)', + 'SilverStripe\Dev\Tests\Validation\Member / FreelancerTeams : Back relation not found', + ], + ], + 'ambiguous many_many through - too many relations' => [ + Member::class, + 'belongs_many_many', + [ + 'FreelancerTeams' => Team::class . '.Freelancers', + 'FreelancerTeamsCopy' => Team::class . '.Freelancers', + ], + [ + 'SilverStripe\Dev\Tests\Validation\Team / Freelancers : Back relation is ambiguous', + ], + ], + ]; + } + + public function ignoredClassesProvider(): array + { + return [ + 'class default' => [ + Team::class, + null, + [], + true, + ], + 'class relation default' => [ + Team::class, + 'Members', + [], + true, + ], + 'page should by included by default (empty namespace)' => [ + Page::class, + null, + [], + false, + ], + 'class relation via allow rules' => [ + Team::class, + 'Members', + [ + 'allow_rules' => ['app' => 'SilverStripe\Dev\Tests\Validation'], + ], + false, + ], + 'class included via allow rules but overwritten by deny rules' => [ + Team::class, + null, + [ + 'allow_rules' => ['app' => 'SilverStripe\Dev\Tests\Validation'], + 'deny_rules' => [Team::class], + ], + true, + ], + 'class relation included via allow rules but overwritten by deny rules' => [ + Team::class, + 'Members', + [ + 'allow_rules' => ['app' => 'SilverStripe\Dev\Tests\Validation'], + 'deny_rules' => [Team::class], + ], + true, + ], + 'class relation included via allow rules but overwritten by deny relations' => [ + Team::class, + 'Members', + [ + 'allow_rules' => ['app' => 'SilverStripe\Dev\Tests\Validation'], + 'deny_relations' => [Team::class . '.Members'], + ], + true, + ], + 'class relation included via allow rules and not overwritten by deny relations of other class' => [ + Member::class, + 'HomeTeam', + [ + 'allow_rules' => ['app' => 'SilverStripe\Dev\Tests\Validation'], + 'deny_rules' => [Team::class], + 'deny_relations' => [Team::class . '.Members'], + ], + false, + ], + ]; + } +} diff --git a/tests/php/Dev/Validation/Team.php b/tests/php/Dev/Validation/Team.php new file mode 100644 index 000000000..701423380 --- /dev/null +++ b/tests/php/Dev/Validation/Team.php @@ -0,0 +1,53 @@ + 'Varchar(255)', + ]; + + /** + * @var array + */ + private static $has_many = [ + 'Members' => Member::class . '.HomeTeam', + 'FreelancerMembers' => Freelancer::class . '.TemporaryTeam', + ]; + + /** + * @var array + */ + private static $many_many = [ + 'ReserveHats' => Hat::class, + 'Freelancers' => [ + 'through' => Freelancer::class, + 'from' => 'TemporaryTeam', + 'to' => 'TemporaryMember', + ], + ]; +}