Passed
Pull Request — 4 (#10276)
by Guy
06:03
created

DataObjectSchema::tableIsReadyForClass()   B

Complexity

Conditions 9
Paths 9

Size

Total Lines 54
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 9
eloc 23
nc 9
nop 1
dl 0
loc 54
rs 8.0555
c 0
b 0
f 0

How to fix   Long Method   

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\ORM;
4
5
use Exception;
6
use InvalidArgumentException;
7
use LogicException;
8
use SilverStripe\Core\ClassInfo;
9
use SilverStripe\Core\Config\Config;
10
use SilverStripe\Core\Config\Configurable;
11
use SilverStripe\Core\Convert;
12
use SilverStripe\Core\Injector\Injectable;
13
use SilverStripe\Core\Injector\Injector;
14
use SilverStripe\Dev\TestOnly;
15
use SilverStripe\ORM\Connect\DBSchemaManager;
16
use SilverStripe\ORM\FieldType\DBComposite;
17
use SilverStripe\ORM\FieldType\DBField;
18
19
/**
20
 * Provides dataobject and database schema mapping functionality
21
 */
22
class DataObjectSchema
23
{
24
    use Injectable;
25
    use Configurable;
26
27
    /**
28
     * Default separate for table namespaces. Can be set to any string for
29
     * databases that do not support some characters.
30
     *
31
     * @config
32
     * @var string
33
     */
34
    private static $table_namespace_separator = '_';
0 ignored issues
show
introduced by
The private property $table_namespace_separator is not used, and could be removed.
Loading history...
35
36
    /**
37
     * Cache of database fields
38
     *
39
     * @var array
40
     */
41
    protected $databaseFields = [];
42
43
    /**
44
     * Cache of database indexes
45
     *
46
     * @var array
47
     */
48
    protected $databaseIndexes = [];
49
50
    /**
51
     * Fields that should be indexed, by class name
52
     *
53
     * @var array
54
     */
55
    protected $defaultDatabaseIndexes = [];
56
57
    /**
58
     * Cache of composite database field
59
     *
60
     * @var array
61
     */
62
    protected $compositeFields = [];
63
64
    /**
65
     * Cache of table names
66
     *
67
     * @var array
68
     */
69
    protected $tableNames = [];
70
71
    /**
72
     * Array of classes that have been confirmed ready for database queries.
73
     * When the database has once been verified as ready, it will not do the
74
     * checks again.
75
     *
76
     * @var boolean[]
77
     */
78
    protected $tableReadyClasses = [];
79
80
    /**
81
     * Clear cached table names
82
     */
83
    public function reset()
84
    {
85
        $this->tableNames = [];
86
        $this->databaseFields = [];
87
        $this->databaseIndexes = [];
88
        $this->defaultDatabaseIndexes = [];
89
        $this->compositeFields = [];
90
    }
91
92
    /**
93
     * Get all table names
94
     *
95
     * @return array
96
     */
97
    public function getTableNames()
98
    {
99
        $this->cacheTableNames();
100
        return $this->tableNames;
101
    }
102
103
    /**
104
     * Given a DataObject class and a field on that class, determine the appropriate SQL for
105
     * selecting / filtering on in a SQL string. Note that $class must be a valid class, not an
106
     * arbitrary table.
107
     *
108
     * The result will be a standard ANSI-sql quoted string in "Table"."Column" format.
109
     *
110
     * @param string $class Class name (not a table).
111
     * @param string $field Name of field that belongs to this class (or a parent class)
112
     * @param string $tablePrefix Optional prefix for table (alias)
113
     *
114
     * @return string The SQL identifier string for the corresponding column for this field
115
     */
116
    public function sqlColumnForField($class, $field, $tablePrefix = null)
117
    {
118
        $table = $this->tableForField($class, $field);
119
        if (!$table) {
120
            throw new InvalidArgumentException("\"{$field}\" is not a field on class \"{$class}\"");
121
        }
122
        return "\"{$tablePrefix}{$table}\".\"{$field}\"";
123
    }
124
125
    /**
126
     * Get table name for the given class.
127
     *
128
     * Note that this does not confirm a table actually exists (or should exist), but returns
129
     * the name that would be used if this table did exist.
130
     *
131
     * @param string $class
132
     *
133
     * @return string Returns the table name, or null if there is no table
134
     */
135
    public function tableName($class)
136
    {
137
        $tables = $this->getTableNames();
138
        $class = ClassInfo::class_name($class);
139
        if (isset($tables[$class])) {
140
            return Convert::raw2sql($tables[$class]);
141
        }
142
        return null;
143
    }
144
145
    /**
146
     * Returns the root class (the first to extend from DataObject) for the
147
     * passed class.
148
     *
149
     * @param string|object $class
150
     *
151
     * @return string
152
     * @throws InvalidArgumentException
153
     */
154
    public function baseDataClass($class)
155
    {
156
        $current = $class;
157
        while ($next = get_parent_class($current)) {
158
            if ($next === DataObject::class) {
159
                // Only use ClassInfo::class_name() to format the class if we've not used get_parent_class()
160
                return ($current === $class) ? ClassInfo::class_name($current) : $current;
161
            }
162
            $current = $next;
163
        }
164
        throw new InvalidArgumentException("$class is not a subclass of DataObject");
165
    }
166
167
    /**
168
     * Get the base table
169
     *
170
     * @param string|object $class
171
     *
172
     * @return string
173
     */
174
    public function baseDataTable($class)
175
    {
176
        return $this->tableName($this->baseDataClass($class));
177
    }
178
179
    /**
180
     * fieldSpec should exclude virtual fields (such as composite fields), and only include fields with a db column.
181
     */
182
    const DB_ONLY = 1;
183
184
    /**
185
     * fieldSpec should only return fields that belong to this table, and not any ancestors
186
     */
187
    const UNINHERITED = 2;
188
189
    /**
190
     * fieldSpec should prefix all field specifications with the class name in RecordClass.Column(spec) format.
191
     */
192
    const INCLUDE_CLASS = 4;
193
194
    /**
195
     * Get all DB field specifications for a class, including ancestors and composite fields.
196
     *
197
     * @param string|DataObject $classOrInstance
198
     * @param int $options Bitmask of options
199
     *  - UNINHERITED Limit to only this table
200
     *  - DB_ONLY Exclude virtual fields (such as composite fields), and only include fields with a db column.
201
     *  - INCLUDE_CLASS Prefix the field specification with the class name in RecordClass.Column(spec) format.
202
     *
203
     * @return array List of fields, where the key is the field name and the value is the field specification.
204
     */
205
    public function fieldSpecs($classOrInstance, $options = 0)
206
    {
207
        $class = ClassInfo::class_name($classOrInstance);
208
209
        // Validate options
210
        if (!is_int($options)) {
0 ignored issues
show
introduced by
The condition is_int($options) is always true.
Loading history...
211
            throw new InvalidArgumentException("Invalid options " . var_export($options, true));
212
        }
213
        $uninherited = ($options & self::UNINHERITED) === self::UNINHERITED;
214
        $dbOnly = ($options & self::DB_ONLY) === self::DB_ONLY;
215
        $includeClass = ($options & self::INCLUDE_CLASS) === self::INCLUDE_CLASS;
216
217
        // Walk class hierarchy
218
        $db = [];
219
        $classes = $uninherited ? [$class] : ClassInfo::ancestry($class);
220
        foreach ($classes as $tableClass) {
221
            // Skip irrelevant parent classes
222
            if (!is_subclass_of($tableClass, DataObject::class)) {
223
                continue;
224
            }
225
226
            // Find all fields on this class
227
            $fields = $this->databaseFields($tableClass, false);
228
            // Merge with composite fields
229
            if (!$dbOnly) {
230
                $compositeFields = $this->compositeFields($tableClass, false);
231
                $fields = array_merge($fields, $compositeFields);
232
            }
233
234
            // Record specification
235
            foreach ($fields as $name => $specification) {
236
                $prefix = $includeClass ? "{$tableClass}." : "";
237
                $db[$name] = $prefix . $specification;
238
            }
239
        }
240
        return $db;
241
    }
242
243
244
    /**
245
     * Get specifications for a single class field
246
     *
247
     * @param string|DataObject $classOrInstance Name or instance of class
248
     * @param string $fieldName Name of field to retrieve
249
     * @param int $options Bitmask of options
250
     *  - UNINHERITED Limit to only this table
251
     *  - DB_ONLY Exclude virtual fields (such as composite fields), and only include fields with a db column.
252
     *  - INCLUDE_CLASS Prefix the field specification with the class name in RecordClass.Column(spec) format.
253
     *
254
     * @return string|null Field will be a string in FieldClass(args) format, or
255
     * RecordClass.FieldClass(args) format if using INCLUDE_CLASS. Will be null if no field is found.
256
     */
257
    public function fieldSpec($classOrInstance, $fieldName, $options = 0)
258
    {
259
        $specs = $this->fieldSpecs($classOrInstance, $options);
260
        return isset($specs[$fieldName]) ? $specs[$fieldName] : null;
261
    }
262
263
    /**
264
     * Find the class for the given table
265
     *
266
     * @param string $table
267
     *
268
     * @return string|null The FQN of the class, or null if not found
269
     */
270
    public function tableClass($table)
271
    {
272
        $tables = $this->getTableNames();
273
        $class = array_search($table, $tables, true);
274
        if ($class) {
275
            return $class;
276
        }
277
278
        // If there is no class for this table, strip table modifiers (e.g. _Live / _Versions)
279
        // from the end and re-attempt a search.
280
        if (preg_match('/^(?<class>.+)(_[^_]+)$/i', $table, $matches)) {
281
            $table = $matches['class'];
282
            $class = array_search($table, $tables, true);
283
            if ($class) {
284
                return $class;
285
            }
286
        }
287
        return null;
288
    }
289
290
    /**
291
     * Cache all table names if necessary
292
     */
293
    protected function cacheTableNames()
294
    {
295
        if ($this->tableNames) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->tableNames of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
296
            return;
297
        }
298
        $this->tableNames = [];
299
        foreach (ClassInfo::subclassesFor(DataObject::class) as $class) {
300
            if ($class === DataObject::class) {
301
                continue;
302
            }
303
            $table = $this->buildTableName($class);
304
305
            // Check for conflicts
306
            $conflict = array_search($table, $this->tableNames, true);
307
            if ($conflict) {
308
                throw new LogicException(
309
                    "Multiple classes (\"{$class}\", \"{$conflict}\") map to the same table: \"{$table}\""
310
                );
311
            }
312
            $this->tableNames[$class] = $table;
313
        }
314
    }
315
316
    /**
317
     * Generate table name for a class.
318
     *
319
     * Note: some DB schema have a hard limit on table name length. This is not enforced by this method.
320
     * See dev/build errors for details in case of table name violation.
321
     *
322
     * @param string $class
323
     *
324
     * @return string
325
     */
326
    protected function buildTableName($class)
327
    {
328
        $table = Config::inst()->get($class, 'table_name', Config::UNINHERITED);
329
330
        // Generate default table name
331
        if ($table) {
332
            return $table;
333
        }
334
335
        if (strpos($class, '\\') === false) {
336
            return $class;
337
        }
338
339
        $separator = DataObjectSchema::config()->uninherited('table_namespace_separator');
340
        $table = str_replace('\\', $separator, trim($class, '\\'));
341
342
        if (!ClassInfo::classImplements($class, TestOnly::class) && $this->classHasTable($class)) {
343
            DBSchemaManager::showTableNameWarning($table, $class);
0 ignored issues
show
Deprecated Code introduced by
The function SilverStripe\ORM\Connect...:showTableNameWarning() has been deprecated: 4.0.0:5.0.0 ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

343
            /** @scrutinizer ignore-deprecated */ DBSchemaManager::showTableNameWarning($table, $class);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
344
        }
345
346
        return $table;
347
    }
348
349
    /**
350
     * Return the complete map of fields to specification on this object, including fixed_fields.
351
     * "ID" will be included on every table.
352
     *
353
     * @param string $class Class name to query from
354
     * @param bool $aggregated Include fields in entire hierarchy, rather than just on this table
355
     *
356
     * @return array Map of fieldname to specification, similar to {@link DataObject::$db}.
357
     */
358
    public function databaseFields($class, $aggregated = true)
359
    {
360
        $class = ClassInfo::class_name($class);
361
        if ($class === DataObject::class) {
362
            return [];
363
        }
364
        $this->cacheDatabaseFields($class);
365
        $fields = $this->databaseFields[$class];
366
367
        if (!$aggregated) {
368
            return $fields;
369
        }
370
371
        // Recursively merge
372
        $parentFields = $this->databaseFields(get_parent_class($class));
373
        return array_merge($fields, array_diff_key($parentFields, $fields));
374
    }
375
376
    /**
377
     * Gets a single database field.
378
     *
379
     * @param string $class Class name to query from
380
     * @param string $field Field name
381
     * @param bool $aggregated Include fields in entire hierarchy, rather than just on this table
382
     *
383
     * @return string|null Field specification, or null if not a field
384
     */
385
    public function databaseField($class, $field, $aggregated = true)
386
    {
387
        $fields = $this->databaseFields($class, $aggregated);
388
        return isset($fields[$field]) ? $fields[$field] : null;
389
    }
390
391
    /**
392
     * @param string $class
393
     * @param bool $aggregated
394
     *
395
     * @return array
396
     */
397
    public function databaseIndexes($class, $aggregated = true)
398
    {
399
        $class = ClassInfo::class_name($class);
400
        if ($class === DataObject::class) {
401
            return [];
402
        }
403
        $this->cacheDatabaseIndexes($class);
404
        $indexes = $this->databaseIndexes[$class];
405
        if (!$aggregated) {
406
            return $indexes;
407
        }
408
        return array_merge($indexes, $this->databaseIndexes(get_parent_class($class)));
409
    }
410
411
    /**
412
     * Check if the given class has a table
413
     *
414
     * @param string $class
415
     *
416
     * @return bool
417
     */
418
    public function classHasTable($class)
419
    {
420
        if (!is_subclass_of($class, DataObject::class)) {
421
            return false;
422
        }
423
424
        $fields = $this->databaseFields($class, false);
425
        return !empty($fields);
426
    }
427
428
    /**
429
     * Returns a list of all the composite if the given db field on the class is a composite field.
430
     * Will check all applicable ancestor classes and aggregate results.
431
     *
432
     * Can be called directly on an object. E.g. Member::composite_fields(), or Member::composite_fields(null, true)
433
     * to aggregate.
434
     *
435
     * Includes composite has_one (Polymorphic) fields
436
     *
437
     * @param string $class Name of class to check
438
     * @param bool $aggregated Include fields in entire hierarchy, rather than just on this table
439
     *
440
     * @return array List of composite fields and their class spec
441
     */
442
    public function compositeFields($class, $aggregated = true)
443
    {
444
        $class = ClassInfo::class_name($class);
445
        if ($class === DataObject::class) {
446
            return [];
447
        }
448
        $this->cacheDatabaseFields($class);
449
450
        // Get fields for this class
451
        $compositeFields = $this->compositeFields[$class];
452
        if (!$aggregated) {
453
            return $compositeFields;
454
        }
455
456
        // Recursively merge
457
        $parentFields = $this->compositeFields(get_parent_class($class));
458
        return array_merge($compositeFields, array_diff_key($parentFields, $compositeFields));
459
    }
460
461
    /**
462
     * Get a composite field for a class
463
     *
464
     * @param string $class Class name to query from
465
     * @param string $field Field name
466
     * @param bool $aggregated Include fields in entire hierarchy, rather than just on this table
467
     *
468
     * @return string|null Field specification, or null if not a field
469
     */
470
    public function compositeField($class, $field, $aggregated = true)
471
    {
472
        $fields = $this->compositeFields($class, $aggregated);
473
        return isset($fields[$field]) ? $fields[$field] : null;
474
    }
475
476
    /**
477
     * Cache all database and composite fields for the given class.
478
     * Will do nothing if already cached
479
     *
480
     * @param string $class Class name to cache
481
     */
482
    protected function cacheDatabaseFields($class)
483
    {
484
        // Skip if already cached
485
        if (isset($this->databaseFields[$class]) && isset($this->compositeFields[$class])) {
486
            return;
487
        }
488
        $compositeFields = [];
489
        $dbFields = [];
490
491
        // Ensure fixed fields appear at the start
492
        $fixedFields = DataObject::config()->uninherited('fixed_fields');
493
        if (get_parent_class($class) === DataObject::class) {
494
            // Merge fixed with ClassName spec and custom db fields
495
            $dbFields = $fixedFields;
496
        } else {
497
            $dbFields['ID'] = $fixedFields['ID'];
498
        }
499
500
        // Check each DB value as either a field or composite field
501
        $db = Config::inst()->get($class, 'db', Config::UNINHERITED) ?: [];
502
        foreach ($db as $fieldName => $fieldSpec) {
503
            $fieldClass = strtok($fieldSpec, '(');
504
            if (singleton($fieldClass) instanceof DBComposite) {
505
                $compositeFields[$fieldName] = $fieldSpec;
506
            } else {
507
                $dbFields[$fieldName] = $fieldSpec;
508
            }
509
        }
510
511
        // Add in all has_ones
512
        $hasOne = Config::inst()->get($class, 'has_one', Config::UNINHERITED) ?: [];
513
        foreach ($hasOne as $fieldName => $hasOneClass) {
514
            if ($hasOneClass === DataObject::class) {
515
                $compositeFields[$fieldName] = 'PolymorphicForeignKey';
516
            } else {
517
                $dbFields["{$fieldName}ID"] = 'ForeignKey';
518
            }
519
        }
520
521
        // Merge composite fields into DB
522
        foreach ($compositeFields as $fieldName => $fieldSpec) {
523
            $fieldObj = Injector::inst()->create($fieldSpec, $fieldName);
524
            $fieldObj->setTable($class);
525
            $nestedFields = $fieldObj->compositeDatabaseFields();
526
            foreach ($nestedFields as $nestedName => $nestedSpec) {
527
                $dbFields["{$fieldName}{$nestedName}"] = $nestedSpec;
528
            }
529
        }
530
531
        // Prevent field-less tables with only 'ID'
532
        if (count($dbFields) < 2) {
533
            $dbFields = [];
534
        }
535
536
        // Return cached results
537
        $this->databaseFields[$class] = $dbFields;
538
        $this->compositeFields[$class] = $compositeFields;
539
    }
540
541
    /**
542
     * Cache all indexes for the given class. Will do nothing if already cached.
543
     *
544
     * @param $class
545
     */
546
    protected function cacheDatabaseIndexes($class)
547
    {
548
        if (!array_key_exists($class, $this->databaseIndexes)) {
549
            $this->databaseIndexes[$class] = array_merge(
550
                $this->buildSortDatabaseIndexes($class),
551
                $this->cacheDefaultDatabaseIndexes($class),
552
                $this->buildCustomDatabaseIndexes($class)
553
            );
554
        }
555
    }
556
557
    /**
558
     * Get "default" database indexable field types
559
     *
560
     * @param  string $class
561
     *
562
     * @return array
563
     */
564
    protected function cacheDefaultDatabaseIndexes($class)
565
    {
566
        if (array_key_exists($class, $this->defaultDatabaseIndexes)) {
567
            return $this->defaultDatabaseIndexes[$class];
568
        }
569
        $this->defaultDatabaseIndexes[$class] = [];
570
571
        $fieldSpecs = $this->fieldSpecs($class, self::UNINHERITED);
572
        foreach ($fieldSpecs as $field => $spec) {
573
            /** @var DBField $fieldObj */
574
            $fieldObj = Injector::inst()->create($spec, $field);
575
            if ($indexSpecs = $fieldObj->getIndexSpecs()) {
576
                $this->defaultDatabaseIndexes[$class][$field] = $indexSpecs;
577
            }
578
        }
579
        return $this->defaultDatabaseIndexes[$class];
580
    }
581
582
    /**
583
     * Look for custom indexes declared on the class
584
     *
585
     * @param  string $class
586
     *
587
     * @return array
588
     * @throws InvalidArgumentException If an index already exists on the class
589
     * @throws InvalidArgumentException If a custom index format is not valid
590
     */
591
    protected function buildCustomDatabaseIndexes($class)
592
    {
593
        $indexes = [];
594
        $classIndexes = Config::inst()->get($class, 'indexes', Config::UNINHERITED) ?: [];
595
        foreach ($classIndexes as $indexName => $indexSpec) {
596
            if (array_key_exists($indexName, $indexes)) {
597
                throw new InvalidArgumentException(sprintf(
598
                    'Index named "%s" already exists on class %s',
599
                    $indexName,
600
                    $class
601
                ));
602
            }
603
            if (is_array($indexSpec)) {
604
                if (!ArrayLib::is_associative($indexSpec)) {
605
                    $indexSpec = [
606
                        'columns' => $indexSpec,
607
                    ];
608
                }
609
                if (!isset($indexSpec['type'])) {
610
                    $indexSpec['type'] = 'index';
611
                }
612
                if (!isset($indexSpec['columns'])) {
613
                    $indexSpec['columns'] = [$indexName];
614
                } elseif (!is_array($indexSpec['columns'])) {
615
                    throw new InvalidArgumentException(sprintf(
616
                        'Index %s on %s is not valid. columns should be an array %s given',
617
                        var_export($indexName, true),
618
                        var_export($class, true),
619
                        var_export($indexSpec['columns'], true)
620
                    ));
621
                }
622
            } else {
623
                $indexSpec = [
624
                    'type' => 'index',
625
                    'columns' => [$indexName],
626
                ];
627
            }
628
            $indexes[$indexName] = $indexSpec;
629
        }
630
        return $indexes;
631
    }
632
633
    protected function buildSortDatabaseIndexes($class)
634
    {
635
        $sort = Config::inst()->get($class, 'default_sort', Config::UNINHERITED);
636
        $indexes = [];
637
638
        if ($sort && is_string($sort)) {
639
            $sort = preg_split('/,(?![^()]*+\\))/', $sort);
640
            foreach ($sort as $value) {
641
                try {
642
                    list ($table, $column) = $this->parseSortColumn(trim($value));
643
                    $table = trim($table, '"');
644
                    $column = trim($column, '"');
645
                    if ($table && strtolower($table) !== strtolower(self::tableName($class))) {
0 ignored issues
show
Bug Best Practice introduced by
The method SilverStripe\ORM\DataObjectSchema::tableName() is not static, but was called statically. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

645
                    if ($table && strtolower($table) !== strtolower(self::/** @scrutinizer ignore-call */ tableName($class))) {
Loading history...
646
                        continue;
647
                    }
648
                    if ($this->databaseField($class, $column, false)) {
649
                        $indexes[$column] = [
650
                            'type' => 'index',
651
                            'columns' => [$column],
652
                        ];
653
                    }
654
                } catch (InvalidArgumentException $e) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
655
                }
656
            }
657
        }
658
        return $indexes;
659
    }
660
661
    /**
662
     * Parses a specified column into a sort field and direction
663
     *
664
     * @param string $column String to parse containing the column name
665
     *
666
     * @return array Resolved table and column.
667
     */
668
    protected function parseSortColumn($column)
669
    {
670
        // Parse column specification, considering possible ansi sql quoting
671
        // Note that table prefix is allowed, but discarded
672
        if (preg_match('/^("?(?<table>[^"\s]+)"?\\.)?"?(?<column>[^"\s]+)"?(\s+(?<direction>((asc)|(desc))(ending)?))?$/i', $column, $match)) {
673
            $table = $match['table'];
674
            $column = $match['column'];
675
        } else {
676
            throw new InvalidArgumentException("Invalid sort() column");
677
        }
678
        return [$table, $column];
679
    }
680
681
    /**
682
     * Returns the table name in the class hierarchy which contains a given
683
     * field column for a {@link DataObject}. If the field does not exist, this
684
     * will return null.
685
     *
686
     * @param string $candidateClass
687
     * @param string $fieldName
688
     *
689
     * @return string
690
     */
691
    public function tableForField($candidateClass, $fieldName)
692
    {
693
        $class = $this->classForField($candidateClass, $fieldName);
694
        if ($class) {
695
            return $this->tableName($class);
696
        }
697
        return null;
698
    }
699
700
    /**
701
     * Returns the class name in the class hierarchy which contains a given
702
     * field column for a {@link DataObject}. If the field does not exist, this
703
     * will return null.
704
     *
705
     * @param string $candidateClass
706
     * @param string $fieldName
707
     *
708
     * @return string
709
     */
710
    public function classForField($candidateClass, $fieldName)
711
    {
712
        // normalise class name
713
        $candidateClass = ClassInfo::class_name($candidateClass);
714
        if ($candidateClass === DataObject::class) {
715
            return null;
716
        }
717
718
        // Short circuit for fixed fields
719
        $fixed = DataObject::config()->uninherited('fixed_fields');
720
        if (isset($fixed[$fieldName])) {
721
            return $this->baseDataClass($candidateClass);
722
        }
723
724
        // Find regular field
725
        while ($candidateClass && $candidateClass !== DataObject::class) {
726
            $fields = $this->databaseFields($candidateClass, false);
727
            if (isset($fields[$fieldName])) {
728
                return $candidateClass;
729
            }
730
            $candidateClass = get_parent_class($candidateClass);
731
        }
732
        return null;
733
    }
734
735
    /**
736
     * Return information about a specific many_many component. Returns a numeric array.
737
     * The first item in the array will be the class name of the relation.
738
     *
739
     * Standard many_many return type is:
740
     *
741
     * [
742
     *  <manyManyClass>,        Name of class for relation. E.g. "Categories"
743
     *  <classname>,            The class that relation is defined in e.g. "Product"
744
     *  <candidateName>,        The target class of the relation e.g. "Category"
745
     *  <parentField>,          The field name pointing to <classname>'s table e.g. "ProductID".
746
     *  <childField>,           The field name pointing to <candidatename>'s table e.g. "CategoryID".
747
     *  <joinTableOrRelation>   The join table between the two classes e.g. "Product_Categories".
748
     *                          If the class name is 'ManyManyThroughList' then this is the name of the
749
     *                          has_many relation.
750
     * ]
751
     *
752
     * @param string $class Name of class to get component for
753
     * @param string $component The component name
754
     *
755
     * @return array|null
756
     */
757
    public function manyManyComponent($class, $component)
758
    {
759
        $classes = ClassInfo::ancestry($class);
760
        foreach ($classes as $parentClass) {
761
            // Check if the component is defined in many_many on this class
762
            $otherManyMany = Config::inst()->get($parentClass, 'many_many', Config::UNINHERITED);
763
            if (isset($otherManyMany[$component])) {
764
                return $this->parseManyManyComponent($parentClass, $component, $otherManyMany[$component]);
765
            }
766
767
            // Check if the component is defined in belongs_many_many on this class
768
            $belongsManyMany = Config::inst()->get($parentClass, 'belongs_many_many', Config::UNINHERITED);
769
            if (!isset($belongsManyMany[$component])) {
770
                continue;
771
            }
772
773
            // Extract class and relation name from dot-notation
774
            $belongs = $this->parseBelongsManyManyComponent(
775
                $parentClass,
776
                $component,
777
                $belongsManyMany[$component]
778
            );
779
780
            // Build inverse relationship from other many_many, and swap parent/child
781
            $otherManyMany = $this->manyManyComponent($belongs['childClass'], $belongs['relationName']);
782
            return [
783
                'relationClass' => $otherManyMany['relationClass'],
784
                'parentClass' => $otherManyMany['childClass'],
785
                'childClass' => $otherManyMany['parentClass'],
786
                'parentField' => $otherManyMany['childField'],
787
                'childField' => $otherManyMany['parentField'],
788
                'join' => $otherManyMany['join'],
789
            ];
790
        }
791
        return null;
792
    }
793
794
795
    /**
796
     * Parse a belongs_many_many component to extract class and relationship name
797
     *
798
     * @param string $parentClass Name of class
799
     * @param string $component Name of relation on class
800
     * @param string $specification specification for this belongs_many_many
801
     *
802
     * @return array Array with child class and relation name
803
     */
804
    protected function parseBelongsManyManyComponent($parentClass, $component, $specification)
805
    {
806
        $childClass = $specification;
807
        $relationName = null;
808
        if (strpos($specification, '.') !== false) {
809
            list($childClass, $relationName) = explode('.', $specification, 2);
810
        }
811
812
        // Check child class exists
813
        if (!class_exists($childClass)) {
814
            throw new LogicException(
815
                "belongs_many_many relation {$parentClass}.{$component} points to "
816
                . "{$childClass} which does not exist"
817
            );
818
        }
819
820
        // We need to find the inverse component name, if not explicitly given
821
        if (!$relationName) {
822
            $relationName = $this->getManyManyInverseRelationship($childClass, $parentClass);
823
        }
824
825
        // Check valid relation found
826
        if (!$relationName) {
827
            throw new LogicException(
828
                "belongs_many_many relation {$parentClass}.{$component} points to "
829
                . "{$specification} without matching many_many"
830
            );
831
        }
832
833
        // Return relatios
834
        return [
835
            'childClass' => $childClass,
836
            'relationName' => $relationName,
837
        ];
838
    }
839
840
    /**
841
     * Return the many-to-many extra fields specification for a specific component.
842
     *
843
     * @param string $class
844
     * @param string $component
845
     *
846
     * @return array|null
847
     */
848
    public function manyManyExtraFieldsForComponent($class, $component)
849
    {
850
        // Get directly declared many_many_extraFields
851
        $extraFields = Config::inst()->get($class, 'many_many_extraFields');
852
        if (isset($extraFields[$component])) {
853
            return $extraFields[$component];
854
        }
855
856
        // If not belongs_many_many then there are no components
857
        while ($class && ($class !== DataObject::class)) {
858
            $belongsManyMany = Config::inst()->get($class, 'belongs_many_many', Config::UNINHERITED);
859
            if (isset($belongsManyMany[$component])) {
860
                // Reverse relationship and find extrafields from child class
861
                $belongs = $this->parseBelongsManyManyComponent(
862
                    $class,
863
                    $component,
864
                    $belongsManyMany[$component]
865
                );
866
                return $this->manyManyExtraFieldsForComponent($belongs['childClass'], $belongs['relationName']);
867
            }
868
            $class = get_parent_class($class);
869
        }
870
        return null;
871
    }
872
873
    /**
874
     * Return data for a specific has_many component.
875
     *
876
     * @param string $class Parent class
877
     * @param string $component
878
     * @param bool $classOnly If this is TRUE, than any has_many relationships in the form
879
     * "ClassName.Field" will have the field data stripped off. It defaults to TRUE.
880
     *
881
     * @return string|null
882
     */
883
    public function hasManyComponent($class, $component, $classOnly = true)
884
    {
885
        $hasMany = (array)Config::inst()->get($class, 'has_many');
886
        if (!isset($hasMany[$component])) {
887
            return null;
888
        }
889
890
        // Remove has_one specifier if given
891
        $hasMany = $hasMany[$component];
892
        $hasManyClass = strtok($hasMany, '.');
893
894
        // Validate
895
        $this->checkRelationClass($class, $component, $hasManyClass, 'has_many');
896
        return $classOnly ? $hasManyClass : $hasMany;
897
    }
898
899
    /**
900
     * Return data for a specific has_one component.
901
     *
902
     * @param string $class
903
     * @param string $component
904
     *
905
     * @return string|null
906
     */
907
    public function hasOneComponent($class, $component)
908
    {
909
        $hasOnes = Config::forClass($class)->get('has_one');
910
        if (!isset($hasOnes[$component])) {
911
            return null;
912
        }
913
914
        // Validate
915
        $relationClass = $hasOnes[$component];
916
        $this->checkRelationClass($class, $component, $relationClass, 'has_one');
917
        return $relationClass;
918
    }
919
920
    /**
921
     * Return data for a specific belongs_to component.
922
     *
923
     * @param string $class
924
     * @param string $component
925
     * @param bool $classOnly If this is TRUE, than any has_many relationships in the
926
     * form "ClassName.Field" will have the field data stripped off. It defaults to TRUE.
927
     *
928
     * @return string|null
929
     */
930
    public function belongsToComponent($class, $component, $classOnly = true)
931
    {
932
        $belongsTo = (array)Config::forClass($class)->get('belongs_to');
933
        if (!isset($belongsTo[$component])) {
934
            return null;
935
        }
936
937
        // Remove has_one specifier if given
938
        $belongsTo = $belongsTo[$component];
939
        $belongsToClass = strtok($belongsTo, '.');
940
941
        // Validate
942
        $this->checkRelationClass($class, $component, $belongsToClass, 'belongs_to');
943
        return $classOnly ? $belongsToClass : $belongsTo;
944
    }
945
946
    /**
947
     * Check class for any unary component
948
     *
949
     * Alias for hasOneComponent() ?: belongsToComponent()
950
     *
951
     * @param string $class
952
     * @param string $component
953
     *
954
     * @return string|null
955
     */
956
    public function unaryComponent($class, $component)
957
    {
958
        return $this->hasOneComponent($class, $component) ?: $this->belongsToComponent($class, $component);
959
    }
960
961
    /**
962
     *
963
     * @param string $parentClass Parent class name
964
     * @param string $component ManyMany name
965
     * @param string|array $specification Declaration of many_many relation type
966
     *
967
     * @return array
968
     */
969
    protected function parseManyManyComponent($parentClass, $component, $specification)
970
    {
971
        // Check if this is many_many_through
972
        if (is_array($specification)) {
973
            // Validate join, parent and child classes
974
            $joinClass = $this->checkManyManyJoinClass($parentClass, $component, $specification);
975
            $parentClass = $this->checkManyManyFieldClass($parentClass, $component, $joinClass, $specification, 'from');
976
            $joinChildClass = $this->checkManyManyFieldClass($parentClass, $component, $joinClass, $specification, 'to');
977
            return [
978
                'relationClass' => ManyManyThroughList::class,
979
                'parentClass' => $parentClass,
980
                'childClass' => $joinChildClass,
981
                /** @internal Polymorphic many_many is experimental */
982
                'parentField' => $specification['from'] . ($parentClass === DataObject::class ? '' : 'ID'),
983
                'childField' => $specification['to'] . 'ID',
984
                'join' => $joinClass,
985
            ];
986
        }
987
988
        // Validate $specification class is valid
989
        $this->checkRelationClass($parentClass, $component, $specification, 'many_many');
990
991
        // automatic scaffolded many_many table
992
        $classTable = $this->tableName($parentClass);
993
        $parentField = "{$classTable}ID";
994
        if ($parentClass === $specification) {
995
            $childField = "ChildID";
996
        } else {
997
            $candidateTable = $this->tableName($specification);
998
            $childField = "{$candidateTable}ID";
999
        }
1000
        $joinTable = "{$classTable}_{$component}";
1001
        return [
1002
            'relationClass' => ManyManyList::class,
1003
            'parentClass' => $parentClass,
1004
            'childClass' => $specification,
1005
            'parentField' => $parentField,
1006
            'childField' => $childField,
1007
            'join' => $joinTable,
1008
        ];
1009
    }
1010
1011
    /**
1012
     * Find a many_many on the child class that points back to this many_many
1013
     *
1014
     * @param string $childClass
1015
     * @param string $parentClass
1016
     *
1017
     * @return string|null
1018
     */
1019
    protected function getManyManyInverseRelationship($childClass, $parentClass)
1020
    {
1021
        $otherManyMany = Config::inst()->get($childClass, 'many_many', Config::UNINHERITED);
1022
        if (!$otherManyMany) {
1023
            return null;
1024
        }
1025
        foreach ($otherManyMany as $inverseComponentName => $manyManySpec) {
1026
            // Normal many-many
1027
            if ($manyManySpec === $parentClass) {
1028
                return $inverseComponentName;
1029
            }
1030
            // many-many through, inspect 'to' for the many_many
1031
            if (is_array($manyManySpec)) {
1032
                $toClass = $this->hasOneComponent($manyManySpec['through'], $manyManySpec['to']);
1033
                if ($toClass === $parentClass) {
1034
                    return $inverseComponentName;
1035
                }
1036
            }
1037
        }
1038
        return null;
1039
    }
1040
1041
    /**
1042
     * Tries to find the database key on another object that is used to store a
1043
     * relationship to this class. If no join field can be found it defaults to 'ParentID'.
1044
     *
1045
     * If the remote field is polymorphic then $polymorphic is set to true, and the return value
1046
     * is in the form 'Relation' instead of 'RelationID', referencing the composite DBField.
1047
     *
1048
     * @param string $class
1049
     * @param string $component Name of the relation on the current object pointing to the
1050
     * remote object.
1051
     * @param string $type the join type - either 'has_many' or 'belongs_to'
1052
     * @param boolean $polymorphic Flag set to true if the remote join field is polymorphic.
1053
     *
1054
     * @return string
1055
     * @throws Exception
1056
     */
1057
    public function getRemoteJoinField($class, $component, $type = 'has_many', &$polymorphic = false)
1058
    {
1059
        // Extract relation from current object
1060
        if ($type === 'has_many') {
1061
            $remoteClass = $this->hasManyComponent($class, $component, false);
1062
        } else {
1063
            $remoteClass = $this->belongsToComponent($class, $component, false);
1064
        }
1065
1066
        if (empty($remoteClass)) {
1067
            throw new Exception("Unknown $type component '$component' on class '$class'");
1068
        }
1069
        if (!ClassInfo::exists(strtok($remoteClass, '.'))) {
1070
            throw new Exception(
1071
                "Class '$remoteClass' not found, but used in $type component '$component' on class '$class'"
1072
            );
1073
        }
1074
1075
        // If presented with an explicit field name (using dot notation) then extract field name
1076
        $remoteField = null;
1077
        if (strpos($remoteClass, '.') !== false) {
1078
            list($remoteClass, $remoteField) = explode('.', $remoteClass);
1079
        }
1080
1081
        // Reference remote has_one to check against
1082
        $remoteRelations = Config::inst()->get($remoteClass, 'has_one');
1083
1084
        // Without an explicit field name, attempt to match the first remote field
1085
        // with the same type as the current class
1086
        if (empty($remoteField)) {
1087
            // look for remote has_one joins on this class or any parent classes
1088
            $remoteRelationsMap = array_flip($remoteRelations);
1089
            foreach (array_reverse(ClassInfo::ancestry($class)) as $ancestryClass) {
1090
                if (array_key_exists($ancestryClass, $remoteRelationsMap)) {
1091
                    $remoteField = $remoteRelationsMap[$ancestryClass];
1092
                    break;
1093
                }
1094
            }
1095
        }
1096
1097
        // In case of an indeterminate remote field show an error
1098
        if (empty($remoteField)) {
1099
            $polymorphic = false;
1100
            $message = "No has_one found on class '$remoteClass'";
1101
            if ($type == 'has_many') {
1102
                // include a hint for has_many that is missing a has_one
1103
                $message .= ", the has_many relation from '$class' to '$remoteClass'";
1104
                $message .= " requires a has_one on '$remoteClass'";
1105
            }
1106
            throw new Exception($message);
1107
        }
1108
1109
        // If given an explicit field name ensure the related class specifies this
1110
        if (empty($remoteRelations[$remoteField])) {
1111
            throw new Exception("Missing expected has_one named '$remoteField'
1112
				on class '$remoteClass' referenced by $type named '$component'
1113
				on class {$class}");
1114
        }
1115
1116
        // Inspect resulting found relation
1117
        if ($remoteRelations[$remoteField] === DataObject::class) {
1118
            $polymorphic = true;
1119
            return $remoteField; // Composite polymorphic field does not include 'ID' suffix
1120
        } else {
1121
            $polymorphic = false;
1122
            return $remoteField . 'ID';
1123
        }
1124
    }
1125
1126
    /**
1127
     * Check if all tables and field columns for a class exist in the database.
1128
     *
1129
     * @param string $class
1130
     * @return boolean
1131
     */
1132
    public function tableIsReadyForClass(string $class): bool
1133
    {
1134
        if (!is_subclass_of($class, DataObject::class)) {
1135
            throw new InvalidArgumentException("$class is not a subclass of " . DataObject::class);
1136
        }
1137
1138
        // Don't check again if we already know the db is ready for this class.
1139
        // Necessary here before the loop to catch situations where a subclass
1140
        // is forced as ready without having to check all the superclasses.
1141
        if (!empty($this->tableReadyClasses[$class])) {
1142
            return true;
1143
        }
1144
1145
        // Check if all tables and fields required for the class exist in the database.
1146
        $requiredClasses = ClassInfo::dataClassesFor($class);
1147
        foreach ($requiredClasses as $required) {
1148
            // Skip test classes, as not all test classes are scaffolded at once
1149
            if (is_a($required, TestOnly::class, true)) {
1150
                continue;
1151
            }
1152
1153
            // Don't check again if we already know the db is ready for this class.
1154
            if (!empty($this->tableReadyClasses[$class])) {
1155
                continue;
1156
            }
1157
1158
            // if any of the tables aren't created in the database
1159
            $table = $this->tableName($required);
1160
            if (!ClassInfo::hasTable($table)) {
1161
                return false;
1162
            }
1163
1164
            // HACK: DataExtensions aren't applied until a class is instantiated for
1165
            // the first time, so create an instance here.
1166
            singleton($required);
1167
1168
            // if any of the tables don't have all fields mapped as table columns
1169
            $dbFields = DB::field_list($table);
1170
            if (!$dbFields) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $dbFields of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
1171
                return false;
1172
            }
1173
1174
            $objFields = $this->databaseFields($required, false);
1175
            $missingFields = array_diff_key($objFields, $dbFields);
1176
1177
            if ($missingFields) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $missingFields of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
1178
                return false;
1179
            }
1180
1181
            // Add each ready class to the cached array.
1182
            $this->tableReadyClasses[$required] = true;
1183
        }
1184
1185
        return true;
1186
    }
