Passed
Push — 4 ( 8f1c68...89c87d )
by Steve
09:03
created

RelationValidationService::isIgnored()   B

Complexity

Conditions 10
Paths 9

Size

Total Lines 47
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 10
eloc 18
c 1
b 0
f 0
nc 9
nop 2
dl 0
loc 47
rs 7.6666

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace SilverStripe\Dev\Validation;
4
5
use ReflectionException;
6
use SilverStripe\Core\ClassInfo;
7
use SilverStripe\Core\Config\Configurable;
8
use SilverStripe\Core\Injector\Injectable;
9
use SilverStripe\Core\Resettable;
10
use SilverStripe\ORM\DataObject;
11
use SilverStripe\ORM\DB;
12
13
/**
14
 * Basic validation of relationship setup, this tool makes sure your relationships are set up correctly in both directions
15
 * The validation is configurable and inspection can be narrowed down by namespace, class and relation name
16
 *
17
 * This tool is opt-in and runs via flush and outputs notices
18
 * For strict validation it is recommended to hook this up to your unit test suite
19
 */
20
class RelationValidationService implements Resettable
21
{
22
    use Configurable;
23
    use Injectable;
24
25
    /**
26
     * Enable / disable validation output during flush
27
     * This is disabled by default (opt-in)
28
     *
29
     * @var bool
30
     */
31
    private static $output_enabled = false;
0 ignored issues
show
introduced by
The private property $output_enabled is not used, and could be removed.
Loading history...
32
33
    /**
34
     * Only inspect classes with the following namespaces/class prefixes
35
     * Empty string is a special value which represents classes without namespaces
36
     * Set the value to null to disable the rule (useful when overriding configuration)
37
     *
38
     * @var array
39
     */
40
    private static $allow_rules = [
0 ignored issues
show
introduced by
The private property $allow_rules is not used, and could be removed.
Loading history...
41
        'empty' => '',
42
        'app' => 'App',
43
    ];
44
45
    /**
46
     * Any classes with the following namespaces/class prefixes will not be inspected
47
     * This config is intended to be used together with @see $allow_rules to narrow down the inspected classes
48
     * Empty string is a special value which represents classes without namespaces
49
     * Set the value to null to disable the rule (useful when overriding configuration)
50
     *
51
     * @var array
52
     */
53
    private static $deny_rules = [];
0 ignored issues
show
introduced by
The private property $deny_rules is not used, and could be removed.
Loading history...
54
55
    /**
56
     * Relations listed here will not be inspected
57
     * Format is <class>.<relation>
58
     * for example: Page::class.'.LinkTracking'
59
     *
60
     * @var array
61
     */
62
    private static $deny_relations = [];
0 ignored issues
show
introduced by
The private property $deny_relations is not used, and could be removed.
Loading history...
63
64
    /**
65
     * Ignore any configuration, useful for debugging specific classes
66
     *
67
     * @var bool
68
     */
69
    protected $ignoreConfig = false;
70
71
    /**
72
     * @var array
73
     */
74
    protected $errors = [];
75
76
    public function clearErrors(): void
77
    {
78
        $this->errors = [];
79
    }
80
81
    public static function reset(): void
82
    {
83
        $service = self::singleton();
84
        $service->clearErrors();
85
        $service->ignoreConfig = false;
86
    }
87
88
    /**
89
     * Hook this into your unit tests and assert for empty array like this
90
     *
91
     * $messages = RelationValidationService::singleton()->validateRelations();
92
     * $this->assertEmpty($messages, print_r($messages, true));
93
     *
94
     * @return array
95
     * @throws ReflectionException
96
     */
97
    public function validateRelations(): array
98
    {
99
        self::reset();
100
        $classes = ClassInfo::subclassesFor(DataObject::class);
101
102
        return $this->validateClasses($classes);
103
    }
104
105
    /**
106
     * @throws ReflectionException
107
     */
108
    public function executeValidation(): void
109
    {
110
        $errors = $this->validateRelations();
111
        $count = count($errors);
112
113
        if ($count === 0) {
114
            return;
115
        }
116
117
        DB::alteration_message(
118
            sprintf(
119
                '%s : %d issues found (listed below)',
120
                ClassInfo::shortName(static::class),
121
                $count
122
            ),
123
            'error'
124
        );
125
126
        foreach ($errors as $message) {
127
            DB::alteration_message($message, 'error');
128
        }
129
    }
130
131
    /**
132
     * Inspect specified classes - this ignores any configuration
133
     * Useful for checking specific classes when trying to fix relation configuration
134
     *
135
     * @param array $classes
136
     * @return array
137
     */
138
    public function inspectClasses(array $classes): array
139
    {
140
        self::reset();
141
        $this->ignoreConfig = true;
142
143
        return $this->validateClasses($classes);
144
    }
145
146
    /**
147
     * Check if class is ignored during inspection or not
148
     * Useful checking if your configuration works as expected
149
     * Check goes through rules in this order (from generic to more specific):
150
     * 1 - Allow rules
151
     * 2 - Deny rules
152
     * 3 - Deny relations
153
     *
154
     * @param string $class
155
     * @param string|null $relation
156
     * @return bool
157
     */
158
    public function isIgnored(string $class, ?string $relation = null): bool
159
    {
160
        // Top level override - bail out if configuration should be ignored
161
        if ($this->ignoreConfig) {
162
            return false;
163
        }
164
165
        // Allow rules - if class doesn't match any allow rule we bail out
166
        if (!$this->matchRules($class, 'allow_rules')) {
167
            return true;
168
        }
169
170
        // Deny rules - if class matches any deny rule we bail out
171
        if ($this->matchRules($class, 'deny_rules')) {
172
            return true;
173
        }
174
175
        if ($relation === null) {
176
            // Check is for the class as a whole so we don't need to check specific relation
177
            // Class is considered NOT ignored
178
            return false;
179
        }
180
181
        // Deny relations
182
        $rules = (array) $this->config()->get('deny_relations');
183
184
        foreach ($rules as $relationData) {
185
            if ($relationData === null) {
186
                // Disabled rule - bail out
187
                continue;
188
            }
189
190
            $parsedRelation = $this->parsePlainRelation($relationData);
191
192
            if ($parsedRelation === null) {
193
                // Invalid rule - bail out
194
                continue;
195
            }
196
197
            if ($class === $parsedRelation['class'] && $relation === $parsedRelation['relation']) {
198
                // This class and relation combination is supposed to be ignored
199
                return true;
200
            }
201
        }
202
203
        // Default - Class is considered NOT ignored
204
        return false;
205
    }
206
207
    /**
208
     * Match class against specified rules
209
     *
210
     * @param string $class
211
     * @param string $rule
212
     * @return bool
213
     */
214
    protected function matchRules(string $class, string $rule): bool
215
    {
216
        $rules = (array) $this->config()->get($rule);
217
218
        foreach ($rules as $key => $pattern) {
219
            if ($pattern === null) {
220
                // Disabled rule - bail out
221
                continue;
222
            }
223
224
            // Special case for classes without a namespace
225
            if ($pattern === '') {
226
                if ($class === ClassInfo::shortName($class)) {
227
                    // This is a class without namespace so we match this rule
228
                    return true;
229
                }
230
231
                continue;
232
            }
233
234
            if (mb_strpos($class, $pattern) === 0) {
235
                // Classname prefix matches the pattern
236
                return true;
237
            }
238
        }
239
240
        return false;
241
    }
242
243
    /**
244
     * Execute validation for specified classes
245
     *
246
     * @param array $classes
247
     * @return array
248
     */
249
    protected function validateClasses(array $classes): array
250
    {
251
        foreach ($classes as $class) {
252
            if ($class === DataObject::class) {
253
                // This is a generic class and doesn't need to be validated
254
                continue;
255
            }
256
257
            if ($this->isIgnored($class)) {
258
                continue;
259
            }
260
261
            $this->validateClass($class);
262
        }
263
264
        return $this->errors;
265
    }
266
267
    /**
268
     * @param string $class
269
     */
270
    protected function validateClass(string $class): void
271
    {
272
        if (!is_subclass_of($class, DataObject::class)) {
273
            $this->logError($class, '', 'Inspected class is not a DataObject.');
274
275
            return;
276
        }
277
278
        $this->validateHasOne($class);
279
        $this->validateBelongsTo($class);
280
        $this->validateHasMany($class);
281
        $this->validateManyMany($class);
282
        $this->validateBelongsManyMany($class);
283
    }
284
285
    /**
286
     * @param string $class
287
     */
288
    protected function validateHasOne(string $class): void
289
    {
290
        $singleton = DataObject::singleton($class);
291
        $relations = (array) $singleton->config()->uninherited('has_one');
292
293
        foreach ($relations as $relationName => $relationData) {
294
            if ($this->isIgnored($class, $relationName)) {
295
                continue;
296
            }
297
298
            if (mb_strpos($relationData, '.') !== false) {
299
                $this->logError(
300
                    $class,
301
                    $relationName,
302
                    sprintf('Relation %s is not in the expected format (needs class only format).', $relationData)
303
                );
304
305
                return;
306
            }
307
308
            if (!is_subclass_of($relationData, DataObject::class)) {
309
                $this->logError(
310
                    $class,
311
                    $relationName,
312
                    sprintf('Related class %s is not a DataObject.', $relationData)
313
                );
314
315
                return;
316
            }
317
318
            $relatedObject = DataObject::singleton($relationData);
319
320
            // Try to find the back relation - it can be either in belongs_to or has_many
321
            $belongsTo = (array) $relatedObject->config()->uninherited('belongs_to');
322
            $hasMany = (array) $relatedObject->config()->uninherited('has_many');
323
            $found = 0;
324
325
            foreach ([$hasMany, $belongsTo] as $relationItem) {
326
                foreach ($relationItem as $key => $value) {
327
                    $parsedRelation = $this->parsePlainRelation($value);
328
329
                    if ($parsedRelation === null) {
330
                        continue;
331
                    }
332
333
                    if ($class !== $parsedRelation['class']) {
334
                        continue;
335
                    }
336
337
                    if ($relationName !== $parsedRelation['relation']) {
338
                        continue;
339
                    }
340
341
                    $found += 1;
342
                }
343
            }
344
345
            if ($found === 0) {
346
                $this->logError(
347
                    $class,
348
                    $relationName,
349
                    'Back relation not found or ambiguous (needs class.relation format)'
350
                );
351
            } elseif ($found > 1) {
352
                $this->logError($class, $relationName, 'Back relation is ambiguous');
353
            }
354
        }
355
    }
356
357
    /**
358
     * @param string $class
359
     */
360
    protected function validateBelongsTo(string $class): void
361
    {
362
        $singleton = DataObject::singleton($class);
363
        $relations = (array) $singleton->config()->uninherited('belongs_to');
364
365
        foreach ($relations as $relationName => $relationData) {
366
            if ($this->isIgnored($class, $relationName)) {
367
                continue;
368
            }
369
370
            $parsedRelation = $this->parsePlainRelation($relationData);
371
372
            if ($parsedRelation === null) {
373
                $this->logError(
374
                    $class,
375
                    $relationName,
376
                    'Relation is not in the expected format (needs class.relation format)'
377
                );
378
379
                continue;
380
            }
381
382
            $relatedClass = $parsedRelation['class'];
383
            $relatedRelation = $parsedRelation['relation'];
384
385
            if (!is_subclass_of($relatedClass, DataObject::class)) {
386
                $this->logError(
387
                    $class,
388
                    $relationName,
389
                    sprintf('Related class %s is not a DataObject.', $relatedClass)
390
                );
391
392
                continue;
393
            }
394
395
            $relatedObject = DataObject::singleton($relatedClass);
396
            $relatedRelations = (array) $relatedObject->config()->uninherited('has_one');
397
398
            if (array_key_exists($relatedRelation, $relatedRelations)) {
399
                continue;
400
            }
401
402
            $this->logError($class, $relationName, 'Back relation not found');
403
        }
404
    }
405
406
    /**
407
     * @param string $class
408
     */
409
    protected function validateHasMany(string $class): void
410
    {
411
        $singleton = DataObject::singleton($class);
412
        $relations = (array) $singleton->config()->uninherited('has_many');
413
414
        foreach ($relations as $relationName => $relationData) {
415
            if ($this->isIgnored($class, $relationName)) {
416
                continue;
417
            }
418
419
            $parsedRelation = $this->parsePlainRelation($relationData);
420
421
            if ($parsedRelation === null) {
422
                $this->logError(
423
                    $class,
424
                    $relationName,
425
                    'Relation is not in the expected format (needs class.relation format)'
426
                );
427
428
                continue;
429
            }
430
431
            $relatedClass = $parsedRelation['class'];
432
            $relatedRelation = $parsedRelation['relation'];
433
434
            if (!is_subclass_of($relatedClass, DataObject::class)) {
435
                $this->logError(
436
                    $class,
437
                    $relationName,
438
                    sprintf('Related class %s is not a DataObject.', $relatedClass)
439
                );
440
441
                continue;
442
            }
443
444
            $relatedObject = DataObject::singleton($relatedClass);
445
            $relatedRelations = (array) $relatedObject->config()->uninherited('has_one');
446
447
            if (array_key_exists($relatedRelation, $relatedRelations)) {
448
                continue;
449
            }
450
451
            $this->logError(
452
                $class,
453
                $relationName,
454
                'Back relation not found or ambiguous (needs class.relation format)'
455
            );
456
        }
457
    }
458
459
    /**
460
     * @param string $class
461
     */
462
    protected function validateManyMany(string $class): void
463
    {
464
        $singleton = DataObject::singleton($class);
465
        $relations = (array) $singleton->config()->uninherited('many_many');
466
467
        foreach ($relations as $relationName => $relationData) {
468
            if ($this->isIgnored($class, $relationName)) {
469
                continue;
470
            }
471
472
            $relatedClass = $this->parseManyManyRelation($relationData);
473
474
            if (!is_subclass_of($relatedClass, DataObject::class)) {
475
                $this->logError(
476
                    $class,
477
                    $relationName,
478
                    sprintf('Related class %s is not a DataObject.', $relatedClass)
479
                );
480
481
                continue;
482
            }
483
484
            $relatedObject = DataObject::singleton($relatedClass);
485
            $relatedRelations = (array) $relatedObject->config()->uninherited('belongs_many_many');
486
            $found = 0;
487
488
            foreach ($relatedRelations as $key => $value) {
489
                $parsedRelation = $this->parsePlainRelation($value);
490
491
                if ($parsedRelation === null) {
492
                    continue;
493
                }
494
495
                if ($class !== $parsedRelation['class']) {
496
                    continue;
497
                }
498
499
                if ($relationName !== $parsedRelation['relation']) {
500
                    continue;
501
                }
502
503
                $found += 1;
504
            }
505
506
            if ($found === 0) {
507
                $this->logError(
508
                    $class,
509
                    $relationName,
510
                    'Back relation not found or ambiguous (needs class.relation format)'
511
                );
512
            } elseif ($found > 1) {
513
                $this->logError($class, $relationName, 'Back relation is ambiguous');
514
            }
515
        }
516
    }
517
518
    /**
519
     * @param string $class
520
     */
521
    protected function validateBelongsManyMany(string $class): void
522
    {
523
        $singleton = DataObject::singleton($class);
524
        $relations = (array) $singleton->config()->uninherited('belongs_many_many');
525
526
        foreach ($relations as $relationName => $relationData) {
527
            if ($this->isIgnored($class, $relationName)) {
528
                continue;
529
            }
530
531
            $parsedRelation = $this->parsePlainRelation($relationData);
532
533
            if ($parsedRelation === null) {
534
                $this->logError(
535
                    $class,
536
                    $relationName,
537
                    'Relation is not in the expected format (needs class.relation format)'
538
                );
539
540
                continue;
541
            }
542
543
            $relatedClass = $parsedRelation['class'];
544
            $relatedRelation = $parsedRelation['relation'];
545
546
            if (!is_subclass_of($relatedClass, DataObject::class)) {
547
                $this->logError(
548
                    $class,
549
                    $relationName,
550
                    sprintf('Related class %s is not a DataObject.', $relatedClass)
551
                );
552
553
                continue;
554
            }
555
556
            $relatedObject = DataObject::singleton($relatedClass);
557
            $relatedRelations = (array) $relatedObject->config()->uninherited('many_many');
558
559
            if (array_key_exists($relatedRelation, $relatedRelations)) {
560
                continue;
561
            }
562
563
            $this->logError($class, $relationName, 'Back relation not found');
564
        }
565
    }
566
567
    /**
568
     * @param string $relationData
569
     * @return array|null
570
     */
571
    protected function parsePlainRelation(string $relationData): ?array
572
    {
573
        if (mb_strpos($relationData, '.') === false) {
574
            return null;
575
        }
576
577
        $segments = explode('.', $relationData);
578
579
        if (count($segments) !== 2) {
580
            return null;
581
        }
582
583
        $class = array_shift($segments);
584
        $relation = array_shift($segments);
585
586
        return [
587
            'class' => $class,
588
            'relation' => $relation,
589
        ];
590
    }
591
592
    /**
593
     * @param array|string $relationData
594
     * @return string|null
595
     */
596
    protected function parseManyManyRelation($relationData): ?string
597
    {
598
        if (is_array($relationData)) {
599
            foreach (['through', 'to'] as $key) {
600
                if (!array_key_exists($key, $relationData)) {
601
                    return null;
602
                }
603
            }
604
605
            $to = $relationData['to'];
606
            $through = $relationData['through'];
607
608
            if (!is_subclass_of($through, DataObject::class)) {
609
                return null;
610
            }
611
612
            $throughObject = DataObject::singleton($through);
613
            $throughRelations = (array) $throughObject->config()->uninherited('has_one');
614
615
            if (!array_key_exists($to, $throughRelations)) {
616
                return null;
617
            }
618
619
            return $throughRelations[$to];
620
        }
621
622
        return $relationData;
623
    }
624
625
    /**
626
     * @param string $class
627
     * @param string $relation
628
     * @param string $message
629
     */
630
    protected function logError(string $class, string $relation, string $message)
631
    {
632
        $classPrefix = $relation ? sprintf('%s / %s', $class, $relation) : $class;
633
        $this->errors[] = sprintf('%s : %s', $classPrefix, $message);
634
    }
635
}
636