mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-10-22 14:05:37 +02:00
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.
This commit is contained in:
parent
8f1c68db42
commit
89c87ddbf8
@ -6,4 +6,7 @@ SilverStripe\Security\Member:
|
||||
- SilverStripe\Security\InheritedPermissionFlusher
|
||||
SilverStripe\Security\Group:
|
||||
extensions:
|
||||
- SilverStripe\Security\InheritedPermissionFlusher
|
||||
- SilverStripe\Security\InheritedPermissionFlusher
|
||||
SilverStripe\ORM\DatabaseAdmin:
|
||||
extensions:
|
||||
- SilverStripe\Dev\Validation\DatabaseAdminExtension
|
||||
|
@ -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.
|
||||
|
34
src/Dev/Validation/DatabaseAdminExtension.php
Normal file
34
src/Dev/Validation/DatabaseAdminExtension.php
Normal file
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace SilverStripe\Dev\Validation;
|
||||
|
||||
use ReflectionException;
|
||||
use SilverStripe\Core\Extension;
|
||||
use SilverStripe\ORM\DatabaseAdmin;
|
||||
|
||||
/**
|
||||
* Hook up static validation to the deb/build process
|
||||
*
|
||||
* @method DatabaseAdmin getOwner()
|
||||
*/
|
||||
class DatabaseAdminExtension extends Extension
|
||||
{
|
||||
/**
|
||||
* Extension point in @see DatabaseAdmin::doBuild()
|
||||
*
|
||||
* @param bool $quiet
|
||||
* @param bool $populate
|
||||
* @param bool $testMode
|
||||
* @throws ReflectionException
|
||||
*/
|
||||
public function onAfterBuild(bool $quiet, bool $populate, bool $testMode): void
|
||||
{
|
||||
$service = RelationValidationService::singleton();
|
||||
|
||||
if (!$service->config()->get('output_enabled')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$service->executeValidation();
|
||||
}
|
||||
}
|
635
src/Dev/Validation/RelationValidationService.php
Normal file
635
src/Dev/Validation/RelationValidationService.php
Normal file
@ -0,0 +1,635 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
36
tests/php/Dev/Validation/Freelancer.php
Normal file
36
tests/php/Dev/Validation/Freelancer.php
Normal file
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace SilverStripe\Dev\Tests\Validation;
|
||||
|
||||
use SilverStripe\Dev\TestOnly;
|
||||
use SilverStripe\ORM\DataObject;
|
||||
|
||||
/**
|
||||
* Class Freelancer
|
||||
*
|
||||
* @property string $Title
|
||||
* @method Team TemporaryTeam()
|
||||
* @method Member TemporaryMember()
|
||||
*/
|
||||
class Freelancer extends DataObject implements TestOnly
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private static $table_name = 'RelationValidationTest_Freelancer';
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
private static $db = [
|
||||
'Title' => 'Varchar(255)',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
private static $has_one = [
|
||||
'TemporaryTeam' => Team::class,
|
||||
'TemporaryMember' => Member::class,
|
||||
];
|
||||
}
|
43
tests/php/Dev/Validation/Hat.php
Normal file
43
tests/php/Dev/Validation/Hat.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace SilverStripe\Dev\Tests\Validation;
|
||||
|
||||
use SilverStripe\Dev\TestOnly;
|
||||
use SilverStripe\ORM\DataObject;
|
||||
use SilverStripe\ORM\ManyManyList;
|
||||
|
||||
/**
|
||||
* Class Hat
|
||||
*
|
||||
* @property string $Title
|
||||
* @method Member Hatter()
|
||||
* @method ManyManyList|Team[] TeamHats()
|
||||
*/
|
||||
class Hat extends DataObject implements TestOnly
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private static $table_name = 'RelationValidationTest_Hat';
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
private static $db = [
|
||||
'Title' => 'Varchar(255)',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
private static $belongs_to = [
|
||||
'Hatter' => Member::class . '.Hat',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
private static $belongs_many_many = [
|
||||
'TeamHats' => Team::class . '.ReserveHats',
|
||||
];
|
||||
}
|
54
tests/php/Dev/Validation/Member.php
Normal file
54
tests/php/Dev/Validation/Member.php
Normal file
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace SilverStripe\Dev\Tests\Validation;
|
||||
|
||||
use SilverStripe\Dev\TestOnly;
|
||||
use SilverStripe\ORM\DataObject;
|
||||
use SilverStripe\ORM\HasManyList;
|
||||
use SilverStripe\ORM\ManyManyThroughList;
|
||||
|
||||
/**
|
||||
* Class Member
|
||||
*
|
||||
* @property string $Title
|
||||
* @method Team HomeTeam()
|
||||
* @method Hat Hat()
|
||||
* @method HasManyList|Freelancer[] TemporaryMembers()
|
||||
* @method ManyManyThroughList|Member[] FreelancerTeams()
|
||||
*/
|
||||
class Member extends DataObject implements TestOnly
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private static $table_name = 'RelationValidationTest_Member';
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
private static $db = [
|
||||
'Title' => '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',
|
||||
];
|
||||
}
|
291
tests/php/Dev/Validation/RelationValidationTest.php
Normal file
291
tests/php/Dev/Validation/RelationValidationTest.php
Normal file
@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
namespace SilverStripe\Dev\Tests\Validation;
|
||||
|
||||
use Page;
|
||||
use SilverStripe\Core\Config\Config;
|
||||
use SilverStripe\Dev\SapphireTest;
|
||||
use SilverStripe\Dev\Validation\RelationValidationService;
|
||||
|
||||
class RelationValidationTest extends SapphireTest
|
||||
{
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected static $extra_dataobjects = [
|
||||
Team::class,
|
||||
Member::class,
|
||||
Hat::class,
|
||||
Freelancer::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* @param string|null $class
|
||||
* @param string|null $field
|
||||
* @param array $value
|
||||
* @param array $expected
|
||||
* @dataProvider validateCasesProvider
|
||||
*/
|
||||
public function testValidation(?string $class, ?string $field, array $value, array $expected): void
|
||||
{
|
||||
if ($class && $field) {
|
||||
Config::modify()->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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
53
tests/php/Dev/Validation/Team.php
Normal file
53
tests/php/Dev/Validation/Team.php
Normal file
@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace SilverStripe\Dev\Tests\Validation;
|
||||
|
||||
use SilverStripe\Dev\TestOnly;
|
||||
use SilverStripe\ORM\DataObject;
|
||||
use SilverStripe\ORM\HasManyList;
|
||||
use SilverStripe\ORM\ManyManyList;
|
||||
use SilverStripe\ORM\ManyManyThroughList;
|
||||
|
||||
/**
|
||||
* Class Team
|
||||
*
|
||||
* @property string $Title
|
||||
* @method HasManyList|Member[] Members()
|
||||
* @method HasManyList|Freelancer[] FreelancerMembers()
|
||||
* @method ManyManyThroughList|Member[] Freelancers()
|
||||
* @method ManyManyList|Hat[] ReserveHats()
|
||||
*/
|
||||
class Team extends DataObject implements TestOnly
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private static $table_name = 'RelationValidationTest_Team';
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
private static $db = [
|
||||
'Title' => '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',
|
||||
],
|
||||
];
|
||||
}
|
Loading…
Reference in New Issue
Block a user