1187
1188
    /**
1189
     * Resets the tableReadyClasses cache.
1190
     *
1191
     * @param string|null $class The specific class to be cleared.
1192
     * If not passed, the cache for all classes is cleared.
1193
     * @param bool $clearFullHeirarchy Whether to clear the full class hierarchy or only the given class.
1194
     */
1195
    public function clearTableReadyForClass(?string $class = null, bool $clearFullHierarchy = true): void
1196
    {
1197
        if ($class) {
1198
            $clearClasses = [$class];
1199
            if ($clearFullHierarchy) {
1200
                $clearClasses = ClassInfo::dataClassesFor($class);
1201
            }
1202
            foreach ($clearClasses as $clear) {
1203
                unset($this->tableReadyClasses[$clear]);
1204
            }
1205
        } else {
1206
            $this->tableReadyClasses = [];
1207
        }
1208
    }
1209
1210
    /**
1211
     * Validate the to or from field on a has_many mapping class
1212
     *
1213
     * @param string $parentClass Name of parent class
1214
     * @param string $component Name of many_many component
1215
     * @param string $joinClass Class for the joined table
1216
     * @param array $specification Complete many_many specification
1217
     * @param string $key Name of key to check ('from' or 'to')
1218
     *
1219
     * @return string Class that matches the given relation
1220
     * @throws InvalidArgumentException
1221
     */
