Passed
Pull Request — 4 (#9874)
by
unknown
09:02
created

RelationValidationService::validateManyMany()   B

Complexity

Conditions 10
Paths 18

Size

Total Lines 52
Code Lines 31

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 10
eloc 31
nc 18
nop 1
dl 0
loc 52
rs 7.6666
c 0
b 0
f 0

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