1222
    protected function checkManyManyFieldClass($parentClass, $component, $joinClass, $specification, $key)
1223
    {
1224
        // Ensure value for this key exists
1225
        if (empty($specification[$key])) {
1226
            throw new InvalidArgumentException(
1227
                "many_many relation {$parentClass}.{$component} has missing {$key} which "
1228
                . "should be a has_one on class {$joinClass}"
1229
            );
1230
        }
1231
1232
        // Check that the field exists on the given object
1233
        $relation = $specification[$key];
1234
        $relationClass = $this->hasOneComponent($joinClass, $relation);
1235
        if (empty($relationClass)) {
1236
            throw new InvalidArgumentException(
1237
                "many_many through relation {$parentClass}.{$component} {$key} references a field name "
1238
                . "{$joinClass}::{$relation} which is not a has_one"
1239
            );
1240
        }
1241
1242
        // Check for polymorphic
1243
        /** @internal Polymorphic many_many is experimental */
1244
        if ($relationClass === DataObject::class) {
1245
            // Currently polymorphic 'from' is supported.
1246
            if ($key === 'from') {
1247
                return $relationClass;
1248
            }
1249
            // @todo support polymorphic 'to'
1250
            throw new InvalidArgumentException(
1251
                "many_many through relation {$parentClass}.{$component} {$key} references a polymorphic field "
1252
                . "{$joinClass}::{$relation} which is not supported"
1253
            );
1254
        }
1255
1256
        // Validate the join class isn't also the name of a field or relation on either side
1257
        // of the relation
1258
        $field = $this->fieldSpec($relationClass, $joinClass);
1259
        if ($field) {
1260
            throw new InvalidArgumentException(
1261
                "many_many through relation {$parentClass}.{$component} {$key} class {$relationClass} "
1262
                . " cannot have a db field of the same name of the join class {$joinClass}"
1263
            );
1264
        }
1265
1266
        // Validate bad types on parent relation
1267
        if ($key === 'from' && $relationClass !== $parentClass) {
1268
            throw new InvalidArgumentException(
1269
                "many_many through relation {$parentClass}.{$component} {$key} references a field name "
1270
                . "{$joinClass}::{$relation} of type {$relationClass}; {$parentClass} expected"
1271
            );
1272
        }
1273
        return $relationClass;
1274
    }
1275
1276
    /**
1277
     * @param string $parentClass Name of parent class
1278
     * @param string $component Name of many_many component
1279
     * @param array $specification Complete many_many specification
1280
     *
1281
     * @return string Name of join class
1282
     */
1283
    protected function checkManyManyJoinClass($parentClass, $component, $specification)
1284
    {
1285
        if (empty($specification['through'])) {
1286
            throw new InvalidArgumentException(
1287
                "many_many relation {$parentClass}.{$component} has missing through which should be "
1288
                . "a DataObject class name to be used as a join table"
1289
            );
1290
        }
1291
        $joinClass = $specification['through'];
1292
        if (!class_exists($joinClass)) {
1293
            throw new InvalidArgumentException(
1294
                "many_many relation {$parentClass}.{$component} has through class \"{$joinClass}\" which does not exist"
1295
            );
1296
        }
1297
        return $joinClass;
1298
    }
1299
1300
    /**
1301
     * Validate a given class is valid for a relation
1302
     *
1303
     * @param string $class Parent class
1304
     * @param string $component Component name
1305
     * @param string $relationClass Candidate class to check
1306
     * @param string $type Relation type (e.g. has_one)
1307
     */
1308
    protected function checkRelationClass($class, $component, $relationClass, $type)
1309
    {
1310
        if (!is_string($component) || is_numeric($component)) {
0 ignored issues
show
introduced by
The condition is_string($component) is always true.
Loading history...
1311
            throw new InvalidArgumentException(
1312
                "{$class} has invalid {$type} relation name"
1313
            );
1314
        }
1315
        if (!is_string($relationClass)) {
0 ignored issues
show
introduced by
The condition is_string($relationClass) is always true.
Loading history...
1316
            throw new InvalidArgumentException(
1317
                "{$type} relation {$class}.{$component} is not a class name"
1318
            );
1319
        }
1320
        if (!class_exists($relationClass)) {
1321
            throw new InvalidArgumentException(
1322
                "{$type} relation {$class}.{$component} references class {$relationClass} which doesn't exist"
1323
            );
1324
        }
1325
        // Support polymorphic has_one
1326
        if ($type === 'has_one') {
1327
            $valid = is_a($relationClass, DataObject::class, true);
1328
        } else {
1329
            $valid = is_subclass_of($relationClass, DataObject::class, true);
1330
        }
1331
        if (!$valid) {
1332
            throw new InvalidArgumentException(
1333
                "{$type} relation {$class}.{$component} references class {$relationClass} "
1334
                . " which is not a subclass of " . DataObject::class
1335
            );
1336
        }
1337
    }
1338
}
1339